diff --git a/CHANGELOG.md b/CHANGELOG.md
index 57c84cc..9e8e485 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
### Changed
+- UI API extension edi 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags, and retained deprecated EdiErrorV1 schema.
+
- Derive qbit metadata version from the Maven artifact version, keeping release metadata and help bundle versions in sync.
- Require QQQ 4.1.0-RC.7 for core help-bundle registration and coverage validation.
diff --git a/README.md b/README.md
index d94f868..d031317 100644
--- a/README.md
+++ b/README.md
@@ -95,8 +95,8 @@ transports/guides and local certificates/identities.
## UI API
-With optional Javalin present, the producer registers the `edi` extension, version `1.0.0`,
-under `/qqq/v1/qbit/edi`. All routes are endpoint specs and pass strict UI-route auditing.
+With optional Javalin present, the producer registers the `edi` extension, version `1.1.0`,
+under `/qqq/v1/qbit/edi`, requiring QQQ 4.1.0-RC.15 (UI API 1.6.0). All routes are endpoint specs and pass strict UI-route auditing.
| Method | Path after `/qqq/v1/qbit/edi` | Result |
|---|---|---|
@@ -133,15 +133,15 @@ The mapper saves `edi_map_rule` through the **core table API**, using its normal
`edi.map_rule.insert|edit|delete` permissions and record locks. There are no custom rule-write routes.
The Next mapper page is separate phase-f work.
-The generated contract is `src/main/resources/openapi/qqq-v1/edi.yaml`; all component names are
-`Edi...V1`. Regenerate intentionally with:
+The generated contract is `src/main/resources/openapi/qqq-v1/edi.yaml`; extension component names are
+`Edi...V1`, alongside core `BasicErrorResponseV1`. `EdiErrorV1` remains deprecated for compatibility. Regenerate with the locally installed migration build:
```powershell
-./mvnw.cmd -B '-Dtest=EdiApiContractTest' '-Dedi.openapi.update=true' test
+./mvnw.cmd -o -B '-Dqqq.snapshot.version=4.1.99-SNAPSHOT' '-Dtest=EdiApiContractTest' '-Dedi.openapi.update=true' test
```
-RC.6 provides `UiApiContract.check`, used to compare generated and checked-in YAML and run
-`OpenApiCompatibilityChecker` against this initial fragment baseline. The extension also appears
+Core provides `UiApiContract.check`, used to compare generated and checked-in YAML, validate
+operation IDs and references, and run `OpenApiCompatibilityChecker` against the committed fragment baseline. The extension also appears
in the host's `/qqq/v1/openapi.yaml` and `/qqq/v1/version`.
## Inbound operation
diff --git a/pom.xml b/pom.xml
index 1323f06..ffb0a24 100644
--- a/pom.xml
+++ b/pom.xml
@@ -242,7 +242,7 @@
true
- 4.1.0-RC.7
+ 4.1.0-RC.15
diff --git a/src/main/java/com/kingsrook/qbits/edi/api/EdiApi.java b/src/main/java/com/kingsrook/qbits/edi/api/EdiApi.java
index fc75eee..47527ad 100644
--- a/src/main/java/com/kingsrook/qbits/edi/api/EdiApi.java
+++ b/src/main/java/com/kingsrook/qbits/edi/api/EdiApi.java
@@ -36,6 +36,7 @@ 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.extensions.MiddlewareSpecExtensionsMetaData;
+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;
@@ -44,7 +45,7 @@ import com.kingsrook.qqq.openapi.model.Type;
import io.javalin.http.HttpStatus;
/*******************************************************************************
- ** The EDI 1.0.0 UI API: four spec'd operations in the default qbit namespace.
+ ** The EDI 1.1.0 UI API: four spec'd operations in the default qbit namespace.
*******************************************************************************/
public final class EdiApi
{
@@ -68,7 +69,7 @@ public final class EdiApi
*******************************************************************************/
public static MiddlewareSpecExtension extension()
{
- return (new MiddlewareSpecExtension().withName("edi").withSemanticVersion("1.0.0")
+ return (new MiddlewareSpecExtension().withName("edi").withSemanticVersion("1.1.0")
.withDescription("EDI sandbox and mapper, using the signed-in user's permissions.")
.withSpec(GuideTreeSpec.class).withSpec(MapTargetSpec.class).withSpec(PreviewSpec.class).withSpec(ValidateSpec.class));
}
@@ -148,11 +149,11 @@ public final class EdiApi
@Override
public List defineAdditionalBasicResponses()
{
- return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid request or sample exceeds 5 MiB", "EdiErrorV1"),
- new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "EdiErrorV1"),
- new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "EdiErrorV1"),
- new BasicResponse(HttpStatus.NOT_FOUND, "Missing or invisible record", "EdiErrorV1"),
- new BasicResponse(HttpStatus.CONTENT_TOO_LARGE, "Host HTTP request size limit exceeded", "EdiErrorV1")));
+ return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid request or sample exceeds 5 MiB", BasicErrorResponseV1.class.getSimpleName()),
+ new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
+ new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", BasicErrorResponseV1.class.getSimpleName()),
+ new BasicResponse(HttpStatus.NOT_FOUND, "Missing or invisible record", BasicErrorResponseV1.class.getSimpleName()),
+ new BasicResponse(HttpStatus.CONTENT_TOO_LARGE, "Host HTTP request size limit exceeded", BasicErrorResponseV1.class.getSimpleName())));
}
/*******************************************************************************
diff --git a/src/main/java/com/kingsrook/qbits/edi/api/EdiSchemas.java b/src/main/java/com/kingsrook/qbits/edi/api/EdiSchemas.java
index f66daa0..8a7d2dd 100644
--- a/src/main/java/com/kingsrook/qbits/edi/api/EdiSchemas.java
+++ b/src/main/java/com/kingsrook/qbits/edi/api/EdiSchemas.java
@@ -489,7 +489,7 @@ public final class EdiSchemas
@Override
public Schema toSchema()
{
- return (new Schema().withType(Type.OBJECT).withDescription("Core middleware error body.")
+ return (new Schema().withType(Type.OBJECT).withDescription("Deprecated since 1.1.0: errors use BasicErrorResponseV1.")
.withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("error", new Schema().withType(Type.STRING).withNullable(true))))));
}
diff --git a/src/main/resources/openapi/qqq-v1/edi.yaml b/src/main/resources/openapi/qqq-v1/edi.yaml
index 0c8cc1c..236b6c9 100644
--- a/src/main/resources/openapi/qqq-v1/edi.yaml
+++ b/src/main/resources/openapi/qqq-v1/edi.yaml
@@ -1,5 +1,44 @@
components:
schemas:
+ BasicErrorResponseV1:
+ description: "The error body of every UI API operation (core and extensions)."
+ properties:
+ error:
+ description: "The error message (the same as message; kept for clients of\
+ \ UI API 1.0 to 1.5)."
+ type: "string"
+ code:
+ description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\
+ \ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\
+ \ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\
+ \ Extensions may add codes; treat an unknown code by the HTTP status.\
+ \ Since 1.6.0."
+ type: "string"
+ message:
+ description: "A message to show the user. Since 1.6.0."
+ type: "string"
+ fieldErrors:
+ description: "Errors about particular request fields, when known. Since\
+ \ 1.6.0."
+ items:
+ properties:
+ field:
+ description: "The field (or parameter) name."
+ type: "string"
+ message:
+ description: "The error about that field."
+ type: "string"
+ code:
+ description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\
+ \ PATTERN."
+ type: "string"
+ type: "object"
+ type: "array"
+ traceId:
+ description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\
+ \ response header); quote it when reporting a problem. Since 1.6.0."
+ type: "string"
+ type: "object"
EdiAckV1:
description: "Concatenated acknowledgement previews; type is 997, 999, TA1,\
\ CONTRL, NONE or MIXED. text is null for PHI."
@@ -93,7 +132,7 @@ components:
type: "string"
type: "object"
EdiErrorV1:
- description: "Core middleware error body."
+ description: "Deprecated since 1.1.0: errors use BasicErrorResponseV1."
properties:
error:
nullable: true
@@ -397,21 +436,55 @@ components:
type: "object"
securitySchemes:
sessionUuidCookie:
+ description: "The session cookie set by POST /qqq/v1/manageSession (web clients)."
in: "cookie"
name: "sessionUUID"
type: "apiKey"
+ bearerAuth:
+ bearerFormat: "JWT or session UUID"
+ description: "`Authorization: Bearer `, where the token is either\n-\
+ \ an OAuth2 access token (a signed JWT) issued by the app's identity provider\
+ \ to the app's client,\n or to its native client (`values.nativeClientId`\
+ \ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\
+ \ grant or auth-code + PKCE. It is verified against the\n provider's keys\
+ \ (issuer, audience, expiry, signature) and gives the same user and permissions\n\
+ \ as signing in on the web. When it expires (401), refresh it at the provider\
+ \ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\
+ \ which the server keeps refreshed."
+ scheme: "bearer"
+ type: "http"
+ oauth2:
+ description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\
+ \ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\
+ \ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\
+ \ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\
+ \ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\
+ \ grant (`device_authorization_endpoint`, RFC 8628), then send the access\
+ \ token\nas `Authorization: Bearer`."
+ flows:
+ authorizationCode:
+ authorizationUrl: "/authorize"
+ scopes:
+ email: "Email address"
+ offline_access: "A refresh token, to renew the access token without signing\
+ \ in again"
+ openid: "OpenID Connect sign-in"
+ profile: "Name, username and groups (permissions come from the groups)"
+ tokenUrl: "/token"
+ type: "oauth2"
info:
contact:
email: "contact@kingsrook.com"
description: "EDI sandbox and mapper, using the signed-in user's permissions."
title: "QQQ Middleware API extension: edi"
- version: "1.0.0"
+ version: "1.1.0"
openapi: "3.0.3"
paths:
/qqq/v1/qbit/edi/guides/{guideId}/tree:
get:
description: "Read the guide tree. Uses the caller session and table read permissions.\
\ Preview also requires edi.map.preview. Rule writes use core table CRUD."
+ operationId: "getQbitEdiGuidesByGuideIdTree"
parameters:
- description: "Positive record identifier."
in: "path"
@@ -424,25 +497,25 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid request or sample exceeds 5 MiB"
401:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Missing or invisible record"
200:
content:
@@ -454,17 +527,39 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Host HTTP request size limit exceeded"
+ 409:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Conflict with the current state, e.g. an edit conflict or\
+ \ an invalid transition (code CONFLICT)."
+ 429:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Read the guide tree"
+ tags:
+ - "edi"
/qqq/v1/qbit/edi/maps/{mapId}/target:
get:
description: "Read target metadata and map rules. Uses the caller session and\
\ table read permissions. Preview also requires edi.map.preview. Rule writes\
\ use core table CRUD."
+ operationId: "getQbitEdiMapsByMapIdTarget"
parameters:
- description: "Positive record identifier."
in: "path"
@@ -477,25 +572,25 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid request or sample exceeds 5 MiB"
401:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Missing or invisible record"
200:
content:
@@ -507,17 +602,39 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Host HTTP request size limit exceeded"
+ 409:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Conflict with the current state, e.g. an edit conflict or\
+ \ an invalid transition (code CONFLICT)."
+ 429:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Read target metadata and map rules"
+ tags:
+ - "edi"
/qqq/v1/qbit/edi/sandbox/preview:
post:
description: "Preview an encrypted sample with optional unsaved rules. Uses\
\ the caller session and table read permissions. Preview also requires edi.map.preview.\
\ Rule writes use core table CRUD."
+ operationId: "postQbitEdiSandboxPreview"
requestBody:
content:
application/json:
@@ -529,25 +646,25 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid request or sample exceeds 5 MiB"
401:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Missing or invisible record"
200:
content:
@@ -559,17 +676,39 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Host HTTP request size limit exceeded"
+ 409:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Conflict with the current state, e.g. an edit conflict or\
+ \ an invalid transition (code CONFLICT)."
+ 429:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Preview an encrypted sample with optional unsaved rules"
+ tags:
+ - "edi"
/qqq/v1/qbit/edi/maps/{mapId}/validate:
post:
description: "Validate unsaved map rules. Uses the caller session and table\
\ read permissions. Preview also requires edi.map.preview. Rule writes use\
\ core table CRUD."
+ operationId: "postQbitEdiMapsByMapIdValidate"
parameters:
- description: "Positive record identifier."
in: "path"
@@ -588,25 +727,25 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid request or sample exceeds 5 MiB"
401:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Missing or invisible record"
200:
content:
@@ -618,9 +757,36 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/EdiErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Host HTTP request size limit exceeded"
+ 409:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Conflict with the current state, e.g. an edit conflict or\
+ \ an invalid transition (code CONFLICT)."
+ 429:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Validate unsaved map rules"
+ tags:
+ - "edi"
+servers:
+- description: "The QQQ server that serves this document (paths include /qqq/v1)."
+ url: "/"
+tags:
+- description: "Operations of the edi extension."
+ name: "edi"
diff --git a/src/test/java/com/kingsrook/qbits/edi/EdiApiContractTest.java b/src/test/java/com/kingsrook/qbits/edi/EdiApiContractTest.java
index 27b339e..df90d57 100644
--- a/src/test/java/com/kingsrook/qbits/edi/EdiApiContractTest.java
+++ b/src/test/java/com/kingsrook/qbits/edi/EdiApiContractTest.java
@@ -35,7 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class EdiApiContractTest
{
/*******************************************************************************
- ** Use RC.6's real helper, including compatibility comparison against this first baseline.
+ ** Check the generated contract and compatibility against the committed baseline.
*******************************************************************************/
@Test
void contract() throws Exception
@@ -53,15 +53,15 @@ class EdiApiContractTest
assertThat(UiApiContract.check(generated, committed, committed, 1,
"mvnw.cmd -B -Dtest=EdiApiContractTest -Dedi.openapi.update=true test")).isEmpty();
var model = version.generateExtensionOpenAPIModel("qqq", extension);
- assertThat(model.getComponents().getSchemas().keySet()).allMatch(n -> n.startsWith("Edi") && n.endsWith("V1"));
+ assertThat(model.getComponents().getSchemas().keySet()).allMatch(n -> (n.startsWith("Edi") && n.endsWith("V1")) || n.equals("BasicErrorResponseV1"));
assertThat(model.getPaths()).hasSize(4);
assertThat(model.getPaths().keySet()).allMatch(n -> n.startsWith("/qqq/v1/qbit/edi/"));
assertThat(extension.getEffectivePathPrefix()).isEqualTo("/qbit/edi");
- assertThat(extension.getSemanticVersion()).isEqualTo("1.0.0");
+ assertThat(extension.getSemanticVersion()).isEqualTo("1.1.0");
}
/*******************************************************************************
- ** Producer registers the extension and all metadata validates with RC.6.
+ ** Producer registers the extension and all metadata validates with core middleware.
*******************************************************************************/
@Test
void registration() throws Exception
diff --git a/src/test/java/com/kingsrook/qbits/edi/EdiApiHttpTest.java b/src/test/java/com/kingsrook/qbits/edi/EdiApiHttpTest.java
index 412466c..70b2dbc 100644
--- a/src/test/java/com/kingsrook/qbits/edi/EdiApiHttpTest.java
+++ b/src/test/java/com/kingsrook/qbits/edi/EdiApiHttpTest.java
@@ -169,8 +169,18 @@ class EdiApiHttpTest
private HttpResponse request(String method, String path, Object body, String role) throws Exception
{
String text = body == null ? "" : body instanceof String s ? s : JSON.writeValueAsString(body);
- return (client.send(HttpRequest.newBuilder(URI.create(baseUrl + path)).header("Cookie", "sessionUUID=" + role + suffix)
- .header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofString(text)).build(), HttpResponse.BodyHandlers.ofString()));
+ var response = client.send(HttpRequest.newBuilder(URI.create(baseUrl + path)).header("Cookie", "sessionUUID=" + role + suffix)
+ .header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofString(text)).build(), HttpResponse.BodyHandlers.ofString());
+ if(response.statusCode() >= 400)
+ {
+ JsonNode error = JSON.readTree(response.body());
+ for(String field : List.of("error", "code", "message", "traceId"))
+ {
+ assertThat(error.path(field).isTextual()).as(field + " in " + response.body()).isTrue();
+ assertThat(error.path(field).asText()).isNotBlank();
+ }
+ }
+ return (response);
}
/*******************************************************************************