diff --git a/apix-common/src/main/java/org/botstandards/apix/common/ServiceBrowsePage.java b/apix-common/src/main/java/org/botstandards/apix/common/ServiceBrowsePage.java new file mode 100644 index 0000000..4b71dfc --- /dev/null +++ b/apix-common/src/main/java/org/botstandards/apix/common/ServiceBrowsePage.java @@ -0,0 +1,5 @@ +package org.botstandards.apix.common; + +import java.util.List; + +public record ServiceBrowsePage(long total, int page, int size, List items) {} diff --git a/apix-common/src/main/java/org/botstandards/apix/common/ServiceSummaryDto.java b/apix-common/src/main/java/org/botstandards/apix/common/ServiceSummaryDto.java index 7b00ff9..cf75af3 100644 --- a/apix-common/src/main/java/org/botstandards/apix/common/ServiceSummaryDto.java +++ b/apix-common/src/main/java/org/botstandards/apix/common/ServiceSummaryDto.java @@ -1,17 +1,18 @@ package org.botstandards.apix.common; -import java.time.Instant; import java.util.List; import java.util.UUID; public record ServiceSummaryDto( UUID id, String name, + String description, List capabilities, OLevel oLevel, LivenessStatus livenessStatus, ServiceStage serviceStage, RegistryStatus registryStatus, String endpoint, - Instant lastCheckedAt + String registrantName, + String registrantJurisdiction ) {} diff --git a/apix-portal/src/main/java/org/botstandards/apix/portal/client/ServiceRegistryClient.java b/apix-portal/src/main/java/org/botstandards/apix/portal/client/ServiceRegistryClient.java new file mode 100644 index 0000000..dfa1716 --- /dev/null +++ b/apix-portal/src/main/java/org/botstandards/apix/portal/client/ServiceRegistryClient.java @@ -0,0 +1,27 @@ +package org.botstandards.apix.portal.client; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import org.botstandards.apix.common.ServiceBrowsePage; +import org.botstandards.apix.portal.dto.PortalServiceView; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@RegisterRestClient(configKey = "registry") +@Produces(MediaType.APPLICATION_JSON) +public interface ServiceRegistryClient { + + @GET + @Path("/services/browse") + ServiceBrowsePage browseServices( + @QueryParam("capability") String capability, + @QueryParam("page") int page, + @QueryParam("size") int size); + + @GET + @Path("/services/{id}") + PortalServiceView getService(@PathParam("id") String id); +} diff --git a/apix-portal/src/main/java/org/botstandards/apix/portal/dto/PortalServiceView.java b/apix-portal/src/main/java/org/botstandards/apix/portal/dto/PortalServiceView.java new file mode 100644 index 0000000..89b61a4 --- /dev/null +++ b/apix-portal/src/main/java/org/botstandards/apix/portal/dto/PortalServiceView.java @@ -0,0 +1,36 @@ +package org.botstandards.apix.portal.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import org.botstandards.apix.common.LivenessStatus; +import org.botstandards.apix.common.OLevel; +import org.botstandards.apix.common.OrgType; +import org.botstandards.apix.common.RegistryStatus; +import org.botstandards.apix.common.ServiceStage; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record PortalServiceView( + UUID id, + String name, + String description, + String endpoint, + List capabilities, + String registrantName, + String registrantJurisdiction, + OrgType registrantOrgType, + String openApiSpecUrl, + String mcpSpecUrl, + String policyUrl, + String securityContactUrl, + OLevel oLevel, + LivenessStatus livenessStatus, + ServiceStage serviceStage, + RegistryStatus registryStatus, + Instant registeredAt, + Instant lastUpdatedAt, + Instant sunsetAt, + String migrationGuideUrl +) {} diff --git a/apix-portal/src/main/java/org/botstandards/apix/portal/resource/ServiceBrowserResource.java b/apix-portal/src/main/java/org/botstandards/apix/portal/resource/ServiceBrowserResource.java new file mode 100644 index 0000000..02922fd --- /dev/null +++ b/apix-portal/src/main/java/org/botstandards/apix/portal/resource/ServiceBrowserResource.java @@ -0,0 +1,74 @@ +package org.botstandards.apix.portal.resource; + +import io.quarkus.qute.CheckedTemplate; +import io.quarkus.qute.TemplateInstance; +import jakarta.inject.Inject; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import org.botstandards.apix.common.ServiceBrowsePage; +import org.botstandards.apix.portal.client.ServiceRegistryClient; +import org.botstandards.apix.portal.dto.PortalServiceView; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.logging.Logger; + +@Path("/browse") +public class ServiceBrowserResource { + + private static final Logger LOG = Logger.getLogger(ServiceBrowserResource.class); + private static final int PAGE_SIZE = 20; + + @Inject + @RestClient + ServiceRegistryClient registryClient; + + @CheckedTemplate + static class Templates { + static native TemplateInstance browse(ServiceBrowsePage page, String capability, int currentPage, int prevPage, int nextPage, long totalPages, boolean hasPrev, boolean hasNext); + static native TemplateInstance detail(PortalServiceView service); + static native TemplateInstance error(String message); + } + + @GET + @Produces(MediaType.TEXT_HTML) + public TemplateInstance browse( + @QueryParam("capability") @DefaultValue("") String capability, + @QueryParam("page") @DefaultValue("0") int page) { + String cap = capability.isBlank() ? null : capability.strip(); + ServiceBrowsePage result; + try { + result = registryClient.browseServices(cap, page, PAGE_SIZE); + } catch (Exception e) { + LOG.errorf(e, "Failed to fetch service list"); + return Templates.error("Registry unavailable — please try again shortly."); + } + boolean hasNext = (long) (page + 1) * PAGE_SIZE < result.total(); + long totalPages = (result.total() + PAGE_SIZE - 1) / PAGE_SIZE; + return Templates.browse(result, capability.strip(), page, page - 1, page + 1, totalPages, page > 0, hasNext); + } + + @GET + @Path("/{id}") + @Produces(MediaType.TEXT_HTML) + public TemplateInstance detail(@PathParam("id") String id) { + PortalServiceView service; + try { + service = registryClient.getService(id); + } catch (WebApplicationException e) { + if (e.getResponse().getStatus() == 404) { + return Templates.error("Service not found."); + } + LOG.errorf(e, "Failed to fetch service %s", id); + return Templates.error("Registry unavailable — please try again shortly."); + } catch (Exception e) { + LOG.errorf(e, "Failed to fetch service %s", id); + return Templates.error("Registry unavailable — please try again shortly."); + } + return Templates.detail(service); + } +} diff --git a/apix-portal/src/main/resources/META-INF/resources/index.html b/apix-portal/src/main/resources/META-INF/resources/index.html index b01c2a2..29cdb49 100644 --- a/apix-portal/src/main/resources/META-INF/resources/index.html +++ b/apix-portal/src/main/resources/META-INF/resources/index.html @@ -390,6 +390,13 @@

