portal: add human-navigable service browser (GET /browse)
Deploy to Production / deploy (push) Successful in 3m16s
Deploy to Production / deploy (push) Successful in 3m16s
Registry: new GET /services/browse endpoint — paginated, no auth required,
returns ServiceBrowsePage (total, page, size, items). ServiceSummaryDto
extended with description, registrantName, registrantJurisdiction; lastCheckedAt
removed (not stored). ServiceBrowsePage added to apix-common as shared type.
Portal: ServiceBrowserResource at /browse (list + /browse/{id} detail);
ServiceRegistryClient for the new endpoints; PortalServiceView DTO for detail
deserialization; three Qute templates (browse, detail, error) in the existing
dark-theme design. Landing page links to /browse in the "Try it" section.
Architecture: registry stays pure JSON API; portal is the HTML layer over the
same data — two subdomains, no content negotiation required.
Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
This commit is contained in:
+22
@@ -7,6 +7,7 @@ import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.*;
|
||||
import org.botstandards.apix.common.BsmPayload;
|
||||
import org.botstandards.apix.common.OLevel;
|
||||
import org.botstandards.apix.common.ServiceBrowsePage;
|
||||
import org.botstandards.apix.registry.dto.ReplacementsResponse;
|
||||
import org.botstandards.apix.registry.dto.ServicePatchRequest;
|
||||
import org.botstandards.apix.registry.dto.ServiceResponse;
|
||||
@@ -58,6 +59,27 @@ public class ServiceResource {
|
||||
.build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/browse")
|
||||
@Operation(
|
||||
summary = "Browse production services",
|
||||
description = "Returns a paginated list of PRODUCTION services in the public registry. " +
|
||||
"Optionally filter by capability keyword. No authentication required. " +
|
||||
"Intended for human-facing portal UIs and developer tooling. " +
|
||||
"Maximum page size is 100."
|
||||
)
|
||||
public ServiceBrowsePage browse(
|
||||
@Parameter(description = "Optional capability filter (e.g. nlp, translation). Omit to list all production services.", example = "translation")
|
||||
@QueryParam("capability") String capability,
|
||||
@Parameter(description = "Zero-based page index.", example = "0")
|
||||
@QueryParam("page") @DefaultValue("0") int page,
|
||||
@Parameter(description = "Items per page. Capped at 100.", example = "20")
|
||||
@QueryParam("size") @DefaultValue("20") int size) {
|
||||
if (size < 1) size = 1;
|
||||
if (size > 100) size = 100;
|
||||
return registryService.browse(capability, page, size);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
@Operation(
|
||||
|
||||
+49
@@ -19,6 +19,9 @@ import org.botstandards.apix.registry.entity.ServiceEntity;
|
||||
import org.botstandards.apix.registry.entity.ServiceReplacementEntity;
|
||||
import org.botstandards.apix.registry.entity.ServiceVersionEntity;
|
||||
|
||||
import org.botstandards.apix.common.ServiceBrowsePage;
|
||||
import org.botstandards.apix.common.ServiceSummaryDto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
@@ -253,6 +256,52 @@ public class RegistryService {
|
||||
return stream.toList();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public ServiceBrowsePage browse(String capability, int page, int size) {
|
||||
boolean hasCapability = capability != null && !capability.isBlank();
|
||||
String capClause = hasCapability
|
||||
? " AND s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap))"
|
||||
: "";
|
||||
String whereClause =
|
||||
"s.registry_status = 'ACTIVE' AND s.service_stage = 'PRODUCTION' AND s.sandbox_id IS NULL"
|
||||
+ capClause;
|
||||
|
||||
Query countQ = em.createNativeQuery(
|
||||
"SELECT COUNT(*) FROM services s WHERE " + whereClause);
|
||||
if (hasCapability) countQ.setParameter("cap", capability);
|
||||
long total = ((Number) countQ.getSingleResult()).longValue();
|
||||
|
||||
Query listQ = em.createNativeQuery(
|
||||
"SELECT s.* FROM services s WHERE " + whereClause
|
||||
+ " ORDER BY s.registered_at DESC LIMIT :lim OFFSET :off",
|
||||
ServiceEntity.class)
|
||||
.setParameter("lim", size)
|
||||
.setParameter("off", (long) page * size);
|
||||
if (hasCapability) listQ.setParameter("cap", capability);
|
||||
|
||||
List<ServiceSummaryDto> items = ((List<ServiceEntity>) listQ.getResultList()).stream()
|
||||
.map(this::toSummary)
|
||||
.toList();
|
||||
return new ServiceBrowsePage(total, page, size, items);
|
||||
}
|
||||
|
||||
private ServiceSummaryDto toSummary(ServiceEntity e) {
|
||||
BsmPayload b = e.bsmPayload;
|
||||
return new ServiceSummaryDto(
|
||||
e.id,
|
||||
b.name(),
|
||||
b.description(),
|
||||
b.capabilities(),
|
||||
e.olevel,
|
||||
e.livenessStatus,
|
||||
e.serviceStage,
|
||||
e.registryStatus,
|
||||
b.endpoint(),
|
||||
b.registrantName(),
|
||||
b.registrantJurisdiction()
|
||||
);
|
||||
}
|
||||
|
||||
public long countAll() {
|
||||
return ((Number) em.createNativeQuery(
|
||||
"SELECT COUNT(*) FROM services WHERE registry_status = 'ACTIVE' AND sandbox_id IS NULL")
|
||||
|
||||
Reference in New Issue
Block a user