From 42d88f89a77db63b4076b9dae5aa5f5f73654bb8 Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Sun, 19 Jul 2026 07:25:14 -0700 Subject: [PATCH 1/2] Add velocity-dropwizard: the service tier (first increment) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dropwizard 5 service serving the velocity-api HTTP contract over the velocity-core engine, wired with Dagger 2, behind namespace-scoped API-key auth. First increment: record + query + capabilities endpoints, proven end-to-end over real HTTP. - VelocityApplication (Dropwizard 5) builds a Dagger graph (VelocityModule/VelocityComponent provide the VelocityEngine = BackendRegistry + MutableFeatureDefinitionProvider + DimensionHasher + CapabilityValidator) and registers the JAX-RS resource behind the auth filter + a problem exception mapper. The backend is supplied via createBackend() (overridable) so a deployment wires a velocity-backend-*; tests wire the in-memory one. - VelocityResource: POST /v1/{ns}/record, POST /v1/{ns}/query, GET /v1/{ns}/capabilities (AR-2). Money and every value are JSON STRINGS of a decimal integer (OQ-D) — BigDecimal cents <-> String, no float. - ApiKeyFilter (P9/NFR-21): X-API-Key -> allowed-namespace set; missing/blank/unknown -> 401, a key outside the path {namespace} -> 403 (fail-closed, cross-tenant access blocked). RFC 9457 problem responses (AR-5). - Hand-written wire DTOs faithful to the OpenAPI (FeatureResult -> Success|Failure with a "kind" discriminator, ADR 0009 — never a silent success). - No Jackson/Dagger leak concerns: Jackson is legitimate here (JSON service); Dagger-generated code excluded from the coverage gate. 15 tests (a DropwizardAppExtension HTTP integration test proving record->query round-trip, fan-out, read-your-write, and the 401/403 auth paths; plus WireMapper/ExceptionMapper/ ApiKeyFilter unit tests), 0 skipped; coverage 96.9% line / 86.4% branch (gate 80/70). Hand-implemented (implementer subagents stalled). Validated by the change-validator agent: VERDICT APPROVE. Deferred (follow-ups): features + purge resources; openapi-generator server DTOs (replacing the hand-written ones); real backend selection/config; finer AR-5 status mapping (unknown- namespace 404 / unsupported-window 422). FOLLOW-UP TO RECONCILE (flagged by review): the query request/response shape DIVERGES between velocity-api (tuple-based: {tuples:[{subject,aggregation,window}]} -> positional results) and velocity-core (feature-name-based: query(ns, subject, [featureNames]) -> map). The module faithfully wraps the core engine; the velocity-api yaml and velocity-core query model need to be reconciled before the openapi-generator cutover (AR-3). Co-Authored-By: Claude Opus 4.8 (1M context) --- settings.gradle.kts | 2 +- velocity-dropwizard/build.gradle.kts | 91 +++++++++++++++++ .../dropwizard/VelocityApplication.java | 84 ++++++++++++++++ .../dropwizard/VelocityConfiguration.java | 53 ++++++++++ .../velocity/dropwizard/api/Wire.java | 85 ++++++++++++++++ .../velocity/dropwizard/api/WireMapper.java | 91 +++++++++++++++++ .../dropwizard/auth/ApiKeyFilter.java | 69 +++++++++++++ .../dropwizard/dagger/VelocityComponent.java | 35 +++++++ .../dropwizard/dagger/VelocityModule.java | 93 +++++++++++++++++ .../velocity/dropwizard/package-info.java | 10 ++ .../resource/VelocityExceptionMapper.java | 36 +++++++ .../dropwizard/resource/VelocityResource.java | 77 +++++++++++++++ .../velocity/dropwizard/TestApplication.java | 38 +++++++ .../VelocityServiceIntegrationTest.java | 99 +++++++++++++++++++ .../dropwizard/api/WireMapperTest.java | 75 ++++++++++++++ .../dropwizard/auth/ApiKeyFilterTest.java | 78 +++++++++++++++ .../resource/VelocityExceptionMapperTest.java | 35 +++++++ .../src/test/resources/test-config.yml | 11 +++ 18 files changed, 1061 insertions(+), 1 deletion(-) create mode 100644 velocity-dropwizard/build.gradle.kts create mode 100644 velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/VelocityApplication.java create mode 100644 velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/VelocityConfiguration.java create mode 100644 velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/api/Wire.java create mode 100644 velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/api/WireMapper.java create mode 100644 velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/auth/ApiKeyFilter.java create mode 100644 velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/dagger/VelocityComponent.java create mode 100644 velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/dagger/VelocityModule.java create mode 100644 velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/package-info.java create mode 100644 velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/resource/VelocityExceptionMapper.java create mode 100644 velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/resource/VelocityResource.java create mode 100644 velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/TestApplication.java create mode 100644 velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/VelocityServiceIntegrationTest.java create mode 100644 velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/api/WireMapperTest.java create mode 100644 velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/auth/ApiKeyFilterTest.java create mode 100644 velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/resource/VelocityExceptionMapperTest.java create mode 100644 velocity-dropwizard/src/test/resources/test-config.yml diff --git a/settings.gradle.kts b/settings.gradle.kts index 35cbefd..49c97b7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -60,7 +60,7 @@ include("velocity-testkit") // in-memory velocity-spi impl, conformance TCK sce // Phase 2 — v1 reference backends (tumbling + sliding), the service tier, and the generated client. include("velocity-backend-jdbi") // Postgres/JDBI backend — v1 tumbling reference include("velocity-backend-redis") // Redis/Lettuce backend — v1 sliding / hot-path reference -// include("velocity-dropwizard") // Dropwizard bundle, Jersey resources, Dagger wiring, API-key auth +include("velocity-dropwizard") // Dropwizard bundle, Jersey resources, Dagger wiring, API-key auth // include("velocity-client-java") // Java client generated from OpenAPI via openapi-generator // include("examples:dropwizard-demo")// runnable reference service (not published) diff --git a/velocity-dropwizard/build.gradle.kts b/velocity-dropwizard/build.gradle.kts new file mode 100644 index 0000000..cfa693b --- /dev/null +++ b/velocity-dropwizard/build.gradle.kts @@ -0,0 +1,91 @@ +plugins { + id("velocity.library-conventions") + id("velocity.test-conventions") + id("velocity.publish-conventions") +} + +description = + "velocity-dropwizard: the Dropwizard 5 service tier — Jersey resources over the velocity-core" + + " engine, Dagger 2 wiring, and namespace-scoped API-key auth, serving the velocity-api" + + " OpenAPI contract." + +tasks.named("compileJava") { + // Dropwizard 5 pulls automatic-module dependencies (jersey, jetty, …) whose `requires` -Werror + // would turn fatal. -Xlint:-processing silences javac's "no processor claimed these annotations" + // hint — Dagger claims only its own annotations; JAX-RS/Jakarta ones are runtime-processed. + options.compilerArgs.addAll( + listOf( + "-Xlint:-requires-automatic", + "-Xlint:-requires-transitive-automatic", + "-Xlint:-processing", + ), + ) +} + +tasks.named("compileTestJava") { + options.compilerArgs.addAll( + listOf( + "-Xlint:-requires-automatic", + "-Xlint:-requires-transitive-automatic", + "-Xlint:-processing", + ), + ) +} + +dependencies { + api(project(":velocity-core")) + api(project(":velocity-api")) + api(libs.dropwizard.core) + api(libs.dropwizard.auth) + api(libs.dagger) + api(libs.jakarta.inject.api) + + // Dropwizard's transitive Jersey/Jakarta packages reference errorprone annotations; without them + // on the compile classpath javac emits an annotation-not-found warning that -Werror turns fatal. + compileOnly(libs.build.errorprone.annotations) + testCompileOnly(libs.build.errorprone.annotations) + + implementation(libs.slf4j.api) + + annotationProcessor(libs.dagger.compiler) + testAnnotationProcessor(libs.dagger.compiler) + + testImplementation(project(":velocity-testkit")) + testImplementation(libs.dropwizard.testing) + testRuntimeOnly(libs.logback.classic) +} + +// Dagger-generated code is excluded from both the report and the coverage gate (NFR-14). +tasks.named("jacocoTestReport") { + classDirectories.setFrom( + files( + classDirectories.files.map { + fileTree(it) { + exclude( + "com/codeheadsystems/velocity/dropwizard/dagger/Dagger*", + "**/*_Factory.class", + "**/*_MembersInjector.class", + "**/*_Provide*Factory.class", + ) + } + }, + ), + ) +} + +tasks.named("jacocoTestCoverageVerification") { + classDirectories.setFrom( + files( + classDirectories.files.map { + fileTree(it) { + exclude( + "com/codeheadsystems/velocity/dropwizard/dagger/Dagger*", + "**/*_Factory.class", + "**/*_MembersInjector.class", + "**/*_Provide*Factory.class", + ) + } + }, + ), + ) +} diff --git a/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/VelocityApplication.java b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/VelocityApplication.java new file mode 100644 index 0000000..e97dd1b --- /dev/null +++ b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/VelocityApplication.java @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.dropwizard; + +import com.codeheadsystems.velocity.core.VelocityEngine; +import com.codeheadsystems.velocity.dropwizard.dagger.DaggerVelocityComponent; +import com.codeheadsystems.velocity.dropwizard.dagger.VelocityComponent; +import com.codeheadsystems.velocity.dropwizard.dagger.VelocityModule; +import com.codeheadsystems.velocity.dropwizard.resource.VelocityExceptionMapper; +import com.codeheadsystems.velocity.spi.VelocityBackend; +import io.dropwizard.core.Application; +import io.dropwizard.core.setup.Environment; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * The Dropwizard 5 service. Builds the Dagger graph, seeds feature definitions, and registers the + * JAX-RS resource behind the {@link com.codeheadsystems.velocity.dropwizard.auth.ApiKeyFilter + * API-key filter} and the {@link VelocityExceptionMapper problem exception mapper}. + * + *