Try it

+
+ Browse +
+ Service Registry — browse & search all registered services → +
Human-navigable view of the same index agents query. Filter by capability, read BSM records, check trust levels.
+
+
API root
diff --git a/apix-portal/src/main/resources/templates/ServiceBrowserResource/browse.html b/apix-portal/src/main/resources/templates/ServiceBrowserResource/browse.html new file mode 100644 index 0000000..ab7f9af --- /dev/null +++ b/apix-portal/src/main/resources/templates/ServiceBrowserResource/browse.html @@ -0,0 +1,302 @@ + + + + + +APIX Registry · Browse Services + + + + +
+ + Service Registry +
+ + + +
+ {page.total} service{#if page.total != 1}s{/if} + {#if capability.isEmpty()}in production · public registry{#else}matching «{capability}»{/if} +
+ +{#if page.items.isEmpty()} +
+

no services found

+

+ {#if capability.isEmpty()} + No production services are registered yet. + {#else} + No production services match capability «{capability}». + {/if} +

+
+{#else} +
+ {#for s in page.items} +
+
+ + {#if s.description != null} +
{s.description}
+ {/if} + {#if s.capabilities != null && s.capabilities.isEmpty() == false} +
+ {#for cap in s.capabilities} + {cap} + {/for} +
+ {/if} +
+ + {s.livenessStatus} + {#if s.registrantName != null} + · + {s.registrantName} + {/if} + {#if s.registrantJurisdiction != null} + · + {s.registrantJurisdiction} + {/if} +
+
+
+ {s.oLevel} +
+
+ {/for} +
+{/if} + + + + + + + diff --git a/apix-portal/src/main/resources/templates/ServiceBrowserResource/detail.html b/apix-portal/src/main/resources/templates/ServiceBrowserResource/detail.html new file mode 100644 index 0000000..ea8cb37 --- /dev/null +++ b/apix-portal/src/main/resources/templates/ServiceBrowserResource/detail.html @@ -0,0 +1,258 @@ + + + + + +{service.name} · APIX Registry + + + + +
+ ← Registry + {service.name} +
+ + {service.oLevel} + {service.serviceStage} +
+
+ +
+ + {#if service.description != null} +
+ + {service.description} +
+ {/if} + +
+ + {service.endpoint} +
+ + {#if service.capabilities != null && service.capabilities.isEmpty() == false} +
+ +
+ {#for cap in service.capabilities} + {cap} + {/for} +
+
+ {/if} + +
+ +
+ {service.oLevel} +
+ {#if service.oLevel.name() == "UNVERIFIED"} + O0 · No verification performed. Default for all newly registered services. + {/if} + {#if service.oLevel.name() == "IDENTITY_VERIFIED"} + O1 · DNS ownership of the registrant domain verified via a DNS TXT record challenge. + {/if} + {#if service.oLevel.name() == "LEGAL_ENTITY_VERIFIED"} + O2 · Legal entity confirmed via GLEIF LEI database or OpenCorporates registry. + {/if} + {#if service.oLevel.name() == "HYGIENE_VERIFIED"} + O3 · Service passes technical hygiene checks — security.txt present, spec accessible, endpoint responding within SLA. + {/if} + {#if service.oLevel.name() == "OPERATIONALLY_VERIFIED"} + O4 · Demonstrated operational history with continuous liveness monitoring. + {/if} + {#if service.oLevel.name() == "AUDITED"} + Highest level · Full independent audit completed by an APIX-accredited auditor. + {/if} +
+
+
+ + {#if service.openApiSpecUrl != null || service.mcpSpecUrl != null || service.policyUrl != null || service.securityContactUrl != null} +
+ + +
+ {/if} + +
+ +
+
{#if service.registrantName != null}{service.registrantName}{#else}—{/if}
+ {#if service.registrantJurisdiction != null} +
{service.registrantJurisdiction}{#if service.registrantOrgType != null} · {service.registrantOrgType}{/if}
+ {/if} +
+
+ +
+ +
+
stage   {service.serviceStage}
+
liveness   {service.livenessStatus}
+ {#if service.registeredAt != null} +
registered   {service.registeredAt}
+ {/if} + {#if service.lastUpdatedAt != null} +
updated   {service.lastUpdatedAt}
+ {/if} + {#if service.sunsetAt != null} +
sunset   {service.sunsetAt}
+ {/if} + {#if service.migrationGuideUrl != null} + + {/if} +
+
+ +
+ + {service.id} +
+ +
+ + + + + diff --git a/apix-portal/src/main/resources/templates/ServiceBrowserResource/error.html b/apix-portal/src/main/resources/templates/ServiceBrowserResource/error.html new file mode 100644 index 0000000..30a8a36 --- /dev/null +++ b/apix-portal/src/main/resources/templates/ServiceBrowserResource/error.html @@ -0,0 +1,48 @@ + + + + + +APIX Registry · Error + + + + +
+ +
+ +
+

error

+

{message}

+ ← Back to registry +
+ + + diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java index f182828..beaad31 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/ServiceResource.java @@ -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( diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java b/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java index 658c891..a8a9446 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/service/RegistryService.java @@ -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 items = ((List) 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")