diff --git a/CHANGELOG.md b/CHANGELOG.md index c5bc350..db1c24c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to this project are documented here. The format follows Keep ## [Unreleased] -Requires QQQ 4.1.0-RC.8 or later (central health checks, help bundles and the spec-managed UI API extension contract), and a QQQ 4.1 build with `ClusterCaches` for multi-node deployments (the pom builds against 4.1.0-SNAPSHOT until the next QQQ RC; a release must pin that RC). +Requires QQQ 4.1.0-RC.15 or later (central health checks, help bundles, the spec-managed UI API extension contract, and `ClusterCaches` for multi-node deployments). ### Fixed - Multi-node deployments: a Quick Search config change (an index row, its fields or the settings row, from the admin UI or a config process) now clears the cached config on every node, not only the node that made the change. The runtime's config cache (the config store snapshot and settings, and the per-table enabled flags) is registered with QQQ `ClusterCaches` as `qbit-quick-search.config`, and `QuickSearchRuntime.invalidateConfig()` publishes a cluster invalidation. The 30 s and 60 s TTLs remain as the safety net. `QuickSearchFieldRules`' warn-once table set is registered as node-local. `QuickSearchClusterCachesTest` audits the package for unregistered static caches and covers a remote invalidation. @@ -16,6 +16,7 @@ Requires QQQ 4.1.0-RC.8 or later (central health checks, help bundles and the sp - Real-time indexing failures now increase the table's error count by the number of failed events. Successful basepulls and full reindexes reset the count. Counter update failures are logged without failing the source write. ### Added +- UI API extension quick-search 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 QuickSearchErrorV1 schema. Existing operationIds and success payloads are preserved. - Central health checks `quick_search.opensearch`, `quick_search.backlog` and the dynamic `quick_search.index.` supplier, all SEARCH/DEPENDENCY checks every 60 seconds. Reuses cached diagnostics with deadline-bounded waits and safe count/flag details. Includes registration, state, deadline and credential-sentinel tests. - Host-neutral `help/qbit-quick-search.yaml` bundle, registered through QQQ help bundles, with coverage and no-help-table tests. Replaces the temporary inline help loader; hosts can override entries with a HOST bundle. - Every `quick_search_*` table and the Quick Search Admin app carry help that says what the table is for and how its rows get created (`help/quick-search.yaml`, QQQ help-bundle format, applied by `QuickSearchHelp` as a stand-in for core help bundles; host help is never overridden). `QuickSearchTablesGuardrailTest` fails when a table is unused by runtime code, neither primed nor listed as starting empty, or has no such help; `QuickSearchSeededDefaultsTest` proves priming populates the config tables on a fresh backend, is idempotent and keeps admin edits. diff --git a/README.md b/README.md index 663ce0a..01ea6ec 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ String homeHref = status.homeHref(); ``` UI clients use **`GET /qqq/v1/qbit/quick-search/status`**, operationId -`getQuickSearchStatus`, extension `quick-search` version `1.0.0`. The endpoint returns +`getQuickSearchStatus`, extension `quick-search` version `1.1.0` (requires QQQ `4.1.0-RC.15`, UI API `1.6.0`). The endpoint returns that same immutable value as JSON; it requires the Quick Search app's configured `adminPermissionRules` (default `quick_search_admin.hasAccess`). Permission is checked before every read, including cache hits. No additional source-table permissions are diff --git a/docs/STATUS-API-HOST-MIGRATION.md b/docs/STATUS-API-HOST-MIGRATION.md index 25d8a86..61f9e1f 100644 --- a/docs/STATUS-API-HOST-MIGRATION.md +++ b/docs/STATUS-API-HOST-MIGRATION.md @@ -34,7 +34,7 @@ group has `tableName`, `tableLabel`, `hasMore` and `items` of `recordId`, `label and nullable `subtitle`. See the [API details](../README.md#typeahead-suggestions) for normalization, bounded-batch semantics and empty responses during outages. -Extension: `quick-search`, version `1.0.0`; core dependency: `4.1.0-RC.6`. +Extension: `quick-search`, version `1.1.0`; core dependency: `4.1.0-RC.15` (UI API `1.6.0`). The requested `/quick-search` prefix cannot validate on the supplied RC.6 baseline: `MiddlewareSpecExtensionsMetaData.ESTABLISHED_PREFIXES` only admits esb, alerts, assistant, and sql-workbench. We use the default namespace without modifying core. diff --git a/docs/TODO.md b/docs/TODO.md index e83d20b..b913a2a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,14 @@ # TODO +## UI API 1.6 migration (2026-10-15) + +- [x] Bump `quick-search` extension to 1.1.0, retain deprecated `QuickSearchErrorV1`, and use core `BasicErrorResponseV1` for errors; preserve operation IDs and success payloads. +- [x] Regenerate the two-operation contract with `-Dquicksearch.openapi.update=true`, then pass the contract test without regeneration; no dangling references or separate baseline were found. +- [x] Set the release BOM default to QQQ `4.1.0-RC.15`; validate offline against the locally installed `4.1.99-SNAPSHOT` using `./mvnw.cmd -o test -Dqqq.snapshot.version=4.1.99-SNAPSHOT -Denforcer.skipRules=requireReleaseDeps` (540 tests, no failures/errors/skips). The command-line exception is needed only because the requested local core is a SNAPSHOT; other guardrails remain enabled. +- [x] Verify strengthened HTTP error-body assertions: 8 status/HTTP tests passed after the full suite. +- [ ] Run Docker integration tests through `verify` in CI; this migration ran the requested `test` lifecycle. + + ## In-app help bundle (2026-10-14) - [x] Pin QQQ RC.7, register the host-neutral QBIT bundle, replace the temporary loader, document HOST overrides, and add coverage/no-help-table checks. diff --git a/pom.xml b/pom.xml index be02741..efc34ce 100644 --- a/pom.xml +++ b/pom.xml @@ -283,7 +283,7 @@ true - 4.1.0-RC.14 + 4.1.0-RC.15 diff --git a/src/main/java/com/kingsrook/qbits/quicksearch/api/QuickSearchApi.java b/src/main/java/com/kingsrook/qbits/quicksearch/api/QuickSearchApi.java index 98c0909..7a9cd9c 100644 --- a/src/main/java/com/kingsrook/qbits/quicksearch/api/QuickSearchApi.java +++ b/src/main/java/com/kingsrook/qbits/quicksearch/api/QuickSearchApi.java @@ -48,7 +48,7 @@ public final class QuickSearchApi *******************************************************************************/ public static MiddlewareSpecExtension extension() { - return (new MiddlewareSpecExtension().withName("quick-search").withSemanticVersion("1.0.0") + return (new MiddlewareSpecExtension().withName("quick-search").withSemanticVersion("1.1.0") .withDescription("Quick Search administration and suggestions").withSpec(QuickSearchStatusSpec.class).withSpec(QuickSearchSuggestSpec.class)); } } diff --git a/src/main/java/com/kingsrook/qbits/quicksearch/api/QuickSearchStatusSpec.java b/src/main/java/com/kingsrook/qbits/quicksearch/api/QuickSearchStatusSpec.java index 1872c8a..a0b4723 100644 --- a/src/main/java/com/kingsrook/qbits/quicksearch/api/QuickSearchStatusSpec.java +++ b/src/main/java/com/kingsrook/qbits/quicksearch/api/QuickSearchStatusSpec.java @@ -27,6 +27,7 @@ import java.util.TreeMap; import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation; import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse; import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec; +import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1; import com.kingsrook.qqq.openapi.model.HttpMethod; import com.kingsrook.qqq.openapi.model.Method; import com.kingsrook.qqq.openapi.model.Parameter; @@ -103,9 +104,9 @@ public class QuickSearchStatusSpec extends AbstractJsonEndpointSpec @Override public List defineAdditionalBasicResponses() { - return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Diagnostic rate limit exceeded", "QuickSearchErrorV1"), - new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "QuickSearchErrorV1"), - new BasicResponse(HttpStatus.FORBIDDEN, "Quick Search admin permission required", "QuickSearchErrorV1"))); + return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Diagnostic rate limit exceeded", BasicErrorResponseV1.class.getSimpleName()), + new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()), + new BasicResponse(HttpStatus.FORBIDDEN, "Quick Search admin permission required", BasicErrorResponseV1.class.getSimpleName()))); } @@ -147,7 +148,8 @@ public class QuickSearchStatusSpec extends AbstractJsonEndpointSpec row.put("lastError", field(Type.STRING, "Persisted indexing error; null when absent.", true)); row.put("realTimeErrorCount", field(Type.INTEGER, "Persisted real-time indexing error count.", true)); return (Map.of("QuickSearchStatusV1", object(status), "QuickSearchIndexStatusV1", object(row), - "QuickSearchErrorV1", object(Map.of("error", field(Type.STRING, "Framework error message.", false))))); + "QuickSearchErrorV1", object(Map.of("error", field(Type.STRING, "Framework error message.", false))) + .withDescription("Deprecated since 1.1.0: errors use BasicErrorResponseV1."))); } diff --git a/src/main/java/com/kingsrook/qbits/quicksearch/api/QuickSearchSuggestSpec.java b/src/main/java/com/kingsrook/qbits/quicksearch/api/QuickSearchSuggestSpec.java index 0c52f4a..be0d087 100644 --- a/src/main/java/com/kingsrook/qbits/quicksearch/api/QuickSearchSuggestSpec.java +++ b/src/main/java/com/kingsrook/qbits/quicksearch/api/QuickSearchSuggestSpec.java @@ -27,6 +27,7 @@ import java.util.TreeMap; import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation; import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse; import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec; +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.Method; @@ -115,9 +116,9 @@ public class QuickSearchSuggestSpec extends AbstractJsonEndpointSpec @Override public List defineAdditionalBasicResponses() { - return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Missing q, normalized term longer than 100 characters, or invalid limits", "QuickSearchErrorV1"), - new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "QuickSearchErrorV1"), - new BasicResponse(HttpStatus.FORBIDDEN, "Session permission denied", "QuickSearchErrorV1"))); + return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Missing q, normalized term longer than 100 characters, or invalid limits", BasicErrorResponseV1.class.getSimpleName()), + new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()), + new BasicResponse(HttpStatus.FORBIDDEN, "Session permission denied", BasicErrorResponseV1.class.getSimpleName()))); } diff --git a/src/main/resources/openapi/qqq-v1/quick-search.yaml b/src/main/resources/openapi/qqq-v1/quick-search.yaml index 5a867e1..460cd48 100644 --- a/src/main/resources/openapi/qqq-v1/quick-search.yaml +++ b/src/main/resources/openapi/qqq-v1/quick-search.yaml @@ -1,6 +1,46 @@ 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" QuickSearchErrorV1: + description: "Deprecated since 1.1.0: errors use BasicErrorResponseV1." properties: error: description: "Framework error message." @@ -194,15 +234,48 @@ 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: "Quick Search administration and suggestions" title: "QQQ Middleware API extension: quick-search" - version: "1.0.0" + version: "1.1.0" openapi: "3.0.3" paths: /qqq/v1/qbit/quick-search/status: @@ -217,19 +290,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/QuickSearchErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Diagnostic rate limit exceeded" 401: content: application/json: schema: - $ref: "#/components/schemas/QuickSearchErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Sign in required" 403: content: application/json: schema: - $ref: "#/components/schemas/QuickSearchErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Quick Search admin permission required" 200: content: @@ -237,10 +310,37 @@ paths: schema: $ref: "#/components/schemas/QuickSearchStatusV1" description: "Successful Response" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Not found, or not visible to this user (code NOT_FOUND)." + 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: "Get Quick Search status" + tags: + - "quick-search" /qqq/v1/qbit/quick-search/suggest: get: description: "Requires a signed-in session, not Quick Search admin permission.\ @@ -290,20 +390,20 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/QuickSearchErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Missing q, normalized term longer than 100 characters, or\ \ invalid limits" 401: content: application/json: schema: - $ref: "#/components/schemas/QuickSearchErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Sign in required" 403: content: application/json: schema: - $ref: "#/components/schemas/QuickSearchErrorV1" + $ref: "#/components/schemas/BasicErrorResponseV1" description: "Session permission denied" 200: content: @@ -311,7 +411,40 @@ paths: schema: $ref: "#/components/schemas/QuickSearchSuggestV1" description: "Successful Response" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/BasicErrorResponseV1" + description: "Not found, or not visible to this user (code NOT_FOUND)." + 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: "Get Quick Search suggestions" + tags: + - "quick-search" +servers: +- description: "The QQQ server that serves this document (paths include /qqq/v1)." + url: "/" +tags: +- description: "Operations of the quick-search extension." + name: "quick-search" diff --git a/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchApiContractTest.java b/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchApiContractTest.java index b2c8908..15152ec 100644 --- a/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchApiContractTest.java +++ b/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchApiContractTest.java @@ -53,11 +53,14 @@ class QuickSearchApiContractTest assertThat(UiApiContract.check(generated, Files.readString(path), null, 1, "mvnw.cmd -Dtest=QuickSearchApiContractTest -Dquicksearch.openapi.update=true test")).isEmpty(); var model = version.generateExtensionOpenAPIModel("qqq", extension); - assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("QuickSearch")); + assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("QuickSearch") || name.equals("BasicErrorResponseV1")); assertThat(model.getPaths().keySet()).containsExactlyInAnyOrder("/qqq/v1/qbit/quick-search/status", "/qqq/v1/qbit/quick-search/suggest"); assertThat(extension.getSpecs()).hasSize(2); assertThat(generated).contains("getQuickSearchSuggestions", "QuickSearchSuggestV1", "QuickSearchSuggestGroupV1", "QuickSearchSuggestItemV1"); var schemas = model.getComponents().getSchemas(); + assertThat(schemas.get("BasicErrorResponseV1").getProperties()).containsOnlyKeys("error", "code", "message", "fieldErrors", "traceId"); + assertThat(schemas.get("QuickSearchErrorV1").getDescription()).isEqualTo("Deprecated since 1.1.0: errors use BasicErrorResponseV1."); + assertThat(generated).contains("getQuickSearchStatus").doesNotContain("#/components/schemas/QuickSearchErrorV1"); assertThat(schemas.get("QuickSearchSuggestV1").getProperties()).containsOnlyKeys("query", "groups", "truncated"); assertThat(schemas.get("QuickSearchSuggestGroupV1").getProperties()).containsOnlyKeys("tableName", "tableLabel", "hasMore", "items"); assertThat(schemas.get("QuickSearchSuggestItemV1").getProperties()).containsOnlyKeys("recordId", "label", "subtitle"); diff --git a/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchStatusTest.java b/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchStatusTest.java index 6f71e3a..45b6c7b 100644 --- a/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchStatusTest.java +++ b/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchStatusTest.java @@ -362,6 +362,14 @@ class QuickSearchStatusTest assertThat(anonymous.statusCode()).as(anonymous.body()).isEqualTo(403); var denied = client.send(HttpRequest.newBuilder(URI.create(url)).header("Cookie", "sessionUUID=reader-" + suffix).GET().build(), HttpResponse.BodyHandlers.ofString()); assertThat(denied.statusCode()).as(denied.body()).isEqualTo(403); + for(var forbidden : List.of(anonymous, denied)) + { + var error = new ObjectMapper().readTree(forbidden.body()); + assertThat(error.path("error").asText()).isNotBlank(); + assertThat(error.path("message")).isEqualTo(error.path("error")); + assertThat(error.path("code").asText()).isEqualTo("PERMISSION_DENIED"); + assertThat(error.path("traceId").asText()).isNotBlank(); + } String suggestUrl = "http://127.0.0.1:" + port + "/qqq/v1/qbit/quick-search/suggest"; for(String query : List.of("?q=x", "?q=%20x%20", "?q=wi&tables=unknown", "?q=wi&tables=", "?q=wi")) { @@ -378,7 +386,11 @@ class QuickSearchStatusTest var invalid = client.send(HttpRequest.newBuilder(URI.create(suggestUrl + query)) .header("Cookie", "sessionUUID=reader-" + suffix).GET().build(), HttpResponse.BodyHandlers.ofString()); assertThat(invalid.statusCode()).as(invalid.body()).isEqualTo(400); - assertThat(new ObjectMapper().readTree(invalid.body()).path("error").isTextual()).isTrue(); + var error = new ObjectMapper().readTree(invalid.body()); + assertThat(error.path("error").asText()).isNotBlank(); + assertThat(error.path("message")).isEqualTo(error.path("error")); + assertThat(error.path("code").asText()).isEqualTo("BAD_REQUEST"); + assertThat(error.path("traceId").asText()).isNotBlank(); } var openapi = client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/qqq/v1/openapi.yaml")).GET().build(), HttpResponse.BodyHandlers.ofString()); assertThat(openapi.body()).contains("/qqq/v1/qbit/quick-search/status", "QuickSearchStatusV1",