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:
@@ -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;
|
package org.botstandards.apix.common;
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record ServiceSummaryDto(
|
public record ServiceSummaryDto(
|
||||||
UUID id,
|
UUID id,
|
||||||
String name,
|
String name,
|
||||||
|
String description,
|
||||||
List<String> capabilities,
|
List<String> capabilities,
|
||||||
OLevel oLevel,
|
OLevel oLevel,
|
||||||
LivenessStatus livenessStatus,
|
LivenessStatus livenessStatus,
|
||||||
ServiceStage serviceStage,
|
ServiceStage serviceStage,
|
||||||
RegistryStatus registryStatus,
|
RegistryStatus registryStatus,
|
||||||
String endpoint,
|
String endpoint,
|
||||||
Instant lastCheckedAt
|
String registrantName,
|
||||||
|
String registrantJurisdiction
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
+27
@@ -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
|
||||||
|
) {}
|
||||||
+74
@@ -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>
|
<h2>Try it</h2>
|
||||||
|
|
||||||
<div class="draft-suite" style="margin-bottom:2.5rem">
|
<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 & 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">
|
<div class="draft-item">
|
||||||
<span class="label">API root</span>
|
<span class="label">API root</span>
|
||||||
<div class="content">
|
<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> {service.serviceStage}</div>
|
||||||
|
<div><span style="color:#484f58;">liveness</span> {service.livenessStatus}</div>
|
||||||
|
{#if service.registeredAt != null}
|
||||||
|
<div><span style="color:#484f58;">registered</span> {service.registeredAt}</div>
|
||||||
|
{/if}
|
||||||
|
{#if service.lastUpdatedAt != null}
|
||||||
|
<div><span style="color:#484f58;">updated</span> {service.lastUpdatedAt}</div>
|
||||||
|
{/if}
|
||||||
|
{#if service.sunsetAt != null}
|
||||||
|
<div style="color:#f85149;"><span style="color:#f8514988;">sunset</span> {service.sunsetAt}</div>
|
||||||
|
{/if}
|
||||||
|
{#if service.migrationGuideUrl != null}
|
||||||
|
<div><span style="color:#484f58;">migration</span> <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>
|
||||||
+22
@@ -7,6 +7,7 @@ import jakarta.ws.rs.*;
|
|||||||
import jakarta.ws.rs.core.*;
|
import jakarta.ws.rs.core.*;
|
||||||
import org.botstandards.apix.common.BsmPayload;
|
import org.botstandards.apix.common.BsmPayload;
|
||||||
import org.botstandards.apix.common.OLevel;
|
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.ReplacementsResponse;
|
||||||
import org.botstandards.apix.registry.dto.ServicePatchRequest;
|
import org.botstandards.apix.registry.dto.ServicePatchRequest;
|
||||||
import org.botstandards.apix.registry.dto.ServiceResponse;
|
import org.botstandards.apix.registry.dto.ServiceResponse;
|
||||||
@@ -58,6 +59,27 @@ public class ServiceResource {
|
|||||||
.build();
|
.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
|
@GET
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
@Operation(
|
@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.ServiceReplacementEntity;
|
||||||
import org.botstandards.apix.registry.entity.ServiceVersionEntity;
|
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.time.Instant;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
@@ -253,6 +256,52 @@ public class RegistryService {
|
|||||||
return stream.toList();
|
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() {
|
public long countAll() {
|
||||||
return ((Number) em.createNativeQuery(
|
return ((Number) em.createNativeQuery(
|
||||||
"SELECT COUNT(*) FROM services WHERE registry_status = 'ACTIVE' AND sandbox_id IS NULL")
|
"SELECT COUNT(*) FROM services WHERE registry_status = 'ACTIVE' AND sandbox_id IS NULL")
|
||||||
|
|||||||
Reference in New Issue
Block a user