diff --git a/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Aai20IReferenceManipulationStrategy.java b/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Aai20IReferenceManipulationStrategy.java index 1ae27fbbb..0a2427003 100755 --- a/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Aai20IReferenceManipulationStrategy.java +++ b/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Aai20IReferenceManipulationStrategy.java @@ -226,4 +226,9 @@ public boolean removeComponent(Document document, String name) { if(removed != null) return true; return model.components.messageBindings.remove(name) != null; } + + @Override + public boolean mergeNode(Node from, Node to) { + return false; + } } diff --git a/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/AbstractReferenceLocalizationStrategy.java b/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/AbstractReferenceLocalizationStrategy.java index 393927a02..7a8dd8a25 100755 --- a/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/AbstractReferenceLocalizationStrategy.java +++ b/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/AbstractReferenceLocalizationStrategy.java @@ -1,7 +1,10 @@ package io.apicurio.datamodels.openapi.visitors.dereference; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.function.Function; +import java.util.function.Supplier; import io.apicurio.datamodels.Library; import io.apicurio.datamodels.core.models.Document; @@ -31,4 +34,18 @@ protected void transform(Map source, Function List cloneNodes(List nodes, Supplier nodeSupplier) { + List clones = new ArrayList<>(nodes.size()); + for (T node : nodes) { + clones.add(cloneNode(node, nodeSupplier)); + } + return clones; + } + + protected static T cloneNode(T node, Supplier nodeSupplier) { + Node clone = Library.readNode(Library.writeNode(node), nodeSupplier.get()); + //noinspection unchecked + return (T) clone; + } } diff --git a/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Dereferencer.java b/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Dereferencer.java index 82e0e3ae5..303254fe5 100755 --- a/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Dereferencer.java +++ b/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Dereferencer.java @@ -157,10 +157,13 @@ public Document dereference() { // TODO maybe avoid exceptions? } } - - if (localRef == null) { + + // if reference can't be attached to components try to merge external node + boolean canMerge = localRef == null && strategy.mergeNode(resolved, (Node) node); + + if (localRef == null && !canMerge) { unresolvable.add(node.getReference()); - } else { + } else if (localRef != null) { // success! // rename the original reference if(!nameReused) { @@ -170,6 +173,9 @@ public Document dereference() { processQueue.add(new Context(originalRef.getRef(), localRef.getNode())); // remember, to prevent cycles resolvedToLocalMap.put(originalRef.getRef(), localRef.getRef()); + } else { + // in case of merge + processQueue.add(new Context(originalRef.getRef(), (Node) node)); } } } diff --git a/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/IReferenceManipulationStrategy.java b/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/IReferenceManipulationStrategy.java index 35575c462..e9b689810 100755 --- a/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/IReferenceManipulationStrategy.java +++ b/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/IReferenceManipulationStrategy.java @@ -63,6 +63,13 @@ public interface IReferenceManipulationStrategy { */ boolean removeComponent(Document document, String name); + /** + * Merge nodes from given from node to give to node. Merge is non-recursive, a property of + * from node will be added to to node only it doesn't already appear in it. + * + * @return true if operation succeed, false otherwise + */ + boolean mergeNode(Node from, Node to); class ReferenceAndNode { private final String ref; diff --git a/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Oas20IReferenceManipulationStrategy.java b/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Oas20IReferenceManipulationStrategy.java index baefc6b46..b6b982336 100755 --- a/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Oas20IReferenceManipulationStrategy.java +++ b/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Oas20IReferenceManipulationStrategy.java @@ -7,6 +7,7 @@ import io.apicurio.datamodels.openapi.v2.models.Oas20Document; import io.apicurio.datamodels.openapi.v2.models.Oas20Parameter; import io.apicurio.datamodels.openapi.v2.models.Oas20ParameterDefinition; +import io.apicurio.datamodels.openapi.v2.models.Oas20PathItem; import io.apicurio.datamodels.openapi.v2.models.Oas20Response; import io.apicurio.datamodels.openapi.v2.models.Oas20ResponseDefinition; import io.apicurio.datamodels.openapi.v2.models.Oas20Schema; @@ -97,4 +98,41 @@ public boolean removeComponent(Document document, String name) { if (removed != null) return true; return model.responses.items.remove(name) != null; } + + @Override + public boolean mergeNode(Node from, Node to) { + if (to instanceof Oas20PathItem && from instanceof Oas20PathItem) { + mergePathItem((Oas20PathItem) from, (Oas20PathItem) to); + return true; + } + return false; + } + + private static void mergePathItem(Oas20PathItem from, Oas20PathItem to) { + if (to.get == null && from.get != null) { + to.get = cloneNode(from.get, () -> to.createOperation("get")); + } + if (to.put == null && from.put != null) { + to.put = cloneNode(from.put, () -> to.createOperation("put")); + } + if (to.post == null && from.post != null) { + to.post = cloneNode(from.post, () -> to.createOperation("post")); + } + if (to.delete == null && from.delete != null) { + to.delete = cloneNode(from.delete, () -> to.createOperation("delete")); + } + if (to.options == null && from.options != null) { + to.options = cloneNode(from.options, () -> to.createOperation("options")); + } + if (to.head == null && from.head != null) { + to.head = cloneNode(from.head, () -> to.createOperation("head")); + } + if (to.patch == null && from.patch != null) { + to.patch = cloneNode(from.patch, () -> to.createOperation("patch")); + } + if (to.parameters == null && from.parameters != null) { + to.parameters = cloneNodes(from.parameters, to::createParameter); + } + to.setReference(null); + } } diff --git a/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Oas30IReferenceManipulationStrategy.java b/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Oas30IReferenceManipulationStrategy.java index 4197296be..72fb04fbf 100755 --- a/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Oas30IReferenceManipulationStrategy.java +++ b/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Oas30IReferenceManipulationStrategy.java @@ -14,8 +14,10 @@ import io.apicurio.datamodels.openapi.v3.models.Oas30HeaderDefinition; import io.apicurio.datamodels.openapi.v3.models.Oas30Link; import io.apicurio.datamodels.openapi.v3.models.Oas30LinkDefinition; +import io.apicurio.datamodels.openapi.v3.models.Oas30Operation; import io.apicurio.datamodels.openapi.v3.models.Oas30Parameter; import io.apicurio.datamodels.openapi.v3.models.Oas30ParameterDefinition; +import io.apicurio.datamodels.openapi.v3.models.Oas30PathItem; import io.apicurio.datamodels.openapi.v3.models.Oas30RequestBody; import io.apicurio.datamodels.openapi.v3.models.Oas30RequestBodyDefinition; import io.apicurio.datamodels.openapi.v3.models.Oas30Response; @@ -37,6 +39,10 @@ public ReferenceAndNode attachAsComponent(Document document, String name, Node c throw new IllegalArgumentException("Oas30Document expected."); Oas30Document model = (Oas30Document) document; + if (model.components == null) { + model.components = model.createComponents(); + } + if (component instanceof Oas30Schema) { if (model.components.getSchemaDefinition(name) != null) throw new IllegalArgumentException("Definition with that name already exists: " + name); @@ -175,4 +181,54 @@ public boolean removeComponent(Document document, String name) { if (removed != null) return true; return model.components.callbacks.remove(name) != null; } + + + @Override + public boolean mergeNode(Node from, Node to) { + if (to instanceof Oas30PathItem && from instanceof Oas30PathItem) { + mergePathItem((Oas30PathItem) from, (Oas30PathItem) to); + return true; + } + return false; + } + + private static void mergePathItem(Oas30PathItem from, Oas30PathItem to) { + if (to.get == null && from.get != null) { + to.get = cloneNode(from.get, () -> to.createOperation("get")); + } + if (to.put == null && from.put != null) { + to.put = cloneNode(from.put, () -> to.createOperation("put")); + } + if (to.post == null && from.post != null) { + to.post = cloneNode(from.post, () -> to.createOperation("post")); + } + if (to.delete == null && from.delete != null) { + to.delete = cloneNode(from.delete, () -> to.createOperation("delete")); + } + if (to.options == null && from.options != null) { + to.options = cloneNode(from.options, () -> to.createOperation("options")); + } + if (to.head == null && from.head != null) { + to.head = cloneNode(from.head, () -> to.createOperation("head")); + } + if (to.patch == null && from.patch != null) { + to.patch = cloneNode(from.patch, () -> to.createOperation("patch")); + } + if (to.parameters == null && from.parameters != null) { + to.parameters = cloneNodes(from.parameters, to::createParameter); + } + if (to.summary == null && from.summary != null) { + to.summary = from.summary; + } + if (to.description == null && from.description != null) { + to.description = from.description; + } + if (to.trace == null && from.trace != null) { + to.trace = cloneNode(from.trace, () -> new Oas30Operation("trace")); + } + if (to.servers == null && from.servers != null) { + to.servers = from.servers; + } + to.setReference(null); + } } diff --git a/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Reference.java b/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Reference.java index bada8885a..d905a9e0b 100755 --- a/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Reference.java +++ b/src/main/java/io/apicurio/datamodels/openapi/visitors/dereference/Reference.java @@ -61,7 +61,7 @@ public String getName() { if (rel == null) throw new RuntimeException("No relative part in the reference."); String[] parts = rel.split("/"); - return parts[parts.length - 1]; + return parts[parts.length - 1].replaceAll("~1", "/").replaceAll("~0", "~"); } /** diff --git a/src/test/resources/fixtures/dereference/oai2/path-item-dereference.expected.json b/src/test/resources/fixtures/dereference/oai2/path-item-dereference.expected.json new file mode 100644 index 000000000..b229a8409 --- /dev/null +++ b/src/test/resources/fixtures/dereference/oai2/path-item-dereference.expected.json @@ -0,0 +1,125 @@ +{ + "swagger": "2.0", + "paths": { + "/pets": { + "get": { + "summary": "List Pets", + "description": "Returns all pets from the system that the user has access to", + "operationId": "listPets", + "parameters": [ + { + "name": "token", + "in": "header", + "description": "token to be passed as a header", + "required": true, + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "collectionFormat": "csv" + }, + { + "name": "user", + "in": "body", + "description": "user to add to the system", + "required": true, + "schema": { + "$ref": "#/definitions/Widget20" + }, + "allowEmptyValue": false + }, + { + "$ref": "#/parameters/skipParam" + } + ], + "responses": { + "200": { + "description": "A simple string response", + "schema": { + "$ref": "#/definitions/Widget20" + } + }, + "500": { + "$ref": "#/responses/GeneralError" + } + } + }, + "post": { + "summary": "Add Pet", + "description": "Add a pet", + "operationId": "AddPets", + "parameters": [ + { + "name": "pet", + "in": "body", + "description": "pet to add to the system", + "required": true, + "schema": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + } + } + } + ], + "responses": { + "200": { + "description": "A simple string response" + } + } + } + } + }, + "definitions": { + "Widget20": { + "title": "Example Schema", + "type": "object", + "minProperties": 2, + "maxProperties": 3, + "readOnly": false, + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "age": { + "description": "Age in years", + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "firstName", + "lastName" + ] + } + }, + "parameters": { + "skipParam": { + "name": "skip", + "in": "query", + "description": "number of items to skip", + "required": true, + "type": "integer", + "format": "int32" + } + }, + "responses": { + "GeneralError": { + "description": "General Error", + "schema": { + "type": "string" + } + } + } +} \ No newline at end of file diff --git a/src/test/resources/fixtures/dereference/oai2/path-item-dereference.input.json b/src/test/resources/fixtures/dereference/oai2/path-item-dereference.input.json new file mode 100644 index 000000000..ea594cf87 --- /dev/null +++ b/src/test/resources/fixtures/dereference/oai2/path-item-dereference.input.json @@ -0,0 +1,37 @@ +{ + "swagger": "2.0", + "paths": { + "/pets": { + "$ref": "https://apis20.example.com/components.js#/paths/~1pets", + "post": { + "summary": "Add Pet", + "description": "Add a pet", + "operationId": "AddPets", + "parameters": [ + { + "name": "pet", + "in": "body", + "description": "pet to add to the system", + "required": true, + "schema": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + } + } + } + ], + "responses": { + "200": { + "description": "A simple string response" + } + } + } + } + } +} \ No newline at end of file diff --git a/src/test/resources/fixtures/dereference/oai3/path-item-dereference.expected.json b/src/test/resources/fixtures/dereference/oai3/path-item-dereference.expected.json new file mode 100644 index 000000000..1a74cc6aa --- /dev/null +++ b/src/test/resources/fixtures/dereference/oai3/path-item-dereference.expected.json @@ -0,0 +1,97 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Sample API", + "version": "1.0.0", + "description": "An example API." + }, + "paths": { + "/invoices": { + "description": "Another description about invoice operations", + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Invoice" + } + } + } + }, + "description": "OK" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + }, + "operationId": "getInvoices" + }, + "post": { + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + }, + "description": "CREATED" + } + }, + "operationId": "AddInvoice" + } + } + }, + "components": { + "schemas": { + "Error": { + "description": "", + "required": [ + "code" + ], + "type": "object", + "properties": { + "code": { + "description": "", + "type": "string" + } + } + }, + "Invoice": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + } + } + }, + "responses": { + "NotFound": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "" + } + } + } +} \ No newline at end of file diff --git a/src/test/resources/fixtures/dereference/oai3/path-item-dereference.input.json b/src/test/resources/fixtures/dereference/oai3/path-item-dereference.input.json new file mode 100644 index 000000000..78d1cdfac --- /dev/null +++ b/src/test/resources/fixtures/dereference/oai3/path-item-dereference.input.json @@ -0,0 +1,37 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Sample API", + "version": "1.0.0", + "description": "An example API." + }, + "paths": { + "/invoices": { + "$ref": "https://schemas.example.org/responses.json#/paths/~1invoices", + "description": "Another description about invoice operations", + "post": { + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + }, + "description": "CREATED" + } + }, + "operationId": "AddInvoice" + } + } + } +} \ No newline at end of file diff --git a/src/test/resources/fixtures/dereference/oai3/response.input.json b/src/test/resources/fixtures/dereference/oai3/response.input.json index ea0c250d6..7934451e3 100755 --- a/src/test/resources/fixtures/dereference/oai3/response.input.json +++ b/src/test/resources/fixtures/dereference/oai3/response.input.json @@ -32,19 +32,6 @@ }, "components": { "schemas": { - "Error": { - "description": "", - "required": [ - "code" - ], - "type": "object", - "properties": { - "code": { - "description": "", - "type": "string" - } - } - }, "Invoice": { "description": "", "type": "object" diff --git a/src/test/resources/fixtures/dereference/tests.json b/src/test/resources/fixtures/dereference/tests.json index 43ae82950..dd788df1d 100755 --- a/src/test/resources/fixtures/dereference/tests.json +++ b/src/test/resources/fixtures/dereference/tests.json @@ -93,5 +93,15 @@ "name" : "[OpenAPI 3] 3-level Dereference (Issue 1366)", "input" : "oai3/3-level-dereference.input.json", "expected" : "oai3/3-level-dereference.expected.json" + }, + { + "name" : "[OpenAPI 3] path item dereference", + "input" : "oai3/path-item-dereference.input.json", + "expected" : "oai3/path-item-dereference.expected.json" + }, + { + "name" : "[OpenAPI 2] path item dereference", + "input" : "oai2/path-item-dereference.input.json", + "expected" : "oai2/path-item-dereference.expected.json" } ] diff --git a/src/test/resources/fixtures/dereference/tests.refs.json b/src/test/resources/fixtures/dereference/tests.refs.json index 5b1bdf6f8..b663abdc0 100755 --- a/src/test/resources/fixtures/dereference/tests.refs.json +++ b/src/test/resources/fixtures/dereference/tests.refs.json @@ -401,5 +401,138 @@ "$ref": "#/components/schemas/JSCalendar" } ] + }, + "https://schemas.example.org/responses.json#/paths/~1invoices": { + "description": "Operations on invoices", + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Invoice" + } + } + } + }, + "description": "OK" + }, + "404": { + "$ref": "https://schemas.example.org/responses.json#/components/responses/NotFound" + } + }, + "operationId": "getInvoices" + }, + "post": { + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Invoice" + } + } + }, + "description": "CREATED" + } + }, + "operationId": "AddInvoice" + } + }, + "https://schemas.example.org/responses.json#/components/schemas/Invoice": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + } + }, + "https://schemas.example.org/responses.json#/components/schemas/Error": { + "description": "", + "required": [ + "code" + ], + "type": "object", + "properties": { + "code": { + "description": "", + "type": "string" + } + } + }, + "https://apis20.example.com/components.js#/paths/~1pets": { + "get": { + "summary": "List Pets", + "description": "Returns all pets from the system that the user has access to", + "operationId": "listPets", + "parameters": [ + { + "name": "token", + "in": "header", + "description": "token to be passed as a header", + "required": true, + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "collectionFormat": "csv" + }, + { + "name": "user", + "in": "body", + "description": "user to add to the system", + "required": true, + "schema": { + "$ref": "#/definitions/Widget20" + }, + "allowEmptyValue": false + }, + { + "$ref": "#/parameters/skipParam" + } + ], + "responses": { + "200": { + "description": "A simple string response", + "schema": { + "$ref": "#/definitions/Widget20" + } + }, + "500": { + "$ref": "#/responses/GeneralError" + } + } + }, + "post": { + "summary": "Add stuff", + "operationId": "AddStuff", + "parameters": [ + { + "name": "stuff", + "in": "body", + "description": "pet to add to the system", + "required": true, + "schema": { + "type": "object", + "properties": { + "id": { + "type": "number" + } + } + } + } + ], + "responses": { + "200": { + "description": "A response" + } + } + } } }