From 152edec881765f17506e184e3f871564377588f8 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Tue, 20 Oct 2026 15:04:33 +0200 Subject: [PATCH 1/6] CAMEL-25887: camel-rest-openapi - option to restrict the external references of the OpenAPI specification The parser loads every external $ref of the specification when the consumer or producer is created, from the working directory and the classpath or over http(s), and the api-doc of a contract-first REST DSL resolves them as well. The new externalReferences option (all, local, none) on the rest-openapi endpoint and component and on the openApi element of the REST DSL checks the references before the parser resolves them, in the specification and in the local documents it references, and fails the start with a message that names a reference the option does not allow. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Andrea Cosentino --- .../catalog/components/rest-openapi.json | 8 +- .../catalog/docs/rest-openapi-component.adoc | 27 ++ .../apache/camel/catalog/models/openApi.json | 3 +- .../camel/catalog/schemas/camel-spring.xsd | 11 + .../camel/catalog/schemas/camel-xml-io.xsd | 11 + .../openapi/OpenApiExternalReferences.java | 195 ++++++++++++++ .../camel/openapi/RestOpenApiReader.java | 22 ++ ...stOpenApiReaderExternalReferencesTest.java | 108 ++++++++ .../resources/external-references-pet.yaml | 27 ++ .../test/resources/external-references.yaml | 41 +++ .../RestOpenApiComponentConfigurer.java | 6 + .../RestOpenApiEndpointConfigurer.java | 6 + .../RestOpenApiEndpointUriFactory.java | 3 +- .../component/rest/openapi/rest-openapi.json | 8 +- .../src/main/docs/rest-openapi-component.adoc | 27 ++ .../openapi/OpenApiExternalReferences.java | 194 ++++++++++++++ .../rest/openapi/RestOpenApiComponent.java | 15 ++ .../rest/openapi/RestOpenApiEndpoint.java | 70 ++++- .../RestOpenApiExternalReferencesTest.java | 242 ++++++++++++++++++ .../resources/external-references-nested.yaml | 39 +++ .../external-references-pet-remote.yaml | 27 ++ .../resources/external-references-pet.yaml | 27 ++ .../resources/external-references-root.yaml | 39 +++ .../org/apache/camel/model/rest/openApi.json | 3 +- .../camel/model/rest/OpenApiDefinition.java | 22 ++ .../camel/model/rest/RestDefinition.java | 3 + .../camel/java/out/JavaDslModelWriter.java | 1 + .../org/apache/camel/xml/in/ModelParser.java | 1 + .../org/apache/camel/xml/out/ModelWriter.java | 1 + .../camel/yaml/out/YamlModelWriter.java | 1 + .../modules/ROOT/pages/rest-dsl-openapi.adoc | 42 +++ .../RestOpenapiComponentBuilderFactory.java | 22 ++ .../RestOpenApiEndpointBuilderFactory.java | 66 +++++ .../jbang/core/commands/ai/eip-samples.json | 4 + .../deserializers/ModelDeserializers.java | 6 + .../schema/camelYamlDsl-canonical.json | 7 + .../resources/schema/camelYamlDsl-model.json | 11 + .../resources/schema/camelYamlDsl.json | 7 + 38 files changed, 1333 insertions(+), 20 deletions(-) create mode 100644 components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java create mode 100644 components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java create mode 100644 components/camel-openapi-java/src/test/resources/external-references-pet.yaml create mode 100644 components/camel-openapi-java/src/test/resources/external-references.yaml create mode 100644 components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java create mode 100644 components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java create mode 100644 components/camel-rest-openapi/src/test/resources/external-references-nested.yaml create mode 100644 components/camel-rest-openapi/src/test/resources/external-references-pet-remote.yaml create mode 100644 components/camel-rest-openapi/src/test/resources/external-references-pet.yaml create mode 100644 components/camel-rest-openapi/src/test/resources/external-references-root.yaml diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/rest-openapi.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/rest-openapi.json index d649162cff1c1..5e6f53bd28b3b 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/rest-openapi.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/rest-openapi.json @@ -43,8 +43,9 @@ "consumes": { "index": 16, "kind": "property", "displayName": "Consumes", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "What payload type this component capable of consuming. Could be one type, like application\/json or multiple types as application\/json, application\/xml; q=0.5 according to the RFC7231. This equates to the value of Accept HTTP header. If set overrides any value found in the OpenApi specification. Can be overridden in endpoint configuration" }, "produces": { "index": 17, "kind": "property", "displayName": "Produces", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "What payload type this component is producing. For example application\/json according to the RFC7231. This equates to the value of Content-Type HTTP header. If set overrides any value present in the OpenApi specification. Can be overridden in endpoint configuration." }, "autowiredEnabled": { "index": 18, "kind": "property", "displayName": "Autowired Enabled", "group": "advanced", "label": "advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "description": "Whether autowiring is enabled. This is used for automatic autowiring options (the option must be marked as autowired) by looking up in the registry to find if there is a single instance of matching type, which then gets configured on the component. This can be used for automatic configuring JDBC data sources, JMS connection factories, AWS Clients, etc." }, - "sslContextParameters": { "index": 19, "kind": "property", "displayName": "Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.camel.support.jsse.SSLContextParameters", "deprecated": false, "autowired": false, "secret": false, "description": "Customize TLS parameters used by the component. If not set defaults to the TLS parameters set in the Camel context" }, - "useGlobalSslContextParameters": { "index": 20, "kind": "property", "displayName": "Use Global Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Enable usage of global SSL context parameters." } + "externalReferences": { "index": 19, "kind": "property", "displayName": "External References", "group": "security", "label": "security", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "all", "local", "none" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "all", "description": "Which external references ($ref) the OpenAPI specification may use: all allows any reference, local allows references to the classpath and the file system only, and none allows only references inside the document. A reference the option does not allow fails the start. Can be overridden in endpoint configuration." }, + "sslContextParameters": { "index": 20, "kind": "property", "displayName": "Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.camel.support.jsse.SSLContextParameters", "deprecated": false, "autowired": false, "secret": false, "description": "Customize TLS parameters used by the component. If not set defaults to the TLS parameters set in the Camel context" }, + "useGlobalSslContextParameters": { "index": 21, "kind": "property", "displayName": "Use Global Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Enable usage of global SSL context parameters." } }, "properties": { "specificationUri": { "index": 0, "kind": "path", "displayName": "Specification Uri", "group": "common", "label": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "openapi.json", "description": "Path to the OpenApi specification file. The scheme, host base path are taken from this specification, but these can be overridden with properties on the component or endpoint level. If not given the component tries to load openapi.json resource from the classpath. Note that the host defined on the component and endpoint of this Component should contain the scheme, hostname and optionally the port in the URI syntax (i.e. http:\/\/api.example.com:8080). Overrides component configuration. The OpenApi specification can be loaded from different sources by prefixing with file: classpath: http: https:. Support for https is limited to using the JDK installed UrlHandler, and as such it can be cumbersome to setup TLS\/SSL certificates for https (such as setting a number of javax.net.ssl JVM system properties). How to do that consult the JDK documentation for UrlHandler. Default value notice: By default loads openapi.json file" }, @@ -67,6 +68,7 @@ "requestValidationEnabled": { "index": 17, "kind": "parameter", "displayName": "Request Validation Enabled", "group": "producer", "label": "producer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Enable validation of requests against the configured OpenAPI specification" }, "componentName": { "index": 18, "kind": "parameter", "displayName": "Component Name", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "Name of the Camel component that will perform the requests. The component must be present in Camel registry and it must implement RestProducerFactory service provider interface. If not set CLASSPATH is searched for single component that implements RestProducerFactory SPI. Overrides component configuration." }, "lazyStartProducer": { "index": 19, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, - "oauthProfile": { "index": 20, "kind": "parameter", "displayName": "OAuth Profile", "group": "security", "label": "consumer,security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "OAuth profile name passed to the HTTP consumer delegate for validating incoming Authorization: Bearer tokens. The selected consumer component must support the oauthProfile option; delegates that ignore unknown options will start without endpoint protection." } + "externalReferences": { "index": 20, "kind": "parameter", "displayName": "External References", "group": "security", "label": "security", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "all", "local", "none" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "all", "description": "Which external references ($ref) the OpenAPI specification may use. The parser loads every external reference when the consumer or producer is created, from the classpath, the file system or over http(s). all allows any reference, local allows references to the classpath and the file system only, and none allows only references inside the document. A reference the option does not allow fails the start, before the parser loads anything. Overrides component configuration." }, + "oauthProfile": { "index": 21, "kind": "parameter", "displayName": "OAuth Profile", "group": "security", "label": "consumer,security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "OAuth profile name passed to the HTTP consumer delegate for validating incoming Authorization: Bearer tokens. The selected consumer component must support the oauthProfile option; delegates that ignore unknown options will start without endpoint protection." } } } diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/rest-openapi-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/rest-openapi-component.adoc index b7138eab3a7dc..7f0455f634fcc 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/rest-openapi-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/rest-openapi-component.adoc @@ -330,6 +330,33 @@ so a misconfigured delegate cannot start without the expected protection. Custom enforce the option must override `RestOpenApiConsumerFactory.supportsOAuthProfile()` to return `true`. +=== Restricting the external references of the specification + +An OpenAPI specification can reference other documents with `$ref`: files on the classpath or the file system, +and documents on an http(s) server. The parser loads every such document when the consumer or producer is +created, and a document it loads can reference further documents. The `externalReferences` option, on the +endpoint or the component, restricts this: + +* `all` (default) allows any reference. +* `local` allows references to the classpath and the file system only. A specification loaded from a URL may + then reference no other document at all, because the parser resolves its references against that URL. +* `none` allows only references inside the document, such as `#/components/schemas/Pet`. + +The check runs on the specification, and on the local documents it references, before the parser resolves +anything. It covers every `$ref` in a document, examples included. A reference the option does not allow fails +the start, with a message that names the reference and where it is. + +[source,java] +---- +from("direct:getPetById") + .to("rest-openapi:petstore-v3.json#getPetById?externalReferences=local"); +---- + +The option can also be set once on the component, for example with +`camel.component.rest-openapi.external-references=none`. For the contract-first Rest DSL, set it on the `openApi` +element as shown in xref:manual::rest-dsl-openapi.adoc[REST DSL with contract-first OpenAPI]; the component setting +applies there as well, also to the specification served as api-doc. + == Request validation API requests can be validated against the configured OpenAPI specification before they are sent by setting the `requestValidationEnabled` option to `true`. diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/openApi.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/openApi.json index 6925078501fa4..e28c784b28d3f 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/openApi.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/openApi.json @@ -21,6 +21,7 @@ "routeId": { "index": 6, "kind": "attribute", "displayName": "Route Id", "group": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The route id." }, "missingOperation": { "index": 7, "kind": "attribute", "displayName": "Missing Operation", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "fail", "ignore", "mock" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "fail", "description": "Whether to fail, ignore or return a mock response for OpenAPI operations that are not mapped to a corresponding route." }, "unmatchedRequestHandling": { "index": 8, "kind": "attribute", "displayName": "Unmatched Request Handling", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "platform", "camel" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "platform", "description": "Who answers requests that match no operation in the OpenAPI specification: the HTTP layer (platform) or Camel via the unmatched request handler (camel)." }, - "mockIncludePattern": { "index": 9, "kind": "attribute", "displayName": "Mock Include Pattern", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "classpath:camel-mock\/**", "description": "Used for inclusive filtering of mock data from directories. The pattern is using Ant-path style pattern. Multiple patterns can be specified separated by comma." } + "mockIncludePattern": { "index": 9, "kind": "attribute", "displayName": "Mock Include Pattern", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "classpath:camel-mock\/**", "description": "Used for inclusive filtering of mock data from directories. The pattern is using Ant-path style pattern. Multiple patterns can be specified separated by comma." }, + "externalReferences": { "index": 10, "kind": "attribute", "displayName": "External References", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "all", "local", "none" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "all", "description": "Which external references ($ref) the OpenAPI specification may use: all allows any reference, local allows references to the classpath and the file system only, and none allows only references inside the document. A reference the option does not allow fails the start." } } } diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-spring.xsd b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-spring.xsd index 7c61586a3645a..fa67b0d989ce6 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-spring.xsd +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-spring.xsd @@ -15655,6 +15655,17 @@ unmatched request handler (camel). Default value: platform + + + + + + + diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-xml-io.xsd b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-xml-io.xsd index c10619d594f40..6190282a800e3 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-xml-io.xsd +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camel-xml-io.xsd @@ -14798,6 +14798,17 @@ unmatched request handler (camel). Default value: platform + + + + + + + diff --git a/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java b/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java new file mode 100644 index 0000000000000..7df77c424a032 --- /dev/null +++ b/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java @@ -0,0 +1,195 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.openapi; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.core.util.Json; +import io.swagger.v3.core.util.Yaml; +import io.swagger.v3.parser.models.RefFormat; +import io.swagger.v3.parser.util.RefUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Checks the references ({@code $ref}) of an OpenAPI specification against the {@code externalReferences} option before + * the parser resolves them. The parser loads every external reference it meets: from the working directory and the + * classpath, or over http(s) when the reference is a URL or the specification itself comes from a URL. + *

+ * This is a copy of the class of the same name in camel-rest-openapi, which neither module can depend on the other for; + * keep the two in sync. + */ +final class OpenApiExternalReferences { + + static final String ALL = "all"; + static final String LOCAL = "local"; + static final String NONE = "none"; + + private static final Logger LOG = LoggerFactory.getLogger(OpenApiExternalReferences.class); + + private OpenApiExternalReferences() { + } + + /** + * The value of the option to apply: {@link #ALL} when none is set. + * + * @throws IllegalArgumentException for a value other than all, local or none + */ + static String policy(String externalReferences) { + if (externalReferences == null || externalReferences.isBlank()) { + return ALL; + } + String policy = externalReferences.trim().toLowerCase(Locale.ROOT); + if (ALL.equals(policy) || LOCAL.equals(policy) || NONE.equals(policy)) { + return policy; + } + throw new IllegalArgumentException( + "Unknown externalReferences value: " + externalReferences + ", expected all, local or none"); + } + + /** + * Checks every {@code $ref} of the specification, and of the local documents it references, against the option. + * + * @param externalReferences the option: all, local or none + * @param specification the specification, as JSON or YAML text + * @param specificationUri where the specification comes from, for the error message + * @param remote whether the specification comes from a URL: the parser then resolves its + * external references against that URL + * @throws IllegalArgumentException for the first reference the option does not allow + */ + static void check(String externalReferences, String specification, String specificationUri, boolean remote) { + String policy = policy(externalReferences); + if (ALL.equals(policy)) { + return; + } + checkDocument(policy, specification, specificationUri, null, remote, new HashSet<>()); + } + + private static void checkDocument( + String policy, String content, String specificationUri, String document, boolean remote, + Set checked) { + DocumentCheck check = new DocumentCheck(policy, specificationUri, document, remote); + check.scan(readTree(content, specificationUri, document), ""); + // a local document can reference further documents, which the parser loads as well + for (String file : check.localDocuments) { + if (checked.add(file)) { + String text = readLocalDocument(file); + if (text != null) { + checkDocument(policy, text, specificationUri, file, false, checked); + } + } + } + } + + private static JsonNode readTree(String content, String specificationUri, String document) { + try { + return (content.trim().startsWith("{") ? Json.mapper() : Yaml.mapper()).readTree(content); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException( + "Cannot read the OpenAPI specification " + specificationUri + + (document != null ? " (" + document + ")" : "") + + " to check its references: " + e.getMessage(), + e); + } + } + + private static String readLocalDocument(String file) { + try { + // how the parser reads a relative reference of a specification that has no location: from the working + // directory, then from the classpath + return RefUtils.readExternalRef(file, RefFormat.RELATIVE, null, new File(".").toPath()); + } catch (RuntimeException e) { + LOG.debug("Cannot read {} to check its references, the parser reports it when it resolves the specification: {}", + file, e.getMessage()); + return null; + } + } + + private static boolean isHttp(String file) { + String lower = file.toLowerCase(Locale.ROOT); + return lower.startsWith("http:") || lower.startsWith("https:"); + } + + /** + * The check of one document. + */ + private static final class DocumentCheck { + + private final String policy; + private final String specificationUri; + private final String document; + private final boolean remote; + private final List localDocuments = new ArrayList<>(); + + DocumentCheck(String policy, String specificationUri, String document, boolean remote) { + this.policy = policy; + this.specificationUri = specificationUri; + this.document = document; + this.remote = remote; + } + + void scan(JsonNode node, String path) { + if (node.isObject()) { + for (Map.Entry field : node.properties()) { + String fieldPath = path.isEmpty() ? field.getKey() : path + "." + field.getKey(); + if ("$ref".equals(field.getKey()) && field.getValue().isTextual()) { + reference(field.getValue().asText(), fieldPath); + } else { + scan(field.getValue(), fieldPath); + } + } + } else if (node.isArray()) { + int i = 0; + for (JsonNode item : node) { + scan(item, path + "[" + i++ + "]"); + } + } + } + + private void reference(String ref, String path) { + if (ref.isEmpty() || ref.startsWith("#")) { + // inside the document + return; + } + int fragment = ref.indexOf('#'); + String file = fragment < 0 ? ref : ref.substring(0, fragment); + String reason = null; + if (NONE.equals(policy)) { + reason = "externalReferences=none allows only references inside the document"; + } else if (remote) { + reason = "externalReferences=local allows no external reference in a specification loaded from a URL," + + " since the parser resolves it against that URL"; + } else if (isHttp(file)) { + reason = "externalReferences=local allows no http(s) reference"; + } + if (reason != null) { + throw new IllegalArgumentException( + "The OpenAPI specification " + specificationUri + " references " + ref + " at " + path + + (document != null ? " in " + document : "") + ", but " + reason); + } + localDocuments.add(file); + } + } +} diff --git a/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/RestOpenApiReader.java b/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/RestOpenApiReader.java index 9176e41adac48..28dfa6b36365d 100644 --- a/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/RestOpenApiReader.java +++ b/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/RestOpenApiReader.java @@ -67,12 +67,14 @@ import io.swagger.v3.parser.OpenAPIV3Parser; import io.swagger.v3.parser.core.models.SwaggerParseResult; import org.apache.camel.CamelContext; +import org.apache.camel.Component; import org.apache.camel.model.rest.ApiKeyDefinition; import org.apache.camel.model.rest.BasicAuthDefinition; import org.apache.camel.model.rest.BearerTokenDefinition; import org.apache.camel.model.rest.CollectionFormat; import org.apache.camel.model.rest.MutualTLSDefinition; import org.apache.camel.model.rest.OAuth2Definition; +import org.apache.camel.model.rest.OpenApiDefinition; import org.apache.camel.model.rest.OpenIdConnectDefinition; import org.apache.camel.model.rest.ParamDefinition; import org.apache.camel.model.rest.ResponseHeaderDefinition; @@ -166,6 +168,9 @@ public OpenAPI read( InputStream is = res.getInputStream(); String data = IOHelper.loadText(is); IOHelper.close(is); + // the parser resolves every external reference of the contract, so check them first + OpenApiExternalReferences.check(externalReferences(camelContext, rest.getOpenApi()), data, + rest.getOpenApi().getSpecification(), false); OpenAPIV3Parser parser = new OpenAPIV3Parser(); SwaggerParseResult out = parser.readContents(data); OpenAPI answer = out.getOpenAPI(); @@ -1347,4 +1352,21 @@ private String fixSchemaReference(String ref, String prefix) { } } + /** + * The externalReferences option of the contract: from its openApi element, or else from the rest-openapi component, + * which consumes the contract and whose setting therefore applies to the api-doc as well. + */ + private static String externalReferences(CamelContext camelContext, OpenApiDefinition openApi) { + String answer = openApi.getExternalReferences(); + if (answer == null) { + Component component = camelContext.hasComponent("rest-openapi"); + if (component != null) { + Object value = PluginHelper.getBeanIntrospection(camelContext) + .getOrElseProperty(component, "externalReferences", null, true); + answer = value != null ? value.toString() : null; + } + } + return answer != null ? CamelContextHelper.parseText(camelContext, answer) : null; + } + } diff --git a/components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java b/components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java new file mode 100644 index 0000000000000..38eb75504ce11 --- /dev/null +++ b/components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.openapi; + +import java.util.List; +import java.util.Map; + +import io.swagger.v3.oas.models.OpenAPI; +import org.apache.camel.CamelContext; +import org.apache.camel.Endpoint; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.model.rest.RestDefinition; +import org.apache.camel.support.DefaultComponent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The externalReferences option of a contract-first openApi element, applied to the contract served as api-doc. + */ +class RestOpenApiReaderExternalReferencesTest { + + private final CamelContext context = new DefaultCamelContext(); + + @BeforeEach + void start() { + context.start(); + } + + @AfterEach + void stop() { + context.stop(); + } + + @Test + void resolvesTheReferencedDocumentByDefault() throws Exception { + RestDefinition rest = new RestDefinition(); + rest.openApi("external-references.yaml"); + + OpenAPI openApi = read(rest); + + assertNotNull(openApi.getPaths().get("/pets/{petId}")); + // the parser imported the Pet schema from external-references-pet.yaml + assertNotNull(openApi.getComponents().getSchemas().get("Pet")); + } + + @Test + void failsWhenTheContractAllowsNoExternalReference() { + RestDefinition rest = new RestDefinition(); + rest.openApi().specification("external-references.yaml").externalReferences("none"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> read(rest)); + + assertTrue(e.getMessage().contains("external-references-pet.yaml#/components/schemas/Pet"), e.getMessage()); + assertTrue(e.getMessage().contains("externalReferences=none"), e.getMessage()); + } + + @Test + void usesTheSettingOfTheRestOpenApiComponent() { + context.addComponent("rest-openapi", new RestrictedComponent()); + RestDefinition rest = new RestDefinition(); + rest.openApi("external-references.yaml"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> read(rest)); + + assertTrue(e.getMessage().contains("externalReferences=none"), e.getMessage()); + } + + private OpenAPI read(RestDefinition rest) throws Exception { + return new RestOpenApiReader().read(context, List.of(rest), new BeanConfig(), context.getName(), + context.getClassResolver()); + } + + /** + * Stands in for the rest-openapi component, which is not on the classpath of this module. Public like the real + * component, since the reader finds the setting through bean introspection. + */ + public static class RestrictedComponent extends DefaultComponent { + + @SuppressWarnings("unused") // read through bean introspection + public String getExternalReferences() { + return "none"; + } + + @Override + protected Endpoint createEndpoint(String uri, String remaining, Map parameters) { + return null; + } + } +} diff --git a/components/camel-openapi-java/src/test/resources/external-references-pet.yaml b/components/camel-openapi-java/src/test/resources/external-references-pet.yaml new file mode 100644 index 0000000000000..a567d1ebc0b06 --- /dev/null +++ b/components/camel-openapi-java/src/test/resources/external-references-pet.yaml @@ -0,0 +1,27 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# The schemas of external-references.yaml +components: + schemas: + Pet: + type: object + properties: + id: + type: integer + name: + type: string diff --git a/components/camel-openapi-java/src/test/resources/external-references.yaml b/components/camel-openapi-java/src/test/resources/external-references.yaml new file mode 100644 index 0000000000000..27d2143a32e06 --- /dev/null +++ b/components/camel-openapi-java/src/test/resources/external-references.yaml @@ -0,0 +1,41 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# A contract whose Pet schema lives in external-references-pet.yaml, next to it on the classpath +openapi: 3.0.3 +info: + title: Pets + version: "1.0" +servers: + - url: http://localhost:8080/api +paths: + /pets/{petId}: + get: + operationId: getPetById + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + "200": + description: The pet + content: + application/json: + schema: + $ref: 'external-references-pet.yaml#/components/schemas/Pet' diff --git a/components/camel-rest-openapi/src/generated/java/org/apache/camel/component/rest/openapi/RestOpenApiComponentConfigurer.java b/components/camel-rest-openapi/src/generated/java/org/apache/camel/component/rest/openapi/RestOpenApiComponentConfigurer.java index 6d7dada1a40c1..dcebcdecf8ab5 100644 --- a/components/camel-rest-openapi/src/generated/java/org/apache/camel/component/rest/openapi/RestOpenApiComponentConfigurer.java +++ b/components/camel-rest-openapi/src/generated/java/org/apache/camel/component/rest/openapi/RestOpenApiComponentConfigurer.java @@ -42,6 +42,8 @@ public boolean configure(CamelContext camelContext, Object obj, String name, Obj case "consumercomponentname": case "consumerComponentName": target.setConsumerComponentName(property(camelContext, java.lang.String.class, value)); return true; case "consumes": target.setConsumes(property(camelContext, java.lang.String.class, value)); return true; + case "externalreferences": + case "externalReferences": target.setExternalReferences(property(camelContext, java.lang.String.class, value)); return true; case "host": target.setHost(property(camelContext, java.lang.String.class, value)); return true; case "lazystartproducer": case "lazyStartProducer": target.setLazyStartProducer(property(camelContext, boolean.class, value)); return true; @@ -88,6 +90,8 @@ public Class getOptionType(String name, boolean ignoreCase) { case "consumercomponentname": case "consumerComponentName": return java.lang.String.class; case "consumes": return java.lang.String.class; + case "externalreferences": + case "externalReferences": return java.lang.String.class; case "host": return java.lang.String.class; case "lazystartproducer": case "lazyStartProducer": return boolean.class; @@ -135,6 +139,8 @@ public Object getOptionValue(Object obj, String name, boolean ignoreCase) { case "consumercomponentname": case "consumerComponentName": return target.getConsumerComponentName(); case "consumes": return target.getConsumes(); + case "externalreferences": + case "externalReferences": return target.getExternalReferences(); case "host": return target.getHost(); case "lazystartproducer": case "lazyStartProducer": return target.isLazyStartProducer(); diff --git a/components/camel-rest-openapi/src/generated/java/org/apache/camel/component/rest/openapi/RestOpenApiEndpointConfigurer.java b/components/camel-rest-openapi/src/generated/java/org/apache/camel/component/rest/openapi/RestOpenApiEndpointConfigurer.java index a61ef46239fd1..6392dab18c071 100644 --- a/components/camel-rest-openapi/src/generated/java/org/apache/camel/component/rest/openapi/RestOpenApiEndpointConfigurer.java +++ b/components/camel-rest-openapi/src/generated/java/org/apache/camel/component/rest/openapi/RestOpenApiEndpointConfigurer.java @@ -42,6 +42,8 @@ public boolean configure(CamelContext camelContext, Object obj, String name, Obj case "exceptionHandler": target.setExceptionHandler(property(camelContext, org.apache.camel.spi.ExceptionHandler.class, value)); return true; case "exchangepattern": case "exchangePattern": target.setExchangePattern(property(camelContext, org.apache.camel.ExchangePattern.class, value)); return true; + case "externalreferences": + case "externalReferences": target.setExternalReferences(property(camelContext, java.lang.String.class, value)); return true; case "host": target.setHost(property(camelContext, java.lang.String.class, value)); return true; case "lazystartproducer": case "lazyStartProducer": target.setLazyStartProducer(property(camelContext, boolean.class, value)); return true; @@ -84,6 +86,8 @@ public Class getOptionType(String name, boolean ignoreCase) { case "exceptionHandler": return org.apache.camel.spi.ExceptionHandler.class; case "exchangepattern": case "exchangePattern": return org.apache.camel.ExchangePattern.class; + case "externalreferences": + case "externalReferences": return java.lang.String.class; case "host": return java.lang.String.class; case "lazystartproducer": case "lazyStartProducer": return boolean.class; @@ -127,6 +131,8 @@ public Object getOptionValue(Object obj, String name, boolean ignoreCase) { case "exceptionHandler": return target.getExceptionHandler(); case "exchangepattern": case "exchangePattern": return target.getExchangePattern(); + case "externalreferences": + case "externalReferences": return target.getExternalReferences(); case "host": return target.getHost(); case "lazystartproducer": case "lazyStartProducer": return target.isLazyStartProducer(); diff --git a/components/camel-rest-openapi/src/generated/java/org/apache/camel/component/rest/openapi/RestOpenApiEndpointUriFactory.java b/components/camel-rest-openapi/src/generated/java/org/apache/camel/component/rest/openapi/RestOpenApiEndpointUriFactory.java index db68455cb90f6..6376949209c7b 100644 --- a/components/camel-rest-openapi/src/generated/java/org/apache/camel/component/rest/openapi/RestOpenApiEndpointUriFactory.java +++ b/components/camel-rest-openapi/src/generated/java/org/apache/camel/component/rest/openapi/RestOpenApiEndpointUriFactory.java @@ -24,7 +24,7 @@ public class RestOpenApiEndpointUriFactory extends org.apache.camel.support.comp private static final Set ENDPOINT_IDENTITY_PROPERTY_NAMES; private static final Map MULTI_VALUE_PREFIXES; static { - Set props = new HashSet<>(21); + Set props = new HashSet<>(22); props.add("apiContextPath"); props.add("basePath"); props.add("bridgeErrorHandler"); @@ -35,6 +35,7 @@ public class RestOpenApiEndpointUriFactory extends org.apache.camel.support.comp props.add("consumes"); props.add("exceptionHandler"); props.add("exchangePattern"); + props.add("externalReferences"); props.add("host"); props.add("lazyStartProducer"); props.add("missingOperation"); diff --git a/components/camel-rest-openapi/src/generated/resources/META-INF/org/apache/camel/component/rest/openapi/rest-openapi.json b/components/camel-rest-openapi/src/generated/resources/META-INF/org/apache/camel/component/rest/openapi/rest-openapi.json index d649162cff1c1..5e6f53bd28b3b 100644 --- a/components/camel-rest-openapi/src/generated/resources/META-INF/org/apache/camel/component/rest/openapi/rest-openapi.json +++ b/components/camel-rest-openapi/src/generated/resources/META-INF/org/apache/camel/component/rest/openapi/rest-openapi.json @@ -43,8 +43,9 @@ "consumes": { "index": 16, "kind": "property", "displayName": "Consumes", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "What payload type this component capable of consuming. Could be one type, like application\/json or multiple types as application\/json, application\/xml; q=0.5 according to the RFC7231. This equates to the value of Accept HTTP header. If set overrides any value found in the OpenApi specification. Can be overridden in endpoint configuration" }, "produces": { "index": 17, "kind": "property", "displayName": "Produces", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "What payload type this component is producing. For example application\/json according to the RFC7231. This equates to the value of Content-Type HTTP header. If set overrides any value present in the OpenApi specification. Can be overridden in endpoint configuration." }, "autowiredEnabled": { "index": 18, "kind": "property", "displayName": "Autowired Enabled", "group": "advanced", "label": "advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "description": "Whether autowiring is enabled. This is used for automatic autowiring options (the option must be marked as autowired) by looking up in the registry to find if there is a single instance of matching type, which then gets configured on the component. This can be used for automatic configuring JDBC data sources, JMS connection factories, AWS Clients, etc." }, - "sslContextParameters": { "index": 19, "kind": "property", "displayName": "Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.camel.support.jsse.SSLContextParameters", "deprecated": false, "autowired": false, "secret": false, "description": "Customize TLS parameters used by the component. If not set defaults to the TLS parameters set in the Camel context" }, - "useGlobalSslContextParameters": { "index": 20, "kind": "property", "displayName": "Use Global Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Enable usage of global SSL context parameters." } + "externalReferences": { "index": 19, "kind": "property", "displayName": "External References", "group": "security", "label": "security", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "all", "local", "none" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "all", "description": "Which external references ($ref) the OpenAPI specification may use: all allows any reference, local allows references to the classpath and the file system only, and none allows only references inside the document. A reference the option does not allow fails the start. Can be overridden in endpoint configuration." }, + "sslContextParameters": { "index": 20, "kind": "property", "displayName": "Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.camel.support.jsse.SSLContextParameters", "deprecated": false, "autowired": false, "secret": false, "description": "Customize TLS parameters used by the component. If not set defaults to the TLS parameters set in the Camel context" }, + "useGlobalSslContextParameters": { "index": 21, "kind": "property", "displayName": "Use Global Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Enable usage of global SSL context parameters." } }, "properties": { "specificationUri": { "index": 0, "kind": "path", "displayName": "Specification Uri", "group": "common", "label": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "openapi.json", "description": "Path to the OpenApi specification file. The scheme, host base path are taken from this specification, but these can be overridden with properties on the component or endpoint level. If not given the component tries to load openapi.json resource from the classpath. Note that the host defined on the component and endpoint of this Component should contain the scheme, hostname and optionally the port in the URI syntax (i.e. http:\/\/api.example.com:8080). Overrides component configuration. The OpenApi specification can be loaded from different sources by prefixing with file: classpath: http: https:. Support for https is limited to using the JDK installed UrlHandler, and as such it can be cumbersome to setup TLS\/SSL certificates for https (such as setting a number of javax.net.ssl JVM system properties). How to do that consult the JDK documentation for UrlHandler. Default value notice: By default loads openapi.json file" }, @@ -67,6 +68,7 @@ "requestValidationEnabled": { "index": 17, "kind": "parameter", "displayName": "Request Validation Enabled", "group": "producer", "label": "producer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Enable validation of requests against the configured OpenAPI specification" }, "componentName": { "index": 18, "kind": "parameter", "displayName": "Component Name", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "Name of the Camel component that will perform the requests. The component must be present in Camel registry and it must implement RestProducerFactory service provider interface. If not set CLASSPATH is searched for single component that implements RestProducerFactory SPI. Overrides component configuration." }, "lazyStartProducer": { "index": 19, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, - "oauthProfile": { "index": 20, "kind": "parameter", "displayName": "OAuth Profile", "group": "security", "label": "consumer,security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "OAuth profile name passed to the HTTP consumer delegate for validating incoming Authorization: Bearer tokens. The selected consumer component must support the oauthProfile option; delegates that ignore unknown options will start without endpoint protection." } + "externalReferences": { "index": 20, "kind": "parameter", "displayName": "External References", "group": "security", "label": "security", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "all", "local", "none" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "all", "description": "Which external references ($ref) the OpenAPI specification may use. The parser loads every external reference when the consumer or producer is created, from the classpath, the file system or over http(s). all allows any reference, local allows references to the classpath and the file system only, and none allows only references inside the document. A reference the option does not allow fails the start, before the parser loads anything. Overrides component configuration." }, + "oauthProfile": { "index": 21, "kind": "parameter", "displayName": "OAuth Profile", "group": "security", "label": "consumer,security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "OAuth profile name passed to the HTTP consumer delegate for validating incoming Authorization: Bearer tokens. The selected consumer component must support the oauthProfile option; delegates that ignore unknown options will start without endpoint protection." } } } diff --git a/components/camel-rest-openapi/src/main/docs/rest-openapi-component.adoc b/components/camel-rest-openapi/src/main/docs/rest-openapi-component.adoc index b7138eab3a7dc..7f0455f634fcc 100644 --- a/components/camel-rest-openapi/src/main/docs/rest-openapi-component.adoc +++ b/components/camel-rest-openapi/src/main/docs/rest-openapi-component.adoc @@ -330,6 +330,33 @@ so a misconfigured delegate cannot start without the expected protection. Custom enforce the option must override `RestOpenApiConsumerFactory.supportsOAuthProfile()` to return `true`. +=== Restricting the external references of the specification + +An OpenAPI specification can reference other documents with `$ref`: files on the classpath or the file system, +and documents on an http(s) server. The parser loads every such document when the consumer or producer is +created, and a document it loads can reference further documents. The `externalReferences` option, on the +endpoint or the component, restricts this: + +* `all` (default) allows any reference. +* `local` allows references to the classpath and the file system only. A specification loaded from a URL may + then reference no other document at all, because the parser resolves its references against that URL. +* `none` allows only references inside the document, such as `#/components/schemas/Pet`. + +The check runs on the specification, and on the local documents it references, before the parser resolves +anything. It covers every `$ref` in a document, examples included. A reference the option does not allow fails +the start, with a message that names the reference and where it is. + +[source,java] +---- +from("direct:getPetById") + .to("rest-openapi:petstore-v3.json#getPetById?externalReferences=local"); +---- + +The option can also be set once on the component, for example with +`camel.component.rest-openapi.external-references=none`. For the contract-first Rest DSL, set it on the `openApi` +element as shown in xref:manual::rest-dsl-openapi.adoc[REST DSL with contract-first OpenAPI]; the component setting +applies there as well, also to the specification served as api-doc. + == Request validation API requests can be validated against the configured OpenAPI specification before they are sent by setting the `requestValidationEnabled` option to `true`. diff --git a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java new file mode 100644 index 0000000000000..3b8f02eb8c985 --- /dev/null +++ b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java @@ -0,0 +1,194 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.openapi; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.core.util.Json; +import io.swagger.v3.core.util.Yaml; +import io.swagger.v3.parser.models.RefFormat; +import io.swagger.v3.parser.util.RefUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Checks the references ({@code $ref}) of an OpenAPI specification against the {@code externalReferences} option before + * the parser resolves them. The parser loads every external reference it meets: from the working directory and the + * classpath, or over http(s) when the reference is a URL or the specification itself comes from a URL. + *

+ * camel-openapi-java has a copy of this class for the specification it serves as api-doc; keep the two in sync. + */ +final class OpenApiExternalReferences { + + static final String ALL = "all"; + static final String LOCAL = "local"; + static final String NONE = "none"; + + private static final Logger LOG = LoggerFactory.getLogger(OpenApiExternalReferences.class); + + private OpenApiExternalReferences() { + } + + /** + * The value of the option to apply: {@link #ALL} when none is set. + * + * @throws IllegalArgumentException for a value other than all, local or none + */ + static String policy(String externalReferences) { + if (externalReferences == null || externalReferences.isBlank()) { + return ALL; + } + String policy = externalReferences.trim().toLowerCase(Locale.ROOT); + if (ALL.equals(policy) || LOCAL.equals(policy) || NONE.equals(policy)) { + return policy; + } + throw new IllegalArgumentException( + "Unknown externalReferences value: " + externalReferences + ", expected all, local or none"); + } + + /** + * Checks every {@code $ref} of the specification, and of the local documents it references, against the option. + * + * @param externalReferences the option: all, local or none + * @param specification the specification, as JSON or YAML text + * @param specificationUri where the specification comes from, for the error message + * @param remote whether the specification comes from a URL: the parser then resolves its + * external references against that URL + * @throws IllegalArgumentException for the first reference the option does not allow + */ + static void check(String externalReferences, String specification, String specificationUri, boolean remote) { + String policy = policy(externalReferences); + if (ALL.equals(policy)) { + return; + } + checkDocument(policy, specification, specificationUri, null, remote, new HashSet<>()); + } + + private static void checkDocument( + String policy, String content, String specificationUri, String document, boolean remote, + Set checked) { + DocumentCheck check = new DocumentCheck(policy, specificationUri, document, remote); + check.scan(readTree(content, specificationUri, document), ""); + // a local document can reference further documents, which the parser loads as well + for (String file : check.localDocuments) { + if (checked.add(file)) { + String text = readLocalDocument(file); + if (text != null) { + checkDocument(policy, text, specificationUri, file, false, checked); + } + } + } + } + + private static JsonNode readTree(String content, String specificationUri, String document) { + try { + return (content.trim().startsWith("{") ? Json.mapper() : Yaml.mapper()).readTree(content); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException( + "Cannot read the OpenAPI specification " + specificationUri + + (document != null ? " (" + document + ")" : "") + + " to check its references: " + e.getMessage(), + e); + } + } + + private static String readLocalDocument(String file) { + try { + // how the parser reads a relative reference of a specification that has no location: from the working + // directory, then from the classpath + return RefUtils.readExternalRef(file, RefFormat.RELATIVE, null, new File(".").toPath()); + } catch (RuntimeException e) { + LOG.debug("Cannot read {} to check its references, the parser reports it when it resolves the specification: {}", + file, e.getMessage()); + return null; + } + } + + private static boolean isHttp(String file) { + String lower = file.toLowerCase(Locale.ROOT); + return lower.startsWith("http:") || lower.startsWith("https:"); + } + + /** + * The check of one document. + */ + private static final class DocumentCheck { + + private final String policy; + private final String specificationUri; + private final String document; + private final boolean remote; + private final List localDocuments = new ArrayList<>(); + + DocumentCheck(String policy, String specificationUri, String document, boolean remote) { + this.policy = policy; + this.specificationUri = specificationUri; + this.document = document; + this.remote = remote; + } + + void scan(JsonNode node, String path) { + if (node.isObject()) { + for (Map.Entry field : node.properties()) { + String fieldPath = path.isEmpty() ? field.getKey() : path + "." + field.getKey(); + if ("$ref".equals(field.getKey()) && field.getValue().isTextual()) { + reference(field.getValue().asText(), fieldPath); + } else { + scan(field.getValue(), fieldPath); + } + } + } else if (node.isArray()) { + int i = 0; + for (JsonNode item : node) { + scan(item, path + "[" + i++ + "]"); + } + } + } + + private void reference(String ref, String path) { + if (ref.isEmpty() || ref.startsWith("#")) { + // inside the document + return; + } + int fragment = ref.indexOf('#'); + String file = fragment < 0 ? ref : ref.substring(0, fragment); + String reason = null; + if (NONE.equals(policy)) { + reason = "externalReferences=none allows only references inside the document"; + } else if (remote) { + reason = "externalReferences=local allows no external reference in a specification loaded from a URL," + + " since the parser resolves it against that URL"; + } else if (isHttp(file)) { + reason = "externalReferences=local allows no http(s) reference"; + } + if (reason != null) { + throw new IllegalArgumentException( + "The OpenAPI specification " + specificationUri + " references " + ref + " at " + path + + (document != null ? " in " + document : "") + ", but " + reason); + } + localDocuments.add(file); + } + } +} diff --git a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiComponent.java b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiComponent.java index 011e2cea78e15..cf4371c17c991 100644 --- a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiComponent.java +++ b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiComponent.java @@ -147,6 +147,12 @@ public final class RestOpenApiComponent extends DefaultComponent implements SSLC + " (platform) or Camel via the unmatched request handler (camel). Can be overridden in endpoint" + " configuration.") private String unmatchedRequestHandling = "platform"; + @Metadata(label = "security", defaultValue = "all", enums = "all,local,none", + description = "Which external references ($ref) the OpenAPI specification may use: all allows any reference," + + " local allows references to the classpath and the file system only, and none allows only" + + " references inside the document. A reference the option does not allow fails the start." + + " Can be overridden in endpoint configuration.") + private String externalReferences = "all"; @Metadata(description = "To use a custom strategy for how to process Rest DSL requests", label = "consumer,advanced") private RestOpenapiProcessorStrategy restOpenapiProcessorStrategy; @Metadata(description = "Enable usage of global SSL context parameters.", label = "security") @@ -185,6 +191,7 @@ protected Endpoint createEndpoint(final String uri, final String remaining, fina endpoint.setMissingOperation(getMissingOperation()); endpoint.setMockIncludePattern(getMockIncludePattern()); endpoint.setUnmatchedRequestHandling(getUnmatchedRequestHandling()); + endpoint.setExternalReferences(getExternalReferences()); if (restOpenapiProcessorStrategy != null) { endpoint.setRestOpenapiProcessorStrategy(restOpenapiProcessorStrategy); } else { @@ -294,6 +301,14 @@ public void setUnmatchedRequestHandling(String unmatchedRequestHandling) { this.unmatchedRequestHandling = unmatchedRequestHandling; } + public String getExternalReferences() { + return externalReferences; + } + + public void setExternalReferences(String externalReferences) { + this.externalReferences = externalReferences; + } + public void setBasePath(final String basePath) { this.basePath = basePath; } diff --git a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiEndpoint.java b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiEndpoint.java index d4c39cf24d1c4..b5e031c614ec5 100644 --- a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiEndpoint.java +++ b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiEndpoint.java @@ -46,6 +46,7 @@ import io.swagger.v3.parser.OpenAPIV3Parser; import io.swagger.v3.parser.core.models.ParseOptions; import io.swagger.v3.parser.core.models.SwaggerParseResult; +import io.swagger.v3.parser.util.RemoteUrl; import org.apache.camel.CamelContext; import org.apache.camel.CamelContextAware; import org.apache.camel.Category; @@ -181,6 +182,14 @@ public final class RestOpenApiEndpoint extends DefaultEndpoint { description = "Who answers requests that match no operation in the OpenAPI specification: the HTTP layer" + " (platform) or Camel via the unmatched request handler (camel).") private String unmatchedRequestHandling = "platform"; + @UriParam(label = "security", defaultValue = "all", enums = "all,local,none", + description = "Which external references ($ref) the OpenAPI specification may use. The parser loads every" + + " external reference when the consumer or producer is created, from the classpath, the file" + + " system or over http(s). all allows any reference, local allows references to the classpath" + + " and the file system only, and none allows only references inside the document. A reference" + + " the option does not allow fails the start, before the parser loads anything. Overrides" + + " component configuration.") + private String externalReferences = "all"; @UriParam(label = "consumer,security", displayName = "OAuth Profile", description = "OAuth profile name passed to the HTTP consumer delegate for validating incoming" + " Authorization: Bearer tokens. The selected consumer component must support the" @@ -227,7 +236,7 @@ public RestOpenApiComponent getComponent() { @Override public Consumer createConsumer(final Processor processor) throws Exception { - OpenAPI doc = loadSpecificationFrom(getCamelContext(), specificationUri); + OpenAPI doc = loadSpecificationFrom(getCamelContext(), specificationUri, externalReferences); String path = determineBasePath(doc); RestOpenApiProcessor openApiProcessor @@ -377,7 +386,7 @@ private Consumer createConsumerFor(String basePath, RestOpenApiProcessor openApi @Override public Producer createProducer() throws Exception { final CamelContext camelContext = getCamelContext(); - final OpenAPI openapiDoc = loadSpecificationFrom(camelContext, specificationUri); + final OpenAPI openapiDoc = loadSpecificationFrom(camelContext, specificationUri, externalReferences); final Paths paths = openapiDoc.getPaths(); for (final Entry pathEntry : paths.entrySet()) { @@ -554,6 +563,14 @@ public void setUnmatchedRequestHandling(String unmatchedRequestHandling) { this.unmatchedRequestHandling = unmatchedRequestHandling; } + public String getExternalReferences() { + return externalReferences; + } + + public void setExternalReferences(String externalReferences) { + this.externalReferences = externalReferences; + } + public String getOauthProfile() { return oauthProfile; } @@ -1006,26 +1023,59 @@ static String hostFrom(final RestConfiguration restConfiguration) { * @return the specification */ static OpenAPI loadSpecificationFrom(final CamelContext camelContext, final String uri) { - final OpenAPIV3Parser openApiParser = new OpenAPIV3Parser(); - final ParseOptions options = new ParseOptions(); - options.setResolveFully(true); + return loadSpecificationFrom(camelContext, uri, OpenApiExternalReferences.ALL); + } + /** + * Loads the OpenApi definition model from the given path, after checking its references against the + * externalReferences option. The parser resolves every external reference of the specification, so a reference the + * option does not allow fails the load before the parser runs. + * + * @param camelContext context to use + * @param uri the specification uri + * @param externalReferences the externalReferences option: all, local or none + * @return the specification + */ + static OpenAPI loadSpecificationFrom( + final CamelContext camelContext, final String uri, final String externalReferences) { + final String policy = OpenApiExternalReferences.policy(externalReferences); + + String location = null; + String content = null; InputStream is = null; - List messages = null; try { - String location = null; - String content = null; Resource resource = ResourceHelper.resolveMandatoryResource(camelContext, uri); if (resource.getScheme().startsWith("http")) { location = resource.getURI().toString(); + if (!OpenApiExternalReferences.ALL.equals(policy)) { + // read the document as the parser would, to check its references before the parser resolves them + content = RemoteUrl.urlToString(location, Collections.emptyList()); + } } else { is = resource.getInputStream(); if (is != null) { content = IOHelper.loadText(is); } } + } catch (Exception e) { + throw new IllegalArgumentException( + "Failed to load OpenAPI specification from: " + uri, e); + } finally { + IOHelper.close(is); + } + if (content != null) { + OpenApiExternalReferences.check(policy, content, uri, location != null); + } + + final OpenAPIV3Parser openApiParser = new OpenAPIV3Parser(); + final ParseOptions options = new ParseOptions(); + options.setResolveFully(true); + List messages = null; + try { SwaggerParseResult openApi = null; - if (location != null) { + if (location != null && content != null) { + openApi = openApiParser.readContents(content, null, options, location); + } else if (location != null) { openApi = openApiParser.readLocation(location, null, options); } else if (content != null) { openApi = openApiParser.readContents(content, null, options); @@ -1044,8 +1094,6 @@ static OpenAPI loadSpecificationFrom(final CamelContext camelContext, final Stri } catch (Exception e) { throw new IllegalArgumentException( "Failed to load OpenAPI specification from: " + uri, e); - } finally { - IOHelper.close(is); } throw new IllegalArgumentException( diff --git a/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java b/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java new file mode 100644 index 0000000000000..5131f430bf344 --- /dev/null +++ b/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java @@ -0,0 +1,242 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.openapi; + +import java.nio.file.Files; +import java.nio.file.Path; + +import com.github.tomakehurst.wiremock.WireMockServer; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import org.apache.camel.CamelContext; +import org.apache.camel.Endpoint; +import org.apache.camel.impl.DefaultCamelContext; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The externalReferences option: which $ref the specification may use, checked before the parser resolves them. + */ +class RestOpenApiExternalReferencesTest { + + private static final WireMockServer SERVER = new WireMockServer(wireMockConfig().dynamicPort()); + + private static final String PET_SCHEMAS = """ + components: + schemas: + Pet: + type: object + properties: + id: + type: integer + name: + type: string + """; + + private final CamelContext camelContext = new DefaultCamelContext(); + + @BeforeAll + static void startServer() { + SERVER.start(); + SERVER.stubFor(get(urlEqualTo("/petstore.yaml")) + .willReturn(aResponse().withHeader("Content-Type", "application/yaml") + .withBody(contract("pet.yaml#/components/schemas/Pet")))); + SERVER.stubFor(get(urlEqualTo("/pet.yaml")) + .willReturn(aResponse().withHeader("Content-Type", "application/yaml").withBody(PET_SCHEMAS))); + SERVER.stubFor(get(urlEqualTo("/self-contained.yaml")) + .willReturn(aResponse().withHeader("Content-Type", "application/yaml") + .withBody(contract("#/components/schemas/Pet") + PET_SCHEMAS))); + } + + @AfterAll + static void stopServer() { + SERVER.stop(); + } + + @BeforeEach + void resetRequests() { + SERVER.resetRequests(); + } + + @Test + void allowsEveryReferenceByDefault() { + OpenAPI openApi = RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "external-references-root.yaml"); + + assertThat(petSchema(openApi).getProperties()).containsKeys("id", "name"); + } + + @Test + void localAllowsAReferenceToTheClasspath() { + OpenAPI openApi + = RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "external-references-root.yaml", "local"); + + assertThat(petSchema(openApi).getProperties()).containsKeys("id", "name"); + } + + @Test + void noneRejectsAReferenceToAnotherDocument() { + assertThatThrownBy( + () -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "external-references-root.yaml", "none")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("external-references-root.yaml") + .hasMessageContaining("external-references-pet.yaml#/components/schemas/Pet") + .hasMessageContaining("at paths./pets/{petId}.get.responses.200.content.application/json.schema.$ref") + .hasMessageContaining("externalReferences=none"); + } + + @Test + void noneAllowsASelfContainedSpecification() { + OpenAPI openApi = RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "openapi-v3.yaml", "none"); + + assertThat(openApi.getPaths()).isNotEmpty(); + } + + @Test + void noneRejectsTheExternalReferencesOfAnOpenApi31Document() { + assertThatThrownBy(() -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "petstore-3.1.yaml", "none")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("/components/schemas/petdetails") + .hasMessageContaining("externalReferences=none"); + } + + @Test + void localRejectsAnHttpReferenceWithoutFetchingIt(@TempDir Path dir) throws Exception { + Path file = dir.resolve("contract.yaml"); + Files.writeString(file, contract(SERVER.baseUrl() + "/pet.yaml#/components/schemas/Pet")); + + assertThatThrownBy(() -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + file, "local")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(SERVER.baseUrl() + "/pet.yaml#/components/schemas/Pet") + .hasMessageContaining("externalReferences=local allows no http(s) reference"); + SERVER.verify(0, getRequestedFor(urlEqualTo("/pet.yaml"))); + } + + @Test + void localChecksTheDocumentsTheSpecificationReferences() { + assertThatThrownBy( + () -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "external-references-nested.yaml", "local")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("https://schemas.example.invalid/owner.yaml#/components/schemas/Owner") + .hasMessageContaining("at components.schemas.Pet.properties.owner.$ref in external-references-pet-remote.yaml") + .hasMessageContaining("externalReferences=local"); + } + + @Test + void localRejectsEveryExternalReferenceOfARemoteSpecification() { + String uri = SERVER.baseUrl() + "/petstore.yaml"; + + assertThatThrownBy(() -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, uri, "local")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("pet.yaml#/components/schemas/Pet") + .hasMessageContaining("loaded from a URL"); + SERVER.verify(1, getRequestedFor(urlEqualTo("/petstore.yaml"))); + SERVER.verify(0, getRequestedFor(urlEqualTo("/pet.yaml"))); + } + + @Test + void allResolvesTheReferencesOfARemoteSpecification() { + OpenAPI openApi = RestOpenApiEndpoint.loadSpecificationFrom(camelContext, SERVER.baseUrl() + "/petstore.yaml"); + + assertThat(petSchema(openApi).getProperties()).containsKeys("id", "name"); + SERVER.verify(1, getRequestedFor(urlEqualTo("/petstore.yaml"))); + SERVER.verify(1, getRequestedFor(urlEqualTo("/pet.yaml"))); + } + + @Test + void noneAllowsASelfContainedRemoteSpecification() { + OpenAPI openApi = RestOpenApiEndpoint.loadSpecificationFrom(camelContext, + SERVER.baseUrl() + "/self-contained.yaml", "none"); + + assertThat(petSchema(openApi).getProperties()).containsKeys("id", "name"); + SERVER.verify(1, getRequestedFor(urlEqualTo("/self-contained.yaml"))); + } + + @Test + void rejectsAnUnknownValue() { + assertThatThrownBy(() -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "openapi-v3.yaml", "remote")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unknown externalReferences value: remote, expected all, local or none"); + } + + @Test + void endpointOptionAppliesToTheProducer() throws Exception { + try (CamelContext context = new DefaultCamelContext()) { + context.start(); + Endpoint endpoint = context + .getEndpoint("rest-openapi:external-references-root.yaml#getPetById?externalReferences=none"); + + assertThatThrownBy(endpoint::createProducer) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("externalReferences=none"); + } + } + + @Test + void componentOptionAppliesToEveryEndpoint() throws Exception { + try (CamelContext context = new DefaultCamelContext()) { + context.start(); + context.getComponent("rest-openapi", RestOpenApiComponent.class).setExternalReferences("none"); + Endpoint endpoint = context.getEndpoint("rest-openapi:external-references-root.yaml#getPetById"); + + assertThatThrownBy(endpoint::createProducer) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("externalReferences=none"); + } + } + + private static Schema petSchema(OpenAPI openApi) { + return openApi.getPaths().get("/pets/{petId}").getGet().getResponses().get("200").getContent() + .get("application/json").getSchema(); + } + + private static String contract(String petSchemaRef) { + return """ + openapi: 3.0.3 + info: + title: Pets + version: "1.0" + paths: + /pets/{petId}: + get: + operationId: getPetById + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + "200": + description: The pet + content: + application/json: + schema: + $ref: '%s' + """.formatted(petSchemaRef); + } +} diff --git a/components/camel-rest-openapi/src/test/resources/external-references-nested.yaml b/components/camel-rest-openapi/src/test/resources/external-references-nested.yaml new file mode 100644 index 0000000000000..a7366e9453054 --- /dev/null +++ b/components/camel-rest-openapi/src/test/resources/external-references-nested.yaml @@ -0,0 +1,39 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# A contract whose Pet schema lives in external-references-pet-remote.yaml, which in turn references a URL +openapi: 3.0.3 +info: + title: Pets + version: "1.0" +paths: + /pets/{petId}: + get: + operationId: getPetById + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + "200": + description: The pet + content: + application/json: + schema: + $ref: 'external-references-pet-remote.yaml#/components/schemas/Pet' diff --git a/components/camel-rest-openapi/src/test/resources/external-references-pet-remote.yaml b/components/camel-rest-openapi/src/test/resources/external-references-pet-remote.yaml new file mode 100644 index 0000000000000..9685156a2f08b --- /dev/null +++ b/components/camel-rest-openapi/src/test/resources/external-references-pet-remote.yaml @@ -0,0 +1,27 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# The schemas of external-references-nested.yaml; the owner schema is on a server that does not exist +components: + schemas: + Pet: + type: object + properties: + id: + type: integer + owner: + $ref: 'https://schemas.example.invalid/owner.yaml#/components/schemas/Owner' diff --git a/components/camel-rest-openapi/src/test/resources/external-references-pet.yaml b/components/camel-rest-openapi/src/test/resources/external-references-pet.yaml new file mode 100644 index 0000000000000..82a3854f7a0e9 --- /dev/null +++ b/components/camel-rest-openapi/src/test/resources/external-references-pet.yaml @@ -0,0 +1,27 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# The schemas of external-references-root.yaml +components: + schemas: + Pet: + type: object + properties: + id: + type: integer + name: + type: string diff --git a/components/camel-rest-openapi/src/test/resources/external-references-root.yaml b/components/camel-rest-openapi/src/test/resources/external-references-root.yaml new file mode 100644 index 0000000000000..c8d171fb4e616 --- /dev/null +++ b/components/camel-rest-openapi/src/test/resources/external-references-root.yaml @@ -0,0 +1,39 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# A contract whose Pet schema lives in external-references-pet.yaml, next to it on the classpath +openapi: 3.0.3 +info: + title: Pets + version: "1.0" +paths: + /pets/{petId}: + get: + operationId: getPetById + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + "200": + description: The pet + content: + application/json: + schema: + $ref: 'external-references-pet.yaml#/components/schemas/Pet' diff --git a/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/rest/openApi.json b/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/rest/openApi.json index 6925078501fa4..e28c784b28d3f 100644 --- a/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/rest/openApi.json +++ b/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/rest/openApi.json @@ -21,6 +21,7 @@ "routeId": { "index": 6, "kind": "attribute", "displayName": "Route Id", "group": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The route id." }, "missingOperation": { "index": 7, "kind": "attribute", "displayName": "Missing Operation", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "fail", "ignore", "mock" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "fail", "description": "Whether to fail, ignore or return a mock response for OpenAPI operations that are not mapped to a corresponding route." }, "unmatchedRequestHandling": { "index": 8, "kind": "attribute", "displayName": "Unmatched Request Handling", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "platform", "camel" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "platform", "description": "Who answers requests that match no operation in the OpenAPI specification: the HTTP layer (platform) or Camel via the unmatched request handler (camel)." }, - "mockIncludePattern": { "index": 9, "kind": "attribute", "displayName": "Mock Include Pattern", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "classpath:camel-mock\/**", "description": "Used for inclusive filtering of mock data from directories. The pattern is using Ant-path style pattern. Multiple patterns can be specified separated by comma." } + "mockIncludePattern": { "index": 9, "kind": "attribute", "displayName": "Mock Include Pattern", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "classpath:camel-mock\/**", "description": "Used for inclusive filtering of mock data from directories. The pattern is using Ant-path style pattern. Multiple patterns can be specified separated by comma." }, + "externalReferences": { "index": 10, "kind": "attribute", "displayName": "External References", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "all", "local", "none" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "all", "description": "Which external references ($ref) the OpenAPI specification may use: all allows any reference, local allows references to the classpath and the file system only, and none allows only references inside the document. A reference the option does not allow fails the start." } } } diff --git a/core/camel-core-model/src/main/java/org/apache/camel/model/rest/OpenApiDefinition.java b/core/camel-core-model/src/main/java/org/apache/camel/model/rest/OpenApiDefinition.java index 4993c41d894cf..1fae2962ad5e3 100644 --- a/core/camel-core-model/src/main/java/org/apache/camel/model/rest/OpenApiDefinition.java +++ b/core/camel-core-model/src/main/java/org/apache/camel/model/rest/OpenApiDefinition.java @@ -62,6 +62,10 @@ public class OpenApiDefinition extends OptionalIdentifiedDefinition switch (key) { case "apiContextPath": def.setApiContextPath(val); yield true; case "disabled": def.setDisabled(val); yield true; + case "externalReferences": def.setExternalReferences(val); yield true; case "missingOperation": def.setMissingOperation(val); yield true; case "mockIncludePattern": def.setMockIncludePattern(val); yield true; case "routeId": def.setRouteId(val); yield true; diff --git a/core/camel-xml-io/src/generated/java/org/apache/camel/xml/out/ModelWriter.java b/core/camel-xml-io/src/generated/java/org/apache/camel/xml/out/ModelWriter.java index c18aec4ad8d63..505c2ac69619e 100644 --- a/core/camel-xml-io/src/generated/java/org/apache/camel/xml/out/ModelWriter.java +++ b/core/camel-xml-io/src/generated/java/org/apache/camel/xml/out/ModelWriter.java @@ -3289,6 +3289,7 @@ protected void doWriteOpenApiDefinition(String name, OpenApiDefinition def) thro doWriteAttribute("missingOperation", def.getMissingOperation(), "fail"); doWriteAttribute("unmatchedRequestHandling", def.getUnmatchedRequestHandling(), "platform"); doWriteAttribute("mockIncludePattern", def.getMockIncludePattern(), "classpath:camel-mock/**"); + doWriteAttribute("externalReferences", def.getExternalReferences(), "all"); endElement(name); } protected void doWriteOpenIdConnectDefinition(String name, OpenIdConnectDefinition def) throws IOException { diff --git a/core/camel-yaml-io/src/generated/java/org/apache/camel/yaml/out/YamlModelWriter.java b/core/camel-yaml-io/src/generated/java/org/apache/camel/yaml/out/YamlModelWriter.java index 614e3877de5e7..d019af156f520 100644 --- a/core/camel-yaml-io/src/generated/java/org/apache/camel/yaml/out/YamlModelWriter.java +++ b/core/camel-yaml-io/src/generated/java/org/apache/camel/yaml/out/YamlModelWriter.java @@ -3290,6 +3290,7 @@ protected JsonObject doWriteOpenApiDefinition(OpenApiDefinition def) { doWriteAttribute(jo, "missingOperation", def.getMissingOperation(), "fail"); doWriteAttribute(jo, "unmatchedRequestHandling", def.getUnmatchedRequestHandling(), "platform"); doWriteAttribute(jo, "mockIncludePattern", def.getMockIncludePattern(), "classpath:camel-mock/**"); + doWriteAttribute(jo, "externalReferences", def.getExternalReferences(), "all"); return jo; } protected JsonObject doWriteOpenIdConnectDefinition(OpenIdConnectDefinition def) { diff --git a/docs/user-manual/modules/ROOT/pages/rest-dsl-openapi.adoc b/docs/user-manual/modules/ROOT/pages/rest-dsl-openapi.adoc index 9bd9ecc4cc64f..6a7d666dc7863 100644 --- a/docs/user-manual/modules/ROOT/pages/rest-dsl-openapi.adoc +++ b/docs/user-manual/modules/ROOT/pages/rest-dsl-openapi.adoc @@ -661,6 +661,48 @@ YAML:: ---- ==== +=== Restricting external references + +A contract can reference other documents with `$ref`: files on the classpath or the file system, and documents on an +http(s) server. Camel loads every such document when the route starts. The `externalReferences` option restricts this: +`all` (default) allows any reference, `local` allows references to the classpath and the file system only, and `none` +allows only references inside the contract. A reference the option does not allow fails the start, with a message that +names it. + +[tabs] +==== + +Java:: ++ +[source,java] +---- + rest().openApi().specification("petstore-v3.json").externalReferences("none"); +---- + +XML:: ++ +[source,xml] +---- + + + +---- + +YAML:: ++ +[source,yaml] +---- +- rest: + openApi: + specification: petstore-v3.json + externalReferences: none +---- +==== + +The option can also be set once on the `rest-openapi` component, for example with +`camel.component.rest-openapi.external-references=none`, which covers every contract and the specification served as +api-doc. See the xref:components::rest-openapi-component.adoc[REST OpenAPI component] for the details. + == Examples You can find a few examples such as: diff --git a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/RestOpenapiComponentBuilderFactory.java b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/RestOpenapiComponentBuilderFactory.java index 9168bc5c13ece..b2d65b555c009 100644 --- a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/RestOpenapiComponentBuilderFactory.java +++ b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/RestOpenapiComponentBuilderFactory.java @@ -421,6 +421,27 @@ default RestOpenapiComponentBuilder autowiredEnabled(boolean autowiredEnabled) { return this; } + + /** + * Which external references ($ref) the OpenAPI specification may use: + * all allows any reference, local allows references to the classpath + * and the file system only, and none allows only references inside the + * document. A reference the option does not allow fails the start. Can + * be overridden in endpoint configuration. + * + * The option is a: <code>java.lang.String</code> type. + * + * Default: all + * Group: security + * + * @param externalReferences the value to set + * @return the dsl builder + */ + default RestOpenapiComponentBuilder externalReferences(java.lang.String externalReferences) { + doSetProperty("externalReferences", externalReferences); + return this; + } + /** * Customize TLS parameters used by the component. If not set defaults * to the TLS parameters set in the Camel context. @@ -488,6 +509,7 @@ protected boolean setPropertyOnComponent( case "consumes": ((RestOpenApiComponent) component).setConsumes((java.lang.String) value); return true; case "produces": ((RestOpenApiComponent) component).setProduces((java.lang.String) value); return true; case "autowiredEnabled": ((RestOpenApiComponent) component).setAutowiredEnabled((boolean) value); return true; + case "externalReferences": ((RestOpenApiComponent) component).setExternalReferences((java.lang.String) value); return true; case "sslContextParameters": ((RestOpenApiComponent) component).setSslContextParameters((org.apache.camel.support.jsse.SSLContextParameters) value); return true; case "useGlobalSslContextParameters": ((RestOpenApiComponent) component).setUseGlobalSslContextParameters((boolean) value); return true; default: return false; diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/RestOpenApiEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/RestOpenApiEndpointBuilderFactory.java index 8830a4f34e75d..dbd18b17df37e 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/RestOpenApiEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/RestOpenApiEndpointBuilderFactory.java @@ -175,6 +175,28 @@ default RestOpenApiEndpointConsumerBuilder missingOperation(String missingOperat doSetProperty("missingOperation", missingOperation); return this; } + /** + * Which external references ($ref) the OpenAPI specification may use. + * The parser loads every external reference when the consumer or + * producer is created, from the classpath, the file system or over + * http(s). all allows any reference, local allows references to the + * classpath and the file system only, and none allows only references + * inside the document. A reference the option does not allow fails the + * start, before the parser loads anything. Overrides component + * configuration. + * + * The option is a: java.lang.String type. + * + * Default: all + * Group: security + * + * @param externalReferences the value to set + * @return the dsl builder + */ + default RestOpenApiEndpointConsumerBuilder externalReferences(String externalReferences) { + doSetProperty("externalReferences", externalReferences); + return this; + } /** * OAuth profile name passed to the HTTP consumer delegate for * validating incoming Authorization: Bearer tokens. The selected @@ -499,6 +521,28 @@ default RestOpenApiEndpointProducerBuilder requestValidationEnabled(String reque doSetProperty("requestValidationEnabled", requestValidationEnabled); return this; } + /** + * Which external references ($ref) the OpenAPI specification may use. + * The parser loads every external reference when the consumer or + * producer is created, from the classpath, the file system or over + * http(s). all allows any reference, local allows references to the + * classpath and the file system only, and none allows only references + * inside the document. A reference the option does not allow fails the + * start, before the parser loads anything. Overrides component + * configuration. + * + * The option is a: java.lang.String type. + * + * Default: all + * Group: security + * + * @param externalReferences the value to set + * @return the dsl builder + */ + default RestOpenApiEndpointProducerBuilder externalReferences(String externalReferences) { + doSetProperty("externalReferences", externalReferences); + return this; + } } /** @@ -602,6 +646,28 @@ default RestOpenApiEndpointBuilder basePath(String basePath) { doSetProperty("basePath", basePath); return this; } + /** + * Which external references ($ref) the OpenAPI specification may use. + * The parser loads every external reference when the consumer or + * producer is created, from the classpath, the file system or over + * http(s). all allows any reference, local allows references to the + * classpath and the file system only, and none allows only references + * inside the document. A reference the option does not allow fails the + * start, before the parser loads anything. Overrides component + * configuration. + * + * The option is a: java.lang.String type. + * + * Default: all + * Group: security + * + * @param externalReferences the value to set + * @return the dsl builder + */ + default RestOpenApiEndpointBuilder externalReferences(String externalReferences) { + doSetProperty("externalReferences", externalReferences); + return this; + } } /** diff --git a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json index e97a971eb6581..20dd9a2296c67 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json +++ b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/org/apache/camel/dsl/jbang/core/commands/ai/eip-samples.json @@ -1102,6 +1102,10 @@ "source": "rest-dsl.adoc", "yaml": "- restConfiguration:\n inlineRoutes: true\n- rest:\n get:\n - path: \"\/customers\/{id}\"\n to: \"direct:customerDetails\"\n - path: \"\/customers\/{id}\/orders\"\n to: \"direct:customerOrders\"\n post:\n - path: \"\/customers\/neworder\"\n to: \"direct:customerNewOrder\"\n" }, + { + "source": "rest-dsl-openapi.adoc", + "yaml": "- rest:\n openApi:\n specification: petstore-v3.json\n externalReferences: none\n" + }, { "source": "rest-dsl.adoc", "yaml": "- route:\n from:\n uri: kafka:new-order\n steps:\n - to:\n uri: direct:customerNewOrder\n" diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/generated/java/org/apache/camel/dsl/yaml/deserializers/ModelDeserializers.java b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/generated/java/org/apache/camel/dsl/yaml/deserializers/ModelDeserializers.java index 162ff134f2397..c33284efb4372 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/generated/java/org/apache/camel/dsl/yaml/deserializers/ModelDeserializers.java +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl-deserializers/src/generated/java/org/apache/camel/dsl/yaml/deserializers/ModelDeserializers.java @@ -10936,6 +10936,7 @@ protected boolean setProperty(OnWhenDefinition target, String propertyKey, @YamlProperty(name = "apiContextPath", type = "string", description = "Context path for exposing the OpenAPI specification file as a REST endpoint, allowing clients to obtain the specification from the running application.", displayName = "Api Context Path"), @YamlProperty(name = "description", type = "string", description = "The description for this node", displayName = "Description"), @YamlProperty(name = "disabled", type = "boolean", defaultValue = "false", description = "Whether to disable all the REST services from the OpenAPI contract from the route during build time. Once an REST service has been disabled then it cannot be enabled later at runtime.", displayName = "Disabled"), + @YamlProperty(name = "externalReferences", type = "enum:all,local,none", defaultValue = "all", description = "Which external references ($ref) the OpenAPI specification may use: all allows any reference, local allows references to the classpath and the file system only, and none allows only references inside the document. A reference the option does not allow fails the start.", displayName = "External References"), @YamlProperty(name = "id", type = "string", description = "The id of this node", displayName = "Id"), @YamlProperty(name = "missingOperation", type = "enum:fail,ignore,mock", defaultValue = "fail", description = "Whether to fail, ignore or return a mock response for OpenAPI operations that are not mapped to a corresponding route.", displayName = "Missing Operation"), @YamlProperty(name = "mockIncludePattern", type = "string", defaultValue = "classpath:camel-mock/**", description = "Used for inclusive filtering of mock data from directories. The pattern is using Ant-path style pattern. Multiple patterns can be specified separated by comma.", displayName = "Mock Include Pattern"), @@ -10970,6 +10971,11 @@ protected boolean setProperty(OpenApiDefinition target, String propertyKey, target.setDisabled(val); break; } + case "externalReferences": { + String val = asText(node); + target.setExternalReferences(val); + break; + } case "missingOperation": { String val = asText(node); target.setMissingOperation(val); diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-canonical.json b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-canonical.json index dcfc26e8c91fa..0c4702cceabf3 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-canonical.json +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-canonical.json @@ -11741,6 +11741,13 @@ "description" : "Whether to disable all the REST services from the OpenAPI contract from the route during build time. Once an REST service has been disabled then it cannot be enabled later at runtime.", "default" : false }, + "externalReferences" : { + "type" : "string", + "title" : "External References", + "description" : "Which external references ($ref) the OpenAPI specification may use: all allows any reference, local allows references to the classpath and the file system only, and none allows only references inside the document. A reference the option does not allow fails the start.", + "default" : "all", + "enum" : [ "all", "local", "none" ] + }, "id" : { "type" : "string", "title" : "Id", diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json index 58eccd8df1bb8..6e6d2f56d5f57 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json @@ -17755,6 +17755,17 @@ "index" : 9, "group" : "advanced", "label" : "advanced" + }, { + "name" : "externalReferences", + "type" : "enum", + "enum" : [ "all", "local", "none" ], + "description" : "Which external references ($ref) the OpenAPI specification may use: all allows any reference, local allows references to the classpath and the file system only, and none allows only references inside the document. A reference the option does not allow fails the start.", + "title" : "External References", + "default" : "all", + "displayName" : "External References", + "kind" : "attribute", + "index" : 10, + "group" : "common" } ] }, "openIdConnect" : { diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl.json b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl.json index d580432221c3e..cd593cb418e60 100644 --- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl.json +++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl.json @@ -14181,6 +14181,13 @@ "description" : "Whether to disable all the REST services from the OpenAPI contract from the route during build time. Once an REST service has been disabled then it cannot be enabled later at runtime.", "default" : false }, + "externalReferences" : { + "type" : "string", + "title" : "External References", + "description" : "Which external references ($ref) the OpenAPI specification may use: all allows any reference, local allows references to the classpath and the file system only, and none allows only references inside the document. A reference the option does not allow fails the start.", + "default" : "all", + "enum" : [ "all", "local", "none" ] + }, "id" : { "type" : "string", "title" : "Id", From f48bfa0e93a7290112147077616fc14f71f8d3e0 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 20 Oct 2026 17:51:01 +0000 Subject: [PATCH 2/6] Regen --- .../camel/catalog/schemas/camelYamlDsl-model.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json index 58eccd8df1bb8..6e6d2f56d5f57 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json @@ -17755,6 +17755,17 @@ "index" : 9, "group" : "advanced", "label" : "advanced" + }, { + "name" : "externalReferences", + "type" : "enum", + "enum" : [ "all", "local", "none" ], + "description" : "Which external references ($ref) the OpenAPI specification may use: all allows any reference, local allows references to the classpath and the file system only, and none allows only references inside the document. A reference the option does not allow fails the start.", + "title" : "External References", + "default" : "all", + "displayName" : "External References", + "kind" : "attribute", + "index" : 10, + "group" : "common" } ] }, "openIdConnect" : { From 47ac40a057686a306d9eb6da3b176f04c30fbec1 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Wed, 21 Oct 2026 10:44:39 +0200 Subject: [PATCH 3/6] CAMEL-25887: camel-rest-openapi - check the external references the way the parser resolves them The checker now scans the tree the parser builds (YAML aliases expanded), resolves each reference against the document that contains it and, for OpenAPI 3.1, against the enclosing $id, reads file: and classpath: documents as the parser does, and fails when a referenced document cannot be read. Regression tests for nested documents, $id, aliases and file: references in both modules. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Andrea Cosentino --- .../catalog/docs/rest-openapi-component.adoc | 8 +- .../openapi/OpenApiExternalReferences.java | 223 ++++++++++----- ...stOpenApiReaderExternalReferencesTest.java | 38 ++- .../resources/external-references-31-id.yaml | 49 ++++ .../resources/external-references-alias.yaml | 42 +++ .../external-references-subdir-remote.yaml | 39 +++ .../external-references-owner-remote.yaml | 27 ++ .../external-references-pet-remote.yaml | 27 ++ .../src/main/docs/rest-openapi-component.adoc | 8 +- .../openapi/OpenApiExternalReferences.java | 223 ++++++++++----- .../RestOpenApiExternalReferencesTest.java | 256 +++++++++++++++++- .../external-references-subdir-remote.yaml | 39 +++ .../resources/external-references-subdir.yaml | 39 +++ .../external-references-owner-remote.yaml | 27 ++ .../schemas/external-references-owner.yaml | 25 ++ .../external-references-pet-remote.yaml | 27 ++ .../schemas/external-references-pet.yaml | 27 ++ .../modules/ROOT/pages/rest-dsl-openapi.adoc | 5 +- 18 files changed, 966 insertions(+), 163 deletions(-) create mode 100644 components/camel-openapi-java/src/test/resources/external-references-31-id.yaml create mode 100644 components/camel-openapi-java/src/test/resources/external-references-alias.yaml create mode 100644 components/camel-openapi-java/src/test/resources/external-references-subdir-remote.yaml create mode 100644 components/camel-openapi-java/src/test/resources/schemas/external-references-owner-remote.yaml create mode 100644 components/camel-openapi-java/src/test/resources/schemas/external-references-pet-remote.yaml create mode 100644 components/camel-rest-openapi/src/test/resources/external-references-subdir-remote.yaml create mode 100644 components/camel-rest-openapi/src/test/resources/external-references-subdir.yaml create mode 100644 components/camel-rest-openapi/src/test/resources/schemas/external-references-owner-remote.yaml create mode 100644 components/camel-rest-openapi/src/test/resources/schemas/external-references-owner.yaml create mode 100644 components/camel-rest-openapi/src/test/resources/schemas/external-references-pet-remote.yaml create mode 100644 components/camel-rest-openapi/src/test/resources/schemas/external-references-pet.yaml diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/rest-openapi-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/rest-openapi-component.adoc index 7f0455f634fcc..837486545657d 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/rest-openapi-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/rest-openapi-component.adoc @@ -342,9 +342,11 @@ endpoint or the component, restricts this: then reference no other document at all, because the parser resolves its references against that URL. * `none` allows only references inside the document, such as `#/components/schemas/Pet`. -The check runs on the specification, and on the local documents it references, before the parser resolves -anything. It covers every `$ref` in a document, examples included. A reference the option does not allow fails -the start, with a message that names the reference and where it is. +The check runs before the parser resolves anything, on the document tree the parser uses (YAML aliases expanded). +It resolves every `$ref` the way the parser does: against the document that contains it and, for OpenAPI 3.1, +against the enclosing `$id`. `file:` and `classpath:` references count as local. The local documents the option +allows are read and checked in turn, and a document that cannot be read fails the check. A reference the option +does not allow fails the start, with a message that names the reference and where it is. [source,java] ---- diff --git a/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java b/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java index 7df77c424a032..af9763dc14b18 100644 --- a/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java +++ b/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java @@ -17,27 +17,34 @@ package org.apache.camel.openapi; import java.io.File; -import java.util.ArrayList; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; import java.util.HashSet; -import java.util.List; +import java.util.LinkedHashMap; import java.util.Locale; import java.util.Map; import java.util.Set; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; -import io.swagger.v3.core.util.Json; -import io.swagger.v3.core.util.Yaml; import io.swagger.v3.parser.models.RefFormat; +import io.swagger.v3.parser.processors.ExternalRefProcessor; +import io.swagger.v3.parser.reference.ReferenceUtils; +import io.swagger.v3.parser.util.ClasspathHelper; +import io.swagger.v3.parser.util.DeserializationUtils; import io.swagger.v3.parser.util.RefUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Checks the references ({@code $ref}) of an OpenAPI specification against the {@code externalReferences} option before * the parser resolves them. The parser loads every external reference it meets: from the working directory and the * classpath, or over http(s) when the reference is a URL or the specification itself comes from a URL. *

+ * The check works on the tree the parser resolves (YAML aliases expanded), resolves each reference the way the parser + * does, that is against the document that contains it and, for OpenAPI 3.1, against the enclosing {@code $id}s, and + * reads the local documents it allows to check their references in turn. A document it cannot read fails the check. + *

* This is a copy of the class of the same name in camel-rest-openapi, which neither module can depend on the other for; * keep the two in sync. */ @@ -47,7 +54,10 @@ final class OpenApiExternalReferences { static final String LOCAL = "local"; static final String NONE = "none"; - private static final Logger LOG = LoggerFactory.getLogger(OpenApiExternalReferences.class); + /** + * The URI the parser gives an OpenAPI 3.1 specification that has no location (OpenAPIDereferencer31). + */ + private static final String LOCAL_URI = "local"; private OpenApiExternalReferences() { } @@ -77,119 +87,190 @@ static String policy(String externalReferences) { * @param specificationUri where the specification comes from, for the error message * @param remote whether the specification comes from a URL: the parser then resolves its * external references against that URL - * @throws IllegalArgumentException for the first reference the option does not allow + * @throws IllegalArgumentException for the first reference the option does not allow, and for a referenced document + * that cannot be read */ static void check(String externalReferences, String specification, String specificationUri, boolean remote) { String policy = policy(externalReferences); if (ALL.equals(policy)) { return; } - checkDocument(policy, specification, specificationUri, null, remote, new HashSet<>()); - } - - private static void checkDocument( - String policy, String content, String specificationUri, String document, boolean remote, - Set checked) { - DocumentCheck check = new DocumentCheck(policy, specificationUri, document, remote); - check.scan(readTree(content, specificationUri, document), ""); - // a local document can reference further documents, which the parser loads as well - for (String file : check.localDocuments) { - if (checked.add(file)) { - String text = readLocalDocument(file); - if (text != null) { - checkDocument(policy, text, specificationUri, file, false, checked); - } - } - } - } - - private static JsonNode readTree(String content, String specificationUri, String document) { - try { - return (content.trim().startsWith("{") ? Json.mapper() : Yaml.mapper()).readTree(content); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException( - "Cannot read the OpenAPI specification " + specificationUri - + (document != null ? " (" + document + ")" : "") - + " to check its references: " + e.getMessage(), - e); - } + new Check(policy, specificationUri, remote).document(specification, null, null); } - private static String readLocalDocument(String file) { - try { - // how the parser reads a relative reference of a specification that has no location: from the working - // directory, then from the classpath - return RefUtils.readExternalRef(file, RefFormat.RELATIVE, null, new File(".").toPath()); - } catch (RuntimeException e) { - LOG.debug("Cannot read {} to check its references, the parser reports it when it resolves the specification: {}", - file, e.getMessage()); - return null; - } - } - - private static boolean isHttp(String file) { - String lower = file.toLowerCase(Locale.ROOT); + private static boolean isHttp(String target) { + String lower = target.toLowerCase(Locale.ROOT); return lower.startsWith("http:") || lower.startsWith("https:"); } /** - * The check of one document. + * The check of one specification: the root document and the documents reached from it. */ - private static final class DocumentCheck { + private static final class Check { private final String policy; private final String specificationUri; - private final String document; private final boolean remote; - private final List localDocuments = new ArrayList<>(); + private final Set checked = new HashSet<>(); + private boolean openApi31; - DocumentCheck(String policy, String specificationUri, String document, boolean remote) { + Check(String policy, String specificationUri, boolean remote) { this.policy = policy; this.specificationUri = specificationUri; - this.document = document; this.remote = remote; } - void scan(JsonNode node, String path) { + /** + * Checks one document. + * + * @param content the document + * @param document the resolved location of the document, as the parser uses it, or null for the root + * @param origin where the root references this document, for the error message, or null for the root + */ + void document(String content, String document, String origin) { + JsonNode tree; + try { + // the tree the parser resolves: the same YAML reader, with the aliases expanded and its limits applied + tree = DeserializationUtils.deserializeIntoTree(content, document != null ? document : specificationUri); + } catch (RuntimeException e) { + throw new IllegalArgumentException( + (document != null ? where(document, origin) : "The OpenAPI specification " + specificationUri) + + " cannot be read to check its references: " + e.getMessage(), + e); + } + if (document == null) { + // the parser resolves an OpenAPI 3.1 document with its own dereferencer, which has other rules + openApi31 = tree.path("openapi").asText("").startsWith("3.1"); + } + // the external references of this document, with where they are, in the order they are met + Map targets = new LinkedHashMap<>(); + String base = openApi31 && document == null ? LOCAL_URI : document; + scan(tree, "", document, base, targets); + for (Map.Entry target : targets.entrySet()) { + if (checked.add(target.getKey())) { + // a referenced local document can reference further documents, which the parser loads as well + document(read(target.getKey(), target.getValue()), target.getKey(), target.getValue()); + } + } + } + + private void scan(JsonNode node, String path, String document, String base, Map targets) { if (node.isObject()) { + String nodeBase = base; + if (openApi31) { + JsonNode id = node.get("$id"); + if (id != null && id.isTextual() && !id.asText().isBlank()) { + nodeBase = resolveBase(id.asText(), base, path, document); + } + } for (Map.Entry field : node.properties()) { String fieldPath = path.isEmpty() ? field.getKey() : path + "." + field.getKey(); if ("$ref".equals(field.getKey()) && field.getValue().isTextual()) { - reference(field.getValue().asText(), fieldPath); + reference(field.getValue().asText(), fieldPath, document, nodeBase, targets); } else { - scan(field.getValue(), fieldPath); + scan(field.getValue(), fieldPath, document, nodeBase, targets); } } } else if (node.isArray()) { int i = 0; for (JsonNode item : node) { - scan(item, path + "[" + i++ + "]"); + scan(item, path + "[" + i++ + "]", document, base, targets); } } } - private void reference(String ref, String path) { + /** + * The base the references below an OpenAPI 3.1 {@code $id} resolve against: the id resolved against the + * enclosing base, as OpenAPI31Traverser.traverseSchema does. + */ + private String resolveBase(String id, String base, String path, String document) { + try { + return ReferenceUtils.toBaseURI(ReferenceUtils.resolve(ReferenceUtils.toBaseURI(id), base)); + } catch (Exception e) { + throw new IllegalArgumentException( + where(id, path, document) + " cannot be resolved as $id: " + e.getMessage(), e); + } + } + + private void reference(String ref, String path, String document, String base, Map targets) { if (ref.isEmpty() || ref.startsWith("#")) { - // inside the document + // inside the document: the parser evaluates the fragment in the document, or in the schema whose $id + // is the base, without loading anything return; } - int fragment = ref.indexOf('#'); - String file = fragment < 0 ? ref : ref.substring(0, fragment); + String target; + try { + if (openApi31) { + // ReferenceVisitor.resolveSchemaRef: the reference resolved against the enclosing $ids and the + // document, without its fragment + target = ReferenceUtils.toBaseURI(ReferenceUtils.resolve(ref, base)); + } else { + // ExternalRefProcessor: a reference met in a referenced document is joined with the path of that + // document; the parser then reads the part before the fragment + String joined = document == null ? ref : ExternalRefProcessor.join(document, ref); + int fragment = joined.indexOf("#/"); + target = fragment < 0 ? joined : joined.substring(0, fragment); + } + } catch (Exception e) { + throw new IllegalArgumentException( + where(ref, path, document) + " cannot be resolved: " + e.getMessage(), e); + } String reason = null; if (NONE.equals(policy)) { reason = "externalReferences=none allows only references inside the document"; } else if (remote) { reason = "externalReferences=local allows no external reference in a specification loaded from a URL," + " since the parser resolves it against that URL"; - } else if (isHttp(file)) { - reason = "externalReferences=local allows no http(s) reference"; + } else if (isHttp(target)) { + reason = "externalReferences=local allows no http(s) reference" + + (target.equals(ref) ? "" : " (" + ref + " resolves to " + target + ")"); } if (reason != null) { + throw new IllegalArgumentException(where(ref, path, document) + ", but " + reason); + } + // a local document: file: and classpath: URIs, and paths the parser reads from the working directory or + // the classpath + targets.putIfAbsent(target, where(ref, path, document)); + } + + /** + * Reads a referenced local document the way the parser does: a {@code file:} or {@code classpath:} URI by its + * path (OpenAPI 3.1, Visitor.readURI), anything else from the working directory and then the classpath + * (RefUtils.readExternalRef). + */ + private String read(String target, String origin) { + try { + String scheme = null; + String schemePath = null; + try { + URI uri = new URI(target); + scheme = uri.getScheme() != null ? uri.getScheme().toLowerCase(Locale.ROOT) : null; + schemePath = uri.getPath(); + } catch (URISyntaxException e) { + // not a URI, so a path + } + if ("file".equals(scheme)) { + return Files.readString(Paths.get(schemePath), StandardCharsets.UTF_8); + } else if ("classpath".equals(scheme)) { + return ClasspathHelper.loadFileFromClasspath(schemePath); + } else { + return RefUtils.readExternalRef(target, RefFormat.RELATIVE, null, new File(".").toPath(), null); + } + } catch (Exception e) { throw new IllegalArgumentException( - "The OpenAPI specification " + specificationUri + " references " + ref + " at " + path - + (document != null ? " in " + document : "") + ", but " + reason); + where(target, origin) + " cannot be read to check its references: " + e.getMessage(), e); } - localDocuments.add(file); + } + + private String where(String ref, String path, String document) { + return "The OpenAPI specification " + specificationUri + " references " + ref + " at " + path + + (document != null ? " in " + document : ""); + } + + private String where(String document, String origin) { + return origin != null + ? origin + ", which" : "The OpenAPI specification " + specificationUri + " references " + + document + ", which"; } } } diff --git a/components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java b/components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java index 38eb75504ce11..ddd3e82347f87 100644 --- a/components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java +++ b/components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java @@ -34,7 +34,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * The externalReferences option of a contract-first openApi element, applied to the contract served as api-doc. + * The externalReferences option of a contract-first openApi element, applied to the contract served as api-doc. The + * contracts that must be rejected reference a host that does not exist; the check runs before the parser, so nothing is + * contacted. */ class RestOpenApiReaderExternalReferencesTest { @@ -73,6 +75,40 @@ void failsWhenTheContractAllowsNoExternalReference() { assertTrue(e.getMessage().contains("externalReferences=none"), e.getMessage()); } + @Test + void checksTheReferencesOfADocumentInASubdirectory() { + RestDefinition rest = new RestDefinition(); + rest.openApi().specification("external-references-subdir-remote.yaml").externalReferences("local"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> read(rest)); + + // the parser reads the Owner reference of schemas/external-references-pet-remote.yaml next to that document + assertTrue(e.getMessage().contains("https://schemas.example.invalid/address.yaml"), e.getMessage()); + assertTrue(e.getMessage().contains("in schemas/external-references-owner-remote.yaml"), e.getMessage()); + } + + @Test + void checksAnOpenApi31ReferenceAgainstTheIdOfItsSchema() { + RestDefinition rest = new RestDefinition(); + rest.openApi().specification("external-references-31-id.yaml").externalReferences("local"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> read(rest)); + + assertTrue(e.getMessage().contains("owner.yaml resolves to https://schemas.example.invalid/schemas/owner.yaml"), + e.getMessage()); + } + + @Test + void checksAReferenceGivenAsAYamlAlias() { + RestDefinition rest = new RestDefinition(); + rest.openApi().specification("external-references-alias.yaml").externalReferences("none"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> read(rest)); + + // the alias is expanded before the check, as the parser expands it + assertTrue(e.getMessage().contains("references https://schemas.example.invalid/schema.yaml"), e.getMessage()); + } + @Test void usesTheSettingOfTheRestOpenApiComponent() { context.addComponent("rest-openapi", new RestrictedComponent()); diff --git a/components/camel-openapi-java/src/test/resources/external-references-31-id.yaml b/components/camel-openapi-java/src/test/resources/external-references-31-id.yaml new file mode 100644 index 0000000000000..7c6c81b466e50 --- /dev/null +++ b/components/camel-openapi-java/src/test/resources/external-references-31-id.yaml @@ -0,0 +1,49 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# An OpenAPI 3.1 contract whose Pet schema has an $id on a server that does not exist, so its owner reference resolves to a URL +openapi: 3.1.0 +info: + title: Pets + version: "1.0" +servers: + - url: http://localhost:8080/api +paths: + /pets/{petId}: + get: + operationId: getPetById + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + "200": + description: The pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + $id: https://schemas.example.invalid/schemas/ + type: object + properties: + owner: + $ref: owner.yaml diff --git a/components/camel-openapi-java/src/test/resources/external-references-alias.yaml b/components/camel-openapi-java/src/test/resources/external-references-alias.yaml new file mode 100644 index 0000000000000..53e922e2179a0 --- /dev/null +++ b/components/camel-openapi-java/src/test/resources/external-references-alias.yaml @@ -0,0 +1,42 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# A contract whose Pet schema reference is a YAML alias of a URL on a server that does not exist +openapi: 3.0.3 +info: + title: Pets + version: "1.0" +servers: + - url: http://localhost:8080/api +x-schema: &#remote 'https://schemas.example.invalid/schema.yaml' +paths: + /pets/{petId}: + get: + operationId: getPetById + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + "200": + description: The pet + content: + application/json: + schema: + $ref: *#remote diff --git a/components/camel-openapi-java/src/test/resources/external-references-subdir-remote.yaml b/components/camel-openapi-java/src/test/resources/external-references-subdir-remote.yaml new file mode 100644 index 0000000000000..8213e05d09fbc --- /dev/null +++ b/components/camel-openapi-java/src/test/resources/external-references-subdir-remote.yaml @@ -0,0 +1,39 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# A contract whose Pet schema lives in schemas/external-references-pet-remote.yaml, which references a document next to it that references a URL +openapi: 3.0.3 +info: + title: Pets + version: "1.0" +paths: + /pets/{petId}: + get: + operationId: getPetById + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + "200": + description: The pet + content: + application/json: + schema: + $ref: 'schemas/external-references-pet-remote.yaml#/components/schemas/Pet' diff --git a/components/camel-openapi-java/src/test/resources/schemas/external-references-owner-remote.yaml b/components/camel-openapi-java/src/test/resources/schemas/external-references-owner-remote.yaml new file mode 100644 index 0000000000000..f7d42247ddcce --- /dev/null +++ b/components/camel-openapi-java/src/test/resources/schemas/external-references-owner-remote.yaml @@ -0,0 +1,27 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# The Owner schema of schemas/external-references-pet-remote.yaml; the address schema is on a server that does not exist +components: + schemas: + Owner: + type: object + properties: + name: + type: string + address: + $ref: 'https://schemas.example.invalid/address.yaml#/components/schemas/Address' diff --git a/components/camel-openapi-java/src/test/resources/schemas/external-references-pet-remote.yaml b/components/camel-openapi-java/src/test/resources/schemas/external-references-pet-remote.yaml new file mode 100644 index 0000000000000..1f9daa5bbbdc7 --- /dev/null +++ b/components/camel-openapi-java/src/test/resources/schemas/external-references-pet-remote.yaml @@ -0,0 +1,27 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# The Pet schema of external-references-subdir-remote.yaml; the parser reads the Owner reference as schemas/external-references-owner-remote.yaml +components: + schemas: + Pet: + type: object + properties: + id: + type: integer + owner: + $ref: 'external-references-owner-remote.yaml#/components/schemas/Owner' diff --git a/components/camel-rest-openapi/src/main/docs/rest-openapi-component.adoc b/components/camel-rest-openapi/src/main/docs/rest-openapi-component.adoc index 7f0455f634fcc..837486545657d 100644 --- a/components/camel-rest-openapi/src/main/docs/rest-openapi-component.adoc +++ b/components/camel-rest-openapi/src/main/docs/rest-openapi-component.adoc @@ -342,9 +342,11 @@ endpoint or the component, restricts this: then reference no other document at all, because the parser resolves its references against that URL. * `none` allows only references inside the document, such as `#/components/schemas/Pet`. -The check runs on the specification, and on the local documents it references, before the parser resolves -anything. It covers every `$ref` in a document, examples included. A reference the option does not allow fails -the start, with a message that names the reference and where it is. +The check runs before the parser resolves anything, on the document tree the parser uses (YAML aliases expanded). +It resolves every `$ref` the way the parser does: against the document that contains it and, for OpenAPI 3.1, +against the enclosing `$id`. `file:` and `classpath:` references count as local. The local documents the option +allows are read and checked in turn, and a document that cannot be read fails the check. A reference the option +does not allow fails the start, with a message that names the reference and where it is. [source,java] ---- diff --git a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java index 3b8f02eb8c985..97025be6e9aab 100644 --- a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java +++ b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java @@ -17,27 +17,34 @@ package org.apache.camel.component.rest.openapi; import java.io.File; -import java.util.ArrayList; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; import java.util.HashSet; -import java.util.List; +import java.util.LinkedHashMap; import java.util.Locale; import java.util.Map; import java.util.Set; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; -import io.swagger.v3.core.util.Json; -import io.swagger.v3.core.util.Yaml; import io.swagger.v3.parser.models.RefFormat; +import io.swagger.v3.parser.processors.ExternalRefProcessor; +import io.swagger.v3.parser.reference.ReferenceUtils; +import io.swagger.v3.parser.util.ClasspathHelper; +import io.swagger.v3.parser.util.DeserializationUtils; import io.swagger.v3.parser.util.RefUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Checks the references ({@code $ref}) of an OpenAPI specification against the {@code externalReferences} option before * the parser resolves them. The parser loads every external reference it meets: from the working directory and the * classpath, or over http(s) when the reference is a URL or the specification itself comes from a URL. *

+ * The check works on the tree the parser resolves (YAML aliases expanded), resolves each reference the way the parser + * does, that is against the document that contains it and, for OpenAPI 3.1, against the enclosing {@code $id}s, and + * reads the local documents it allows to check their references in turn. A document it cannot read fails the check. + *

* camel-openapi-java has a copy of this class for the specification it serves as api-doc; keep the two in sync. */ final class OpenApiExternalReferences { @@ -46,7 +53,10 @@ final class OpenApiExternalReferences { static final String LOCAL = "local"; static final String NONE = "none"; - private static final Logger LOG = LoggerFactory.getLogger(OpenApiExternalReferences.class); + /** + * The URI the parser gives an OpenAPI 3.1 specification that has no location (OpenAPIDereferencer31). + */ + private static final String LOCAL_URI = "local"; private OpenApiExternalReferences() { } @@ -76,119 +86,190 @@ static String policy(String externalReferences) { * @param specificationUri where the specification comes from, for the error message * @param remote whether the specification comes from a URL: the parser then resolves its * external references against that URL - * @throws IllegalArgumentException for the first reference the option does not allow + * @throws IllegalArgumentException for the first reference the option does not allow, and for a referenced document + * that cannot be read */ static void check(String externalReferences, String specification, String specificationUri, boolean remote) { String policy = policy(externalReferences); if (ALL.equals(policy)) { return; } - checkDocument(policy, specification, specificationUri, null, remote, new HashSet<>()); - } - - private static void checkDocument( - String policy, String content, String specificationUri, String document, boolean remote, - Set checked) { - DocumentCheck check = new DocumentCheck(policy, specificationUri, document, remote); - check.scan(readTree(content, specificationUri, document), ""); - // a local document can reference further documents, which the parser loads as well - for (String file : check.localDocuments) { - if (checked.add(file)) { - String text = readLocalDocument(file); - if (text != null) { - checkDocument(policy, text, specificationUri, file, false, checked); - } - } - } - } - - private static JsonNode readTree(String content, String specificationUri, String document) { - try { - return (content.trim().startsWith("{") ? Json.mapper() : Yaml.mapper()).readTree(content); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException( - "Cannot read the OpenAPI specification " + specificationUri - + (document != null ? " (" + document + ")" : "") - + " to check its references: " + e.getMessage(), - e); - } + new Check(policy, specificationUri, remote).document(specification, null, null); } - private static String readLocalDocument(String file) { - try { - // how the parser reads a relative reference of a specification that has no location: from the working - // directory, then from the classpath - return RefUtils.readExternalRef(file, RefFormat.RELATIVE, null, new File(".").toPath()); - } catch (RuntimeException e) { - LOG.debug("Cannot read {} to check its references, the parser reports it when it resolves the specification: {}", - file, e.getMessage()); - return null; - } - } - - private static boolean isHttp(String file) { - String lower = file.toLowerCase(Locale.ROOT); + private static boolean isHttp(String target) { + String lower = target.toLowerCase(Locale.ROOT); return lower.startsWith("http:") || lower.startsWith("https:"); } /** - * The check of one document. + * The check of one specification: the root document and the documents reached from it. */ - private static final class DocumentCheck { + private static final class Check { private final String policy; private final String specificationUri; - private final String document; private final boolean remote; - private final List localDocuments = new ArrayList<>(); + private final Set checked = new HashSet<>(); + private boolean openApi31; - DocumentCheck(String policy, String specificationUri, String document, boolean remote) { + Check(String policy, String specificationUri, boolean remote) { this.policy = policy; this.specificationUri = specificationUri; - this.document = document; this.remote = remote; } - void scan(JsonNode node, String path) { + /** + * Checks one document. + * + * @param content the document + * @param document the resolved location of the document, as the parser uses it, or null for the root + * @param origin where the root references this document, for the error message, or null for the root + */ + void document(String content, String document, String origin) { + JsonNode tree; + try { + // the tree the parser resolves: the same YAML reader, with the aliases expanded and its limits applied + tree = DeserializationUtils.deserializeIntoTree(content, document != null ? document : specificationUri); + } catch (RuntimeException e) { + throw new IllegalArgumentException( + (document != null ? where(document, origin) : "The OpenAPI specification " + specificationUri) + + " cannot be read to check its references: " + e.getMessage(), + e); + } + if (document == null) { + // the parser resolves an OpenAPI 3.1 document with its own dereferencer, which has other rules + openApi31 = tree.path("openapi").asText("").startsWith("3.1"); + } + // the external references of this document, with where they are, in the order they are met + Map targets = new LinkedHashMap<>(); + String base = openApi31 && document == null ? LOCAL_URI : document; + scan(tree, "", document, base, targets); + for (Map.Entry target : targets.entrySet()) { + if (checked.add(target.getKey())) { + // a referenced local document can reference further documents, which the parser loads as well + document(read(target.getKey(), target.getValue()), target.getKey(), target.getValue()); + } + } + } + + private void scan(JsonNode node, String path, String document, String base, Map targets) { if (node.isObject()) { + String nodeBase = base; + if (openApi31) { + JsonNode id = node.get("$id"); + if (id != null && id.isTextual() && !id.asText().isBlank()) { + nodeBase = resolveBase(id.asText(), base, path, document); + } + } for (Map.Entry field : node.properties()) { String fieldPath = path.isEmpty() ? field.getKey() : path + "." + field.getKey(); if ("$ref".equals(field.getKey()) && field.getValue().isTextual()) { - reference(field.getValue().asText(), fieldPath); + reference(field.getValue().asText(), fieldPath, document, nodeBase, targets); } else { - scan(field.getValue(), fieldPath); + scan(field.getValue(), fieldPath, document, nodeBase, targets); } } } else if (node.isArray()) { int i = 0; for (JsonNode item : node) { - scan(item, path + "[" + i++ + "]"); + scan(item, path + "[" + i++ + "]", document, base, targets); } } } - private void reference(String ref, String path) { + /** + * The base the references below an OpenAPI 3.1 {@code $id} resolve against: the id resolved against the + * enclosing base, as OpenAPI31Traverser.traverseSchema does. + */ + private String resolveBase(String id, String base, String path, String document) { + try { + return ReferenceUtils.toBaseURI(ReferenceUtils.resolve(ReferenceUtils.toBaseURI(id), base)); + } catch (Exception e) { + throw new IllegalArgumentException( + where(id, path, document) + " cannot be resolved as $id: " + e.getMessage(), e); + } + } + + private void reference(String ref, String path, String document, String base, Map targets) { if (ref.isEmpty() || ref.startsWith("#")) { - // inside the document + // inside the document: the parser evaluates the fragment in the document, or in the schema whose $id + // is the base, without loading anything return; } - int fragment = ref.indexOf('#'); - String file = fragment < 0 ? ref : ref.substring(0, fragment); + String target; + try { + if (openApi31) { + // ReferenceVisitor.resolveSchemaRef: the reference resolved against the enclosing $ids and the + // document, without its fragment + target = ReferenceUtils.toBaseURI(ReferenceUtils.resolve(ref, base)); + } else { + // ExternalRefProcessor: a reference met in a referenced document is joined with the path of that + // document; the parser then reads the part before the fragment + String joined = document == null ? ref : ExternalRefProcessor.join(document, ref); + int fragment = joined.indexOf("#/"); + target = fragment < 0 ? joined : joined.substring(0, fragment); + } + } catch (Exception e) { + throw new IllegalArgumentException( + where(ref, path, document) + " cannot be resolved: " + e.getMessage(), e); + } String reason = null; if (NONE.equals(policy)) { reason = "externalReferences=none allows only references inside the document"; } else if (remote) { reason = "externalReferences=local allows no external reference in a specification loaded from a URL," + " since the parser resolves it against that URL"; - } else if (isHttp(file)) { - reason = "externalReferences=local allows no http(s) reference"; + } else if (isHttp(target)) { + reason = "externalReferences=local allows no http(s) reference" + + (target.equals(ref) ? "" : " (" + ref + " resolves to " + target + ")"); } if (reason != null) { + throw new IllegalArgumentException(where(ref, path, document) + ", but " + reason); + } + // a local document: file: and classpath: URIs, and paths the parser reads from the working directory or + // the classpath + targets.putIfAbsent(target, where(ref, path, document)); + } + + /** + * Reads a referenced local document the way the parser does: a {@code file:} or {@code classpath:} URI by its + * path (OpenAPI 3.1, Visitor.readURI), anything else from the working directory and then the classpath + * (RefUtils.readExternalRef). + */ + private String read(String target, String origin) { + try { + String scheme = null; + String schemePath = null; + try { + URI uri = new URI(target); + scheme = uri.getScheme() != null ? uri.getScheme().toLowerCase(Locale.ROOT) : null; + schemePath = uri.getPath(); + } catch (URISyntaxException e) { + // not a URI, so a path + } + if ("file".equals(scheme)) { + return Files.readString(Paths.get(schemePath), StandardCharsets.UTF_8); + } else if ("classpath".equals(scheme)) { + return ClasspathHelper.loadFileFromClasspath(schemePath); + } else { + return RefUtils.readExternalRef(target, RefFormat.RELATIVE, null, new File(".").toPath(), null); + } + } catch (Exception e) { throw new IllegalArgumentException( - "The OpenAPI specification " + specificationUri + " references " + ref + " at " + path - + (document != null ? " in " + document : "") + ", but " + reason); + where(target, origin) + " cannot be read to check its references: " + e.getMessage(), e); } - localDocuments.add(file); + } + + private String where(String ref, String path, String document) { + return "The OpenAPI specification " + specificationUri + " references " + ref + " at " + path + + (document != null ? " in " + document : ""); + } + + private String where(String document, String origin) { + return origin != null + ? origin + ", which" : "The OpenAPI specification " + specificationUri + " references " + + document + ", which"; } } } diff --git a/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java b/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java index 5131f430bf344..dcb443d5a8c94 100644 --- a/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java +++ b/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java @@ -20,6 +20,7 @@ import java.nio.file.Path; import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.media.Schema; import org.apache.camel.CamelContext; @@ -32,6 +33,8 @@ import org.junit.jupiter.api.io.TempDir; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.anyRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; @@ -40,7 +43,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * The externalReferences option: which $ref the specification may use, checked before the parser resolves them. + * The externalReferences option: which $ref the specification may use, checked before the parser resolves them. The + * server stands in for every URL a specification could reach, so that a test can assert that nothing was fetched. */ class RestOpenApiExternalReferencesTest { @@ -58,19 +62,24 @@ class RestOpenApiExternalReferencesTest { type: string """; + private static final String OWNER_SCHEMA = """ + type: object + properties: + name: + type: string + """; + private final CamelContext camelContext = new DefaultCamelContext(); @BeforeAll static void startServer() { SERVER.start(); - SERVER.stubFor(get(urlEqualTo("/petstore.yaml")) - .willReturn(aResponse().withHeader("Content-Type", "application/yaml") - .withBody(contract("pet.yaml#/components/schemas/Pet")))); - SERVER.stubFor(get(urlEqualTo("/pet.yaml")) - .willReturn(aResponse().withHeader("Content-Type", "application/yaml").withBody(PET_SCHEMAS))); + SERVER.stubFor(get(urlEqualTo("/petstore.yaml")).willReturn(yaml(contract("pet.yaml#/components/schemas/Pet")))); + SERVER.stubFor(get(urlEqualTo("/pet.yaml")).willReturn(yaml(PET_SCHEMAS))); SERVER.stubFor(get(urlEqualTo("/self-contained.yaml")) - .willReturn(aResponse().withHeader("Content-Type", "application/yaml") - .withBody(contract("#/components/schemas/Pet") + PET_SCHEMAS))); + .willReturn(yaml(contract("'#/components/schemas/Pet'") + PET_SCHEMAS))); + SERVER.stubFor(get(urlEqualTo("/schema.yaml")).willReturn(yaml(OWNER_SCHEMA))); + SERVER.stubFor(get(urlEqualTo("/schemas/owner.yaml")).willReturn(yaml(OWNER_SCHEMA))); } @AfterAll @@ -126,14 +135,14 @@ void noneRejectsTheExternalReferencesOfAnOpenApi31Document() { @Test void localRejectsAnHttpReferenceWithoutFetchingIt(@TempDir Path dir) throws Exception { - Path file = dir.resolve("contract.yaml"); - Files.writeString(file, contract(SERVER.baseUrl() + "/pet.yaml#/components/schemas/Pet")); + Path file = Files.writeString(dir.resolve("contract.yaml"), + contract(SERVER.baseUrl() + "/pet.yaml#/components/schemas/Pet")); assertThatThrownBy(() -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + file, "local")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining(SERVER.baseUrl() + "/pet.yaml#/components/schemas/Pet") .hasMessageContaining("externalReferences=local allows no http(s) reference"); - SERVER.verify(0, getRequestedFor(urlEqualTo("/pet.yaml"))); + SERVER.verify(0, anyRequestedFor(anyUrl())); } @Test @@ -146,6 +155,184 @@ void localChecksTheDocumentsTheSpecificationReferences() { .hasMessageContaining("externalReferences=local"); } + @Test + void localResolvesTheReferencesOfADocumentInASubdirectory() { + OpenAPI openApi + = RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "external-references-subdir.yaml", "local"); + + // the parser read the Owner reference of schemas/external-references-pet.yaml next to that document + Schema owner = (Schema) petSchema(openApi).getProperties().get("owner"); + assertThat(owner.getProperties()).containsKey("name"); + } + + @Test + void localChecksTheReferencesOfADocumentInASubdirectory() { + assertThatThrownBy(() -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, + "external-references-subdir-remote.yaml", "local")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("https://schemas.example.invalid/address.yaml#/components/schemas/Address") + .hasMessageContaining( + "at components.schemas.Owner.properties.address.$ref in schemas/external-references-owner-remote.yaml") + .hasMessageContaining("externalReferences=local"); + } + + @Test + void localChecksADocumentReferencedByAbsolutePath(@TempDir Path dir) throws Exception { + Path specs = Files.createDirectory(dir.resolve("specs")); + Files.writeString(specs.resolve("first.yaml"), """ + components: + schemas: + Pet: + type: object + properties: + owner: + $ref: 'second.yaml#/components/schemas/Owner' + """); + Files.writeString(specs.resolve("second.yaml"), """ + components: + schemas: + Owner: + $ref: '%s/schema.yaml' + """.formatted(SERVER.baseUrl())); + Path root = Files.writeString(dir.resolve("root.yaml"), + contract(specs.resolve("first.yaml") + "#/components/schemas/Pet")); + + assertThatThrownBy(() -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + root, "local")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(SERVER.baseUrl() + "/schema.yaml") + // the parser reads second.yaml next to first.yaml, not from the working directory + .hasMessageContaining("in " + specs.resolve("second.yaml")) + .hasMessageContaining("externalReferences=local"); + SERVER.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void localFailsWhenAReferencedDocumentCannotBeRead(@TempDir Path dir) throws Exception { + Path root = Files.writeString(dir.resolve("root.yaml"), contract("missing.yaml#/components/schemas/Pet")); + + assertThatThrownBy(() -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + root, "local")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("references missing.yaml#/components/schemas/Pet") + .hasMessageContaining("cannot be read to check its references"); + // the default lets the parser report the missing document and keep the rest + assertThat(RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + root).getPaths()) + .containsKey("/pets/{petId}"); + } + + @Test + void noneRejectsAReferenceGivenAsAYamlAlias(@TempDir Path dir) throws Exception { + // an anchor whose name starts with # looks like a reference inside the document before the alias is expanded + Path root = Files.writeString(dir.resolve("root.yaml"), "x-schema: &#remote '" + SERVER.baseUrl() + "/schema.yaml'\n" + + contract("*#remote")); + + assertThatThrownBy(() -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + root, "none")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("references " + SERVER.baseUrl() + "/schema.yaml") + .hasMessageContaining("externalReferences=none"); + SERVER.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void localRejectsAReferenceGivenAsAYamlAlias(@TempDir Path dir) throws Exception { + Path root = Files.writeString(dir.resolve("root.yaml"), "x-schema: &remote '" + SERVER.baseUrl() + "/schema.yaml'\n" + + contract("*remote")); + + assertThatThrownBy(() -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + root, "local")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("references " + SERVER.baseUrl() + "/schema.yaml") + .hasMessageContaining("externalReferences=local allows no http(s) reference"); + SERVER.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void allResolvesAReferenceGivenAsAYamlAlias(@TempDir Path dir) throws Exception { + Path root = Files.writeString(dir.resolve("root.yaml"), "x-schema: &remote '" + SERVER.baseUrl() + "/schema.yaml'\n" + + contract("*remote")); + + OpenAPI openApi = RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + root); + + // the parser expands the alias and fetches the schema + assertThat(petSchema(openApi).getProperties()).containsKey("name"); + SERVER.verify(1, getRequestedFor(urlEqualTo("/schema.yaml"))); + } + + @Test + void localRejectsAnOpenApi31ReferenceResolvedAgainstAnHttpId(@TempDir Path dir) throws Exception { + Path root = Files.writeString(dir.resolve("root.yaml"), contract31(SERVER.baseUrl() + "/schemas/", "owner.yaml")); + + assertThatThrownBy(() -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + root, "local")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("references owner.yaml at components.schemas.Pet.properties.owner.$ref") + .hasMessageContaining("(owner.yaml resolves to " + SERVER.baseUrl() + "/schemas/owner.yaml)") + .hasMessageContaining("externalReferences=local"); + SERVER.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void allResolvesAnOpenApi31ReferenceAgainstItsId(@TempDir Path dir) throws Exception { + Path root = Files.writeString(dir.resolve("root.yaml"), contract31(SERVER.baseUrl() + "/schemas/", "owner.yaml")); + + OpenAPI openApi = RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + root); + + // the parser resolves the reference against the $id of the schema and fetches it + Schema owner = (Schema) petSchema(openApi).getProperties().get("owner"); + assertThat(owner.getProperties()).containsKey("name"); + SERVER.verify(1, getRequestedFor(urlEqualTo("/schemas/owner.yaml"))); + } + + @Test + void noneAllowsAReferenceInsideTheDocumentBelowAnHttpId(@TempDir Path dir) throws Exception { + Path root = Files.writeString(dir.resolve("root.yaml"), + contract31(SERVER.baseUrl() + "/schemas/", "#/components/schemas/Owner") + """ + Owner: + type: object + properties: + name: + type: string + """); + + OpenAPI openApi = RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + root, "none"); + + // the parser evaluates the fragment in the document, whatever the $id above it + Schema owner = (Schema) petSchema(openApi).getProperties().get("owner"); + assertThat(owner.getProperties()).containsKey("name"); + SERVER.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void localChecksAnOpenApi31FileReference(@TempDir Path dir) throws Exception { + Path schema = Files.writeString(dir.resolve("schema.yaml"), """ + components: + schemas: + Pet: + type: object + properties: + owner: + $ref: '%s/schema.yaml' + """.formatted(SERVER.baseUrl())); + Path root = Files.writeString(dir.resolve("root.yaml"), + contract31(null, schema.toUri() + "#/components/schemas/Pet")); + + assertThatThrownBy(() -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + root, "local")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("references " + SERVER.baseUrl() + "/schema.yaml") + .hasMessageContaining("in " + schema.toUri()) + .hasMessageContaining("externalReferences=local"); + SERVER.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void localAllowsAnOpenApi31FileReference(@TempDir Path dir) throws Exception { + Path schema = Files.writeString(dir.resolve("schema.yaml"), PET_SCHEMAS); + Path root = Files.writeString(dir.resolve("root.yaml"), + contract31(null, schema.toUri() + "#/components/schemas/Pet")); + + OpenAPI openApi = RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + root, "local"); + + Schema owner = (Schema) petSchema(openApi).getProperties().get("owner"); + assertThat(owner.getProperties()).containsKeys("id", "name"); + } + @Test void localRejectsEveryExternalReferenceOfARemoteSpecification() { String uri = SERVER.baseUrl() + "/petstore.yaml"; @@ -209,11 +396,19 @@ void componentOptionAppliesToEveryEndpoint() throws Exception { } } + private static ResponseDefinitionBuilder yaml(String body) { + return aResponse().withHeader("Content-Type", "application/yaml").withBody(body); + } + private static Schema petSchema(OpenAPI openApi) { return openApi.getPaths().get("/pets/{petId}").getGet().getResponses().get("200").getContent() .get("application/json").getSchema(); } + /** + * An OpenAPI 3.0 contract whose response schema is the given reference, written as given (so a YAML alias stays an + * alias; a reference that starts with # must be quoted). + */ private static String contract(String petSchemaRef) { return """ openapi: 3.0.3 @@ -236,7 +431,44 @@ private static String contract(String petSchemaRef) { content: application/json: schema: - $ref: '%s' + $ref: %s """.formatted(petSchemaRef); } + + /** + * An OpenAPI 3.1 contract whose Pet schema has the given $id (none when null) and whose owner property is the given + * reference. Further schemas can be appended under components.schemas. + */ + private static String contract31(String petId, String ownerRef) { + return """ + openapi: 3.1.0 + info: + title: Pets + version: "1.0" + paths: + /pets/{petId}: + get: + operationId: getPetById + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + "200": + description: The pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + components: + schemas: + Pet: + %s type: object + properties: + owner: + $ref: '%s' + """.formatted(petId != null ? " $id: " + petId + "\n" : "", ownerRef); + } } diff --git a/components/camel-rest-openapi/src/test/resources/external-references-subdir-remote.yaml b/components/camel-rest-openapi/src/test/resources/external-references-subdir-remote.yaml new file mode 100644 index 0000000000000..8213e05d09fbc --- /dev/null +++ b/components/camel-rest-openapi/src/test/resources/external-references-subdir-remote.yaml @@ -0,0 +1,39 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# A contract whose Pet schema lives in schemas/external-references-pet-remote.yaml, which references a document next to it that references a URL +openapi: 3.0.3 +info: + title: Pets + version: "1.0" +paths: + /pets/{petId}: + get: + operationId: getPetById + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + "200": + description: The pet + content: + application/json: + schema: + $ref: 'schemas/external-references-pet-remote.yaml#/components/schemas/Pet' diff --git a/components/camel-rest-openapi/src/test/resources/external-references-subdir.yaml b/components/camel-rest-openapi/src/test/resources/external-references-subdir.yaml new file mode 100644 index 0000000000000..4b5b1e52c6703 --- /dev/null +++ b/components/camel-rest-openapi/src/test/resources/external-references-subdir.yaml @@ -0,0 +1,39 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# A contract whose Pet schema lives in schemas/external-references-pet.yaml, which references a document next to it +openapi: 3.0.3 +info: + title: Pets + version: "1.0" +paths: + /pets/{petId}: + get: + operationId: getPetById + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + "200": + description: The pet + content: + application/json: + schema: + $ref: 'schemas/external-references-pet.yaml#/components/schemas/Pet' diff --git a/components/camel-rest-openapi/src/test/resources/schemas/external-references-owner-remote.yaml b/components/camel-rest-openapi/src/test/resources/schemas/external-references-owner-remote.yaml new file mode 100644 index 0000000000000..f7d42247ddcce --- /dev/null +++ b/components/camel-rest-openapi/src/test/resources/schemas/external-references-owner-remote.yaml @@ -0,0 +1,27 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# The Owner schema of schemas/external-references-pet-remote.yaml; the address schema is on a server that does not exist +components: + schemas: + Owner: + type: object + properties: + name: + type: string + address: + $ref: 'https://schemas.example.invalid/address.yaml#/components/schemas/Address' diff --git a/components/camel-rest-openapi/src/test/resources/schemas/external-references-owner.yaml b/components/camel-rest-openapi/src/test/resources/schemas/external-references-owner.yaml new file mode 100644 index 0000000000000..28734baab68aa --- /dev/null +++ b/components/camel-rest-openapi/src/test/resources/schemas/external-references-owner.yaml @@ -0,0 +1,25 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# The Owner schema of schemas/external-references-pet.yaml +components: + schemas: + Owner: + type: object + properties: + name: + type: string diff --git a/components/camel-rest-openapi/src/test/resources/schemas/external-references-pet-remote.yaml b/components/camel-rest-openapi/src/test/resources/schemas/external-references-pet-remote.yaml new file mode 100644 index 0000000000000..1f9daa5bbbdc7 --- /dev/null +++ b/components/camel-rest-openapi/src/test/resources/schemas/external-references-pet-remote.yaml @@ -0,0 +1,27 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# The Pet schema of external-references-subdir-remote.yaml; the parser reads the Owner reference as schemas/external-references-owner-remote.yaml +components: + schemas: + Pet: + type: object + properties: + id: + type: integer + owner: + $ref: 'external-references-owner-remote.yaml#/components/schemas/Owner' diff --git a/components/camel-rest-openapi/src/test/resources/schemas/external-references-pet.yaml b/components/camel-rest-openapi/src/test/resources/schemas/external-references-pet.yaml new file mode 100644 index 0000000000000..d4ef280f0d114 --- /dev/null +++ b/components/camel-rest-openapi/src/test/resources/schemas/external-references-pet.yaml @@ -0,0 +1,27 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# The Pet schema of external-references-subdir.yaml; the parser reads the Owner reference as schemas/external-references-owner.yaml +components: + schemas: + Pet: + type: object + properties: + id: + type: integer + owner: + $ref: 'external-references-owner.yaml#/components/schemas/Owner' diff --git a/docs/user-manual/modules/ROOT/pages/rest-dsl-openapi.adoc b/docs/user-manual/modules/ROOT/pages/rest-dsl-openapi.adoc index 6a7d666dc7863..601967f0d0632 100644 --- a/docs/user-manual/modules/ROOT/pages/rest-dsl-openapi.adoc +++ b/docs/user-manual/modules/ROOT/pages/rest-dsl-openapi.adoc @@ -666,8 +666,9 @@ YAML:: A contract can reference other documents with `$ref`: files on the classpath or the file system, and documents on an http(s) server. Camel loads every such document when the route starts. The `externalReferences` option restricts this: `all` (default) allows any reference, `local` allows references to the classpath and the file system only, and `none` -allows only references inside the contract. A reference the option does not allow fails the start, with a message that -names it. +allows only references inside the contract. The check follows the references into the local documents it allows, and +a document it cannot read fails the start too. A reference the option does not allow fails the start, with a message +that names it. [tabs] ==== From 8e60618a6883f0b0657346c36278b692bcf1a8c7 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Wed, 21 Oct 2026 13:06:38 +0200 Subject: [PATCH 4/6] CAMEL-25887: camel-core-model - label the externalReferences option of openApi as security, like the endpoint and component options Co-Authored-By: Claude Fable 5.1 Signed-off-by: Andrea Cosentino --- .../resources/org/apache/camel/catalog/models/openApi.json | 2 +- .../org/apache/camel/catalog/schemas/camelYamlDsl-model.json | 3 ++- .../META-INF/org/apache/camel/model/rest/openApi.json | 2 +- .../java/org/apache/camel/model/rest/OpenApiDefinition.java | 2 +- .../src/generated/resources/schema/camelYamlDsl-model.json | 3 ++- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/openApi.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/openApi.json index e28c784b28d3f..7cb3798e889bc 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/openApi.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/openApi.json @@ -22,6 +22,6 @@ "missingOperation": { "index": 7, "kind": "attribute", "displayName": "Missing Operation", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "fail", "ignore", "mock" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "fail", "description": "Whether to fail, ignore or return a mock response for OpenAPI operations that are not mapped to a corresponding route." }, "unmatchedRequestHandling": { "index": 8, "kind": "attribute", "displayName": "Unmatched Request Handling", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "platform", "camel" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "platform", "description": "Who answers requests that match no operation in the OpenAPI specification: the HTTP layer (platform) or Camel via the unmatched request handler (camel)." }, "mockIncludePattern": { "index": 9, "kind": "attribute", "displayName": "Mock Include Pattern", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "classpath:camel-mock\/**", "description": "Used for inclusive filtering of mock data from directories. The pattern is using Ant-path style pattern. Multiple patterns can be specified separated by comma." }, - "externalReferences": { "index": 10, "kind": "attribute", "displayName": "External References", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "all", "local", "none" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "all", "description": "Which external references ($ref) the OpenAPI specification may use: all allows any reference, local allows references to the classpath and the file system only, and none allows only references inside the document. A reference the option does not allow fails the start." } + "externalReferences": { "index": 10, "kind": "attribute", "displayName": "External References", "group": "security", "label": "security", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "all", "local", "none" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "all", "description": "Which external references ($ref) the OpenAPI specification may use: all allows any reference, local allows references to the classpath and the file system only, and none allows only references inside the document. A reference the option does not allow fails the start." } } } diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json index 6e6d2f56d5f57..8a5bcadedecfd 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json @@ -17765,7 +17765,8 @@ "displayName" : "External References", "kind" : "attribute", "index" : 10, - "group" : "common" + "group" : "security", + "label" : "security" } ] }, "openIdConnect" : { diff --git a/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/rest/openApi.json b/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/rest/openApi.json index e28c784b28d3f..7cb3798e889bc 100644 --- a/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/rest/openApi.json +++ b/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/rest/openApi.json @@ -22,6 +22,6 @@ "missingOperation": { "index": 7, "kind": "attribute", "displayName": "Missing Operation", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "fail", "ignore", "mock" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "fail", "description": "Whether to fail, ignore or return a mock response for OpenAPI operations that are not mapped to a corresponding route." }, "unmatchedRequestHandling": { "index": 8, "kind": "attribute", "displayName": "Unmatched Request Handling", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "platform", "camel" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "platform", "description": "Who answers requests that match no operation in the OpenAPI specification: the HTTP layer (platform) or Camel via the unmatched request handler (camel)." }, "mockIncludePattern": { "index": 9, "kind": "attribute", "displayName": "Mock Include Pattern", "group": "advanced", "label": "advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "classpath:camel-mock\/**", "description": "Used for inclusive filtering of mock data from directories. The pattern is using Ant-path style pattern. Multiple patterns can be specified separated by comma." }, - "externalReferences": { "index": 10, "kind": "attribute", "displayName": "External References", "group": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "all", "local", "none" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "all", "description": "Which external references ($ref) the OpenAPI specification may use: all allows any reference, local allows references to the classpath and the file system only, and none allows only references inside the document. A reference the option does not allow fails the start." } + "externalReferences": { "index": 10, "kind": "attribute", "displayName": "External References", "group": "security", "label": "security", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "all", "local", "none" ], "deprecated": false, "autowired": false, "secret": false, "defaultValue": "all", "description": "Which external references ($ref) the OpenAPI specification may use: all allows any reference, local allows references to the classpath and the file system only, and none allows only references inside the document. A reference the option does not allow fails the start." } } } diff --git a/core/camel-core-model/src/main/java/org/apache/camel/model/rest/OpenApiDefinition.java b/core/camel-core-model/src/main/java/org/apache/camel/model/rest/OpenApiDefinition.java index 1fae2962ad5e3..ab98ec353d0ca 100644 --- a/core/camel-core-model/src/main/java/org/apache/camel/model/rest/OpenApiDefinition.java +++ b/core/camel-core-model/src/main/java/org/apache/camel/model/rest/OpenApiDefinition.java @@ -64,7 +64,7 @@ public class OpenApiDefinition extends OptionalIdentifiedDefinition Date: Wed, 21 Oct 2026 19:01:57 +0200 Subject: [PATCH 5/6] CAMEL-25887: camel-rest-openapi - carry the inherited OpenAPI 3.1 $id across a reference edge The parser keeps the $ids of the schemas enclosing a reference when it follows that reference to another document, so an absolute $id decides what the relative references inside that document resolve to. The check now carries those $ids into the referenced document, folds its base from them the way the parser does, and keys the visited documents by location and inherited $ids. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Andrea Cosentino --- .../openapi/OpenApiExternalReferences.java | 80 ++++++++++++++----- ...stOpenApiReaderExternalReferencesTest.java | 13 +++ .../external-references-31-bridge.yaml | 22 +++++ .../external-references-31-inherited-id.yaml | 49 ++++++++++++ .../openapi/OpenApiExternalReferences.java | 80 ++++++++++++++----- .../RestOpenApiExternalReferencesTest.java | 39 +++++++++ 6 files changed, 243 insertions(+), 40 deletions(-) create mode 100644 components/camel-openapi-java/src/test/resources/external-references-31-bridge.yaml create mode 100644 components/camel-openapi-java/src/test/resources/external-references-31-inherited-id.yaml diff --git a/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java b/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java index af9763dc14b18..7b156f4e9c5f9 100644 --- a/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java +++ b/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java @@ -22,8 +22,10 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; @@ -42,8 +44,9 @@ * classpath, or over http(s) when the reference is a URL or the specification itself comes from a URL. *

* The check works on the tree the parser resolves (YAML aliases expanded), resolves each reference the way the parser - * does, that is against the document that contains it and, for OpenAPI 3.1, against the enclosing {@code $id}s, and - * reads the local documents it allows to check their references in turn. A document it cannot read fails the check. + * does, that is against the document that contains it and, for OpenAPI 3.1, against the enclosing {@code $id}s, which + * the parser also carries into the documents it reaches through a reference, and reads the local documents it allows to + * check their references in turn. A document it cannot read fails the check. *

* This is a copy of the class of the same name in camel-rest-openapi, which neither module can depend on the other for; * keep the two in sync. @@ -95,7 +98,7 @@ static void check(String externalReferences, String specification, String specif if (ALL.equals(policy)) { return; } - new Check(policy, specificationUri, remote).document(specification, null, null); + new Check(policy, specificationUri, remote).document(specification, null, null, List.of()); } private static boolean isHttp(String target) { @@ -103,6 +106,27 @@ private static boolean isHttp(String target) { return lower.startsWith("http:") || lower.startsWith("https:"); } + /** + * A document a reference leads to: where the reference is, and the OpenAPI 3.1 {@code $id}s the parser carries into + * the document, which decide what the references inside it resolve to. + */ + private static final class Target { + + private final String location; + private final String origin; + private final List inheritedIds; + + Target(String location, String origin, List inheritedIds) { + this.location = location; + this.origin = origin; + this.inheritedIds = inheritedIds; + } + + String key() { + return inheritedIds.isEmpty() ? location : location + " under " + inheritedIds; + } + } + /** * The check of one specification: the root document and the documents reached from it. */ @@ -123,11 +147,12 @@ private static final class Check { /** * Checks one document. * - * @param content the document - * @param document the resolved location of the document, as the parser uses it, or null for the root - * @param origin where the root references this document, for the error message, or null for the root + * @param content the document + * @param document the resolved location of the document, as the parser uses it, or null for the root + * @param origin where the root references this document, for the error message, or null for the root + * @param inheritedIds the {@code $id}s of the schemas enclosing the reference that led here (OpenAPI 3.1) */ - void document(String content, String document, String origin) { + void document(String content, String document, String origin, List inheritedIds) { JsonNode tree; try { // the tree the parser resolves: the same YAML reader, with the aliases expanded and its limits applied @@ -142,39 +167,52 @@ void document(String content, String document, String origin) { // the parser resolves an OpenAPI 3.1 document with its own dereferencer, which has other rules openApi31 = tree.path("openapi").asText("").startsWith("3.1"); } + String base = document; + if (openApi31) { + // OpenAPI31Traverser.traverseSchema: the inherited $ids resolved, in turn, against the document + base = document == null ? LOCAL_URI : document; + for (String id : inheritedIds) { + base = resolveBase(id, base, "", document); + } + } // the external references of this document, with where they are, in the order they are met - Map targets = new LinkedHashMap<>(); - String base = openApi31 && document == null ? LOCAL_URI : document; - scan(tree, "", document, base, targets); - for (Map.Entry target : targets.entrySet()) { - if (checked.add(target.getKey())) { + Map targets = new LinkedHashMap<>(); + scan(tree, "", document, base, inheritedIds, targets); + for (Target target : targets.values()) { + if (checked.add(target.key())) { // a referenced local document can reference further documents, which the parser loads as well - document(read(target.getKey(), target.getValue()), target.getKey(), target.getValue()); + document(read(target.location, target.origin), target.location, target.origin, + target.inheritedIds); } } } - private void scan(JsonNode node, String path, String document, String base, Map targets) { + private void scan( + JsonNode node, String path, String document, String base, List ids, + Map targets) { if (node.isObject()) { String nodeBase = base; + List nodeIds = ids; if (openApi31) { JsonNode id = node.get("$id"); if (id != null && id.isTextual() && !id.asText().isBlank()) { nodeBase = resolveBase(id.asText(), base, path, document); + nodeIds = new ArrayList<>(ids); + nodeIds.add(id.asText()); } } for (Map.Entry field : node.properties()) { String fieldPath = path.isEmpty() ? field.getKey() : path + "." + field.getKey(); if ("$ref".equals(field.getKey()) && field.getValue().isTextual()) { - reference(field.getValue().asText(), fieldPath, document, nodeBase, targets); + reference(field.getValue().asText(), fieldPath, document, nodeBase, nodeIds, targets); } else { - scan(field.getValue(), fieldPath, document, nodeBase, targets); + scan(field.getValue(), fieldPath, document, nodeBase, nodeIds, targets); } } } else if (node.isArray()) { int i = 0; for (JsonNode item : node) { - scan(item, path + "[" + i++ + "]", document, base, targets); + scan(item, path + "[" + i++ + "]", document, base, ids, targets); } } } @@ -192,7 +230,8 @@ private String resolveBase(String id, String base, String path, String document) } } - private void reference(String ref, String path, String document, String base, Map targets) { + private void reference( + String ref, String path, String document, String base, List ids, Map targets) { if (ref.isEmpty() || ref.startsWith("#")) { // inside the document: the parser evaluates the fragment in the document, or in the schema whose $id // is the base, without loading anything @@ -229,8 +268,9 @@ private void reference(String ref, String path, String document, String base, Ma throw new IllegalArgumentException(where(ref, path, document) + ", but " + reason); } // a local document: file: and classpath: URIs, and paths the parser reads from the working directory or - // the classpath - targets.putIfAbsent(target, where(ref, path, document)); + // the classpath; the parser carries the enclosing $ids into it (ReferenceVisitor.resolveSchemaRef) + Target found = new Target(target, where(ref, path, document), ids); + targets.putIfAbsent(found.key(), found); } /** diff --git a/components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java b/components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java index ddd3e82347f87..4f791a47d812d 100644 --- a/components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java +++ b/components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java @@ -98,6 +98,19 @@ void checksAnOpenApi31ReferenceAgainstTheIdOfItsSchema() { e.getMessage()); } + @Test + void keepsTheInheritedIdAcrossAnExternalReference() { + RestDefinition rest = new RestDefinition(); + rest.openApi().specification("external-references-31-inherited-id.yaml").externalReferences("local"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> read(rest)); + + // the parser carries the $id of the Pet schema into the bridge schema it references on the classpath + assertTrue(e.getMessage().contains("in classpath:///external-references-31-bridge.yaml"), e.getMessage()); + assertTrue(e.getMessage().contains("owner.yaml resolves to https://schemas.example.invalid/schemas/owner.yaml"), + e.getMessage()); + } + @Test void checksAReferenceGivenAsAYamlAlias() { RestDefinition rest = new RestDefinition(); diff --git a/components/camel-openapi-java/src/test/resources/external-references-31-bridge.yaml b/components/camel-openapi-java/src/test/resources/external-references-31-bridge.yaml new file mode 100644 index 0000000000000..40eb0bd09340f --- /dev/null +++ b/components/camel-openapi-java/src/test/resources/external-references-31-bridge.yaml @@ -0,0 +1,22 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# The owner schema of external-references-31-inherited-id.yaml; under the inherited $id the parser resolves owner.yaml to a URL +type: object +properties: + owner: + $ref: owner.yaml diff --git a/components/camel-openapi-java/src/test/resources/external-references-31-inherited-id.yaml b/components/camel-openapi-java/src/test/resources/external-references-31-inherited-id.yaml new file mode 100644 index 0000000000000..2cd3543c57162 --- /dev/null +++ b/components/camel-openapi-java/src/test/resources/external-references-31-inherited-id.yaml @@ -0,0 +1,49 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# An OpenAPI 3.1 contract whose Pet schema has an $id on a server that does not exist and references a schema on the classpath; the parser keeps the $id when it follows that reference +openapi: 3.1.0 +info: + title: Pets + version: "1.0" +servers: + - url: http://localhost:8080/api +paths: + /pets/{petId}: + get: + operationId: getPetById + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + "200": + description: The pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + $id: https://schemas.example.invalid/schemas/ + type: object + properties: + owner: + $ref: 'classpath:///external-references-31-bridge.yaml' diff --git a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java index 97025be6e9aab..5691fe75b56ed 100644 --- a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java +++ b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java @@ -22,8 +22,10 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; @@ -42,8 +44,9 @@ * classpath, or over http(s) when the reference is a URL or the specification itself comes from a URL. *

* The check works on the tree the parser resolves (YAML aliases expanded), resolves each reference the way the parser - * does, that is against the document that contains it and, for OpenAPI 3.1, against the enclosing {@code $id}s, and - * reads the local documents it allows to check their references in turn. A document it cannot read fails the check. + * does, that is against the document that contains it and, for OpenAPI 3.1, against the enclosing {@code $id}s, which + * the parser also carries into the documents it reaches through a reference, and reads the local documents it allows to + * check their references in turn. A document it cannot read fails the check. *

* camel-openapi-java has a copy of this class for the specification it serves as api-doc; keep the two in sync. */ @@ -94,7 +97,7 @@ static void check(String externalReferences, String specification, String specif if (ALL.equals(policy)) { return; } - new Check(policy, specificationUri, remote).document(specification, null, null); + new Check(policy, specificationUri, remote).document(specification, null, null, List.of()); } private static boolean isHttp(String target) { @@ -102,6 +105,27 @@ private static boolean isHttp(String target) { return lower.startsWith("http:") || lower.startsWith("https:"); } + /** + * A document a reference leads to: where the reference is, and the OpenAPI 3.1 {@code $id}s the parser carries into + * the document, which decide what the references inside it resolve to. + */ + private static final class Target { + + private final String location; + private final String origin; + private final List inheritedIds; + + Target(String location, String origin, List inheritedIds) { + this.location = location; + this.origin = origin; + this.inheritedIds = inheritedIds; + } + + String key() { + return inheritedIds.isEmpty() ? location : location + " under " + inheritedIds; + } + } + /** * The check of one specification: the root document and the documents reached from it. */ @@ -122,11 +146,12 @@ private static final class Check { /** * Checks one document. * - * @param content the document - * @param document the resolved location of the document, as the parser uses it, or null for the root - * @param origin where the root references this document, for the error message, or null for the root + * @param content the document + * @param document the resolved location of the document, as the parser uses it, or null for the root + * @param origin where the root references this document, for the error message, or null for the root + * @param inheritedIds the {@code $id}s of the schemas enclosing the reference that led here (OpenAPI 3.1) */ - void document(String content, String document, String origin) { + void document(String content, String document, String origin, List inheritedIds) { JsonNode tree; try { // the tree the parser resolves: the same YAML reader, with the aliases expanded and its limits applied @@ -141,39 +166,52 @@ void document(String content, String document, String origin) { // the parser resolves an OpenAPI 3.1 document with its own dereferencer, which has other rules openApi31 = tree.path("openapi").asText("").startsWith("3.1"); } + String base = document; + if (openApi31) { + // OpenAPI31Traverser.traverseSchema: the inherited $ids resolved, in turn, against the document + base = document == null ? LOCAL_URI : document; + for (String id : inheritedIds) { + base = resolveBase(id, base, "", document); + } + } // the external references of this document, with where they are, in the order they are met - Map targets = new LinkedHashMap<>(); - String base = openApi31 && document == null ? LOCAL_URI : document; - scan(tree, "", document, base, targets); - for (Map.Entry target : targets.entrySet()) { - if (checked.add(target.getKey())) { + Map targets = new LinkedHashMap<>(); + scan(tree, "", document, base, inheritedIds, targets); + for (Target target : targets.values()) { + if (checked.add(target.key())) { // a referenced local document can reference further documents, which the parser loads as well - document(read(target.getKey(), target.getValue()), target.getKey(), target.getValue()); + document(read(target.location, target.origin), target.location, target.origin, + target.inheritedIds); } } } - private void scan(JsonNode node, String path, String document, String base, Map targets) { + private void scan( + JsonNode node, String path, String document, String base, List ids, + Map targets) { if (node.isObject()) { String nodeBase = base; + List nodeIds = ids; if (openApi31) { JsonNode id = node.get("$id"); if (id != null && id.isTextual() && !id.asText().isBlank()) { nodeBase = resolveBase(id.asText(), base, path, document); + nodeIds = new ArrayList<>(ids); + nodeIds.add(id.asText()); } } for (Map.Entry field : node.properties()) { String fieldPath = path.isEmpty() ? field.getKey() : path + "." + field.getKey(); if ("$ref".equals(field.getKey()) && field.getValue().isTextual()) { - reference(field.getValue().asText(), fieldPath, document, nodeBase, targets); + reference(field.getValue().asText(), fieldPath, document, nodeBase, nodeIds, targets); } else { - scan(field.getValue(), fieldPath, document, nodeBase, targets); + scan(field.getValue(), fieldPath, document, nodeBase, nodeIds, targets); } } } else if (node.isArray()) { int i = 0; for (JsonNode item : node) { - scan(item, path + "[" + i++ + "]", document, base, targets); + scan(item, path + "[" + i++ + "]", document, base, ids, targets); } } } @@ -191,7 +229,8 @@ private String resolveBase(String id, String base, String path, String document) } } - private void reference(String ref, String path, String document, String base, Map targets) { + private void reference( + String ref, String path, String document, String base, List ids, Map targets) { if (ref.isEmpty() || ref.startsWith("#")) { // inside the document: the parser evaluates the fragment in the document, or in the schema whose $id // is the base, without loading anything @@ -228,8 +267,9 @@ private void reference(String ref, String path, String document, String base, Ma throw new IllegalArgumentException(where(ref, path, document) + ", but " + reason); } // a local document: file: and classpath: URIs, and paths the parser reads from the working directory or - // the classpath - targets.putIfAbsent(target, where(ref, path, document)); + // the classpath; the parser carries the enclosing $ids into it (ReferenceVisitor.resolveSchemaRef) + Target found = new Target(target, where(ref, path, document), ids); + targets.putIfAbsent(found.key(), found); } /** diff --git a/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java b/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java index dcb443d5a8c94..f6b5b8ca61a2c 100644 --- a/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java +++ b/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java @@ -280,6 +280,45 @@ void allResolvesAnOpenApi31ReferenceAgainstItsId(@TempDir Path dir) throws Excep SERVER.verify(1, getRequestedFor(urlEqualTo("/schemas/owner.yaml"))); } + @Test + void localKeepsTheInheritedIdAcrossAnExternalReference(@TempDir Path dir) throws Exception { + Path bridge = Files.writeString(dir.resolve("bridge.yaml"), """ + type: object + properties: + owner: + $ref: owner.yaml + """); + Files.writeString(dir.resolve("owner.yaml"), OWNER_SCHEMA); + Path root = Files.writeString(dir.resolve("root.yaml"), + contract31(SERVER.baseUrl() + "/schemas/", bridge.toUri().toString())); + + // the parser carries the $id of the Pet schema into bridge.yaml, so owner.yaml is not the file next to it + assertThatThrownBy(() -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + root, "local")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("references owner.yaml at properties.owner.$ref in " + bridge.toUri()) + .hasMessageContaining("(owner.yaml resolves to " + SERVER.baseUrl() + "/schemas/owner.yaml)") + .hasMessageContaining("externalReferences=local"); + SERVER.verify(0, anyRequestedFor(anyUrl())); + } + + @Test + void allResolvesAnOpenApi31ReferenceAgainstTheInheritedId(@TempDir Path dir) throws Exception { + Path bridge = Files.writeString(dir.resolve("bridge.yaml"), """ + type: object + properties: + owner: + $ref: owner.yaml + """); + Files.writeString(dir.resolve("owner.yaml"), OWNER_SCHEMA); + Path root = Files.writeString(dir.resolve("root.yaml"), + contract31(SERVER.baseUrl() + "/schemas/", bridge.toUri().toString())); + + RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + root); + + // the parser fetches owner.yaml from the server of the inherited $id, not from the directory of bridge.yaml + SERVER.verify(1, getRequestedFor(urlEqualTo("/schemas/owner.yaml"))); + } + @Test void noneAllowsAReferenceInsideTheDocumentBelowAnHttpId(@TempDir Path dir) throws Exception { Path root = Files.writeString(dir.resolve("root.yaml"), From bd86d735b1e9eb3bfe8e3939e94c6691a7db33a7 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Mon, 26 Oct 2026 13:48:02 +0100 Subject: [PATCH 6/6] CAMEL-25887: Address review feedback - terminate cycle detection for recursive local schemas The visited key appended the full inheritedIds list, so a schema that references itself under its own $id produced a new key on every visit ([id], [id, id], ...) and the check never stopped - an OutOfMemoryError under externalReferences=local for a valid recursive local schema. Key the visited set by the resolved location and the effective base the references resolve against (the inherited $ids folded in, the way OpenAPI31Traverser.traverseSchema does) instead of the raw inheritedIds list. The effective base is unchanged when a schema is reached again under its own $id, so the recursion ends, while a document reached under a genuinely different base still gets a different key and is checked again. Applied to both copies (camel-rest-openapi and camel-openapi-java). Tests in both modules: a schema that references itself finishes under local, and a shared document reached first without an $id and then below an http $id is checked again and its now-http reference rejected with zero HTTP requests. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Andrea Cosentino --- .../openapi/OpenApiExternalReferences.java | 70 +++++++++---------- ...stOpenApiReaderExternalReferencesTest.java | 25 +++++++ .../external-references-31-node.yaml | 23 ++++++ .../external-references-31-owner.yaml | 22 ++++++ .../external-references-31-recursive.yaml | 48 +++++++++++++ .../external-references-31-revisit.yaml | 54 ++++++++++++++ .../external-references-31-shared.yaml | 22 ++++++ .../openapi/OpenApiExternalReferences.java | 70 +++++++++---------- .../RestOpenApiExternalReferencesTest.java | 67 ++++++++++++++++++ 9 files changed, 331 insertions(+), 70 deletions(-) create mode 100644 components/camel-openapi-java/src/test/resources/external-references-31-node.yaml create mode 100644 components/camel-openapi-java/src/test/resources/external-references-31-owner.yaml create mode 100644 components/camel-openapi-java/src/test/resources/external-references-31-recursive.yaml create mode 100644 components/camel-openapi-java/src/test/resources/external-references-31-revisit.yaml create mode 100644 components/camel-openapi-java/src/test/resources/external-references-31-shared.yaml diff --git a/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java b/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java index 7b156f4e9c5f9..908bd3f10d641 100644 --- a/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java +++ b/components/camel-openapi-java/src/main/java/org/apache/camel/openapi/OpenApiExternalReferences.java @@ -98,7 +98,7 @@ static void check(String externalReferences, String specification, String specif if (ALL.equals(policy)) { return; } - new Check(policy, specificationUri, remote).document(specification, null, null, List.of()); + new Check(policy, specificationUri, remote).root(specification); } private static boolean isHttp(String target) { @@ -121,10 +121,6 @@ private static final class Target { this.origin = origin; this.inheritedIds = inheritedIds; } - - String key() { - return inheritedIds.isEmpty() ? location : location + " under " + inheritedIds; - } } /** @@ -144,47 +140,52 @@ private static final class Check { this.remote = remote; } + void root(String specification) { + JsonNode tree = tree(specification, null, null); + // the parser resolves an OpenAPI 3.1 document with its own dereferencer, which has other rules + openApi31 = tree.path("openapi").asText("").startsWith("3.1"); + follow(tree, null, openApi31 ? LOCAL_URI : null, List.of()); + } + /** - * Checks one document. + * Checks the references of one document and then the documents they lead to. * - * @param content the document + * @param tree the document * @param document the resolved location of the document, as the parser uses it, or null for the root - * @param origin where the root references this document, for the error message, or null for the root + * @param base what the references of the document resolve against * @param inheritedIds the {@code $id}s of the schemas enclosing the reference that led here (OpenAPI 3.1) */ - void document(String content, String document, String origin, List inheritedIds) { - JsonNode tree; + private void follow(JsonNode tree, String document, String base, List inheritedIds) { + // the external references of this document, with where they are, in the order they are met + Map targets = new LinkedHashMap<>(); + scan(tree, "", document, base, inheritedIds, targets); + for (Target target : targets.values()) { + String targetBase = target.location; + if (openApi31) { + // OpenAPI31Traverser.traverseSchema: the inherited $ids resolved, in turn, against the document + for (String id : target.inheritedIds) { + targetBase = resolveBase(id, targetBase, "", target.location); + } + } + // a document resolves its references the same way whenever it is reached with the same base, whatever + // chain of $ids led to it; this also ends the visit of a schema that references itself + if (checked.add(target.location + " under " + targetBase)) { + JsonNode targetTree = tree(read(target.location, target.origin), target.location, target.origin); + follow(targetTree, target.location, targetBase, target.inheritedIds); + } + } + } + + private JsonNode tree(String content, String document, String origin) { try { // the tree the parser resolves: the same YAML reader, with the aliases expanded and its limits applied - tree = DeserializationUtils.deserializeIntoTree(content, document != null ? document : specificationUri); + return DeserializationUtils.deserializeIntoTree(content, document != null ? document : specificationUri); } catch (RuntimeException e) { throw new IllegalArgumentException( (document != null ? where(document, origin) : "The OpenAPI specification " + specificationUri) + " cannot be read to check its references: " + e.getMessage(), e); } - if (document == null) { - // the parser resolves an OpenAPI 3.1 document with its own dereferencer, which has other rules - openApi31 = tree.path("openapi").asText("").startsWith("3.1"); - } - String base = document; - if (openApi31) { - // OpenAPI31Traverser.traverseSchema: the inherited $ids resolved, in turn, against the document - base = document == null ? LOCAL_URI : document; - for (String id : inheritedIds) { - base = resolveBase(id, base, "", document); - } - } - // the external references of this document, with where they are, in the order they are met - Map targets = new LinkedHashMap<>(); - scan(tree, "", document, base, inheritedIds, targets); - for (Target target : targets.values()) { - if (checked.add(target.key())) { - // a referenced local document can reference further documents, which the parser loads as well - document(read(target.location, target.origin), target.location, target.origin, - target.inheritedIds); - } - } } private void scan( @@ -269,8 +270,7 @@ private void reference( } // a local document: file: and classpath: URIs, and paths the parser reads from the working directory or // the classpath; the parser carries the enclosing $ids into it (ReferenceVisitor.resolveSchemaRef) - Target found = new Target(target, where(ref, path, document), ids); - targets.putIfAbsent(found.key(), found); + targets.putIfAbsent(target + " under " + ids, new Target(target, where(ref, path, document), ids)); } /** diff --git a/components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java b/components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java index 4f791a47d812d..a993dd23efec4 100644 --- a/components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java +++ b/components/camel-openapi-java/src/test/java/org/apache/camel/openapi/RestOpenApiReaderExternalReferencesTest.java @@ -111,6 +111,31 @@ void keepsTheInheritedIdAcrossAnExternalReference() { e.getMessage()); } + @Test + void finishesForASchemaThatReferencesItself() throws Exception { + RestDefinition rest = new RestDefinition(); + rest.openApi().specification("external-references-31-recursive.yaml").externalReferences("local"); + + // the schema is reached again under its own $id, with the same base, so the check visits it once + OpenAPI openApi = read(rest); + + assertNotNull(openApi.getPaths().get("/pets/{petId}")); + } + + @Test + void checksADocumentReachedAgainUnderAnotherId() { + RestDefinition rest = new RestDefinition(); + rest.openApi().specification("external-references-31-revisit.yaml").externalReferences("local"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> read(rest)); + + // the shared schema passes without an $id and is checked again below the $id of Pet + assertTrue(e.getMessage().contains("in classpath:///external-references-31-shared.yaml"), e.getMessage()); + assertTrue(e.getMessage().contains( + "external-references-31-owner.yaml resolves to https://schemas.example.invalid/schemas/external-references-31-owner.yaml"), + e.getMessage()); + } + @Test void checksAReferenceGivenAsAYamlAlias() { RestDefinition rest = new RestDefinition(); diff --git a/components/camel-openapi-java/src/test/resources/external-references-31-node.yaml b/components/camel-openapi-java/src/test/resources/external-references-31-node.yaml new file mode 100644 index 0000000000000..603fb8274d12c --- /dev/null +++ b/components/camel-openapi-java/src/test/resources/external-references-31-node.yaml @@ -0,0 +1,23 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# A schema that references itself under its own $id, which the check must visit once +$id: classpath:///external-references-31-node.yaml +type: object +properties: + next: + $ref: classpath:///external-references-31-node.yaml diff --git a/components/camel-openapi-java/src/test/resources/external-references-31-owner.yaml b/components/camel-openapi-java/src/test/resources/external-references-31-owner.yaml new file mode 100644 index 0000000000000..1cab86db9d817 --- /dev/null +++ b/components/camel-openapi-java/src/test/resources/external-references-31-owner.yaml @@ -0,0 +1,22 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# The owner schema of external-references-31-shared.yaml +type: object +properties: + name: + type: string diff --git a/components/camel-openapi-java/src/test/resources/external-references-31-recursive.yaml b/components/camel-openapi-java/src/test/resources/external-references-31-recursive.yaml new file mode 100644 index 0000000000000..8096bb541b722 --- /dev/null +++ b/components/camel-openapi-java/src/test/resources/external-references-31-recursive.yaml @@ -0,0 +1,48 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# An OpenAPI 3.1 contract whose Pet schema references a schema on the classpath that references itself +openapi: 3.1.0 +info: + title: Pets + version: "1.0" +servers: + - url: http://localhost:8080/api +paths: + /pets/{petId}: + get: + operationId: getPetById + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + "200": + description: The pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + type: object + properties: + owner: + $ref: 'classpath:///external-references-31-node.yaml' diff --git a/components/camel-openapi-java/src/test/resources/external-references-31-revisit.yaml b/components/camel-openapi-java/src/test/resources/external-references-31-revisit.yaml new file mode 100644 index 0000000000000..7018caebf83b1 --- /dev/null +++ b/components/camel-openapi-java/src/test/resources/external-references-31-revisit.yaml @@ -0,0 +1,54 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# An OpenAPI 3.1 contract that references the same classpath schema twice: once without an $id, so its owner reference is a classpath document, and once below an $id on a server that does not exist +openapi: 3.1.0 +info: + title: Pets + version: "1.0" +servers: + - url: http://localhost:8080/api +paths: + /pets/{petId}: + get: + operationId: getPetById + parameters: + - name: petId + in: path + required: true + schema: + type: integer + responses: + "200": + description: The pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +components: + schemas: + Safe: + type: object + properties: + shared: + $ref: 'classpath:///external-references-31-shared.yaml' + Pet: + $id: https://schemas.example.invalid/schemas/ + type: object + properties: + shared: + $ref: 'classpath:///external-references-31-shared.yaml' diff --git a/components/camel-openapi-java/src/test/resources/external-references-31-shared.yaml b/components/camel-openapi-java/src/test/resources/external-references-31-shared.yaml new file mode 100644 index 0000000000000..895989f480d93 --- /dev/null +++ b/components/camel-openapi-java/src/test/resources/external-references-31-shared.yaml @@ -0,0 +1,22 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# The shared schema of external-references-31-revisit.yaml; its owner reference resolves against the $id the parser carries into it +type: object +properties: + owner: + $ref: external-references-31-owner.yaml diff --git a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java index 5691fe75b56ed..2e6bda6c80069 100644 --- a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java +++ b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/OpenApiExternalReferences.java @@ -97,7 +97,7 @@ static void check(String externalReferences, String specification, String specif if (ALL.equals(policy)) { return; } - new Check(policy, specificationUri, remote).document(specification, null, null, List.of()); + new Check(policy, specificationUri, remote).root(specification); } private static boolean isHttp(String target) { @@ -120,10 +120,6 @@ private static final class Target { this.origin = origin; this.inheritedIds = inheritedIds; } - - String key() { - return inheritedIds.isEmpty() ? location : location + " under " + inheritedIds; - } } /** @@ -143,47 +139,52 @@ private static final class Check { this.remote = remote; } + void root(String specification) { + JsonNode tree = tree(specification, null, null); + // the parser resolves an OpenAPI 3.1 document with its own dereferencer, which has other rules + openApi31 = tree.path("openapi").asText("").startsWith("3.1"); + follow(tree, null, openApi31 ? LOCAL_URI : null, List.of()); + } + /** - * Checks one document. + * Checks the references of one document and then the documents they lead to. * - * @param content the document + * @param tree the document * @param document the resolved location of the document, as the parser uses it, or null for the root - * @param origin where the root references this document, for the error message, or null for the root + * @param base what the references of the document resolve against * @param inheritedIds the {@code $id}s of the schemas enclosing the reference that led here (OpenAPI 3.1) */ - void document(String content, String document, String origin, List inheritedIds) { - JsonNode tree; + private void follow(JsonNode tree, String document, String base, List inheritedIds) { + // the external references of this document, with where they are, in the order they are met + Map targets = new LinkedHashMap<>(); + scan(tree, "", document, base, inheritedIds, targets); + for (Target target : targets.values()) { + String targetBase = target.location; + if (openApi31) { + // OpenAPI31Traverser.traverseSchema: the inherited $ids resolved, in turn, against the document + for (String id : target.inheritedIds) { + targetBase = resolveBase(id, targetBase, "", target.location); + } + } + // a document resolves its references the same way whenever it is reached with the same base, whatever + // chain of $ids led to it; this also ends the visit of a schema that references itself + if (checked.add(target.location + " under " + targetBase)) { + JsonNode targetTree = tree(read(target.location, target.origin), target.location, target.origin); + follow(targetTree, target.location, targetBase, target.inheritedIds); + } + } + } + + private JsonNode tree(String content, String document, String origin) { try { // the tree the parser resolves: the same YAML reader, with the aliases expanded and its limits applied - tree = DeserializationUtils.deserializeIntoTree(content, document != null ? document : specificationUri); + return DeserializationUtils.deserializeIntoTree(content, document != null ? document : specificationUri); } catch (RuntimeException e) { throw new IllegalArgumentException( (document != null ? where(document, origin) : "The OpenAPI specification " + specificationUri) + " cannot be read to check its references: " + e.getMessage(), e); } - if (document == null) { - // the parser resolves an OpenAPI 3.1 document with its own dereferencer, which has other rules - openApi31 = tree.path("openapi").asText("").startsWith("3.1"); - } - String base = document; - if (openApi31) { - // OpenAPI31Traverser.traverseSchema: the inherited $ids resolved, in turn, against the document - base = document == null ? LOCAL_URI : document; - for (String id : inheritedIds) { - base = resolveBase(id, base, "", document); - } - } - // the external references of this document, with where they are, in the order they are met - Map targets = new LinkedHashMap<>(); - scan(tree, "", document, base, inheritedIds, targets); - for (Target target : targets.values()) { - if (checked.add(target.key())) { - // a referenced local document can reference further documents, which the parser loads as well - document(read(target.location, target.origin), target.location, target.origin, - target.inheritedIds); - } - } } private void scan( @@ -268,8 +269,7 @@ private void reference( } // a local document: file: and classpath: URIs, and paths the parser reads from the working directory or // the classpath; the parser carries the enclosing $ids into it (ReferenceVisitor.resolveSchemaRef) - Target found = new Target(target, where(ref, path, document), ids); - targets.putIfAbsent(found.key(), found); + targets.putIfAbsent(target + " under " + ids, new Target(target, where(ref, path, document), ids)); } /** diff --git a/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java b/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java index f6b5b8ca61a2c..8408b3db09e92 100644 --- a/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java +++ b/components/camel-rest-openapi/src/test/java/org/apache/camel/component/rest/openapi/RestOpenApiExternalReferencesTest.java @@ -319,6 +319,73 @@ void allResolvesAnOpenApi31ReferenceAgainstTheInheritedId(@TempDir Path dir) thr SERVER.verify(1, getRequestedFor(urlEqualTo("/schemas/owner.yaml"))); } + @Test + void localFinishesForASchemaThatReferencesItself(@TempDir Path dir) throws Exception { + Path node = dir.resolve("node.yaml"); + Files.writeString(node, """ + $id: %s + type: object + properties: + next: + $ref: %s + """.formatted(node.toUri(), node.toUri())); + Path root = Files.writeString(dir.resolve("root.yaml"), contract31(null, node.toUri().toString())); + + // the schema is reached again under its own $id, with the same base, so the check visits it once + OpenAPI openApi = RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + root, "local"); + + assertThat(openApi.getPaths()).containsKey("/pets/{petId}"); + } + + @Test + void localChecksADocumentReachedAgainUnderAnotherId(@TempDir Path dir) throws Exception { + Path shared = Files.writeString(dir.resolve("shared.yaml"), """ + type: object + properties: + owner: + $ref: owner.yaml + """); + Files.writeString(dir.resolve("owner.yaml"), OWNER_SCHEMA); + Path root = Files.writeString(dir.resolve("root.yaml"), """ + openapi: 3.1.0 + info: + title: Pets + version: "1.0" + paths: + /pets/{petId}: + get: + operationId: getPetById + responses: + "200": + description: The pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + components: + schemas: + Safe: + type: object + properties: + shared: + $ref: '%s' + Pet: + $id: %s/schemas/ + type: object + properties: + shared: + $ref: '%s' + """.formatted(shared.toUri(), SERVER.baseUrl(), shared.toUri())); + + // shared.yaml is first reached without an $id, where owner.yaml is the file next to it, then below the $id of + // Pet, where the parser resolves owner.yaml against the server: the second visit must not be skipped + assertThatThrownBy(() -> RestOpenApiEndpoint.loadSpecificationFrom(camelContext, "file:" + root, "local")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("references owner.yaml at properties.owner.$ref in " + shared.toUri()) + .hasMessageContaining("(owner.yaml resolves to " + SERVER.baseUrl() + "/schemas/owner.yaml)"); + SERVER.verify(0, anyRequestedFor(anyUrl())); + } + @Test void noneAllowsAReferenceInsideTheDocumentBelowAnHttpId(@TempDir Path dir) throws Exception { Path root = Files.writeString(dir.resolve("root.yaml"),