fix(sandbox): two sandbox bugs blocking Peter Steinberger beta test
Bug 1 — sandbox-scoped discovery returns empty:
GET /services?capability=nlp with a sandbox API key returns [] because
Bunny.net CDN caches the public (PRODUCTION-scoped) empty response and
serves it regardless of the X-Api-Key header. Fix: ServiceResource.search()
now returns Cache-Control: private, no-store when a sandbox key is active,
opting the response out of CDN caching.
Bug 2 — _links.sandbox href uses sandbox name instead of UUID:
IndexResource.index() used sandbox.name as the URL path segment instead of
sandbox.id. Name-based URLs return 404 since migration-020 made UUID the
sole resource identifier. Fix: sandbox.name → sandbox.id.
Also: SandboxResource GET /{uuid} simplified to SandboxIndexResponse
(metadata + links only); heavy usage stats + agent visits are on
/telemetry. SandboxService feedback INSERT uses CAST(:x AS jsonb) for
portability across PostgreSQL JDBC driver versions.
Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -47,7 +47,7 @@ public class IndexResource {
|
|||||||
if (apiKey != null && !apiKey.isBlank()) {
|
if (apiKey != null && !apiKey.isBlank()) {
|
||||||
SandboxEntity sandbox = sandboxService.findByKey(apiKey);
|
SandboxEntity sandbox = sandboxService.findByKey(apiKey);
|
||||||
if (sandbox != null) {
|
if (sandbox != null) {
|
||||||
sandboxLink = new LinkRef(baseUrl + "/sandbox/" + sandbox.name);
|
sandboxLink = new LinkRef(baseUrl + "/sandbox/" + sandbox.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-6
@@ -7,7 +7,6 @@ import jakarta.ws.rs.*;
|
|||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import org.botstandards.apix.common.BsmPayload;
|
import org.botstandards.apix.common.BsmPayload;
|
||||||
import org.botstandards.apix.common.SandboxDashboardResponse;
|
|
||||||
import org.botstandards.apix.registry.dto.*;
|
import org.botstandards.apix.registry.dto.*;
|
||||||
import org.botstandards.apix.registry.entity.SandboxEntity;
|
import org.botstandards.apix.registry.entity.SandboxEntity;
|
||||||
import org.botstandards.apix.registry.service.SandboxService;
|
import org.botstandards.apix.registry.service.SandboxService;
|
||||||
@@ -69,16 +68,18 @@ public class SandboxResource {
|
|||||||
@GET
|
@GET
|
||||||
@Path("/{uuid}")
|
@Path("/{uuid}")
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "Sandbox root — HATEOAS navigation and dashboard data",
|
summary = "Sandbox root — HATEOAS navigation",
|
||||||
description = "Returns sandbox metadata, navigation links, usage stats, and agent visit data. " +
|
description = "Returns sandbox metadata and navigation links. " +
|
||||||
"No authentication required. The UUID is the permanent resource identifier."
|
"No authentication required. The UUID is the permanent resource identifier."
|
||||||
)
|
)
|
||||||
public SandboxDashboardResponse index(@PathParam("uuid") String uuidStr) {
|
public SandboxIndexResponse index(@PathParam("uuid") String uuidStr) {
|
||||||
UUID id = parseUuid(uuidStr);
|
UUID id = parseUuid(uuidStr);
|
||||||
SandboxDashboardResponse dashboard = sandboxService.getDashboard(id);
|
var sb = sandboxService.requireById(id);
|
||||||
sandboxService.recordUsage(id.toString(), SandboxService.EVENT_SANDBOX_VIEWED);
|
sandboxService.recordUsage(id.toString(), SandboxService.EVENT_SANDBOX_VIEWED);
|
||||||
meters.counter("apix.sandbox.views", "sandbox", id.toString()).increment();
|
meters.counter("apix.sandbox.views", "sandbox", id.toString()).increment();
|
||||||
return dashboard;
|
String base = baseUrl + "/sandbox/" + id;
|
||||||
|
return new SandboxIndexResponse(sb.id.toString(), sb.name, sb.tier, sb.ratePerMinute,
|
||||||
|
sb.expiresAt, sandboxService.sandboxLinks(base, id.toString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
|
|||||||
+9
-2
@@ -89,6 +89,8 @@ public class ServiceResource {
|
|||||||
summary = "Search services by capability and optional extension properties",
|
summary = "Search services by capability and optional extension properties",
|
||||||
description = "Returns services matching the given capability keyword. " +
|
description = "Returns services matching the given capability keyword. " +
|
||||||
"Omitting ?stage= defaults to PRODUCTION only — this is the standard agent query. " +
|
"Omitting ?stage= defaults to PRODUCTION only — this is the standard agent query. " +
|
||||||
|
"When a valid sandbox API key is provided via X-Api-Key, results are scoped to that sandbox " +
|
||||||
|
"and stage defaults to DEVELOPMENT. " +
|
||||||
"Use ?stage=DEVELOPMENT or ?stage=BETA to discover pre-production services. " +
|
"Use ?stage=DEVELOPMENT or ?stage=BETA to discover pre-production services. " +
|
||||||
"Capability values are lowercase kebab-case strings defined by the registrant (e.g. nlp, translation, speech-to-text). " +
|
"Capability values are lowercase kebab-case strings defined by the registrant (e.g. nlp, translation, speech-to-text). " +
|
||||||
"The search matches exact capability tokens, not substrings. " +
|
"The search matches exact capability tokens, not substrings. " +
|
||||||
@@ -96,7 +98,7 @@ public class ServiceResource {
|
|||||||
"Multiple ?property= parameters are ANDed together. " +
|
"Multiple ?property= parameters are ANDed together. " +
|
||||||
"Example: ?capability=translation&property=region:eu&property=dataResidency:DE"
|
"Example: ?capability=translation&property=region:eu&property=dataResidency:DE"
|
||||||
)
|
)
|
||||||
public List<ServiceResponse> search(
|
public Response search(
|
||||||
@Parameter(description = "Capability keyword to search for (e.g. nlp, translation, speech-to-text). Required.", example = "nlp")
|
@Parameter(description = "Capability keyword to search for (e.g. nlp, translation, speech-to-text). Required.", example = "nlp")
|
||||||
@QueryParam("capability") String capability,
|
@QueryParam("capability") String capability,
|
||||||
@Parameter(description = "Lifecycle stage filter. Defaults to PRODUCTION if omitted. Valid values: DEVELOPMENT, BETA, PRODUCTION, DEPRECATED.", example = "PRODUCTION")
|
@Parameter(description = "Lifecycle stage filter. Defaults to PRODUCTION if omitted. Valid values: DEVELOPMENT, BETA, PRODUCTION, DEPRECATED.", example = "PRODUCTION")
|
||||||
@@ -123,7 +125,12 @@ public class ServiceResource {
|
|||||||
"capability", tv(capability))
|
"capability", tv(capability))
|
||||||
.record(results.size());
|
.record(results.size());
|
||||||
|
|
||||||
return results;
|
Response.ResponseBuilder rb = Response.ok(results);
|
||||||
|
// Sandbox responses are scoped to a specific API key — must not be cached by the CDN.
|
||||||
|
if (sandboxId != null) {
|
||||||
|
rb.header("Cache-Control", "private, no-store");
|
||||||
|
}
|
||||||
|
return rb.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PATCH
|
@PATCH
|
||||||
|
|||||||
+1
-1
@@ -377,7 +377,7 @@ public class SandboxService {
|
|||||||
em.createNativeQuery(
|
em.createNativeQuery(
|
||||||
"INSERT INTO sandbox_feedback " +
|
"INSERT INTO sandbox_feedback " +
|
||||||
"(sandbox_id, scores, agent_identifier, comment, model_identifier, model_provider) " +
|
"(sandbox_id, scores, agent_identifier, comment, model_identifier, model_provider) " +
|
||||||
"VALUES (:sid, :scores::jsonb, :agent, :comment, :modelId, :modelProvider)")
|
"VALUES (:sid, CAST(:scores AS jsonb), :agent, :comment, :modelId, :modelProvider)")
|
||||||
.setParameter("sid", sandboxId)
|
.setParameter("sid", sandboxId)
|
||||||
.setParameter("scores", scoresJson)
|
.setParameter("scores", scoresJson)
|
||||||
.setParameter("agent", req.agentIdentifier())
|
.setParameter("agent", req.agentIdentifier())
|
||||||
|
|||||||
Reference in New Issue
Block a user