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
@@ -0,0 +1,5 @@
package org.botstandards.apix.common;
import java.util.List;
public record ServiceBrowsePage(long total, int page, int size, List<ServiceSummaryDto> items) {}
@@ -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<String> capabilities,
OLevel oLevel,
LivenessStatus livenessStatus,
ServiceStage serviceStage,
RegistryStatus registryStatus,
String endpoint,
Instant lastCheckedAt
String registrantName,
String registrantJurisdiction
) {}
@@ -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);
}
@@ -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<String> 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
) {}
@@ -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);
}
}
@@ -390,6 +390,13 @@
<h2>Try it</h2>
<div class="draft-suite" style="margin-bottom:2.5rem">
<div class="draft-item" style="background:#f9f9f9">
<span class="label">Browse</span>
<div class="content">
<a href="/browse">Service Registry — browse &amp; search all registered services →</a>
<div class="sub">Human-navigable view of the same index agents query. Filter by capability, read BSM records, check trust levels.</div>
</div>
</div>
<div class="draft-item">
<span class="label">API root</span>
<div class="content">
@@ -0,0 +1,302 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>APIX Registry · Browse Services</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d1117;
color: #c9d1d9;
font-family: 'SF Mono', 'Consolas', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.6;
}
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
/* ── Header ── */
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid #21262d;
}
.header-logo { color: #8b949e; font-size: 0.8rem; }
.header-title { font-size: 1rem; color: #e6edf3; font-weight: 600; }
/* ── Search ── */
.search-bar {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #21262d;
display: flex;
gap: 0.6rem;
align-items: center;
}
.search-input {
flex: 1;
background: #161b22;
border: 1px solid #30363d;
color: #c9d1d9;
padding: 0.5rem 0.75rem;
font-family: inherit;
font-size: 13px;
border-radius: 4px;
outline: none;
}
.search-input:focus { border-color: #58a6ff; }
.search-input::placeholder { color: #484f58; }
.btn {
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
padding: 0.45rem 0.9rem;
font-family: inherit;
font-size: 13px;
cursor: pointer;
border-radius: 4px;
white-space: nowrap;
}
.btn:hover { border-color: #58a6ff; color: #58a6ff; }
.btn-link { background: transparent; border: none; color: #484f58; cursor: pointer; font-family: inherit; font-size: 13px; }
.btn-link:hover { color: #58a6ff; }
/* ── Stats bar ── */
.stats-bar {
padding: 0.45rem 1.5rem;
font-size: 0.7rem;
color: #484f58;
border-bottom: 1px solid #21262d;
letter-spacing: 0.03em;
}
/* ── Service list ── */
.service-list {
display: flex;
flex-direction: column;
gap: 1px;
background: #21262d;
}
.service-card {
background: #0d1117;
padding: 1rem 1.5rem;
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: start;
cursor: pointer;
transition: background 0.1s;
}
.service-card:hover { background: #161b22; }
.card-name {
color: #e6edf3;
font-weight: 600;
font-size: 0.95rem;
}
.card-name a { color: #e6edf3; }
.card-name a:hover { color: #58a6ff; text-decoration: none; }
.card-desc {
color: #8b949e;
font-size: 0.78rem;
margin-top: 0.2rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 72ch;
}
.caps {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
.cap-tag {
background: #161b22;
border: 1px solid #30363d;
color: #8b949e;
padding: 0.05rem 0.4rem;
border-radius: 3px;
font-size: 0.65rem;
letter-spacing: 0.02em;
}
.card-meta {
font-size: 0.68rem;
color: #484f58;
margin-top: 0.45rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
/* ── Liveness dot ── */
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.dot-LIVE { background: #3fb950; box-shadow: 0 0 5px #3fb95088; }
.dot-DEGRADED { background: #d29922; }
.dot-UNREACHABLE { background: #f85149; }
.dot-PENDING { background: #484f58; }
/* ── O-level badge ── */
.o-badge {
font-size: 0.62rem;
padding: 0.15rem 0.5rem;
border-radius: 10px;
border: 1px solid;
white-space: nowrap;
letter-spacing: 0.04em;
text-transform: uppercase;
align-self: flex-start;
margin-top: 0.15rem;
}
.o-UNVERIFIED { color: #484f58; border-color: #30363d; background: transparent; }
.o-IDENTITY_VERIFIED { color: #d29922; border-color: #d2992255; background: #d2992211; }
.o-LEGAL_ENTITY_VERIFIED { color: #e3b341; border-color: #e3b34155; background: #e3b34111; }
.o-HYGIENE_VERIFIED { color: #58a6ff; border-color: #58a6ff55; background: #58a6ff11; }
.o-OPERATIONALLY_VERIFIED { color: #3fb950; border-color: #3fb95055; background: #3fb95011; }
.o-AUDITED { color: #bc8cff; border-color: #bc8cff55; background: #bc8cff11; }
/* ── Pagination ── */
.pagination {
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid #21262d;
font-size: 0.78rem;
color: #484f58;
}
.page-btn { display: inline-block; color: #8b949e; border: 1px solid #30363d; padding: 0.3rem 0.65rem; border-radius: 4px; }
.page-btn:hover { border-color: #58a6ff; color: #58a6ff; text-decoration: none; }
.page-btn-off { color: #30363d; border: 1px solid #21262d; padding: 0.3rem 0.65rem; border-radius: 4px; }
/* ── Empty state ── */
.empty {
padding: 4rem 1.5rem;
text-align: center;
color: #484f58;
}
.empty h2 { font-size: 1rem; margin-bottom: 0.5rem; color: #30363d; }
/* ── Footer ── */
.footer {
padding: 1rem 1.5rem;
border-top: 1px solid #21262d;
font-size: 0.7rem;
color: #484f58;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
</style>
</head>
<body>
<div class="header">
<span class="header-logo"><a href="/browse">APIX</a></span>
<span class="header-title">Service Registry</span>
</div>
<form class="search-bar" method="get" action="/browse">
<input class="search-input" type="text" name="capability" value="{capability}"
placeholder="search by capability (e.g. nlp, translation, speech-to-text)"
autocomplete="off" spellcheck="false">
<button class="btn" type="submit">Search</button>
{#if capability.isEmpty() == false}
<a href="/browse" class="btn-link">clear</a>
{/if}
</form>
<div class="stats-bar">
{page.total} service{#if page.total != 1}s{/if}
{#if capability.isEmpty()}in production · public registry{#else}matching «{capability}»{/if}
</div>
{#if page.items.isEmpty()}
<div class="empty">
<h2>no services found</h2>
<p>
{#if capability.isEmpty()}
No production services are registered yet.
{#else}
No production services match capability «{capability}».
{/if}
</p>
</div>
{#else}
<div class="service-list">
{#for s in page.items}
<div class="service-card" onclick="location.href='/browse/{s.id}'">
<div>
<div class="card-name"><a href="/browse/{s.id}">{s.name}</a></div>
{#if s.description != null}
<div class="card-desc">{s.description}</div>
{/if}
{#if s.capabilities != null && s.capabilities.isEmpty() == false}
<div class="caps">
{#for cap in s.capabilities}
<span class="cap-tag">{cap}</span>
{/for}
</div>
{/if}
<div class="card-meta">
<span class="dot dot-{s.livenessStatus}"></span>
<span>{s.livenessStatus}</span>
{#if s.registrantName != null}
<span>·</span>
<span>{s.registrantName}</span>
{/if}
{#if s.registrantJurisdiction != null}
<span>·</span>
<span>{s.registrantJurisdiction}</span>
{/if}
</div>
</div>
<div>
<span class="o-badge o-{s.oLevel}">{s.oLevel}</span>
</div>
</div>
{/for}
</div>
{/if}
<div class="pagination">
<span>
{#if hasPrev}
<a href="/browse?page={prevPage}{#if capability.isEmpty() == false}&capability={capability}{/if}" class="page-btn">← prev</a>
{#else}
<span class="page-btn-off">← prev</span>
{/if}
</span>
<span>page {currentPage + 1} of {totalPages}</span>
<span>
{#if hasNext}
<a href="/browse?page={nextPage}{#if capability.isEmpty() == false}&capability={capability}{/if}" class="page-btn">next →</a>
{#else}
<span class="page-btn-off">next →</span>
{/if}
</span>
</div>
<div class="footer">
<span>APIX Registry · open protocol</span>
<span>
<a href="https://api-index.org">api-index.org</a> ·
<a href="https://api-index.org/q/openapi">OpenAPI</a>
</span>
</div>
</body>
</html>
@@ -0,0 +1,258 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{service.name} · APIX Registry</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d1117;
color: #c9d1d9;
font-family: 'SF Mono', 'Consolas', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.6;
}
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
/* ── Header ── */
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.9rem 1.5rem;
border-bottom: 1px solid #21262d;
flex-wrap: wrap;
}
.back-link { color: #8b949e; font-size: 0.78rem; }
.back-link:hover { color: #58a6ff; }
.header-name { font-size: 1rem; color: #e6edf3; font-weight: 600; flex: 1; }
.header-badges { display: flex; align-items: center; gap: 0.5rem; }
/* ── Liveness ── */
.dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
}
.dot-LIVE { background: #3fb950; box-shadow: 0 0 6px #3fb95088; }
.dot-DEGRADED { background: #d29922; }
.dot-UNREACHABLE { background: #f85149; }
.dot-PENDING { background: #484f58; }
/* ── O-level badge ── */
.o-badge {
font-size: 0.65rem; padding: 0.15rem 0.55rem; border-radius: 10px;
border: 1px solid; letter-spacing: 0.04em; text-transform: uppercase;
}
.o-UNVERIFIED { color: #484f58; border-color: #30363d; }
.o-IDENTITY_VERIFIED { color: #d29922; border-color: #d2992255; background: #d2992211; }
.o-LEGAL_ENTITY_VERIFIED { color: #e3b341; border-color: #e3b34155; background: #e3b34111; }
.o-HYGIENE_VERIFIED { color: #58a6ff; border-color: #58a6ff55; background: #58a6ff11; }
.o-OPERATIONALLY_VERIFIED { color: #3fb950; border-color: #3fb95055; background: #3fb95011; }
.o-AUDITED { color: #bc8cff; border-color: #bc8cff55; background: #bc8cff11; }
/* ── Stage badge ── */
.stage-badge {
font-size: 0.62rem; padding: 0.12rem 0.45rem; border-radius: 3px;
background: #161b22; border: 1px solid #30363d; color: #8b949e;
letter-spacing: 0.04em;
}
/* ── Body ── */
.detail-body {
max-width: 820px;
padding: 2rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0;
}
/* ── Section ── */
.section {
padding: 1rem 0;
border-bottom: 1px solid #21262d;
display: grid;
grid-template-columns: 180px 1fr;
gap: 0.5rem 1.5rem;
align-items: start;
}
.section:last-child { border-bottom: none; }
.section-label {
font-size: 0.65rem;
color: #484f58;
letter-spacing: 0.08em;
text-transform: uppercase;
padding-top: 0.1rem;
}
.section-value { color: #c9d1d9; word-break: break-all; }
.section-value a { word-break: break-all; }
/* ── Caps ── */
.caps { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.cap-tag {
background: #161b22; border: 1px solid #30363d; color: #8b949e;
padding: 0.1rem 0.45rem; border-radius: 3px; font-size: 0.7rem;
}
/* ── O-level description ── */
.o-desc {
font-size: 0.75rem;
color: #8b949e;
margin-top: 0.4rem;
padding: 0.6rem 0.75rem;
background: #161b22;
border: 1px solid #21262d;
border-radius: 4px;
line-height: 1.5;
}
/* ── Links group ── */
.links-list { display: flex; flex-direction: column; gap: 0.35rem; }
.link-item { font-size: 0.8rem; }
.link-label { color: #484f58; font-size: 0.65rem; letter-spacing: 0.05em; text-transform: uppercase; margin-right: 0.5rem; }
/* ── ID display ── */
.uuid { font-size: 0.75rem; color: #484f58; font-family: inherit; }
/* ── Footer ── */
.footer {
padding: 1rem 1.5rem;
border-top: 1px solid #21262d;
font-size: 0.7rem;
color: #484f58;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
</style>
</head>
<body>
<div class="header">
<a href="/browse" class="back-link">← Registry</a>
<span class="header-name">{service.name}</span>
<div class="header-badges">
<span class="dot dot-{service.livenessStatus}"></span>
<span class="o-badge o-{service.oLevel}">{service.oLevel}</span>
<span class="stage-badge">{service.serviceStage}</span>
</div>
</div>
<div class="detail-body">
{#if service.description != null}
<div class="section">
<span class="section-label">Description</span>
<span class="section-value">{service.description}</span>
</div>
{/if}
<div class="section">
<span class="section-label">Endpoint</span>
<span class="section-value"><a href="{service.endpoint}">{service.endpoint}</a></span>
</div>
{#if service.capabilities != null && service.capabilities.isEmpty() == false}
<div class="section">
<span class="section-label">Capabilities</span>
<div class="caps">
{#for cap in service.capabilities}
<span class="cap-tag">{cap}</span>
{/for}
</div>
</div>
{/if}
<div class="section">
<span class="section-label">Trust Level</span>
<div>
<span class="o-badge o-{service.oLevel}" style="font-size:0.75rem;padding:0.2rem 0.6rem;">{service.oLevel}</span>
<div class="o-desc">
{#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}
</div>
</div>
</div>
{#if service.openApiSpecUrl != null || service.mcpSpecUrl != null || service.policyUrl != null || service.securityContactUrl != null}
<div class="section">
<span class="section-label">Links</span>
<div class="links-list">
{#if service.openApiSpecUrl != null}
<div class="link-item"><span class="link-label">OpenAPI</span><a href="{service.openApiSpecUrl}">{service.openApiSpecUrl}</a></div>
{/if}
{#if service.mcpSpecUrl != null}
<div class="link-item"><span class="link-label">MCP Spec</span><a href="{service.mcpSpecUrl}">{service.mcpSpecUrl}</a></div>
{/if}
{#if service.policyUrl != null}
<div class="link-item"><span class="link-label">Policy</span><a href="{service.policyUrl}">{service.policyUrl}</a></div>
{/if}
{#if service.securityContactUrl != null}
<div class="link-item"><span class="link-label">Security</span><a href="{service.securityContactUrl}">{service.securityContactUrl}</a></div>
{/if}
</div>
</div>
{/if}
<div class="section">
<span class="section-label">Registrant</span>
<div>
<div>{#if service.registrantName != null}{service.registrantName}{#else}—{/if}</div>
{#if service.registrantJurisdiction != null}
<div style="font-size:0.75rem;color:#8b949e;margin-top:0.15rem;">{service.registrantJurisdiction}{#if service.registrantOrgType != null} · {service.registrantOrgType}{/if}</div>
{/if}
</div>
</div>
<div class="section">
<span class="section-label">Lifecycle</span>
<div style="display:flex;flex-direction:column;gap:0.25rem;font-size:0.8rem;">
<div><span style="color:#484f58;">stage</span> &nbsp; {service.serviceStage}</div>
<div><span style="color:#484f58;">liveness</span> &nbsp; {service.livenessStatus}</div>
{#if service.registeredAt != null}
<div><span style="color:#484f58;">registered</span> &nbsp; {service.registeredAt}</div>
{/if}
{#if service.lastUpdatedAt != null}
<div><span style="color:#484f58;">updated</span> &nbsp; {service.lastUpdatedAt}</div>
{/if}
{#if service.sunsetAt != null}
<div style="color:#f85149;"><span style="color:#f8514988;">sunset</span> &nbsp; {service.sunsetAt}</div>
{/if}
{#if service.migrationGuideUrl != null}
<div><span style="color:#484f58;">migration</span> &nbsp; <a href="{service.migrationGuideUrl}">{service.migrationGuideUrl}</a></div>
{/if}
</div>
</div>
<div class="section">
<span class="section-label">Service ID</span>
<span class="uuid">{service.id}</span>
</div>
</div>
<div class="footer">
<span><a href="/browse">← Back to registry</a></span>
<span><a href="https://api-index.org/services/{service.id}">JSON record</a> · <a href="https://api-index.org">api-index.org</a></span>
</div>
</body>
</html>
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>APIX Registry · Error</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d1117;
color: #c9d1d9;
font-family: 'SF Mono', 'Consolas', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.6;
}
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid #21262d;
}
.header-logo { color: #8b949e; font-size: 0.8rem; }
.error-body {
padding: 4rem 1.5rem;
text-align: center;
color: #484f58;
}
.error-body h2 { color: #f85149; font-size: 1rem; margin-bottom: 0.75rem; }
.error-body p { margin-bottom: 1.5rem; }
</style>
</head>
<body>
<div class="header">
<span class="header-logo"><a href="/browse">APIX</a></span>
</div>
<div class="error-body">
<h2>error</h2>
<p>{message}</p>
<a href="/browse">← Back to registry</a>
</div>
</body>
</html>
@@ -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")