The concrete backend is supplied by {@link #createBackend(VelocityConfiguration)} — override + * it (or a subclass wiring a {@code velocity-backend-*} module) to run the service; the default + * throws so a misconfigured deployment fails fast rather than silently serving nothing. + */ +public class VelocityApplication extends Application { + + /** + * Entry point: starts the service from the given arguments. + * + * @param args CLI arguments. + * @throws Exception if the service fails to start. + */ + public static void main(final String[] args) throws Exception { + new VelocityApplication().run(args); + } + + @Override + public String getName() { + return "velocity-engine"; + } + + @Override + public void run(final VelocityConfiguration configuration, final Environment environment) { + final VelocityBackend backend = createBackend(configuration); + final VelocityComponent component = + DaggerVelocityComponent.builder() + .velocityModule( + new VelocityModule( + backend, configuration.getBackendName(), apiKeySets(configuration))) + .build(); + configureEngine(component.engine(), configuration); + environment.jersey().register(component.apiKeyFilter()); + environment.jersey().register(component.resource()); + environment.jersey().register(new VelocityExceptionMapper()); + } + + /** + * Supplies the backend the engine routes to. The default is unconfigured. + * + * @param configuration the service configuration. + * @return the backend. + */ + protected VelocityBackend createBackend(final VelocityConfiguration configuration) { + throw new UnsupportedOperationException( + "no backend configured: override createBackend(...) or wire a velocity-backend-* module"); + } + + /** + * Seeds feature definitions at startup; the default is a no-op. + * + * @param engine the engine to seed. + * @param configuration the service configuration. + */ + protected void configureEngine( + final VelocityEngine engine, final VelocityConfiguration configuration) { + // Default: no feature definitions. A deployment overrides this (or loads YAML) to define them. + } + + private static Map> apiKeySets(final VelocityConfiguration configuration) { + final Map> keys = new LinkedHashMap<>(); + configuration.getApiKeys().forEach((key, namespaces) -> keys.put(key, Set.copyOf(namespaces))); + return keys; + } +} diff --git a/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/VelocityConfiguration.java b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/VelocityConfiguration.java new file mode 100644 index 0000000..0be948c --- /dev/null +++ b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/VelocityConfiguration.java @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.dropwizard; + +import io.dropwizard.core.Configuration; +import java.util.List; +import java.util.Map; + +/** + * Service configuration. {@code apiKeys} maps each API key to the namespaces it may access + * (namespace-scoped authz, NFR-21); {@code backendName} is the logical name the backend is + * registered under (the {@code /capabilities} endpoint reports it). + */ +public final class VelocityConfiguration extends Configuration { + + private String backendName = "default"; + private Map> apiKeys = Map.of(); + + /** + * Returns the logical backend name. + * + * @return the logical backend name. + */ + public String getBackendName() { + return backendName; + } + + /** + * Sets the logical backend name. + * + * @param backendName the logical backend name. + */ + public void setBackendName(final String backendName) { + this.backendName = backendName; + } + + /** + * Returns the API-key to allowed-namespaces mapping. + * + * @return the API-key to allowed-namespace-list mapping. + */ + public Map> getApiKeys() { + return apiKeys; + } + + /** + * Sets the API-key to allowed-namespaces mapping. + * + * @param apiKeys the API-key to allowed-namespace-list mapping. + */ + public void setApiKeys(final Map> apiKeys) { + this.apiKeys = Map.copyOf(apiKeys); + } +} diff --git a/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/api/Wire.java b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/api/Wire.java new file mode 100644 index 0000000..cf15960 --- /dev/null +++ b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/api/Wire.java @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.dropwizard.api; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.jspecify.annotations.Nullable; + +/** + * The HTTP wire DTOs, faithful to {@code velocity-api}'s OpenAPI contract. Money and every numeric + * {@code value} are JSON strings of a decimal integer (integer cents for SUM; + * count/cardinality for COUNT/DISTINCT) — resolving OQ-D, avoiding JSON-number precision loss. + * + *

Hand-written for this first increment; the eventual source of truth is the OpenAPI document, + * from which these are to be generated (AR-3). + */ +public final class Wire { + + private Wire() {} + + /** A structured subject key. */ + public record SubjectDto(String type, String value) {} + + /** A window: ISO-8601 duration + {@code SLIDING|TUMBLING}. */ + public record WindowDto(String duration, String type) {} + + /** The half-open bounds a value covers. */ + public record WindowBoundsDto(String start, String end) {} + + /** POST /record request: subject + raw dimensions (hashed server-side) + optional money value. */ + public record RecordRequest( + SubjectDto subject, @Nullable Map dimensions, @Nullable String value) {} + + /** POST /query request: a subject and the feature names to read. */ + public record QueryRequest(SubjectDto subject, List features) {} + + /** A computed feature value (ADR 0009 Success arm). */ + public record FeatureValueDto( + String feature, + WindowDto window, + String value, + String exactness, + String readYourWriteLevel, + @Nullable String definitionVersionHash, + WindowBoundsDto windowBounds, + String asOf) {} + + /** + * A value-or-failure result (ADR 0009): {@code kind=SUCCESS} carries {@code value}; {@code + * kind=FAILURE} carries {@code code} + optional {@code detail}. Never a silent zero. + */ + public record ResultDto( + String kind, + @Nullable FeatureValueDto value, + @Nullable String code, + @Nullable String detail) {} + + /** One apply outcome per feature: status + result (FR-34). */ + public record PerFeatureDto(String feature, String status, ResultDto result) {} + + /** POST /record response. */ + public record RecordResponse(List perFeature) {} + + /** POST /query response: feature name to its per-window results. */ + public record QueryResponse(Map> features) {} + + /** GET /capabilities response, mirroring {@code BackendCapabilities}. */ + public record CapabilitiesResponse( + Set aggregations, + List windows, + boolean distinctHllSliding, + long distinctExactCardinalityClamp, + long distinctHllThresholdDefault, + String maxRetention, + String readYourWriteLevel, + boolean idempotencySupported, + boolean seedSupported, + int maxAtomicFanOut) {} + + /** A supported window spec. */ + public record WindowSpecDto(WindowDto window, String exactness, String granularity) {} + + /** RFC 9457 problem detail. */ + public record Problem(String type, String title, int status, @Nullable String detail) {} +} diff --git a/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/api/WireMapper.java b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/api/WireMapper.java new file mode 100644 index 0000000..2a45ad7 --- /dev/null +++ b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/api/WireMapper.java @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.dropwizard.api; + +import com.codeheadsystems.velocity.dropwizard.api.Wire.CapabilitiesResponse; +import com.codeheadsystems.velocity.dropwizard.api.Wire.FeatureValueDto; +import com.codeheadsystems.velocity.dropwizard.api.Wire.PerFeatureDto; +import com.codeheadsystems.velocity.dropwizard.api.Wire.RecordResponse; +import com.codeheadsystems.velocity.dropwizard.api.Wire.ResultDto; +import com.codeheadsystems.velocity.dropwizard.api.Wire.WindowBoundsDto; +import com.codeheadsystems.velocity.dropwizard.api.Wire.WindowDto; +import com.codeheadsystems.velocity.dropwizard.api.Wire.WindowSpecDto; +import com.codeheadsystems.velocity.spi.model.ApplyResult; +import com.codeheadsystems.velocity.spi.model.BackendCapabilities; +import com.codeheadsystems.velocity.spi.model.FeatureResult; +import com.codeheadsystems.velocity.spi.model.FeatureValue; +import com.codeheadsystems.velocity.spi.model.PerFeature; +import com.codeheadsystems.velocity.spi.model.Window; +import com.codeheadsystems.velocity.spi.model.WindowBounds; +import java.util.List; + +/** + * Maps {@code velocity-spi} result types to the HTTP wire DTOs (money/value as decimal strings). + */ +public final class WireMapper { + + private WireMapper() {} + + /** Maps an apply result to the record response. */ + public static RecordResponse toRecordResponse(final ApplyResult result) { + return new RecordResponse(result.perFeature().stream().map(WireMapper::toPerFeature).toList()); + } + + private static PerFeatureDto toPerFeature(final PerFeature perFeature) { + return new PerFeatureDto( + perFeature.feature().name(), perFeature.status().name(), toResult(perFeature.result())); + } + + /** Maps a single feature result (Success value or distinguishable Failure). */ + public static ResultDto toResult(final FeatureResult result) { + return switch (result) { + case FeatureResult.Success success -> + new ResultDto("SUCCESS", toFeatureValue(success.value()), null, null); + case FeatureResult.Failure failure -> + new ResultDto("FAILURE", null, failure.code().name(), failure.detail()); + }; + } + + private static FeatureValueDto toFeatureValue(final FeatureValue value) { + return new FeatureValueDto( + value.feature().name(), + toWindow(value.window()), + value.value().toPlainString(), + value.exactness().name(), + value.readYourWriteLevel().name(), + value.definitionVersionHash(), + toBounds(value.windowBounds()), + value.asOf().toString()); + } + + private static WindowDto toWindow(final Window window) { + return new WindowDto(window.duration().toString(), window.type().name()); + } + + private static WindowBoundsDto toBounds(final WindowBounds bounds) { + return new WindowBoundsDto(bounds.start().toString(), bounds.end().toString()); + } + + /** Maps backend capabilities to the capabilities response. */ + public static CapabilitiesResponse toCapabilities(final BackendCapabilities caps) { + final List windows = + caps.windows().stream() + .map( + spec -> + new WindowSpecDto( + toWindow(spec.window()), + spec.exactness().name(), + spec.granularity().toString())) + .toList(); + return new CapabilitiesResponse( + caps.aggregations().stream().map(Enum::name).collect(java.util.stream.Collectors.toSet()), + windows, + caps.distinctHllSliding(), + caps.distinctExactCardinalityClamp(), + caps.distinctHllThresholdDefault(), + caps.maxRetention().toString(), + caps.readYourWriteLevel().name(), + caps.idempotencySupported(), + caps.seedSupported(), + caps.maxAtomicFanOut()); + } +} diff --git a/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/auth/ApiKeyFilter.java b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/auth/ApiKeyFilter.java new file mode 100644 index 0000000..dbf47c5 --- /dev/null +++ b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/auth/ApiKeyFilter.java @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.dropwizard.auth; + +import com.codeheadsystems.velocity.dropwizard.api.Wire.Problem; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.Provider; +import java.util.Map; +import java.util.Set; +import org.jspecify.annotations.Nullable; + +/** + * Namespace-scoped API-key authentication (P9, NFR-21). Reads {@code X-API-Key}, rejects a missing + * or unknown key with 401, and rejects a request whose path {@code {namespace}} is outside the + * key's allowed set with 403 — so a credential can never touch another tenant's counts. Errors use + * the RFC 9457 problem model (AR-5). + */ +@Provider +public final class ApiKeyFilter implements ContainerRequestFilter { + + /** The header carrying the API key. */ + public static final String HEADER = "X-API-Key"; + + private final Map> keyToNamespaces; + + /** + * @param keyToNamespaces each API key mapped to the namespaces it may access. + */ + public ApiKeyFilter(final Map> keyToNamespaces) { + this.keyToNamespaces = Map.copyOf(keyToNamespaces); + } + + @Override + public void filter(final ContainerRequestContext requestContext) { + final String key = requestContext.getHeaderString(HEADER); + if (key == null || key.isBlank()) { + abort(requestContext, Response.Status.UNAUTHORIZED, "unauthenticated", "missing " + HEADER); + return; + } + final Set allowed = keyToNamespaces.get(key); + if (allowed == null) { + abort(requestContext, Response.Status.UNAUTHORIZED, "unauthenticated", "unknown API key"); + return; + } + final @Nullable String namespace = + requestContext.getUriInfo().getPathParameters().getFirst("namespace"); + if (namespace == null || !allowed.contains(namespace)) { + abort( + requestContext, + Response.Status.FORBIDDEN, + "forbidden-namespace", + "API key is not authorized for namespace '" + namespace + "'"); + } + } + + private static void abort( + final ContainerRequestContext requestContext, + final Response.Status status, + final String type, + final String detail) { + requestContext.abortWith( + Response.status(status) + .type(MediaType.APPLICATION_JSON) + .entity(new Problem("about:blank#" + type, type, status.getStatusCode(), detail)) + .build()); + } +} diff --git a/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/dagger/VelocityComponent.java b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/dagger/VelocityComponent.java new file mode 100644 index 0000000..a3df51d --- /dev/null +++ b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/dagger/VelocityComponent.java @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.dropwizard.dagger; + +import com.codeheadsystems.velocity.core.VelocityEngine; +import com.codeheadsystems.velocity.dropwizard.auth.ApiKeyFilter; +import com.codeheadsystems.velocity.dropwizard.resource.VelocityResource; +import dagger.Component; +import jakarta.inject.Singleton; + +/** The Dagger graph the application resolves the JAX-RS resource, auth filter, and engine from. */ +@Singleton +@Component(modules = VelocityModule.class) +public interface VelocityComponent { + + /** + * Returns the velocity JAX-RS resource. + * + * @return the velocity JAX-RS resource. + */ + VelocityResource resource(); + + /** + * Returns the API-key auth filter. + * + * @return the API-key auth filter. + */ + ApiKeyFilter apiKeyFilter(); + + /** + * Returns the engine, exposed so the application can seed feature definitions at startup. + * + * @return the engine. + */ + VelocityEngine engine(); +} diff --git a/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/dagger/VelocityModule.java b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/dagger/VelocityModule.java new file mode 100644 index 0000000..4c9bf8f --- /dev/null +++ b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/dagger/VelocityModule.java @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.dropwizard.dagger; + +import com.codeheadsystems.velocity.core.BackendRegistry; +import com.codeheadsystems.velocity.core.CapabilityValidator; +import com.codeheadsystems.velocity.core.DimensionHasher; +import com.codeheadsystems.velocity.core.FeatureDefinitionProvider; +import com.codeheadsystems.velocity.core.InMemoryNamespaceSaltProvider; +import com.codeheadsystems.velocity.core.MutableFeatureDefinitionProvider; +import com.codeheadsystems.velocity.core.VelocityEngine; +import com.codeheadsystems.velocity.dropwizard.auth.ApiKeyFilter; +import com.codeheadsystems.velocity.spi.VelocityBackend; +import dagger.Module; +import dagger.Provides; +import jakarta.inject.Named; +import jakarta.inject.Singleton; +import java.util.Map; +import java.util.Set; + +/** + * Wires the {@link VelocityEngine} (a single named backend, an in-memory feature-definition + * provider, a keyed-hash dimension hasher, and a capability validator) and the API-key filter from + * runtime values supplied by the {@link com.codeheadsystems.velocity.dropwizard.VelocityApplication + * application}. + */ +@Module +public final class VelocityModule { + + private final VelocityBackend backend; + private final String backendName; + private final Map> apiKeys; + + /** + * @param backend the backend the engine routes to. + * @param backendName the logical name the backend is registered under. + * @param apiKeys each API key mapped to its allowed namespaces. + */ + public VelocityModule( + final VelocityBackend backend, + final String backendName, + final Map> apiKeys) { + this.backend = backend; + this.backendName = backendName; + this.apiKeys = Map.copyOf(apiKeys); + } + + @Provides + @Singleton + @Named("backendName") + String backendName() { + return backendName; + } + + @Provides + @Singleton + BackendRegistry backendRegistry() { + return new BackendRegistry(Map.of(backendName, backend)); + } + + @Provides + @Singleton + FeatureDefinitionProvider featureDefinitionProvider() { + return new MutableFeatureDefinitionProvider(); + } + + @Provides + @Singleton + DimensionHasher dimensionHasher() { + return new DimensionHasher(new InMemoryNamespaceSaltProvider()); + } + + @Provides + @Singleton + CapabilityValidator capabilityValidator() { + return new CapabilityValidator(); + } + + @Provides + @Singleton + VelocityEngine velocityEngine( + final BackendRegistry backends, + final FeatureDefinitionProvider provider, + final DimensionHasher hasher, + final CapabilityValidator validator) { + return new VelocityEngine(backends, provider, hasher, validator); + } + + @Provides + @Singleton + ApiKeyFilter apiKeyFilter() { + return new ApiKeyFilter(apiKeys); + } +} diff --git a/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/package-info.java b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/package-info.java new file mode 100644 index 0000000..20e438f --- /dev/null +++ b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/package-info.java @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BSD-3-Clause +/** + * The Dropwizard 5 service tier: Jersey resources over the {@code velocity-core} engine, Dagger 2 + * wiring, and namespace-scoped API-key auth, serving the {@code velocity-api} OpenAPI contract. See + * {@link com.codeheadsystems.velocity.dropwizard.VelocityApplication}. + */ +@NullMarked +package com.codeheadsystems.velocity.dropwizard; + +import org.jspecify.annotations.NullMarked; diff --git a/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/resource/VelocityExceptionMapper.java b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/resource/VelocityExceptionMapper.java new file mode 100644 index 0000000..7f1040b --- /dev/null +++ b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/resource/VelocityExceptionMapper.java @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.dropwizard.resource; + +import com.codeheadsystems.velocity.dropwizard.api.Wire.Problem; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +/** + * Maps engine/JAX-RS exceptions to the RFC 9457 problem model (AR-5). A JAX-RS {@link + * WebApplicationException} keeps its own status; an {@link IllegalArgumentException} (validation / + * unknown backend or namespace) maps to 400; anything else is a 500. + */ +@Provider +public final class VelocityExceptionMapper implements ExceptionMapper { + + @Override + public Response toResponse(final RuntimeException exception) { + if (exception instanceof WebApplicationException webException) { + return webException.getResponse(); + } + final Response.Status status = + exception instanceof IllegalArgumentException + ? Response.Status.BAD_REQUEST + : Response.Status.INTERNAL_SERVER_ERROR; + final String type = status == Response.Status.BAD_REQUEST ? "validation" : "internal"; + return Response.status(status) + .type(MediaType.APPLICATION_JSON) + .entity( + new Problem( + "about:blank#" + type, type, status.getStatusCode(), exception.getMessage())) + .build(); + } +} diff --git a/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/resource/VelocityResource.java b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/resource/VelocityResource.java new file mode 100644 index 0000000..9bf2774 --- /dev/null +++ b/velocity-dropwizard/src/main/java/com/codeheadsystems/velocity/dropwizard/resource/VelocityResource.java @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.dropwizard.resource; + +import com.codeheadsystems.velocity.core.VelocityEngine; +import com.codeheadsystems.velocity.dropwizard.api.Wire; +import com.codeheadsystems.velocity.dropwizard.api.WireMapper; +import com.codeheadsystems.velocity.spi.model.FeatureResult; +import com.codeheadsystems.velocity.spi.model.Namespace; +import com.codeheadsystems.velocity.spi.model.Subject; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** The velocity HTTP surface (AR-2): record, query, and capabilities, all namespace-scoped. */ +@Path("/v1/{namespace}") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public final class VelocityResource { + + private final VelocityEngine engine; + private final String backendName; + + /** + * @param engine the counting engine. + * @param backendName the logical backend name whose capabilities are reported. + */ + @Inject + public VelocityResource( + final VelocityEngine engine, @Named("backendName") final String backendName) { + this.engine = engine; + this.backendName = backendName; + } + + /** Records an event and returns the post-increment per-feature velocities (FR-2). */ + @POST + @Path("/record") + public Wire.RecordResponse record( + @PathParam("namespace") final String namespace, final Wire.RecordRequest request) { + final Subject subject = new Subject(request.subject().type(), request.subject().value()); + final Map dimensions = + request.dimensions() == null ? Map.of() : request.dimensions(); + final BigDecimal value = request.value() == null ? null : new BigDecimal(request.value()); + return WireMapper.toRecordResponse( + engine.record(new Namespace(namespace), subject, dimensions, value)); + } + + /** Queries the named features for a subject (FR-6). */ + @POST + @Path("/query") + public Wire.QueryResponse query( + @PathParam("namespace") final String namespace, final Wire.QueryRequest request) { + final Subject subject = new Subject(request.subject().type(), request.subject().value()); + final Map> results = + engine.query(new Namespace(namespace), subject, request.features()); + final Map> wire = new LinkedHashMap<>(); + results.forEach( + (name, list) -> wire.put(name, list.stream().map(WireMapper::toResult).toList())); + return new Wire.QueryResponse(wire); + } + + /** Returns the active backend's declared capabilities (FR-12). */ + @GET + @Path("/capabilities") + public Wire.CapabilitiesResponse capabilities(@PathParam("namespace") final String namespace) { + return WireMapper.toCapabilities(engine.capabilities(backendName)); + } +} diff --git a/velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/TestApplication.java b/velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/TestApplication.java new file mode 100644 index 0000000..2d9fac4 --- /dev/null +++ b/velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/TestApplication.java @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.dropwizard; + +import com.codeheadsystems.velocity.core.VelocityEngine; +import com.codeheadsystems.velocity.core.model.FeatureDefinition; +import com.codeheadsystems.velocity.spi.VelocityBackend; +import com.codeheadsystems.velocity.spi.model.Namespace; +import com.codeheadsystems.velocity.spi.model.Window; +import com.codeheadsystems.velocity.spi.model.WindowType; +import com.codeheadsystems.velocity.testkit.InMemoryVelocityBackend; +import com.codeheadsystems.velocity.testkit.MutableClock; +import java.time.Duration; +import java.util.List; + +/** + * Test application: wires the in-memory backend (fixed clock, so record and query share a window) + * and seeds two feature definitions in the {@code acme} namespace — {@code card.count} and {@code + * card.sum} over a 1-minute sliding window on the {@code default} backend. + */ +public final class TestApplication extends VelocityApplication { + + private static final Window SLIDING_1M = new Window(Duration.ofMinutes(1), WindowType.SLIDING); + + @Override + protected VelocityBackend createBackend(final VelocityConfiguration configuration) { + return new InMemoryVelocityBackend(MutableClock.atNow()); + } + + @Override + protected void configureEngine( + final VelocityEngine engine, final VelocityConfiguration configuration) { + engine.reload( + new Namespace("acme"), + List.of( + FeatureDefinition.count("card.count", "card", "default", List.of(SLIDING_1M)), + FeatureDefinition.sum("card.sum", "card", "default", List.of(SLIDING_1M)))); + } +} diff --git a/velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/VelocityServiceIntegrationTest.java b/velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/VelocityServiceIntegrationTest.java new file mode 100644 index 0000000..6e193ff --- /dev/null +++ b/velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/VelocityServiceIntegrationTest.java @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.dropwizard; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.codeheadsystems.velocity.dropwizard.api.Wire; +import com.codeheadsystems.velocity.dropwizard.auth.ApiKeyFilter; +import io.dropwizard.testing.ResourceHelpers; +import io.dropwizard.testing.junit5.DropwizardAppExtension; +import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.Response; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** End-to-end HTTP tests: the service over the in-memory engine, behind namespace-scoped auth. */ +@ExtendWith(DropwizardExtensionsSupport.class) +final class VelocityServiceIntegrationTest { + + private static final DropwizardAppExtension APP = + new DropwizardAppExtension<>( + TestApplication.class, ResourceHelpers.resourceFilePath("test-config.yml")); + + private static String url(final String path) { + return "http://localhost:" + APP.getLocalPort() + path; + } + + @Test + void recordThenQueryRoundTripOverHttp() { + final Wire.RecordResponse recorded = + APP.client() + .target(url("/v1/acme/record")) + .request() + .header(ApiKeyFilter.HEADER, "key-acme") + .post( + Entity.json( + new Wire.RecordRequest(new Wire.SubjectDto("card", "1234"), Map.of(), "14950")), + Wire.RecordResponse.class); + assertThat(recorded.perFeature()).isNotEmpty(); + + final Wire.QueryResponse queried = + APP.client() + .target(url("/v1/acme/query")) + .request() + .header(ApiKeyFilter.HEADER, "key-acme") + .post( + Entity.json( + new Wire.QueryRequest( + new Wire.SubjectDto("card", "1234"), List.of("card.count", "card.sum"))), + Wire.QueryResponse.class); + + // COUNT ignored the money value; SUM took it — money round-trips as a decimal-integer string. + assertThat(successValue(queried, "card.count")).isEqualTo("1"); + assertThat(successValue(queried, "card.sum")).isEqualTo("14950"); + } + + @Test + void missingApiKeyIsUnauthorized() { + try (Response response = APP.client().target(url("/v1/acme/capabilities")).request().get()) { + assertThat(response.getStatus()).isEqualTo(401); + } + } + + @Test + void keyScopedToAnotherNamespaceIsForbidden() { + try (Response response = + APP.client() + .target(url("/v1/acme/capabilities")) + .request() + .header(ApiKeyFilter.HEADER, "key-other") + .get()) { + assertThat(response.getStatus()).isEqualTo(403); + } + } + + @Test + void capabilitiesReportsDeclaredWindowsAndAggregations() { + final Wire.CapabilitiesResponse caps = + APP.client() + .target(url("/v1/acme/capabilities")) + .request() + .header(ApiKeyFilter.HEADER, "key-acme") + .get(Wire.CapabilitiesResponse.class); + assertThat(caps.aggregations()).contains("COUNT", "SUM", "DISTINCT"); + assertThat(caps.windows()).isNotEmpty(); + assertThat(caps.readYourWriteLevel()).isEqualTo("ATOMIC"); + } + + private static String successValue(final Wire.QueryResponse response, final String feature) { + final List results = response.features().get(feature); + assertThat(results).as("results for %s", feature).isNotEmpty(); + final Wire.ResultDto first = results.get(0); + assertThat(first.kind()).isEqualTo("SUCCESS"); + assertThat(first.value()).isNotNull(); + return first.value().value(); + } +} diff --git a/velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/api/WireMapperTest.java b/velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/api/WireMapperTest.java new file mode 100644 index 0000000..d0d8b0f --- /dev/null +++ b/velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/api/WireMapperTest.java @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.dropwizard.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.codeheadsystems.velocity.spi.model.Aggregation; +import com.codeheadsystems.velocity.spi.model.ApplyContext; +import com.codeheadsystems.velocity.spi.model.ApplyResult; +import com.codeheadsystems.velocity.spi.model.Feature; +import com.codeheadsystems.velocity.spi.model.FeatureResult; +import com.codeheadsystems.velocity.spi.model.Intent.CountIntent; +import com.codeheadsystems.velocity.spi.model.Namespace; +import com.codeheadsystems.velocity.spi.model.QueryContext; +import com.codeheadsystems.velocity.spi.model.QueryTuple; +import com.codeheadsystems.velocity.spi.model.Subject; +import com.codeheadsystems.velocity.spi.model.Window; +import com.codeheadsystems.velocity.spi.model.WindowType; +import com.codeheadsystems.velocity.testkit.InMemoryVelocityBackend; +import com.codeheadsystems.velocity.testkit.MutableClock; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Maps real {@code velocity-spi} results (from the in-memory backend) to the wire DTOs. */ +final class WireMapperTest { + + private static final Namespace NS = new Namespace("acme"); + private static final Subject SUBJECT = new Subject("card", "1"); + private static final Window SLIDING_1M = new Window(Duration.ofMinutes(1), WindowType.SLIDING); + + private final InMemoryVelocityBackend backend = new InMemoryVelocityBackend(MutableClock.atNow()); + + @Test + void mapsRecordResponseSuccessWithStringValue() { + final Feature feature = new Feature("card.count", Aggregation.count(), List.of(SLIDING_1M)); + final ApplyResult result = + backend.applyCount(new ApplyContext(NS, null), List.of(new CountIntent(feature, SUBJECT))); + + final Wire.RecordResponse response = WireMapper.toRecordResponse(result); + assertThat(response.perFeature()).hasSize(1); + final Wire.PerFeatureDto perFeature = response.perFeature().get(0); + assertThat(perFeature.feature()).isEqualTo("card.count"); + assertThat(perFeature.status()).isEqualTo("APPLIED"); + assertThat(perFeature.result().kind()).isEqualTo("SUCCESS"); + assertThat(perFeature.result().value()).isNotNull(); + assertThat(perFeature.result().value().value()).isEqualTo("1"); + assertThat(perFeature.result().value().exactness()).isEqualTo("EXACT"); + assertThat(perFeature.result().value().readYourWriteLevel()).isEqualTo("ATOMIC"); + assertThat(perFeature.result().value().window().type()).isEqualTo("SLIDING"); + } + + @Test + void mapsFailureResultForUnsupportedWindow() { + final Window unsupported = new Window(Duration.ofDays(7), WindowType.SLIDING); + final List results = + backend.queryCount( + new QueryContext(NS, null), + List.of(new QueryTuple(SUBJECT, Aggregation.count(), unsupported))); + + final Wire.ResultDto dto = WireMapper.toResult(results.get(0)); + assertThat(dto.kind()).isEqualTo("FAILURE"); + assertThat(dto.code()).isEqualTo("UNSUPPORTED_WINDOW"); + assertThat(dto.value()).isNull(); + } + + @Test + void mapsCapabilities() { + final Wire.CapabilitiesResponse caps = WireMapper.toCapabilities(backend.capabilities()); + assertThat(caps.aggregations()).contains("COUNT", "SUM", "DISTINCT"); + assertThat(caps.windows()).isNotEmpty(); + assertThat(caps.readYourWriteLevel()).isEqualTo("ATOMIC"); + assertThat(caps.seedSupported()).isTrue(); + assertThat(caps.distinctHllSliding()).isFalse(); + } +} diff --git a/velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/auth/ApiKeyFilterTest.java b/velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/auth/ApiKeyFilterTest.java new file mode 100644 index 0000000..a9461a9 --- /dev/null +++ b/velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/auth/ApiKeyFilterTest.java @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.dropwizard.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** Namespace-scoped API-key filter branches (NFR-21). */ +final class ApiKeyFilterTest { + + private final ApiKeyFilter filter = new ApiKeyFilter(Map.of("key-acme", Set.of("acme"))); + + private static ContainerRequestContext request(final String apiKey, final String namespace) { + final ContainerRequestContext ctx = mock(ContainerRequestContext.class); + when(ctx.getHeaderString(ApiKeyFilter.HEADER)).thenReturn(apiKey); + final UriInfo uriInfo = mock(UriInfo.class); + final MultivaluedHashMap pathParams = new MultivaluedHashMap<>(); + if (namespace != null) { + pathParams.putSingle("namespace", namespace); + } + lenient().when(uriInfo.getPathParameters()).thenReturn(pathParams); + lenient().when(ctx.getUriInfo()).thenReturn(uriInfo); + return ctx; + } + + private static int abortStatus(final ContainerRequestContext ctx) { + final ArgumentCaptor captor = ArgumentCaptor.forClass(Response.class); + verify(ctx).abortWith(captor.capture()); + return captor.getValue().getStatus(); + } + + @Test + void missingKeyIsUnauthorized() { + final ContainerRequestContext ctx = request(null, "acme"); + filter.filter(ctx); + assertThat(abortStatus(ctx)).isEqualTo(401); + } + + @Test + void blankKeyIsUnauthorized() { + final ContainerRequestContext ctx = request(" ", "acme"); + filter.filter(ctx); + assertThat(abortStatus(ctx)).isEqualTo(401); + } + + @Test + void unknownKeyIsUnauthorized() { + final ContainerRequestContext ctx = request("nope", "acme"); + filter.filter(ctx); + assertThat(abortStatus(ctx)).isEqualTo(401); + } + + @Test + void keyScopedToAnotherNamespaceIsForbidden() { + final ContainerRequestContext ctx = request("key-acme", "other"); + filter.filter(ctx); + assertThat(abortStatus(ctx)).isEqualTo(403); + } + + @Test + void authorizedKeyPasses() { + final ContainerRequestContext ctx = request("key-acme", "acme"); + filter.filter(ctx); + verify(ctx, never()).abortWith(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/resource/VelocityExceptionMapperTest.java b/velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/resource/VelocityExceptionMapperTest.java new file mode 100644 index 0000000..cb5933d --- /dev/null +++ b/velocity-dropwizard/src/test/java/com/codeheadsystems/velocity/dropwizard/resource/VelocityExceptionMapperTest.java @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.dropwizard.resource; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.Test; + +/** The exception mapper's status mapping (AR-5 problem model). */ +final class VelocityExceptionMapperTest { + + private final VelocityExceptionMapper mapper = new VelocityExceptionMapper(); + + @Test + void webApplicationExceptionKeepsItsStatus() { + try (Response response = mapper.toResponse(new NotFoundException())) { + assertThat(response.getStatus()).isEqualTo(404); + } + } + + @Test + void illegalArgumentMapsToBadRequest() { + try (Response response = mapper.toResponse(new IllegalArgumentException("bad input"))) { + assertThat(response.getStatus()).isEqualTo(400); + } + } + + @Test + void otherRuntimeMapsToInternalServerError() { + try (Response response = mapper.toResponse(new IllegalStateException("boom"))) { + assertThat(response.getStatus()).isEqualTo(500); + } + } +} diff --git a/velocity-dropwizard/src/test/resources/test-config.yml b/velocity-dropwizard/src/test/resources/test-config.yml new file mode 100644 index 0000000..45e9dd6 --- /dev/null +++ b/velocity-dropwizard/src/test/resources/test-config.yml @@ -0,0 +1,11 @@ +backendName: default +apiKeys: + key-acme: ["acme"] + key-other: ["other"] +server: + applicationConnectors: + - type: http + port: 0 + adminConnectors: + - type: http + port: 0 From 60fff75f3c3e265f68b6ddeb484f9776821a8ab4 Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Sun, 19 Jul 2026 07:29:41 -0700 Subject: [PATCH 2/2] Reconcile the query contract to feature-name-based (velocity-api) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The velocity-api OpenAPI query was tuple-based ({tuples:[{subject,aggregation, window}]} -> positional results) while velocity-core (and the velocity-dropwizard service that wraps it) is feature-name-based. Align the contract to the engine: - QueryRequest is now {subject, features:[name]}; QueryResponse maps each feature name to its per-window FeatureResult list — matching VelocityEngine.query and the service's wire DTOs (which were already feature-name-based). - Remove the now-unused QueryTuple schema; update the OpenApiSpecTest schema list. Resolves the query request/response divergence flagged in this PR's review, unblocking velocity-client-java generation from a contract that agrees with the engine. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../openapi/velocity-engine-api.yaml | 51 ++++++++----------- .../velocity/api/OpenApiSpecTest.java | 3 +- 2 files changed, 24 insertions(+), 30 deletions(-) diff --git a/velocity-api/src/main/resources/openapi/velocity-engine-api.yaml b/velocity-api/src/main/resources/openapi/velocity-engine-api.yaml index 484b042..02c30f2 100644 --- a/velocity-api/src/main/resources/openapi/velocity-engine-api.yaml +++ b/velocity-api/src/main/resources/openapi/velocity-engine-api.yaml @@ -95,12 +95,13 @@ paths: post: tags: [query] operationId: queryVelocities - summary: Query current velocities for a batch of tuples (no mutation). + summary: Query current velocities for a subject's named features (no mutation). description: >- - Returns the current velocity for each requested `(subject, aggregation, window)` tuple - without recording an event (FR-6). Results are aligned **positionally** with the request - tuples: `results[i]` is the outcome for `tuples[i]`, each a value-or-failure `FeatureResult` - (ADR 0009) — a failing tuple never collapses the batch. + Returns the current velocity for each requested feature (by name) for a subject, without + recording an event (FR-6). The response maps each feature name to its per-window + value-or-failure `FeatureResult`s (ADR 0009) — a failing feature never collapses the batch. + The engine resolves a feature name to its aggregation, windows, and backend from the + namespace's feature definitions (feature-name-based query — matches `velocity-core`). parameters: - $ref: "#/components/parameters/NamespacePath" requestBody: @@ -111,7 +112,7 @@ paths: $ref: "#/components/schemas/QueryRequest" responses: "200": - description: Per-tuple results, positionally aligned with the request tuples. + description: Each requested feature name mapped to its per-window results. content: application/json: schema: @@ -635,41 +636,33 @@ components: $ref: "#/components/schemas/Window" additionalProperties: false - QueryTuple: + QueryRequest: type: object - description: A single read request `(subject, aggregation, window)` (FR-6). - required: [subject, aggregation, window] + description: A subject and the stable feature names to read for it (FR-6/FR-8). + required: [subject, features] properties: subject: $ref: "#/components/schemas/Subject" - aggregation: - $ref: "#/components/schemas/Aggregation" - window: - $ref: "#/components/schemas/Window" - additionalProperties: false - - QueryRequest: - type: object - description: A batch of tuples to read in one call (FR-8). - required: [tuples] - properties: - tuples: + features: type: array minItems: 1 + description: The stable feature names to read (resolved from the namespace's definitions). items: - $ref: "#/components/schemas/QueryTuple" + type: string additionalProperties: false QueryResponse: type: object - description: Per-tuple results, aligned **positionally** with the request tuples. - required: [results] + description: Each requested feature name mapped to its per-window value-or-failure results. + required: [features] properties: - results: - type: array - description: "`results[i]` is the outcome for the request's `tuples[i]`." - items: - $ref: "#/components/schemas/FeatureResult" + features: + type: object + description: Feature name to the list of its per-window `FeatureResult`s (one per window). + additionalProperties: + type: array + items: + $ref: "#/components/schemas/FeatureResult" additionalProperties: false PurgeRequest: diff --git a/velocity-api/src/test/java/com/codeheadsystems/velocity/api/OpenApiSpecTest.java b/velocity-api/src/test/java/com/codeheadsystems/velocity/api/OpenApiSpecTest.java index e84c3bf..07f673c 100644 --- a/velocity-api/src/test/java/com/codeheadsystems/velocity/api/OpenApiSpecTest.java +++ b/velocity-api/src/test/java/com/codeheadsystems/velocity/api/OpenApiSpecTest.java @@ -105,7 +105,8 @@ void topLevelSchemasArePresent() { "FeatureResult", "PerFeature", "ApplyResult", - "QueryTuple", + "QueryRequest", + "QueryResponse", "BackendCapabilities", "FeatureDefinition", "Problem");