feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.15) #56

Merged
KofTwentyTwo merged 3 commits from feature/ui-api-1.6 into develop 2026-10-15 21:12:49 +00:00
6 changed files with 479 additions and 121 deletions

No files matched your search

+5
View File
@@ -1,5 +1,10 @@
# Changelog
## Unreleased - UI API 1.6.0
- UI API extension assistant 1.3.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags and trace IDs. Preserve HTTP statuses and assistant-specific error codes.
- Retain AssistantErrorV1 as a deprecated component for compatibility. Remove dangling `null` schema references from the two bodyless 204 responses (delete conversation and feedback), with an empty-content workaround for core schema traversal. Success payloads are unchanged; the contract test retains the release baseline with explicit exceptions for the error schema migration and these documentation corrections.
## Unreleased - Central health
### Added
+1 -1
View File
@@ -217,7 +217,7 @@
<properties>
<!-- RC.7 provides classified-value tokens, log redaction and native help bundles; -->
<!-- RC.8 provides core job tracking (JobTracking, the Jobs tray). -->
<qqq.snapshot.version>4.1.0-RC.8</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -37,9 +37,11 @@ import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Response;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
import io.javalin.http.ContentType;
@@ -54,14 +56,14 @@ import io.javalin.http.HttpStatus;
** routes, with the same URLs and bodies, so they appear in the served
** /qqq/v1/openapi.yaml and a generated client covers them.
**
** Errors keep the assistant's shape: HTTP status plus {"error", "code"}.
** Errors use BasicErrorResponseV1 and retain assistant-specific codes.
** Turns and events are text/event-stream; each event type is documented as
** AssistantTurnEventV1.
*******************************************************************************/
public final class AssistantSpecs
{
public static final String EXTENSION_NAME = "assistant";
public static final String EXTENSION_VERSION = "1.2.0";
public static final String EXTENSION_VERSION = "1.3.0";
public static final String PREFIX = "/assistant";
@@ -227,7 +229,7 @@ public final class AssistantSpecs
rs.put("AssistantTurnEventV1", object("One server-sent event of a turn (event: <type>, id: <seq>, data: this object). Types: message.start, turn.phase, text.delta, tool.start, tool.result, ui.navigate, notice, usage, message.end, error, ping.",
"seq", integer("Sequence number; resume with ?after=<seq>."), "type", string("The event type (also the SSE event name)."),
"data", freeObject("The event's fields, by type. message.start includes startedAt (ISO-8601). turn.phase includes phase (thinking, tool, writing), optional toolName and label.")));
rs.put("AssistantErrorV1", object("An error.", "error", string("A message for the user."), "code", string("BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED, NO_PERMISSION...").withNullable(true)));
rs.put("AssistantErrorV1", object("Deprecated since " + EXTENSION_VERSION + ": errors use BasicErrorResponseV1.", "error", string("A message for the user."), "code", string("BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED, NO_PERMISSION...").withNullable(true)));
return (rs);
}
@@ -260,7 +262,7 @@ public final class AssistantSpecs
/*******************************************************************************
** Base of the assistant's JSON specs: the user's own AssistantApi (with the
** request's credentials, forwarded to the UI API by the tools), errors in
** the assistant's {"error","code"} shape with their status.
** the standard error shape with their status and assistant-specific code.
*******************************************************************************/
public abstract static class AssistantJsonSpec extends AbstractJsonEndpointSpec
{
@@ -336,6 +338,26 @@ public final class AssistantSpecs
/*******************************************************************************
** A bodyless response has empty content. Core 1.6's schema traversal
** requires a non-null map even when the response has no schema.
*******************************************************************************/
@Override
public Map<Integer, Response> defineResponses()
{
Map<Integer, Response> responses = super.defineResponses();
for(Response response : responses.values())
{
if(response.getContent() == null)
{
response.withContent(Map.of());
}
}
return (responses);
}
/*******************************************************************************
** Keep the request (for its credentials and the server's local port).
*******************************************************************************/
@@ -403,7 +425,7 @@ public final class AssistantSpecs
/*******************************************************************************
** Assistant errors keep their status and {"error","code"}; others go
** Assistant errors keep their status and code in the standard shape; others go
** through QQQ's mapping (400 / 401 / 403 / 404 / 500).
*******************************************************************************/
@Override
@@ -413,15 +435,16 @@ public final class AssistantSpecs
AssistantApiException apiException = ExceptionUtils.findClassInRootChain(e, AssistantApiException.class);
if(apiException != null)
{
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", apiException.getMessage());
context.contentType(ContentType.APPLICATION_JSON);
QJavalinUtils.respondWithError(context, org.eclipse.jetty.http.HttpStatus.getCode(apiException.getStatus()), apiException.getMessage());
if(apiException.getCode() != null)
{
body.put("code", apiException.getCode());
context.result(JsonUtils.toJson(new BasicErrorResponseV1()
.withError(apiException.getMessage())
.withCode(apiException.getCode())
.withTraceId(QJavalinUtils.getTraceId(context))
.toMap()));
}
context.status(apiException.getStatus());
context.contentType(ContentType.APPLICATION_JSON);
context.result(JsonUtils.toJson(body));
return;
}
QJavalinUtils.handleException(null, context, e);
@@ -436,12 +459,12 @@ public final class AssistantSpecs
public List<BasicResponse> defineAdditionalBasicResponses()
{
return (List.of(
new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "AssistantErrorV1"),
new BasicResponse(HttpStatus.FORBIDDEN, "No access to the assistant", "AssistantErrorV1"),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (or not the user's own)", "AssistantErrorV1"),
new BasicResponse(HttpStatus.CONFLICT, "Already finished or decided", "AssistantErrorV1"),
new BasicResponse(HttpStatus.TOO_MANY_REQUESTS, "Budget or rate limit", "AssistantErrorV1"),
new BasicResponse(HttpStatus.SERVICE_UNAVAILABLE, "Disabled, not configured, or the kill switch is on", "AssistantErrorV1")));
new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.FORBIDDEN, "No access to the assistant", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (or not the user's own)", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.CONFLICT, "Already finished or decided", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.TOO_MANY_REQUESTS, "Budget or rate limit", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.SERVICE_UNAVAILABLE, "Disabled, not configured, or the kill switch is on", BasicErrorResponseV1.class.getSimpleName())));
}
File diff suppressed because it is too large. Load diff
@@ -179,7 +179,11 @@ class AssistantRouteProviderTest extends BaseAssistantTest
assertThat((Map<String, Object>) config.get("features")).containsEntry("writes", false);
assertThat(json(send("GET", "/config", NO_ACCESS, null))).containsEntry("enabled", false).containsEntry("reason", "NO_PERMISSION");
assertThat(send("GET", "/conversations", NO_ACCESS, null).statusCode()).isEqualTo(403);
HttpResponse<String> denied = send("GET", "/conversations", NO_ACCESS, null);
assertThat(denied.statusCode()).isEqualTo(403);
assertThat(json(denied)).containsEntry("code", "NO_PERMISSION")
.containsEntry("message", json(denied).get("error"))
.containsEntry("traceId", denied.headers().firstValue("X-QQQ-Trace-Id").orElseThrow());
setKillSwitch(true);
assertThat(json(send("GET", "/config", EDITOR, null))).containsEntry("enabled", false).containsEntry("reason", "KILL_SWITCH");
@@ -217,6 +221,9 @@ class AssistantRouteProviderTest extends BaseAssistantTest
HttpResponse<String> foreign = send("GET", "/conversations/" + id, OTHER_EDITOR, null);
assertThat(foreign.statusCode()).isEqualTo(404);
assertThat(foreign.body()).doesNotContain("Posts");
assertThat(json(foreign)).containsEntry("code", "NOT_FOUND")
.containsEntry("message", json(foreign).get("error"))
.containsEntry("traceId", foreign.headers().firstValue("X-QQQ-Trace-Id").orElseThrow());
assertThat(send("PATCH", "/conversations/" + id, OTHER_EDITOR, "{\"title\":\"x\"}").statusCode()).isEqualTo(404);
assertThat(send("DELETE", "/conversations/" + id, OTHER_EDITOR, null).statusCode()).isEqualTo(404);
assertThat(send("POST", "/conversations/" + id + "/turns", OTHER_EDITOR, "{\"text\":\"hi\"}").statusCode()).isEqualTo(404);
@@ -238,7 +245,11 @@ class AssistantRouteProviderTest extends BaseAssistantTest
HttpRequest form = HttpRequest.newBuilder(URI.create(base + "/conversations")).header("Cookie", "sessionId=" + EDITOR)
.header("Content-Type", "application/x-www-form-urlencoded").POST(HttpRequest.BodyPublishers.ofString("title=x")).build();
assertThat(client.send(form, HttpResponse.BodyHandlers.ofString()).statusCode()).isEqualTo(400);
assertThat(send("GET", "/conversations/abc", EDITOR, null).statusCode()).isEqualTo(400);
HttpResponse<String> invalid = send("GET", "/conversations/abc", EDITOR, null);
assertThat(invalid.statusCode()).isEqualTo(400);
assertThat(json(invalid)).containsEntry("code", "BAD_REQUEST")
.containsEntry("message", json(invalid).get("error"))
.containsEntry("traceId", invalid.headers().firstValue("X-QQQ-Trace-Id").orElseThrow());
assertThat(send("POST", "/conversations/" + id + "/turns", EDITOR, "{}").statusCode()).isEqualTo(400);
assertThat(send("DELETE", "/conversations/" + id, EDITOR, null).statusCode()).isEqualTo(204);
@@ -65,8 +65,17 @@ class AssistantSpecsContractTest extends BaseAssistantTest
assertThat(generated).contains("/qqq/v1/assistant/conversations/{id}/turns").contains("text/event-stream").contains("/qqq/v1/assistant/actions/{id}/confirm")
.contains("AssistantTurnEventV1").contains("/qqq/v1/assistant/access");
///////////////////////////////////////////////////////////////////////////////////////
// The standard error schema retains error/code and adds message/traceId/fieldErrors. //
// The two 204 responses never had bodies; their old refs pointed to schema "null". //
// Keep the release baseline and accept only these documented migration corrections. //
///////////////////////////////////////////////////////////////////////////////////////
List<String> acceptedChanges = List.of(
"$ref changed from #/components/schemas/AssistantErrorV1 to #/components/schemas/BasicErrorResponseV1",
"DELETE /qqq/v1/assistant/conversations/{id} response 204: content type removed: application/json",
"POST /qqq/v1/assistant/messages/{id}/feedback response 204: content type removed: application/json");
List<String> problems = UiApiContract.check(generated, Files.readString(COMMITTED, StandardCharsets.UTF_8),
Files.exists(BASELINE) ? Files.readString(BASELINE, StandardCharsets.UTF_8) : null, 1, "./mvnw test -pl qbit-assistant -Dtest=AssistantSpecsContractTest -Dassistant.regenerateOpenApi=true");
Files.exists(BASELINE) ? Files.readString(BASELINE, StandardCharsets.UTF_8) : null, 1, "./mvnw test -pl qbit-assistant -Dtest=AssistantSpecsContractTest -Dassistant.regenerateOpenApi=true", acceptedChanges);
assertThat(problems).isEmpty();
}