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 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 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 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 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 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 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 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 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 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