diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ApiOperationValidationPolicy.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ApiOperationValidationPolicy.java index bbafac85..afe1abc5 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ApiOperationValidationPolicy.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/ApiOperationValidationPolicy.java @@ -6,6 +6,7 @@ import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.RelationshipCollectionRoute; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.RelationshipInstanceRoute; import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRoute; +import uk.co.compendiumdev.thingifier.adapter.http.lifecycle.ThingifierApiLifecycleContext; import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; import uk.co.compendiumdev.thingifier.api.http.ThingifierRequestContext; import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyFields; @@ -64,6 +65,45 @@ public ApiResponse rejectIfInvalid( final String rawBody, final QueryFilterParams queryParams, final String operationType) { + return rejectIfInvalid( + verb, + publicPath, + route, + context, + bodyFields, + rawBody, + queryParams, + operationType, + null); + } + + /** + * Rejects a request when one of the route's operation validators rejects it. + * + *

The lifecycle context is supplied for HTTP requests so mounted public paths and internal + * route paths can both be exposed to validators. + * + * @param verb routing verb being processed + * @param publicPath public API path requested by the caller + * @param route resolved Thingifier route target + * @param context active request context after auth and data-scope selection + * @param bodyFields parsed request body fields + * @param rawBody raw request body text + * @param queryParams parsed query parameters + * @param operationType resolved operation type label + * @param lifecycle lifecycle context when processing an HTTP request, otherwise null + * @return rejection response, or null when validation accepts the operation + */ + public ApiResponse rejectIfInvalid( + final RoutingVerb verb, + final String publicPath, + final ThingRoute route, + final ThingifierRequestContext context, + final ApiBodyFields bodyFields, + final String rawBody, + final QueryFilterParams queryParams, + final String operationType, + final ThingifierApiLifecycleContext lifecycle) { Optional routeRule = routeRuleFor(verb, route, publicPath); if (routeRule.isEmpty() || !routeRule.get().hasApiOperationValidators()) { return null; @@ -78,7 +118,8 @@ public ApiResponse rejectIfInvalid( bodyFields, rawBody, queryParams, - operationType); + operationType, + lifecycle); for (ApiOperationValidatorDefinition definition : routeRule.get().apiOperationValidators()) { @@ -105,7 +146,8 @@ private ApiOperationValidationContext validationContextFor( final ApiBodyFields bodyFields, final String rawBody, final QueryFilterParams queryParams, - final String operationType) { + final String operationType, + final ThingifierApiLifecycleContext lifecycle) { EntityDefinition targetEntity = targetEntityFor(route); String targetEntityName = targetEntity == null ? null : targetEntity.getName(); String targetIdentifier = targetIdentifierFor(route); @@ -114,7 +156,11 @@ private ApiOperationValidationContext validationContextFor( return new ApiOperationValidationContext( verb, - publicPath, + lifecycle == null ? publicPath : lifecycle.requestPath(), + lifecycle == null ? publicPath : lifecycle.mountedPath(), + lifecycle == null ? publicPath : lifecycle.internalPath(), + lifecycle == null ? null : lifecycle.mountName(), + lifecycle == null ? "" : lifecycle.mountPrefix(), route, targetEntityName, targetIdentifier, diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteCallbackContextFactory.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteCallbackContextFactory.java index 50c9413e..1818a2e7 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteCallbackContextFactory.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/apihandlers/RouteCallbackContextFactory.java @@ -91,7 +91,11 @@ ThingifierApiOperationContext contextFor( : (lifecycle == null ? null : lifecycle.requestContext()); return new ThingifierApiOperationContext( verb, - publicPath, + publicPathFor(publicPath, lifecycle), + mountedPathFor(publicPath, lifecycle), + internalPathFor(publicPath, lifecycle), + lifecycle == null ? null : lifecycle.mountName(), + lifecycle == null ? "" : lifecycle.mountPrefix(), route, routeRule, targetEntityName(route, lifecycle), @@ -112,6 +116,21 @@ ThingifierApiOperationContext contextFor( runtime.apiConfig()); } + private String publicPathFor( + final String publicPath, final ThingifierApiLifecycleContext lifecycle) { + return lifecycle == null ? publicPath : lifecycle.requestPath(); + } + + private String mountedPathFor( + final String publicPath, final ThingifierApiLifecycleContext lifecycle) { + return lifecycle == null ? publicPath : lifecycle.mountedPath(); + } + + private String internalPathFor( + final String publicPath, final ThingifierApiLifecycleContext lifecycle) { + return lifecycle == null ? publicPath : lifecycle.internalPath(); + } + /** * Resolves the operation type label for route operation callbacks. * diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/lifecycle/ThingifierApiLifecycleContext.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/lifecycle/ThingifierApiLifecycleContext.java index 132e73b8..406f4de9 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/lifecycle/ThingifierApiLifecycleContext.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/lifecycle/ThingifierApiLifecycleContext.java @@ -144,6 +144,31 @@ public String path() { return request.getPath(); } + @Override + public String requestPath() { + return request.getRequestPath(); + } + + @Override + public String mountedPath() { + return request.getMountedPath(); + } + + @Override + public String internalPath() { + return request.getPath(); + } + + @Override + public String mountName() { + return request.getMountName(); + } + + @Override + public String mountPrefix() { + return request.getMountPrefix(); + } + /** * Returns the API path prefix used when matching scoped hooks and API spec rules. * diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/lifecycle/ThingifierApiLifecycleContextView.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/lifecycle/ThingifierApiLifecycleContextView.java index 5311b900..963f62af 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/lifecycle/ThingifierApiLifecycleContextView.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/http/lifecycle/ThingifierApiLifecycleContextView.java @@ -38,6 +38,41 @@ public interface ThingifierApiLifecycleContextView { */ String path(); + /** + * Returns the public request path received by Thingifier before mount or prefix stripping. + * + * @return public request path without a leading slash + */ + String requestPath(); + + /** + * Returns the public mounted path for this request. + * + * @return mounted path without a leading slash + */ + String mountedPath(); + + /** + * Returns the canonical Thingifier path used by generated handlers. + * + * @return internal route path without a leading slash + */ + String internalPath(); + + /** + * Returns the active named mount. + * + * @return mount name, or null when no named mount matched + */ + String mountName(); + + /** + * Returns the active public mount prefix. + * + * @return mount prefix with a leading slash, or empty when no prefix applies + */ + String mountPrefix(); + /** * Returns the mapped Thingifier route. * diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationContext.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationContext.java index 289c515a..49ae7e27 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationContext.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/callbacks/ThingifierApiOperationContext.java @@ -23,6 +23,10 @@ public final class ThingifierApiOperationContext { private final RoutingVerb verb; private final String publicPath; + private final String mountedPath; + private final String internalPath; + private final String mountName; + private final String mountPrefix; private final ThingRoute route; private final ThingifierApiRouteRule routeRule; private final String targetEntityName; @@ -81,8 +85,90 @@ public ThingifierApiOperationContext( final ApiBodyFields parsedRequestBody, final String rawRequestBody, final ThingifierApiConfig apiConfig) { + this( + verb, + publicPath, + publicPath, + publicPath, + null, + "", + route, + routeRule, + targetEntityName, + targetIdentifier, + parentEntityName, + parentIdentifier, + relationshipName, + childIdentifier, + dataScopeName, + store, + authenticatedPrincipals, + requestHeaders, + queryParams, + parsedRequestBody, + rawRequestBody, + apiConfig); + } + + /** + * Creates a callback context with explicit mounted route details. + * + *

Mounted requests expose both the public path requested by the caller and the canonical + * internal path processed by Thingifier. Keeping both values here lets application callbacks + * make route-aware decisions without guessing which prefix was stripped. + * + * @param verb route verb being processed + * @param publicPath public request path + * @param mountedPath active mounted path + * @param internalPath canonical Thingifier route path + * @param mountName active mount name, or null + * @param mountPrefix active mount prefix, or empty + * @param route resolved generated route + * @param routeRule matched route rule that owns the callback + * @param targetEntityName target entity name, or null + * @param targetIdentifier target identifier, or null + * @param parentEntityName relationship parent entity name, or null + * @param parentIdentifier relationship parent identifier, or null + * @param relationshipName relationship route name, or null + * @param childIdentifier relationship child identifier, or null + * @param dataScopeName active data-scope name + * @param store active store + * @param authenticatedPrincipals authenticated principals by scheme name + * @param requestHeaders request headers + * @param queryParams parsed query parameters + * @param parsedRequestBody parsed body fields + * @param rawRequestBody raw request body text + * @param apiConfig active API configuration + */ + public ThingifierApiOperationContext( + final RoutingVerb verb, + final String publicPath, + final String mountedPath, + final String internalPath, + final String mountName, + final String mountPrefix, + final ThingRoute route, + final ThingifierApiRouteRule routeRule, + final String targetEntityName, + final String targetIdentifier, + final String parentEntityName, + final String parentIdentifier, + final String relationshipName, + final String childIdentifier, + final String dataScopeName, + final ThingStore store, + final Map authenticatedPrincipals, + final HttpHeadersBlock requestHeaders, + final QueryFilterParams queryParams, + final ApiBodyFields parsedRequestBody, + final String rawRequestBody, + final ThingifierApiConfig apiConfig) { this.verb = verb; this.publicPath = normalizedPublicPath(publicPath); + this.mountedPath = normalizedPublicPath(mountedPath); + this.internalPath = normalizedPublicPath(internalPath); + this.mountName = mountName; + this.mountPrefix = normalizedMountPrefix(mountPrefix); this.route = route; this.routeRule = routeRule; this.targetEntityName = targetEntityName; @@ -120,6 +206,42 @@ public String publicPath() { return publicPath; } + /** + * Returns the active mounted path requested by the caller. + * + * @return mounted path with a leading slash + */ + public String mountedPath() { + return mountedPath; + } + + /** + * Returns the canonical Thingifier path processed by generated handlers. + * + * @return internal route path with a leading slash + */ + public String internalPath() { + return internalPath; + } + + /** + * Returns the active public mount name. + * + * @return mount name when a named mount matched, otherwise empty + */ + public Optional mountName() { + return Optional.ofNullable(mountName); + } + + /** + * Returns the active public mount prefix. + * + * @return mount prefix with a leading slash, or empty when no prefix applies + */ + public String mountPrefix() { + return mountPrefix; + } + /** * @return resolved generated route target */ @@ -283,4 +405,11 @@ private String normalizedPublicPath(final String path) { } return path.startsWith("/") ? path : "/" + path; } + + private String normalizedMountPrefix(final String prefix) { + if (prefix == null || prefix.isEmpty() || "/".equals(prefix)) { + return prefix == null ? "" : prefix; + } + return prefix.startsWith("/") ? prefix : "/" + prefix; + } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinition.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinition.java index 65e6a7dc..cf625a42 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinition.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinition.java @@ -80,6 +80,21 @@ public RoutingDefinition addRouting( return defn; } + /** + * Adds an already-built route definition. + * + *

This is used when documentation is projected through public API mounts. The canonical + * generated route is copied first so mounted routes keep the same security, payload, view, + * response-shape, fixed-resource, and header metadata. + * + * @param routingDefinition route definition to add + */ + public void addRouting(final RoutingDefinition routingDefinition) { + if (routingDefinition != null) { + routings.add(routingDefinition); + } + } + /** * Registers the model entity under the schema names generated routes may reference. * diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java index 69d8583e..55f259df 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java @@ -58,10 +58,12 @@ public ApiRoutingDefinitionDocGenerator(final Thingifier thingifier) { // TODO: have the ability to override these from config and define from config rather than code public ApiRoutingDefinition generate(String apiPathPrefix) { ApiRoutingDefinition defn = new ApiRoutingDefinition(); + final String generationPathPrefix = + thingifier.apiSpec().hasDocumentedMounts() ? "" : apiPathPrefix; String endPointPrefix = ""; - if (apiPathPrefix != null && !apiPathPrefix.isEmpty()) { - endPointPrefix = apiPathPrefix + "/"; + if (generationPathPrefix != null && !generationPathPrefix.isEmpty()) { + endPointPrefix = generationPathPrefix + "/"; } for (EntityDefinition entityDefn : thingifier.getERmodel().getEntityDefinitions()) { @@ -377,10 +379,10 @@ public ApiRoutingDefinition generate(String apiPathPrefix) { } } - thingifier.apiSpec().addFixedRouteDefinitionsTo(defn, apiPathPrefix); - new WriteMethodRoutePolicy(thingifier).applyTo(defn, apiPathPrefix); - thingifier.apiSpec().applyTo(defn, apiPathPrefix); - return defn; + thingifier.apiSpec().addFixedRouteDefinitionsTo(defn, generationPathPrefix); + new WriteMethodRoutePolicy(thingifier).applyTo(defn, generationPathPrefix); + thingifier.apiSpec().applyTo(defn, generationPathPrefix); + return thingifier.apiSpec().projectMountedDocumentation(defn); } private Field getUniqueIdField(final EntityDefinition thingDefn) { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java index 516e8fd7..85a9ddd4 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java @@ -144,6 +144,64 @@ public String urlWithParamFormatter(String prefix, String postfix) { return url.replaceAll("\\/:([^\\/\\?]+)", "/" + prefix + "$1" + postfix); } + /** + * Creates a metadata-equivalent copy with a different public URL. + * + *

Mounted API documentation uses this to expose a canonical generated route through one or + * more public prefixes without losing route-specific auth declarations, response policies, + * request/response views, fixed-resource metadata, or path parameters. + * + * @param newUrl replacement route URL + * @return independent route definition copy + */ + public RoutingDefinition copyWithUrl(final String newUrl) { + return copyWithUrlAndDocumentation(newUrl, documentation); + } + + /** + * Creates a metadata-equivalent copy with a different public URL and documentation text. + * + *

Mounted API documentation uses this when generated route text mentions the route path. The + * public mounted route should then be visible in both the OpenAPI path key and the operation + * summary/description. + * + * @param newUrl replacement route URL + * @param newDocumentation replacement documentation text + * @return independent route definition copy + */ + public RoutingDefinition copyWithUrlAndDocumentation( + final String newUrl, final String newDocumentation) { + final RoutingDefinition copy = new RoutingDefinition(verb, newUrl, routingStatus, header); + copy.documentation = newDocumentation == null ? "" : newDocumentation; + copy.isFilterable = isFilterable; + copy.filterableEntityDefn = filterableEntityDefn; + copy.possibleStatusResponses = new ArrayList<>(possibleStatusResponses); + copy.returnPayload = new HashMap<>(returnPayload); + copy.requestPayload = requestPayload; + copy.requestContentTypes = new ArrayList<>(requestContentTypes); + copy.requestUrlParams = new ArrayList<>(requestUrlParams); + copy.customHeaders = new HashMap<>(customHeaders); + copy.responseHeaders = new HashMap<>(responseHeaders); + copy.usesBasicAuth = usesBasicAuth; + copy.basicAuthSchemeName = basicAuthSchemeName; + copy.usesBearerAuth = usesBearerAuth; + copy.bearerAuthSchemeName = bearerAuthSchemeName; + copy.usesApiKeyAuth = usesApiKeyAuth; + copy.apiKeyAuthSchemeName = apiKeyAuthSchemeName; + copy.apiKeyHeaderName = apiKeyHeaderName; + copy.apiKeyHeaderNameConfigured = apiKeyHeaderNameConfigured; + copy.authSchemeNames = new ArrayList<>(authSchemeNames); + copy.hiddenFromDocumentation = hiddenFromDocumentation; + copy.disabled = disabled; + copy.requestEntityViewName = requestEntityViewName; + copy.responseEntityViewNames = new HashMap<>(responseEntityViewNames); + copy.responseShape = responseShape; + copy.fixedEntityName = fixedEntityName; + copy.fixedIdentifier = fixedIdentifier; + copy.fixedResourcePolicy = fixedResourcePolicy; + return copy; + } + /** * Returns the legacy response header name attached to the route. * diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/HttpApiRequest.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/HttpApiRequest.java index 62c27a83..f0bb1274 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/HttpApiRequest.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/HttpApiRequest.java @@ -3,11 +3,17 @@ import java.util.*; import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeaderPair; import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; +import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiMountSelection; import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; public final class HttpApiRequest { private String path = ""; + private String requestPath = ""; + private String mountedPath = ""; + private String mountName; + private String mountPrefix = ""; + private boolean rewriteLocationHeadersToMount; private HttpHeadersBlock headers; private String body = ""; private Map queryParams; // only contains the first query param value @@ -30,6 +36,27 @@ public void removePrefixFromPath(String prefix) { } } + /** + * Applies the public mount selected for this request. + * + *

The HTTP adapter uses {@link #getPath()} as the canonical Thingifier route path. The + * original public path and mount metadata remain available to callbacks and hooks that need to + * report what the client actually requested. + * + * @param mountSelection resolved mount selection + */ + public void applyMountSelection(final ThingifierApiMountSelection mountSelection) { + if (mountSelection == null) { + return; + } + requestPath = justThePath(mountSelection.requestPath()); + mountedPath = justThePath(mountSelection.mountedPath()); + path = justThePath(mountSelection.internalPath()); + mountName = mountSelection.mountName(); + mountPrefix = mountSelection.mountPrefix(); + rewriteLocationHeadersToMount = mountSelection.shouldRewriteLocationHeaders(); + } + public enum VERB { GET, HEAD, @@ -45,6 +72,8 @@ public enum VERB { public HttpApiRequest(final String pathInfo) { path = justThePath(pathInfo); + requestPath = path; + mountedPath = path; headers = new HttpHeadersBlock(); queryParams = new HashMap<>(); filterableQueryParams = new QueryFilterParams(); @@ -131,6 +160,60 @@ public String getPath() { return this.path; } + /** + * Returns the public request path as supplied to Thingifier before mount stripping. + * + * @return request path without a leading slash + */ + public String getRequestPath() { + return requestPath; + } + + /** + * Returns the public mounted path for this request. + * + * @return mounted path without a leading slash + */ + public String getMountedPath() { + return mountedPath; + } + + /** + * Returns the active mount name. + * + * @return mount name, or null when no named mount matched + */ + public String getMountName() { + return mountName; + } + + /** + * Returns the active public mount prefix. + * + * @return mount prefix with a leading slash, or empty when no prefix applies + */ + public String getMountPrefix() { + return mountPrefix; + } + + /** + * Reports whether this request matched a named mount. + * + * @return true when a named mount matched + */ + public boolean hasActiveMount() { + return mountName != null; + } + + /** + * Reports whether final relative Location headers should be rewritten to the active mount. + * + * @return true when the active mount requested Location rewriting + */ + public boolean shouldRewriteLocationHeadersToMount() { + return rewriteLocationHeadersToMount; + } + public HttpHeadersBlock getHeaders() { return this.headers; } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java index 38903e93..e96f8538 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java @@ -34,6 +34,7 @@ import uk.co.compendiumdev.thingifier.api.http.bodyparser.JsonBodyValueConverter; import uk.co.compendiumdev.thingifier.api.response.ApiResponse; import uk.co.compendiumdev.thingifier.api.response.EntityResponseViewResolver; +import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiMountSelection; import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule; import uk.co.compendiumdev.thingifier.apiconfig.EntityPatchUpdateStyle; import uk.co.compendiumdev.thingifier.application.schema.RelationshipSpec; @@ -206,16 +207,7 @@ private ThingifierHttpApi( */ private HttpApiResponse handleRequest(final HttpApiRequest request, HttpVerb verb) { - // if the request.url has the 'prefix' then remove the prefix and process the request - // if(request.getPath()) - - String prefix = thingifier.apiConfig().getApiEndPointPrefix(); - if (prefix != null && !prefix.isEmpty()) { - if (prefix.startsWith("/")) { - prefix = prefix.substring(1); - } - request.removePrefixFromPath(prefix); - } + resolveMountedPath(request); final HttpVerb effectiveVerb = MethodOverrideParser.getEffectiveVerb(request, verb); @@ -321,7 +313,40 @@ private ThingifierApiLifecycleContext lifecycleContextFor( effectiveVerb, routingVerbFor(effectiveVerb), route, - thingifier.apiConfig().getApiEndPointPrefix()); + activeApiPathPrefix(request)); + } + + /** + * Resolves the active public mount or legacy API prefix for this request. + * + *

Generated handlers always see the canonical internal path through {@link + * HttpApiRequest#getPath()}, while callback and hook contexts can still inspect the public + * mounted path. + * + * @param request HTTP API request to update + */ + private void resolveMountedPath(final HttpApiRequest request) { + final ThingifierApiMountSelection mountSelection = + thingifier + .apiSpec() + .resolveMountFor( + request.getPath(), thingifier.apiConfig().getApiEndPointPrefix()); + request.applyMountSelection(mountSelection); + } + + /** + * Returns the prefix that scoped hooks and API spec matching should ignore for this request. + * + * @param request HTTP API request + * @return active mount prefix, or the legacy configured prefix + */ + private String activeApiPathPrefix(final HttpApiRequest request) { + if (request != null + && request.getMountPrefix() != null + && !request.getMountPrefix().isEmpty()) { + return request.getMountPrefix(); + } + return thingifier.apiConfig().getApiEndPointPrefix(); } /** @@ -366,6 +391,7 @@ private HttpApiResponse httpResponseFor( request.getHeaders(), response -> applyResponseEntityView(request, effectiveVerb, response)); + applyMountedLocationHeader(request, policyResponse); HttpApiResponse httpResponse = new HttpApiResponse( @@ -390,6 +416,58 @@ private HttpApiResponse httpResponseFor( return httpResponse; } + /** + * Rewrites relative Thingifier Location headers to the active public mount prefix. + * + *

The rewrite happens after route response policies so explicit policies can still remove or + * replace the header. Absolute URLs are preserved because they are already public by + * definition. + * + * @param request HTTP API request + * @param response structured API response to amend + */ + private void applyMountedLocationHeader( + final HttpApiRequest request, final ApiResponse response) { + if (request == null || response == null || !request.shouldRewriteLocationHeadersToMount()) { + return; + } + + final String location = response.getHeaderValue("Location"); + if (location == null || location.trim().isEmpty() || isAbsoluteLocation(location)) { + return; + } + + final String normalizedLocation = normalizeRelativeLocation(location); + final String normalizedPrefix = normalizeRelativeLocation(request.getMountPrefix()); + if (normalizedPrefix.isEmpty() + || normalizedLocation.equals(normalizedPrefix) + || normalizedLocation.startsWith(normalizedPrefix + "/")) { + return; + } + + final String rewritten = + "/" + + normalizedPrefix + + (normalizedLocation.isEmpty() ? "" : "/" + normalizedLocation); + response.setLocationHeader(rewritten); + } + + private boolean isAbsoluteLocation(final String location) { + final String trimmedLocation = location.trim(); + return trimmedLocation.contains("://") || trimmedLocation.startsWith("//"); + } + + private String normalizeRelativeLocation(final String location) { + String normalized = location == null ? "" : location.trim(); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (normalized.endsWith("/") && normalized.length() > 0) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + /** * Runs route-level final-response callbacks after HTTP rendering and before legacy hooks. * @@ -446,7 +524,7 @@ private Optional routeRuleFor( .ruleFor( RoutingVerb.valueOf(verb.name()), request.getPath(), - thingifier.apiConfig().getApiEndPointPrefix()); + activeApiPathPrefix(request)); } catch (IllegalArgumentException e) { return Optional.empty(); } @@ -613,7 +691,7 @@ private HttpApiResponse validateEntityViewInput( .requestEntityViewFor( routingVerbFor(verb), request.getPath(), - thingifier.apiConfig().getApiEndPointPrefix(), + activeApiPathPrefix(request), entity); if (configuredViewName.isEmpty()) { return null; @@ -1139,6 +1217,7 @@ public HttpApiResponse queryRequest(final HttpApiRequest request) { * @return HTTP API response */ public HttpApiResponse query(final HttpApiRequest request, final String query) { + resolveMountedPath(request); HttpApiResponse httpResponse = runTheHttpApiRequestHooksOn(request, HttpVerb.GET); @@ -1167,9 +1246,7 @@ private HttpApiResponse runTheHttpApiResponseHooksOn( final RoutingVerb routingVerb = routingVerbFor(verb); for (ScopedHook scopedHook : apiHookRegistry.responseHooks()) { if (!scopedHook.matches( - request.getPath(), - routingVerb, - thingifier.apiConfig().getApiEndPointPrefix())) { + request.getPath(), routingVerb, activeApiPathPrefix(request))) { continue; } HttpApiResponse returnImmediately = @@ -1203,9 +1280,7 @@ private HttpApiResponse runTheHttpApiRequestHooksOn( final RoutingVerb routingVerb = routingVerbFor(verb); for (ScopedHook scopedHook : apiHookRegistry.requestHooks()) { if (!scopedHook.matches( - request.getPath(), - routingVerb, - thingifier.apiConfig().getApiEndPointPrefix())) { + request.getPath(), routingVerb, activeApiPathPrefix(request))) { continue; } HttpApiResponse response = scopedHook.hook().run(request, thingifier.apiConfig()); diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiDeleteHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiDeleteHandler.java index c25479a1..f1725d9d 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiDeleteHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiDeleteHandler.java @@ -141,7 +141,8 @@ public ApiResponse handle( "", queryParams, writePolicy.operationTypeFor( - RoutingVerb.DELETE, route, ApiBodyFields.empty(), context)); + RoutingVerb.DELETE, route, ApiBodyFields.empty(), context), + lifecycle); if (operationValidationResponse != null) { return operationValidationResponse; } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandler.java index 2bed45f5..f0ff83a7 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandler.java @@ -165,7 +165,8 @@ private ApiResponse handle( ApiBodyFields.empty(), "", effectiveQueryParams.queryParams(), - readOperationType(verb)); + readOperationType(verb), + lifecycle); if (operationValidationResponse != null) { return operationValidationResponse; } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPatchHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPatchHandler.java index 6713f86d..c41634ac 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPatchHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPatchHandler.java @@ -161,7 +161,8 @@ public ApiResponse handle( rawBody, queryParams, policy.operationTypeFor( - RoutingVerb.PATCH, route, bodyFields, context)); + RoutingVerb.PATCH, route, bodyFields, context), + lifecycle); if (operationValidationResponse != null) { return operationValidationResponse; } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPostHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPostHandler.java index e0ae0328..356c9611 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPostHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPostHandler.java @@ -180,7 +180,8 @@ public ApiResponse handle( rawBody, queryParams, writePolicy.operationTypeFor( - RoutingVerb.POST, route, bodyFields, context)); + RoutingVerb.POST, route, bodyFields, context), + lifecycle); if (operationValidationResponse != null) { return operationValidationResponse; } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPutHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPutHandler.java index 7020f55c..9ff60b25 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPutHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPutHandler.java @@ -177,7 +177,8 @@ public ApiResponse handle( rawBody, queryParams, writePolicy.operationTypeFor( - RoutingVerb.PUT, route, bodyFields, context)); + RoutingVerb.PUT, route, bodyFields, context), + lifecycle); if (operationValidationResponse != null) { return operationValidationResponse; } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiQueryHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiQueryHandler.java index 203cc043..f8f705c4 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiQueryHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiQueryHandler.java @@ -150,7 +150,8 @@ public ApiResponse handle( ApiBodyFields.empty(), queryBody, mapping.queryParams, - "QUERY"); + "QUERY", + lifecycle); if (operationValidationResponse != null) { return operationValidationResponse; } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiMountDefinition.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiMountDefinition.java new file mode 100644 index 00000000..451da9c2 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiMountDefinition.java @@ -0,0 +1,266 @@ +package uk.co.compendiumdev.thingifier.api.spec; + +import java.util.ArrayList; +import java.util.List; + +/** + * Declares one public route prefix that exposes the canonical Thingifier API route set. + * + *

A mount is intentionally a public-facing alias, not a second copy of the model routes. Runtime + * handling strips the matched prefix before command/query mapping, while documentation and server + * registration can show the mounted public paths. This lets applications publish the same + * Thingifier-managed API under prefixes such as {@code /api} without custom bridge code. + */ +public final class ThingifierApiMountDefinition { + + private final String name; + private String prefix; + private final List includeRoutePatterns; + private boolean hiddenFromDocs; + private boolean rewriteLocationHeaders; + + ThingifierApiMountDefinition(final String name) { + this.name = requireName(name); + this.prefix = "/"; + this.includeRoutePatterns = new ArrayList<>(); + this.hiddenFromDocs = false; + this.rewriteLocationHeaders = false; + } + + /** + * Sets the public path prefix for this mount. + * + *

The root mount {@code /} is allowed and acts as a public alias for canonical routes. + * Non-root prefixes match exact path segments, so {@code /api} matches {@code /api/todos} but + * not {@code /apix/todos}. + * + * @param publicPathPrefix public prefix, with or without a leading slash + * @return this mount definition so configuration can be chained + */ + public ThingifierApiMountDefinition at(final String publicPathPrefix) { + this.prefix = normalizePrefix(publicPathPrefix); + return this; + } + + /** + * Limits the canonical Thingifier routes exposed through this mount. + * + *

Patterns are matched against internal route paths before the mount prefix is added. Exact + * paths and route-parameter patterns use normal Thingifier matching. A pattern ending in {@code + * /**} includes the base path and all descendants, e.g. {@code /todos/**} includes {@code + * /todos} and {@code /todos/1}. + * + * @param routePatterns canonical route patterns to expose + * @return this mount definition so configuration can be chained + */ + public ThingifierApiMountDefinition includeRoutes(final String... routePatterns) { + if (routePatterns == null) { + return this; + } + for (String routePattern : routePatterns) { + final String normalized = normalizePath(routePattern); + if (!normalized.isEmpty() && !includeRoutePatterns.contains(normalized)) { + includeRoutePatterns.add(normalized); + } + } + return this; + } + + /** + * Rewrites relative Location headers created by Thingifier to use this mount prefix. + * + *

This is useful when a create operation internally returns {@code /todos/21} but the caller + * used {@code /api/todos}. Absolute URLs are left alone. + * + * @return this mount definition so configuration can be chained + */ + public ThingifierApiMountDefinition rewriteLocationHeadersToMount() { + rewriteLocationHeaders = true; + return this; + } + + /** + * Hides this public mount from generated documentation while leaving it callable. + * + * @return this mount definition so configuration can be chained + */ + public ThingifierApiMountDefinition hideFromDocs() { + hiddenFromDocs = true; + return this; + } + + /** + * Shows this public mount in generated documentation. + * + * @return this mount definition so configuration can be chained + */ + public ThingifierApiMountDefinition exposeInDocs() { + hiddenFromDocs = false; + return this; + } + + /** + * Returns the stable mount name. + * + * @return mount name + */ + public String name() { + return name; + } + + /** + * Returns the public mount prefix with a leading slash. + * + * @return public prefix, or {@code /} for the root mount + */ + public String prefix() { + return prefix; + } + + /** + * Reports whether this mount should be hidden from generated documentation. + * + * @return true when hidden from docs + */ + public boolean isHiddenFromDocs() { + return hiddenFromDocs; + } + + /** + * Reports whether relative Location headers should be rewritten to this mount. + * + * @return true when Location headers should include the active mount prefix + */ + public boolean shouldRewriteLocationHeaders() { + return rewriteLocationHeaders; + } + + /** + * Returns the configured canonical include patterns. + * + * @return immutable include pattern list + */ + public List includeRoutePatterns() { + return List.copyOf(includeRoutePatterns); + } + + /** + * Reports whether the supplied public request path belongs to this mount. + * + * @param requestPath request path, with or without a leading slash + * @return true when this mount prefix matches + */ + public boolean matchesRequestPath(final String requestPath) { + final String normalizedRequestPath = normalizePath(requestPath); + final String normalizedPrefix = normalizedPrefix(); + if (normalizedPrefix.isEmpty()) { + return true; + } + return normalizedRequestPath.equals(normalizedPrefix) + || normalizedRequestPath.startsWith(normalizedPrefix + "/"); + } + + /** + * Converts a public request path into the canonical Thingifier route path for this mount. + * + * @param requestPath request path, with or without a leading slash + * @return canonical internal path without a leading slash + */ + public String internalPathFor(final String requestPath) { + final String normalizedRequestPath = normalizePath(requestPath); + final String normalizedPrefix = normalizedPrefix(); + if (normalizedPrefix.isEmpty()) { + return normalizedRequestPath; + } + if (normalizedRequestPath.equals(normalizedPrefix)) { + return ""; + } + if (normalizedRequestPath.startsWith(normalizedPrefix + "/")) { + return normalizedRequestPath.substring(normalizedPrefix.length() + 1); + } + return normalizedRequestPath; + } + + /** + * Reports whether this mount exposes a canonical route path. + * + * @param internalPath canonical Thingifier route path + * @return true when the route is included + */ + public boolean includesRoute(final String internalPath) { + if (includeRoutePatterns.isEmpty()) { + return true; + } + final String normalizedPath = normalizePath(internalPath); + for (String pattern : includeRoutePatterns) { + if (matchesIncludePattern(pattern, normalizedPath)) { + return true; + } + } + return false; + } + + /** + * Builds the public route URL for a canonical Thingifier route. + * + * @param internalPath canonical route path + * @return mounted route path without a leading slash for route metadata + */ + public String publicRouteUrlFor(final String internalPath) { + final String normalizedPath = normalizePath(internalPath); + final String normalizedPrefix = normalizedPrefix(); + if (normalizedPrefix.isEmpty()) { + return normalizedPath; + } + if (normalizedPath.isEmpty()) { + return normalizedPrefix; + } + return normalizedPrefix + "/" + normalizedPath; + } + + int prefixLength() { + return normalizedPrefix().length(); + } + + private boolean matchesIncludePattern(final String pattern, final String normalizedPath) { + if ("**".equals(pattern) || "*".equals(pattern)) { + return true; + } + if (pattern.endsWith("/**")) { + final String basePath = pattern.substring(0, pattern.length() - 3); + return normalizedPath.equals(basePath) || normalizedPath.startsWith(basePath + "/"); + } + return ApiRoutePathMatcher.pathsMatch(pattern, normalizedPath, ""); + } + + private String normalizedPrefix() { + return "/".equals(prefix) ? "" : normalizePath(prefix); + } + + private String normalizePrefix(final String rawPrefix) { + final String normalized = normalizePath(rawPrefix); + if (normalized.isEmpty()) { + return "/"; + } + return "/" + normalized; + } + + private String normalizePath(final String rawPath) { + String normalized = rawPath == null ? "" : rawPath.trim(); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (normalized.endsWith("/") && normalized.length() > 0) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private String requireName(final String rawName) { + final String normalizedName = rawName == null ? "" : rawName.trim(); + if (normalizedName.isEmpty()) { + throw new IllegalArgumentException("mount name is required"); + } + return normalizedName; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiMountSelection.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiMountSelection.java new file mode 100644 index 00000000..0cb19728 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiMountSelection.java @@ -0,0 +1,139 @@ +package uk.co.compendiumdev.thingifier.api.spec; + +/** + * Immutable result of resolving one request against configured API mounts. + * + *

The selection records both the public path that arrived at Thingifier and the canonical + * internal path that generated handlers should process. It is created before request hooks, + * authentication, validators, and callbacks so every later phase can agree on the same mount and + * route decision. + */ +public final class ThingifierApiMountSelection { + + private final boolean mounted; + private final String mountName; + private final String mountPrefix; + private final String requestPath; + private final String internalPath; + private final boolean rewriteLocationHeaders; + + private ThingifierApiMountSelection( + final boolean mounted, + final String mountName, + final String mountPrefix, + final String requestPath, + final String internalPath, + final boolean rewriteLocationHeaders) { + this.mounted = mounted; + this.mountName = mountName; + this.mountPrefix = normalizedPrefix(mountPrefix); + this.requestPath = normalizePath(requestPath); + this.internalPath = normalizePath(internalPath); + this.rewriteLocationHeaders = rewriteLocationHeaders; + } + + static ThingifierApiMountSelection none(final String requestPath) { + return new ThingifierApiMountSelection(false, null, "", requestPath, requestPath, false); + } + + static ThingifierApiMountSelection forMount( + final ThingifierApiMountDefinition mount, + final String requestPath, + final String internalPath) { + return new ThingifierApiMountSelection( + true, + mount.name(), + mount.prefix(), + requestPath, + internalPath, + mount.shouldRewriteLocationHeaders()); + } + + static ThingifierApiMountSelection forLegacyPrefix( + final String prefix, final String requestPath, final String internalPath) { + return new ThingifierApiMountSelection( + false, null, prefix, requestPath, internalPath, false); + } + + /** + * Reports whether a named mount matched this request. + * + * @return true when the request matched a configured mount + */ + public boolean isMounted() { + return mounted; + } + + /** + * Returns the active mount name. + * + * @return mount name, or null when no named mount matched + */ + public String mountName() { + return mountName; + } + + /** + * Returns the active public prefix with a leading slash. + * + * @return active prefix, or an empty string when no prefix was applied + */ + public String mountPrefix() { + return mountPrefix; + } + + /** + * Returns the public request path as received by Thingifier, without a leading slash. + * + * @return public request path + */ + public String requestPath() { + return requestPath; + } + + /** + * Returns the mounted public path for the request, without a leading slash. + * + * @return mounted path, or the request path when no named mount matched + */ + public String mountedPath() { + return requestPath; + } + + /** + * Returns the canonical Thingifier path handlers should process, without a leading slash. + * + * @return internal route path + */ + public String internalPath() { + return internalPath; + } + + /** + * Reports whether generated relative Location headers should be rewritten through this mount. + * + * @return true when Location rewriting is enabled for the active mount + */ + public boolean shouldRewriteLocationHeaders() { + return rewriteLocationHeaders && !mountPrefix.isEmpty() && !"/".equals(mountPrefix); + } + + private static String normalizedPrefix(final String rawPrefix) { + final String normalized = normalizePath(rawPrefix); + if (normalized.isEmpty()) { + return ""; + } + return "/" + normalized; + } + + private static String normalizePath(final String rawPath) { + String normalized = rawPath == null ? "" : rawPath.trim(); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (normalized.endsWith("/") && normalized.length() > 0) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java index b09f727f..f49da78b 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java @@ -46,6 +46,7 @@ public final class ThingifierApiSpec { private final ThingifierApiSecuritySpec securitySpec; private final Map authenticators; private final Map scopedSessions; + private final List mounts; public ThingifierApiSpec() { routeRules = new ArrayList<>(); @@ -56,6 +57,7 @@ public ThingifierApiSpec() { securitySpec = new ThingifierApiSecuritySpec(); authenticators = new HashMap<>(); scopedSessions = new LinkedHashMap<>(); + mounts = new ArrayList<>(); } /** @@ -102,6 +104,103 @@ public ThingifierApiPathRule route(final String pathPattern) { return new ThingifierApiPathRule(this, pathPattern); } + /** + * Creates or returns a named public mount for the generated Thingifier API. + * + *

Routes configured in the API spec remain canonical, e.g. {@code /todos}. A mount exposes + * those canonical routes under a public prefix such as {@code /api}, while runtime handling + * strips the prefix before generated command/query mapping. Named mounts are code-only API + * surface configuration and are intentionally not represented in model YAML. + * + * @param mountName stable mount name used in request/callback context + * @return mutable mount definition + */ + public ThingifierApiMountDefinition mount(final String mountName) { + final String normalizedName = mountName == null ? "" : mountName.trim(); + if (normalizedName.isEmpty()) { + throw new IllegalArgumentException("mount name is required"); + } + return mounts.stream() + .filter(mount -> mount.name().equals(normalizedName)) + .findFirst() + .orElseGet( + () -> { + final ThingifierApiMountDefinition mount = + new ThingifierApiMountDefinition(normalizedName); + mounts.add(mount); + return mount; + }); + } + + /** + * Returns configured public mounts in declaration order. + * + * @return immutable mount definitions + */ + public List mounts() { + return List.copyOf(mounts); + } + + /** + * Reports whether any named public mounts are configured. + * + * @return true when runtime mount resolution should be attempted + */ + public boolean hasMounts() { + return !mounts.isEmpty(); + } + + /** + * Reports whether any configured mounts should be shown in generated documentation. + * + * @return true when documentation should be projected through visible mounts + */ + public boolean hasDocumentedMounts() { + return mounts.stream().anyMatch(mount -> !mount.isHiddenFromDocs()); + } + + /** + * Resolves the public request path to the canonical Thingifier route path for this request. + * + *

Named mounts take precedence over the legacy single API endpoint prefix. When more than + * one mount could match, the most specific prefix wins and the root mount is considered last. + * + * @param requestPath public request path, with or without a leading slash + * @param legacyApiPathPrefix existing global API endpoint prefix + * @return mount selection describing public and internal paths + */ + public ThingifierApiMountSelection resolveMountFor( + final String requestPath, final String legacyApiPathPrefix) { + final Optional selectedMount = + mounts.stream() + .filter(mount -> mount.matchesRequestPath(requestPath)) + .filter(mount -> mount.includesRoute(mount.internalPathFor(requestPath))) + .max( + (left, right) -> + Integer.compare(left.prefixLength(), right.prefixLength())); + + if (selectedMount.isPresent()) { + final ThingifierApiMountDefinition mount = selectedMount.get(); + return ThingifierApiMountSelection.forMount( + mount, requestPath, mount.internalPathFor(requestPath)); + } + + final String normalizedRequestPath = normalize(requestPath); + final String normalizedLegacyPrefix = normalize(legacyApiPathPrefix); + if (!normalizedLegacyPrefix.isEmpty() + && (normalizedRequestPath.equals(normalizedLegacyPrefix) + || normalizedRequestPath.startsWith(normalizedLegacyPrefix + "/"))) { + final String internalPath = + normalizedRequestPath.equals(normalizedLegacyPrefix) + ? "" + : normalizedRequestPath.substring(normalizedLegacyPrefix.length() + 1); + return ThingifierApiMountSelection.forLegacyPrefix( + "/" + normalizedLegacyPrefix, requestPath, internalPath); + } + + return ThingifierApiMountSelection.none(requestPath); + } + /** * Returns the security declaration builder for this API. * @@ -326,6 +425,59 @@ public void applyTo(final ApiRoutingDefinition routingDefinition, final String a routingDefinition.updateOptionsAllowHeaders(); } + /** + * Projects canonical generated route metadata through visible public mounts. + * + *

This is deliberately a documentation/server-registration transformation only. Runtime + * route rules, validators, and handlers continue to work against canonical paths; the HTTP + * adapter resolves the active mount before those phases run. + * + * @param routingDefinition canonical generated route definitions + * @return mounted public route definitions when visible mounts exist, otherwise the original + * definitions + */ + public ApiRoutingDefinition projectMountedDocumentation( + final ApiRoutingDefinition routingDefinition) { + if (!hasDocumentedMounts()) { + return routingDefinition; + } + + final ApiRoutingDefinition projected = new ApiRoutingDefinition(); + for (EntityDefinition schema : routingDefinition.getObjectSchemas()) { + projected.addObjectSchema(schema); + } + + for (RoutingDefinition route : routingDefinition.definitions()) { + for (ThingifierApiMountDefinition mount : mounts) { + if (mount.isHiddenFromDocs() || !mount.includesRoute(route.url())) { + continue; + } + final String publicRouteUrl = mount.publicRouteUrlFor(route.url()); + projected.addRouting( + route.copyWithUrlAndDocumentation( + publicRouteUrl, mountedDocumentationFor(route, publicRouteUrl))); + } + } + projected.updateOptionsAllowHeaders(); + return projected; + } + + private String mountedDocumentationFor( + final RoutingDefinition route, final String publicRouteUrl) { + final String documentation = route.getDocumentation(); + final String publicPath = "/" + normalize(publicRouteUrl); + + if (documentation.startsWith("show all Options for endpoint of ")) { + return "show all Options for endpoint of " + publicPath; + } + if (documentation.startsWith("return supported verbs for fixed route ")) { + return "return supported verbs for fixed route " + publicPath; + } + + final String canonicalPath = "/" + normalize(route.url()); + return documentation.replace(canonicalPath, publicPath); + } + /** * Reports whether a route is disabled for a string verb. * diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/validation/ApiOperationValidationContext.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/validation/ApiOperationValidationContext.java index 5c4fc93a..b3063577 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/validation/ApiOperationValidationContext.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/validation/ApiOperationValidationContext.java @@ -21,6 +21,10 @@ public final class ApiOperationValidationContext { private final RoutingVerb verb; private final String publicPath; + private final String mountedPath; + private final String internalPath; + private final String mountName; + private final String mountPrefix; private final ThingRoute route; private final String targetEntityName; private final String targetIdentifier; @@ -65,8 +69,76 @@ public ApiOperationValidationContext( final String rawBody, final String requestEntityView, final String responseEntityView) { + this( + verb, + publicPath, + publicPath, + publicPath, + null, + "", + route, + targetEntityName, + targetIdentifier, + operationType, + requestContext, + headers, + queryParameters, + requestBody, + rawBody, + requestEntityView, + responseEntityView); + } + + /** + * Creates an operation validation context with explicit mounted route details. + * + *

Mounted requests expose both the public route requested by the caller and the canonical + * internal route Thingifier will process. Validators can use those facts without treating a + * prefix such as {@code /api} as part of the model route. + * + * @param verb route verb being processed + * @param publicPath public API path requested by the caller + * @param mountedPath active mounted public path + * @param internalPath canonical Thingifier route path + * @param mountName active mount name, or null + * @param mountPrefix active mount prefix, or empty + * @param route resolved Thingifier route target + * @param targetEntityName target entity name, or null when the route is not entity-backed + * @param targetIdentifier target instance identifier, or null for collection routes + * @param operationType read/write operation label such as CREATE, UPDATE, DELETE, or QUERY + * @param requestContext active request context after authentication and data-scope selection + * @param headers request headers + * @param queryParameters parsed query parameters + * @param requestBody parsed request body fields + * @param rawBody raw request body text + * @param requestEntityView request entity view selected for this route, or null + * @param responseEntityView response entity view selected for the expected success status, or + * null + */ + public ApiOperationValidationContext( + final RoutingVerb verb, + final String publicPath, + final String mountedPath, + final String internalPath, + final String mountName, + final String mountPrefix, + final ThingRoute route, + final String targetEntityName, + final String targetIdentifier, + final String operationType, + final ThingifierRequestContext requestContext, + final HttpHeadersBlock headers, + final QueryFilterParams queryParameters, + final ApiBodyFields requestBody, + final String rawBody, + final String requestEntityView, + final String responseEntityView) { this.verb = verb; this.publicPath = publicPath == null ? "" : publicPath; + this.mountedPath = mountedPath == null ? "" : mountedPath; + this.internalPath = internalPath == null ? "" : internalPath; + this.mountName = mountName; + this.mountPrefix = mountPrefix == null ? "" : mountPrefix; this.route = route; this.targetEntityName = targetEntityName; this.targetIdentifier = targetIdentifier; @@ -98,6 +170,42 @@ public String publicPath() { return publicPath; } + /** + * Returns the active mounted public path. + * + * @return mounted path without a leading slash + */ + public String mountedPath() { + return mountedPath; + } + + /** + * Returns the canonical Thingifier route path. + * + * @return internal route path without a leading slash + */ + public String internalPath() { + return internalPath; + } + + /** + * Returns the active public mount name. + * + * @return mount name, or null when no named mount matched + */ + public String mountName() { + return mountName; + } + + /** + * Returns the active public mount prefix. + * + * @return mount prefix with a leading slash, or empty when no prefix applies + */ + public String mountPrefix() { + return mountPrefix; + } + /** * Returns the resolved Thingifier route target. * diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiMountTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiMountTest.java new file mode 100644 index 00000000..b459e154 --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiMountTest.java @@ -0,0 +1,333 @@ +package uk.co.compendiumdev.thingifier.api.spec; + +import static uk.co.compendiumdev.thingifier.apiconfig.EntityWriteOperation.UPDATE; +import static uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType.AUTO_INCREMENT; +import static uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType.STRING; + +import io.swagger.v3.oas.models.OpenAPI; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.adapter.httpserver.HttpRouteRegistry; +import uk.co.compendiumdev.thingifier.adapter.httpserver.HttpRouteVerb; +import uk.co.compendiumdev.thingifier.adapter.httpserver.ThingifierHttpApiRoutings; +import uk.co.compendiumdev.thingifier.api.callbacks.ThingifierApiOperationContext; +import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinitionDocGenerator; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.api.docgen.ThingifierApiDocumentationDefn; +import uk.co.compendiumdev.thingifier.api.http.HttpApiRequest; +import uk.co.compendiumdev.thingifier.api.http.HttpApiResponse; +import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi; +import uk.co.compendiumdev.thingifier.api.validation.ApiOperationValidationContext; +import uk.co.compendiumdev.thingifier.api.validation.ApiOperationValidationResult; +import uk.co.compendiumdev.thingifier.core.EntityRelModel; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; +import uk.co.compendiumdev.thingifier.swaggerizer.Swaggerizer; + +class ThingifierApiMountTest { + + @Test + void visibleMountProjectsGeneratedRoutesIntoDocumentation() { + final Thingifier thingifier = todoModel(); + thingifier.apiSpec().mount("api").at("/api").includeRoutes("/todos/**"); + + final ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(thingifier).generate(""); + + Assertions.assertNotNull(route(definition, RoutingVerb.GET, "api/todos")); + Assertions.assertTrue(routes(definition, RoutingVerb.GET, "todos").isEmpty()); + } + + @Test + void includeRoutesLimitsMountedDocumentationToMatchingCanonicalRoutes() { + final Thingifier thingifier = todoAndProjectModel(); + thingifier.apiSpec().mount("api").at("/api").includeRoutes("/todos/**"); + + final ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(thingifier).generate(""); + + Assertions.assertNotNull(route(definition, RoutingVerb.GET, "api/todos")); + Assertions.assertTrue(routes(definition, RoutingVerb.GET, "api/projects").isEmpty()); + } + + @Test + void hiddenMountDoesNotCreateDocumentedAlias() { + final Thingifier thingifier = todoModel(); + thingifier.apiSpec().mount("api").at("/api").includeRoutes("/todos/**"); + thingifier.apiSpec().mount("legacy").at("/").includeRoutes("/todos/**").hideFromDocs(); + + final ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(thingifier).generate(""); + + Assertions.assertNotNull(route(definition, RoutingVerb.GET, "api/todos")); + Assertions.assertTrue(routes(definition, RoutingVerb.GET, "todos").isEmpty()); + } + + @Test + void openApiDocumentsMountedPublicPathOnly() { + final Thingifier thingifier = todoModel(); + thingifier.apiSpec().mount("api").at("/api").includeRoutes("/todos/**"); + thingifier.apiSpec().mount("legacy").at("/").includeRoutes("/todos/**").hideFromDocs(); + final ThingifierApiDocumentationDefn apiDefn = new ThingifierApiDocumentationDefn(); + apiDefn.setThingifier(thingifier); + apiDefn.setPathPrefix(""); + + final OpenAPI openApi = new Swaggerizer(apiDefn).swagger(); + + Assertions.assertNotNull(openApi.getPaths().get("/api/todos")); + Assertions.assertNull(openApi.getPaths().get("/todos")); + } + + @ParameterizedTest + @CsvSource({ + "api/todos, /api/todos", + "api/todos/:id, /api/todos/:id", + }) + void mountedOptionsRouteDocumentationUsesPublicPath( + final String routeUrl, final String documentedPath) { + final Thingifier thingifier = todoModel(); + thingifier.apiSpec().mount("api").at("/api").includeRoutes("/todos/**"); + + final ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(thingifier).generate(""); + + Assertions.assertEquals( + "show all Options for endpoint of " + documentedPath, + route(definition, RoutingVerb.OPTIONS, routeUrl).getDocumentation()); + } + + @ParameterizedTest + @CsvSource({ + "/api/todos, /api/todos", + "/api/todos/{id}, /api/todos/:id", + }) + void openApiMountedOptionsSummaryUsesPublicPath( + final String openApiPath, final String documentedPath) { + final Thingifier thingifier = todoModel(); + thingifier.apiSpec().mount("api").at("/api").includeRoutes("/todos/**"); + final ThingifierApiDocumentationDefn apiDefn = new ThingifierApiDocumentationDefn(); + apiDefn.setThingifier(thingifier); + apiDefn.setPathPrefix(""); + + final OpenAPI openApi = new Swaggerizer(apiDefn).swagger(); + + Assertions.assertEquals( + "show all Options for endpoint of " + documentedPath, + openApi.getPaths().get(openApiPath).getOptions().getSummary()); + } + + @Test + void mountedFixedRoutesGenerateOptionsAllowHeader() { + final Thingifier thingifier = secretModel(); + getSecretNoteRoute(thingifier); + postSecretNoteRoute(thingifier); + thingifier.apiSpec().mount("api").at("/api").includeRoutes("/secret/**"); + + final ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(thingifier).generate(""); + + Assertions.assertEquals( + "OPTIONS, GET, HEAD, POST", + route(definition, RoutingVerb.OPTIONS, "api/secret/note").headerValue()); + } + + @Test + void mountedFixedOptionsRouteDocumentationUsesPublicPath() { + final Thingifier thingifier = secretModel(); + getSecretNoteRoute(thingifier); + thingifier.apiSpec().mount("api").at("/api").includeRoutes("/secret/**"); + + final ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(thingifier).generate(""); + + Assertions.assertEquals( + "return supported verbs for fixed route /api/secret/note", + route(definition, RoutingVerb.OPTIONS, "api/secret/note").getDocumentation()); + } + + @Test + void mountedFixedOptionsRouteIsRegisteredForHttpServer() { + final Thingifier thingifier = secretModel(); + getSecretNoteRoute(thingifier); + thingifier.apiSpec().mount("api").at("/api").includeRoutes("/secret/**"); + final HttpRouteRegistry registry = new HttpRouteRegistry(); + HttpRouteRegistry.use(registry); + + try { + final ThingifierApiDocumentationDefn apiDefn = new ThingifierApiDocumentationDefn(); + apiDefn.setThingifier(thingifier); + apiDefn.setPathPrefix(""); + new ThingifierHttpApiRoutings(thingifier, apiDefn); + + Assertions.assertTrue( + registry.routes().stream() + .anyMatch( + route -> + route.verb() == HttpRouteVerb.OPTIONS + && route.path().equals("api/secret/note"))); + } finally { + HttpRouteRegistry.clearCurrent(); + } + } + + @Test + void mountedFixedRouteUsesInternalTargetAndCallbackSeesPublicPath() { + final Thingifier thingifier = secretModel(); + createSecretNote(thingifier, "note", "mounted note"); + final AtomicReference seenContext = new AtomicReference<>(); + getSecretNoteRoute(thingifier) + .afterResponse((context, response) -> seenContext.set(context)); + thingifier + .apiSpec() + .mount("api") + .at("/api") + .includeRoutes("/secret/**") + .rewriteLocationHeadersToMount(); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier).get(jsonRequest("/api/secret/note")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertTrue(response.getBody().contains("mounted note")); + Assertions.assertEquals("/api/secret/note", seenContext.get().publicPath()); + Assertions.assertEquals("/secret/note", seenContext.get().internalPath()); + Assertions.assertEquals("/api/secret/note", seenContext.get().mountedPath()); + Assertions.assertEquals("api", seenContext.get().mountName().orElseThrow()); + Assertions.assertEquals("/api", seenContext.get().mountPrefix()); + Assertions.assertEquals("note", seenContext.get().targetIdentifier().orElseThrow()); + } + + @Test + void mountedCreateRewritesRelativeLocationHeaderToActiveMount() { + final Thingifier thingifier = todoModel(); + thingifier + .apiSpec() + .mount("api") + .at("/api") + .includeRoutes("/todos/**") + .rewriteLocationHeadersToMount(); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier) + .post(jsonPost("/api/todos", "{\"title\":\"new todo\"}")); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertTrue(response.getHeaders().get("Location").startsWith("/api/todos/")); + } + + @Test + void mountedOperationValidatorReceivesPublicAndInternalPaths() { + final Thingifier thingifier = todoModel(); + final AtomicReference seenContext = new AtomicReference<>(); + thingifier + .apiSpec() + .route(RoutingVerb.POST, "/todos") + .withApiOperationValidator( + "captureMountedPaths", + context -> { + seenContext.set(context); + return ApiOperationValidationResult.accept(); + }); + thingifier.apiSpec().mount("api").at("/api").includeRoutes("/todos/**"); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier) + .post(jsonPost("/api/todos", "{\"title\":\"new todo\"}")); + + Assertions.assertEquals(201, response.getStatusCode()); + Assertions.assertEquals("api/todos", seenContext.get().publicPath()); + Assertions.assertEquals("api/todos", seenContext.get().mountedPath()); + Assertions.assertEquals("todos", seenContext.get().internalPath()); + Assertions.assertEquals("api", seenContext.get().mountName()); + Assertions.assertEquals("/api", seenContext.get().mountPrefix()); + } + + private Thingifier todoModel() { + final Thingifier thingifier = new Thingifier(); + final EntityDefinition todo = thingifier.defineThing("todo", "todos", 5); + todo.addAsPrimaryKeyField(Field.is("id", AUTO_INCREMENT)); + todo.addField(Field.is("title", STRING).makeMandatory()); + return thingifier; + } + + private Thingifier todoAndProjectModel() { + final Thingifier thingifier = todoModel(); + final EntityDefinition project = thingifier.defineThing("project", "projects", 5); + project.addAsPrimaryKeyField(Field.is("id", AUTO_INCREMENT)); + project.addField(Field.is("name", STRING)); + return thingifier; + } + + private Thingifier secretModel() { + final Thingifier thingifier = new Thingifier(); + final EntityDefinition note = thingifier.defineThing("secretnote", "secretnotes", 10); + note.addAsPrimaryKeyField(Field.is("id", STRING)); + note.addField(Field.is("text", STRING)); + return thingifier; + } + + private ThingifierApiRouteRule getSecretNoteRoute(final Thingifier thingifier) { + return thingifier + .apiSpec() + .route(RoutingVerb.GET, "/secret/note") + .mapsToEntity("secretnote") + .withFixedIdentifier("note"); + } + + private ThingifierApiRouteRule postSecretNoteRoute(final Thingifier thingifier) { + return thingifier + .apiSpec() + .route(RoutingVerb.POST, "/secret/note") + .mapsToEntity("secretnote") + .withFixedIdentifier("note") + .entityCan(UPDATE); + } + + private void createSecretNote(final Thingifier thingifier, final String id, final String text) { + final EntityDefinition note = thingifier.getDefinitionNamed("secretnote"); + thingifier + .getStore(EntityRelModel.DEFAULT_DATABASE_NAME) + .entities() + .create( + EntityInstanceDraft.forEntity(note) + .withField("id", id) + .withField("text", text)); + } + + private HttpApiRequest jsonRequest(final String path) { + return new HttpApiRequest(path).addHeader("Accept", "application/json"); + } + + private HttpApiRequest jsonPost(final String path, final String body) { + return new HttpApiRequest(path) + .setVerb("POST") + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .setBody(body); + } + + private RoutingDefinition route( + final ApiRoutingDefinition definition, final RoutingVerb verb, final String url) { + return definition.definitions().stream() + .filter(route -> route.verb() == verb) + .filter(route -> route.url().equals(url)) + .findFirst() + .orElseThrow(); + } + + private List routes( + final ApiRoutingDefinition definition, final RoutingVerb verb, final String url) { + return definition.definitions().stream() + .filter(route -> route.verb() == verb) + .filter(route -> route.url().equals(url)) + .toList(); + } +}