portal: add human-navigable service browser (GET /browse)
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:
Carsten Rehfeld
2026-05-19 10:52:37 +02:00
parent 9b70599d98
commit 89aa2c47cb
11 changed files with 831 additions and 2 deletions
@@ -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(
@@ -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")