diff --git a/__tests__/__datasets__/circular.json b/__tests__/__datasets__/circular.json index 448e4dcd..53c6e0b7 100644 --- a/__tests__/__datasets__/circular.json +++ b/__tests__/__datasets__/circular.json @@ -6,7 +6,7 @@ }, "servers": [ { - "url": "https://httpsbin.org" + "url": "https://httpbin.org/anything" } ], "paths": { @@ -14,6 +14,7 @@ "get": { "responses": { "200": { + "description": "OK", "content": { "application/json": { "schema": { @@ -60,6 +61,51 @@ } } } + }, + "responses": { + "200": { + "description": "OK" + } + } + }, + "put": { + "description": "This operation is different because it has a circular ref array as a parameter and in its response, but not its request body.", + "parameters": [ + { + "name": "content", + "in": "header", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SalesLine" + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SalesLine" + } + } + } + } + }, + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SalesLine" + } + } + } + } } } } @@ -106,6 +152,24 @@ } } } + }, + "SalesLine": { + "type": "object", + "properties": { + "stock": { + "$ref": "#/components/schemas/ProductStock" + } + } + }, + "ProductStock": { + "properties": { + "test_param": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SalesLine" + } + } + } } } } diff --git a/__tests__/__fixtures__/create-oas.js b/__tests__/__fixtures__/create-oas.js new file mode 100644 index 00000000..d103943a --- /dev/null +++ b/__tests__/__fixtures__/create-oas.js @@ -0,0 +1,17 @@ +const Oas = require('../../src'); + +module.exports = function createOas(operation, components) { + const schema = { + paths: { + '/': { + get: operation, + }, + }, + }; + + if (components) { + schema.components = components; + } + + return new Oas(schema); +}; diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 810e8df4..7edffcb1 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -1130,6 +1130,7 @@ describe('#dereference()', () => { expect(oas.paths['/'].get).toStrictEqual({ responses: { 200: { + description: 'OK', content: { 'application/json': { schema: { diff --git a/__tests__/lib/openapi-to-json-schema.test.js b/__tests__/lib/openapi-to-json-schema.test.js index 327e02aa..10b0c5cc 100644 --- a/__tests__/lib/openapi-to-json-schema.test.js +++ b/__tests__/lib/openapi-to-json-schema.test.js @@ -738,6 +738,7 @@ describe('`example` / `examples` support', () => { const schema = operation.getParametersAsJsonSchema()[0].schema; + expect(schema.components).toBeUndefined(); expect(schema.properties.id.examples).toStrictEqual([20]); // Not `buster` because `doggie` is set directly alongside `name` in the definition. @@ -749,11 +750,6 @@ describe('`example` / `examples` support', () => { examples: ['https://example.com/dog.png'], }, }); - - // `Pet` schema `id` example should not be present because that `id` was set against the `requestBody`, not the - // component. - expect(schema.components.schemas.Pet.properties.id.examples).toBeUndefined(); - expect(schema.components.schemas.Pet.properties.name.examples).toStrictEqual(['doggie']); }); }); diff --git a/__tests__/operation/__snapshots__/get-parameters-as-json-schema.test.js.snap b/__tests__/operation/__snapshots__/get-parameters-as-json-schema.test.js.snap index 24a3b97d..d4c8b425 100644 --- a/__tests__/operation/__snapshots__/get-parameters-as-json-schema.test.js.snap +++ b/__tests__/operation/__snapshots__/get-parameters-as-json-schema.test.js.snap @@ -1,447 +1,130 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`parameters should convert parameters to JSON schema 1`] = ` +exports[`$ref quirks should retain $ref pointers in the schema even if they're circular 1`] = ` Array [ - Object { - "label": "Path Params", - "schema": Object { - "properties": Object { - "petId": Object { - "description": "Pet id to delete", - "format": "int64", - "type": "integer", - }, - }, - "required": Array [ - "petId", - ], - "type": "object", - }, - "type": "path", - }, Object { "label": "Headers", - "schema": Object { - "properties": Object { - "api_key": Object { - "type": "string", - }, - }, - "required": Array [], - "type": "object", - }, - "type": "header", - }, -] -`; - -exports[`request bodies should convert request bodies to JSON schema (application/json) 1`] = ` -Array [ - Object { - "label": "Body Params", "schema": Object { "components": Object { - "requestBodies": Object { - "Pet": Object { - "content": Object { - "application/json": Object { - "schema": Object { - "properties": Object { - "category": Object { - "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", - }, - }, - "type": "object", - "xml": Object { - "name": "Category", - }, - }, - "id": Object { - "format": "int64", - "readOnly": true, - "type": "integer", - }, - "name": Object { - "example": "doggie", - "type": "string", - }, - "photoUrls": Object { - "items": Object { - "type": "string", - }, - "type": "array", - "xml": Object { - "name": "photoUrl", - "wrapped": true, - }, - }, - "status": Object { - "description": "pet status in the store", - "enum": Array [ - "available", - "pending", - "sold", - ], - "type": "string", - }, - "tags": Object { - "items": Object { - "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", - }, - }, - "type": "object", - "xml": Object { - "name": "Tag", - }, - }, - "type": "array", - "xml": Object { - "name": "tag", - "wrapped": true, - }, - }, - }, - "required": Array [ - "name", - "photoUrls", - ], - "type": "object", - "xml": Object { - "name": "Pet", - }, - }, - }, - "application/xml": Object { - "schema": Object { - "properties": Object { - "category": Object { - "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", - }, - }, - "type": "object", - "xml": Object { - "name": "Category", - }, - }, - "id": Object { - "format": "int64", - "readOnly": true, - "type": "integer", - }, - "name": Object { - "example": "doggie", - "type": "string", - }, - "photoUrls": Object { - "items": Object { - "type": "string", - }, - "type": "array", - "xml": Object { - "name": "photoUrl", - "wrapped": true, - }, - }, - "status": Object { - "description": "pet status in the store", - "enum": Array [ - "available", - "pending", - "sold", - ], - "type": "string", - }, - "tags": Object { - "items": Object { - "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", - }, - }, - "type": "object", - "xml": Object { - "name": "Tag", - }, - }, - "type": "array", - "xml": Object { - "name": "tag", - "wrapped": true, - }, - }, - }, - "required": Array [ - "name", - "photoUrls", - ], - "type": "object", - "xml": Object { - "name": "Pet", - }, - }, - }, - }, - "description": "Pet object that needs to be added to the store", - "required": true, - }, - "UserArray": Object { - "content": Object { - "application/json": Object { - "schema": Object { - "items": Object { - "properties": Object { - "email": Object { - "type": "string", - }, - "firstName": Object { - "type": "string", - }, - "id": Object { - "format": "int64", - "type": "integer", - }, - "lastName": Object { - "type": "string", - }, - "password": Object { - "type": "string", - }, - "phone": Object { - "type": "string", - }, - "userStatus": Object { - "description": "User Status", - "format": "int32", - "type": "integer", - }, - "username": Object { - "type": "string", - }, - }, - "type": "object", - "xml": Object { - "name": "User", - }, - }, - "type": "array", - }, - }, - }, - "description": "List of user object", - "required": true, - }, - }, "schemas": Object { - "ApiResponse": Object { + "ProductStock": Object { "properties": Object { - "code": Object { - "format": "int32", - "type": "integer", - }, - "message": Object { - "type": "string", - }, - "type": Object { - "type": "string", + "test_param": Object { + "items": Object { + "$ref": "#/components/schemas/SalesLine", + }, + "type": "array", }, }, "type": "object", }, - "Category": Object { + "SalesLine": Object { "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", + "stock": Object { + "$ref": "#/components/schemas/ProductStock", }, }, "type": "object", }, - "Order": Object { + "dateTime": Object { + "format": "date-time", + "type": "string", + }, + "offset": Object { "properties": Object { - "complete": Object { - "default": false, - "type": "boolean", - }, "id": Object { - "format": "int64", - "type": "integer", - }, - "petId": Object { - "format": "int64", - "type": "integer", - }, - "quantity": Object { - "format": "int32", - "type": "integer", - }, - "shipDate": Object { - "format": "date-time", "type": "string", }, - "status": Object { - "description": "Order Status", - "enum": Array [ - "placed", - "approved", - "delivered", - ], - "type": "string", + "rules": Object { + "$ref": "#/components/schemas/rules", }, }, "type": "object", }, - "Pet": Object { + "offsetTransition": Object { "properties": Object { - "category": Object { - "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", - }, - }, - "type": "object", - }, - "id": Object { - "format": "int64", - "readOnly": true, - "type": "integer", - }, - "name": Object { - "examples": Array [ - "doggie", - ], + "dateTime": Object { + "format": "date-time", "type": "string", }, - "photoUrls": Object { - "items": Object { - "type": "string", - }, - "type": "array", + "offsetAfter": Object { + "$ref": "#/components/schemas/offset", }, - "status": Object { - "description": "pet status in the store", - "enum": Array [ - "available", - "pending", - "sold", - ], - "type": "string", - }, - "tags": Object { - "items": Object { - "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", - }, - }, - "type": "object", - }, - "type": "array", + "offsetBefore": Object { + "$ref": "#/components/schemas/offset", }, }, - "required": Array [ - "name", - "photoUrls", - ], "type": "object", }, - "Tag": Object { + "rules": Object { "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", - }, - }, - "type": "object", - }, - "User": Object { - "properties": Object { - "email": Object { - "type": "string", - }, - "firstName": Object { - "type": "string", - }, - "id": Object { - "format": "int64", - "type": "integer", - }, - "lastName": Object { - "type": "string", - }, - "password": Object { - "type": "string", - }, - "phone": Object { - "type": "string", - }, - "userStatus": Object { - "description": "User Status", - "format": "int32", - "type": "integer", - }, - "username": Object { - "type": "string", + "transitions": Object { + "items": Object { + "$ref": "#/components/schemas/offsetTransition", + }, + "type": "array", }, }, "type": "object", }, }, - "securitySchemes": Object { - "api_key": Object { - "in": "header", - "name": "api_key", - "type": "apiKey", - }, - "petstore_auth": Object { - "flows": Object { - "implicit": Object { - "authorizationUrl": "http://petstore.swagger.io/oauth/dialog", - "scopes": Object { - "read:pets": "read your pets", - "write:pets": "modify pets in your account", - }, - }, - }, - "type": "oauth2", + }, + "properties": Object { + "content": Object { + "items": Object { + "$ref": "#/components/schemas/SalesLine", }, + "type": "array", + }, + }, + "required": Array [], + "type": "object", + }, + "type": "header", + }, +] +`; + +exports[`parameters should convert parameters to JSON schema 1`] = ` +Array [ + Object { + "label": "Path Params", + "schema": Object { + "properties": Object { + "petId": Object { + "description": "Pet id to delete", + "format": "int64", + "type": "integer", }, }, + "required": Array [ + "petId", + ], + "type": "object", + }, + "type": "path", + }, + Object { + "label": "Headers", + "schema": Object { + "properties": Object { + "api_key": Object { + "type": "string", + }, + }, + "required": Array [], + "type": "object", + }, + "type": "header", + }, +] +`; + +exports[`request bodies should convert request bodies to JSON schema (application/json) 1`] = ` +Array [ + Object { + "label": "Body Params", + "schema": Object { "properties": Object { "category": Object { "properties": Object { @@ -530,405 +213,6 @@ Array [ Object { "label": "Form Data", "schema": Object { - "components": Object { - "requestBodies": Object { - "Pet": Object { - "content": Object { - "application/json": Object { - "schema": Object { - "properties": Object { - "category": Object { - "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", - }, - }, - "type": "object", - "xml": Object { - "name": "Category", - }, - }, - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "example": "doggie", - "type": "string", - }, - "photoUrls": Object { - "items": Object { - "type": "string", - }, - "type": "array", - "xml": Object { - "name": "photoUrl", - "wrapped": true, - }, - }, - "status": Object { - "description": "pet status in the store", - "enum": Array [ - "available", - "pending", - "sold", - ], - "type": "string", - }, - "tags": Object { - "items": Object { - "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", - }, - }, - "type": "object", - "xml": Object { - "name": "Tag", - }, - }, - "type": "array", - "xml": Object { - "name": "tag", - "wrapped": true, - }, - }, - }, - "required": Array [ - "name", - "photoUrls", - ], - "type": "object", - "xml": Object { - "name": "Pet", - }, - }, - }, - "application/xml": Object { - "schema": Object { - "properties": Object { - "category": Object { - "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", - }, - }, - "type": "object", - "xml": Object { - "name": "Category", - }, - }, - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "example": "doggie", - "type": "string", - }, - "photoUrls": Object { - "items": Object { - "type": "string", - }, - "type": "array", - "xml": Object { - "name": "photoUrl", - "wrapped": true, - }, - }, - "status": Object { - "description": "pet status in the store", - "enum": Array [ - "available", - "pending", - "sold", - ], - "type": "string", - }, - "tags": Object { - "items": Object { - "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", - }, - }, - "type": "object", - "xml": Object { - "name": "Tag", - }, - }, - "type": "array", - "xml": Object { - "name": "tag", - "wrapped": true, - }, - }, - }, - "required": Array [ - "name", - "photoUrls", - ], - "type": "object", - "xml": Object { - "name": "Pet", - }, - }, - }, - }, - "description": "Pet object that needs to be added to the store", - "required": true, - }, - "UserArray": Object { - "content": Object { - "application/json": Object { - "schema": Object { - "items": Object { - "properties": Object { - "email": Object { - "type": "string", - }, - "firstName": Object { - "type": "string", - }, - "id": Object { - "format": "int64", - "type": "integer", - }, - "lastName": Object { - "type": "string", - }, - "password": Object { - "type": "string", - }, - "phone": Object { - "type": "string", - }, - "userStatus": Object { - "description": "User Status", - "format": "int32", - "type": "integer", - }, - "username": Object { - "type": "string", - }, - }, - "type": "object", - "xml": Object { - "name": "User", - }, - }, - "type": "array", - }, - }, - }, - "description": "List of user object", - "required": true, - }, - }, - "schemas": Object { - "ApiResponse": Object { - "properties": Object { - "code": Object { - "format": "int32", - "type": "integer", - }, - "message": Object { - "type": "string", - }, - "type": Object { - "type": "string", - }, - }, - "type": "object", - }, - "Category": Object { - "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", - }, - }, - "type": "object", - }, - "Order": Object { - "properties": Object { - "complete": Object { - "default": false, - "type": "boolean", - }, - "id": Object { - "format": "int64", - "type": "integer", - }, - "petId": Object { - "format": "int64", - "type": "integer", - }, - "quantity": Object { - "format": "int32", - "type": "integer", - }, - "shipDate": Object { - "format": "date-time", - "type": "string", - }, - "status": Object { - "description": "Order Status", - "enum": Array [ - "placed", - "approved", - "delivered", - ], - "type": "string", - }, - }, - "type": "object", - }, - "Pet": Object { - "properties": Object { - "category": Object { - "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", - }, - }, - "type": "object", - }, - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "examples": Array [ - "doggie", - ], - "type": "string", - }, - "photoUrls": Object { - "items": Object { - "type": "string", - }, - "type": "array", - }, - "status": Object { - "description": "pet status in the store", - "enum": Array [ - "available", - "pending", - "sold", - ], - "type": "string", - }, - "tags": Object { - "items": Object { - "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", - }, - }, - "type": "object", - }, - "type": "array", - }, - }, - "required": Array [ - "name", - "photoUrls", - ], - "type": "object", - }, - "Tag": Object { - "properties": Object { - "id": Object { - "format": "int64", - "type": "integer", - }, - "name": Object { - "type": "string", - }, - }, - "type": "object", - }, - "User": Object { - "properties": Object { - "email": Object { - "type": "string", - }, - "firstName": Object { - "type": "string", - }, - "id": Object { - "format": "int64", - "type": "integer", - }, - "lastName": Object { - "type": "string", - }, - "password": Object { - "type": "string", - }, - "phone": Object { - "type": "string", - }, - "userStatus": Object { - "description": "User Status", - "format": "int32", - "type": "integer", - }, - "username": Object { - "type": "string", - }, - }, - "type": "object", - }, - }, - "securitySchemes": Object { - "api_key": Object { - "in": "header", - "name": "api_key", - "type": "apiKey", - }, - "petstore_auth": Object { - "flows": Object { - "implicit": Object { - "authorizationUrl": "http://petstore.swagger.io/oauth/dialog", - "scopes": Object { - "read:pets": "read your pets", - "write:pets": "modify pets in your account", - }, - }, - }, - "type": "oauth2", - }, - }, - }, "properties": Object { "name": Object { "description": "Updated name of the pet", diff --git a/__tests__/operation/__snapshots__/get-response-as-json-schema.test.js.snap b/__tests__/operation/__snapshots__/get-response-as-json-schema.test.js.snap new file mode 100644 index 00000000..da4f9833 --- /dev/null +++ b/__tests__/operation/__snapshots__/get-response-as-json-schema.test.js.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`$ref quirks should retain $ref pointers in the schema even if they're circular 1`] = ` +Array [ + Object { + "description": "OK", + "label": "Response body", + "schema": Object { + "components": Object { + "schemas": Object { + "ProductStock": Object { + "properties": Object { + "test_param": Object { + "items": Object { + "$ref": "#/components/schemas/SalesLine", + }, + "type": "array", + }, + }, + }, + "SalesLine": Object { + "properties": Object { + "stock": Object { + "$ref": "#/components/schemas/ProductStock", + }, + }, + "type": "object", + }, + "dateTime": Object { + "format": "date-time", + "type": "string", + }, + "offset": Object { + "properties": Object { + "id": Object { + "type": "string", + }, + "rules": Object { + "$ref": "#/components/schemas/rules", + }, + }, + "type": "object", + }, + "offsetTransition": Object { + "properties": Object { + "dateTime": Object { + "format": "date-time", + "type": "string", + }, + "offsetAfter": Object { + "$ref": "#/components/schemas/offset", + }, + "offsetBefore": Object { + "$ref": "#/components/schemas/offset", + }, + }, + "type": "object", + }, + "rules": Object { + "properties": Object { + "transitions": Object { + "items": Object { + "$ref": "#/components/schemas/offsetTransition", + }, + "type": "array", + }, + }, + "type": "object", + }, + }, + }, + "items": Object { + "$ref": "#/components/schemas/SalesLine", + }, + "type": "array", + }, + "type": "array", + }, +] +`; diff --git a/__tests__/operation/get-parameters-as-json-schema.test.js b/__tests__/operation/get-parameters-as-json-schema.test.js index 3a467839..9469a0a7 100644 --- a/__tests__/operation/get-parameters-as-json-schema.test.js +++ b/__tests__/operation/get-parameters-as-json-schema.test.js @@ -1,19 +1,10 @@ const Oas = require('../../src'); + +const createOas = require('../__fixtures__/create-oas'); +const circular = require('../__datasets__/circular.json'); const petstore = require('@readme/oas-examples/3.0/json/petstore.json'); const petstoreServerVars = require('../__datasets__/petstore-server-vars.json'); -const createOas = (operation, components) => { - const schema = { - paths: { '/': { get: operation } }, - }; - - if (components) { - schema.components = components; - } - - return new Oas(schema); -}; - test('it should return with null if there are no parameters', () => { expect(createOas({ parameters: [] }).operation('/', 'get').getParametersAsJsonSchema()).toBeNull(); expect(createOas({}).operation('/', 'get').getParametersAsJsonSchema()).toBeNull(); @@ -320,6 +311,15 @@ describe('request bodies', () => { }); }); +describe('$ref quirks', () => { + it("should retain $ref pointers in the schema even if they're circular", async () => { + const oas = new Oas(circular); + await oas.dereference(); + + expect(oas.operation('/', 'put').getParametersAsJsonSchema()).toMatchSnapshot(); + }); +}); + describe('type', () => { describe('request bodies', () => { describe('repair invalid schema that has no `type`', () => { @@ -335,6 +335,15 @@ describe('type', () => { type: 'string', format: 'uri', }, + messages: { + type: 'array', + items: { + $ref: '#/components/schemas/messages', + }, + }, + user: { + $ref: '#/components/schemas/user', + }, }, }, }, @@ -343,14 +352,14 @@ describe('type', () => { }, { schemas: { - ErrorResponse: { + messages: { properties: { message: { type: 'string', }, }, }, - NewUser: { + user: { required: ['user_id'], properties: { user_id: { @@ -362,10 +371,12 @@ describe('type', () => { } ); + // So we can test that components are transformed, this test intentionally does **not** dereference the API + // definition. const schema = oas.operation('/', 'get').getParametersAsJsonSchema(); - expect(schema[0].schema.components.schemas.ErrorResponse.type).toBe('object'); - expect(schema[0].schema.components.schemas.NewUser.type).toBe('object'); + expect(schema[0].schema.components.schemas.messages.type).toBe('object'); + expect(schema[0].schema.components.schemas.user.type).toBe('object'); }); }); }); diff --git a/__tests__/operation/get-response-as-json-schema.test.js b/__tests__/operation/get-response-as-json-schema.test.js index 647a9d70..d5e7cac3 100644 --- a/__tests__/operation/get-response-as-json-schema.test.js +++ b/__tests__/operation/get-response-as-json-schema.test.js @@ -1,16 +1,8 @@ const Oas = require('../../src'); -const createOas = (responses, components) => { - const schema = { - paths: { '/': { get: { responses } } }, - }; - - if (components) { - schema.components = components; - } - - return new Oas(schema); -}; +const createOas = require('../__fixtures__/create-oas'); +const circular = require('../__datasets__/circular.json'); +const petstore = require('@readme/oas-examples/3.0/json/petstore.json'); const simpleObjectSchema = () => ({ type: 'object', @@ -21,140 +13,160 @@ const simpleObjectSchema = () => ({ }); test('it should return with null if there is not a response', () => { - expect(createOas({}).operation('/', 'get').getResponseAsJsonSchema('200')).toBeNull(); + expect(createOas({ responses: {} }).operation('/', 'get').getResponseAsJsonSchema('200')).toBeNull(); }); -test('it should return a schema when one is present', () => { - expect( - createOas({ - 200: { - description: 'response level description', - content: { - 'application/json': { - schema: simpleObjectSchema(), - }, +test('it should return a response as JSON Schema', async () => { + const oas = new Oas(petstore); + await oas.dereference(); + + const operation = oas.operation('/pet/{petId}/uploadImage', 'post'); + + expect(operation.getResponseAsJsonSchema('200')).toStrictEqual([ + { + schema: { + type: 'object', + properties: { + code: { type: 'integer', format: 'int32' }, + type: { type: 'string' }, + message: { type: 'string' }, }, }, - }) - .operation('/', 'get') - .getResponseAsJsonSchema('200') - ).toStrictEqual([ - { - schema: simpleObjectSchema(), type: 'object', label: 'Response body', - description: 'response level description', + description: 'successful operation', }, ]); }); -test('it should return a schema when one is present with a vendor content type', () => { - expect( - createOas({ - 200: { - description: 'response level description', - content: { - 'application/vnd.partytime+json': { - schema: simpleObjectSchema(), +describe('content type handling', () => { + it('should return a schema when one is present with a JSON-identifying vendor-prefixed content type', () => { + expect( + createOas({ + responses: { + 200: { + description: 'response level description', + content: { + 'application/vnd.partytime+json': { + schema: simpleObjectSchema(), + }, + }, }, }, + }) + .operation('/', 'get') + .getResponseAsJsonSchema('200') + ).toStrictEqual([ + { + label: 'Response body', + description: 'response level description', + type: 'object', + schema: simpleObjectSchema(), }, - }) - .operation('/', 'get') - .getResponseAsJsonSchema('200') - ).toStrictEqual([ - { - schema: simpleObjectSchema(), - type: 'object', - label: 'Response body', - description: 'response level description', - }, - ]); -}); + ]); + }); -test('it should return a schema when more than one content type is present', () => { - expect( - createOas({ - 200: { - description: 'response level description', - content: { - 'img/png': { - schema: { type: 'string' }, - }, - 'application/json': { - schema: simpleObjectSchema(), + it('should prefer the JSON-identifying content type when multiple content types are present', () => { + expect( + createOas({ + responses: { + 200: { + description: 'response level description', + content: { + 'img/png': { + schema: { type: 'string' }, + }, + 'application/json': { + schema: simpleObjectSchema(), + }, + }, }, }, + }) + .operation('/', 'get') + .getResponseAsJsonSchema('200') + ).toStrictEqual([ + { + schema: simpleObjectSchema(), + type: 'object', + label: 'Response body', + description: 'response level description', }, - }) - .operation('/', 'get') - .getResponseAsJsonSchema('200') - ).toStrictEqual([ - { - schema: simpleObjectSchema(), - type: 'object', - label: 'Response body', - description: 'response level description', - }, - ]); -}); + ]); + }); -test('the returned schema should include components if they exist', () => { - const components = { - schemas: { - unusedSchema: simpleObjectSchema(), - }, - }; + it('should not return a JSON Schema object for a content type that is not JSON-identifying', () => { + expect( + createOas({ + responses: { + 200: { + description: 'response level description', + content: { + 'img/png': { + schema: { type: 'string' }, + }, + }, + }, + }, + }) + .operation('/', 'get') + .getResponseAsJsonSchema('200') + ).toBeNull(); + }); +}); - expect( - createOas( - { +describe('`headers` support', () => { + // https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#responseObject + it('should include headers (OAS 3.0.3) if they exist', () => { + const oas = createOas({ + responses: { 200: { description: 'response level description', - content: { - 'application/json': { - schema: simpleObjectSchema(), + headers: { + foo: { + schema: { type: 'string' }, + }, + bar: { + schema: { type: 'number' }, }, }, }, }, - components - ) - .operation('/', 'get') - .getResponseAsJsonSchema('200') - ).toStrictEqual([ - { - schema: { ...simpleObjectSchema(), components }, - type: 'object', - label: 'Response body', - description: 'response level description', - }, - ]); -}); + }); -test('the returned schema should include headers (OAS 3.0.3) if they exist', () => { - expect( - createOas({ - 200: { + expect(oas.operation('/', 'get').getResponseAsJsonSchema('200')).toStrictEqual([ + { + label: 'Headers', description: 'response level description', - headers: { - foo: { - schema: { type: 'string' }, - }, - bar: { - schema: { type: 'number' }, - }, + type: 'object', + schema: simpleObjectSchema(), + }, + ]); + }); +}); + +describe('$ref quirks', () => { + it("should retain $ref pointers in the schema even if they're circular", () => { + const oas = new Oas(circular); + const operation = oas.operation('/', 'put'); + + expect(operation.getResponseAsJsonSchema('200')).toMatchSnapshot(); + }); + + it('should default the root schema to a `string` if there is a circular `$ref` at the root', () => { + const oas = new Oas(circular); + const operation = oas.operation('/', 'put'); + + expect(operation.getResponseAsJsonSchema('201')).toStrictEqual([ + { + description: 'OK', + label: 'Response body', + type: 'string', + schema: { + $ref: '#/components/schemas/SalesLine', + components: circular.components, }, }, - }) - .operation('/', 'get') - .getResponseAsJsonSchema('200') - ).toStrictEqual([ - { - schema: simpleObjectSchema(), - type: 'object', - label: 'Headers', - description: 'response level description', - }, - ]); + ]); + }); }); diff --git a/__tests__/operation/get-response-examples.test.js b/__tests__/operation/get-response-examples.test.js index 75d8ad52..13c6f28d 100644 --- a/__tests__/operation/get-response-examples.test.js +++ b/__tests__/operation/get-response-examples.test.js @@ -463,6 +463,7 @@ test('sample generation should not corrupt the supplied operation', async () => await spec.dereference(); const operation = spec.operation('/', 'post'); + const today = new Date().toISOString().substring(0, 10); // Running this before `getResponseExamples` should have no effects on the output of the `getResponseExamples` call. expect(operation.getRequestBodyExamples()).toStrictEqual([ @@ -472,8 +473,8 @@ test('sample generation should not corrupt the supplied operation', async () => { value: { product_id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', - start_date: '2021-08-09', - end_date: '2021-08-09', + start_date: today, + end_date: today, }, }, ], @@ -489,8 +490,8 @@ test('sample generation should not corrupt the supplied operation', async () => value: { id: 'string', product_id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', - start_date: '2021-08-09', - end_date: '2021-08-09', + start_date: today, + end_date: today, start_hour: 'string', end_hour: 'string', }, diff --git a/src/lib/openapi-to-json-schema.js b/src/lib/openapi-to-json-schema.js index 9df41a65..e7c501d6 100644 --- a/src/lib/openapi-to-json-schema.js +++ b/src/lib/openapi-to-json-schema.js @@ -168,23 +168,27 @@ function searchForExampleByPointer(pointer, examples = []) { * @link https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md * @param {Object} data * @param {Object} opts - * @param {Object[]} opts.prevSchemas - Array of parent schemas to utilize when attempting to path together examples. * @param {String} opts.currentLocation - Current location within the schema -- this is a JSON pointer. * @param {Object} opts.globalDefaults - Object containing a global set of defaults that we should apply to schemas that match it. * @param {Boolean} opts.isPolymorphicAllOfChild - Is this schema the child of a polymorphic `allOf` schema? + * @param {Object[]} opts.prevSchemas - Array of parent schemas to utilize when attempting to path together examples. + * @param {Function} opts.refLogger - A function that's called anytime a (circular) `$ref` is found. */ function toJSONSchema(data, opts = {}) { const schema = { ...data }; - const { prevSchemas, currentLocation, globalDefaults, isPolymorphicAllOfChild } = { - prevSchemas: [], + const { currentLocation, globalDefaults, isPolymorphicAllOfChild, prevSchemas, refLogger } = { currentLocation: '', globalDefaults: {}, isPolymorphicAllOfChild: false, + prevSchemas: [], + refLogger: () => {}, ...opts, }; // If this schema contains a `$ref`, it's circular and we shouldn't try to resolve it. Just return and move along. if (schema.$ref) { + refLogger(schema.$ref); + return { $ref: schema.$ref, }; @@ -196,10 +200,11 @@ function toJSONSchema(data, opts = {}) { if (polyType in schema && Array.isArray(schema[polyType])) { schema[polyType].forEach((item, idx) => { const polyOptions = { - prevSchemas, currentLocation: `${currentLocation}/${idx}`, globalDefaults, isPolymorphicAllOfChild: polyType === 'allOf', + prevSchemas, + refLogger, }; // When `properties` or `items` are present alongside a polymorphic schema instead of letting whatever JSON @@ -261,6 +266,7 @@ function toJSONSchema(data, opts = {}) { const example = schema.examples[name]; if ('$ref' in example) { // no-op because any `$ref` example here after dereferencing is circular so we should ignore it + refLogger(example.$ref); } else if ('value' in example) { if (isPrimitive(example.value)) { examples.push(example.value); @@ -307,13 +313,15 @@ function toJSONSchema(data, opts = {}) { Object.keys(schema.items).length === 1 && typeof schema.items.$ref !== 'undefined' ) { - // `items` contains a `$ref`, so since it's circular we should do a no-op here and ignore it. + // `items` contains a `$ref`, so since it's circular we should do a no-op here and log and ignore it. + refLogger(schema.items.$ref); } else { // Run through the arrays contents and clean them up. schema.items = toJSONSchema(schema.items, { - prevSchemas, currentLocation: `${currentLocation}/0`, globalDefaults, + prevSchemas, + refLogger, }); } } else if ('properties' in schema || 'additionalProperties' in schema) { @@ -331,9 +339,10 @@ function toJSONSchema(data, opts = {}) { if ('properties' in schema) { Object.keys(schema.properties).map(prop => { schema.properties[prop] = toJSONSchema(schema.properties[prop], { - prevSchemas, currentLocation: `${currentLocation}/${encodePointer(prop)}`, globalDefaults, + prevSchemas, + refLogger, }); return true; @@ -352,9 +361,10 @@ function toJSONSchema(data, opts = {}) { schema.additionalProperties = true; } else { schema.additionalProperties = toJSONSchema(data.additionalProperties, { - prevSchemas, currentLocation, globalDefaults, + prevSchemas, + refLogger, }); } } diff --git a/src/operation/get-parameters-as-json-schema.js b/src/operation/get-parameters-as-json-schema.js index cb68fa2e..19c22c99 100644 --- a/src/operation/get-parameters-as-json-schema.js +++ b/src/operation/get-parameters-as-json-schema.js @@ -1,5 +1,3 @@ -// This library is built to translate OpenAPI schemas into schemas compatible with `@readme/oas-form`, and should -// not at this time be used for general purpose consumption. const getSchema = require('../lib/get-schema'); const { json: isJSON } = require('../lib/matches-mimetype'); const toJSONSchema = require('../lib/openapi-to-json-schema'); @@ -20,30 +18,69 @@ function cloneObject(obj) { return JSON.parse(JSON.stringify(obj)); } -function getRequestBody(operation, oas, globalDefaults) { - const schema = getSchema(operation, oas); - if (!schema || !schema.schema) return null; +/** + * @param {string} path + * @param {Operation} operation + * @param {Oas} oas + * @param {Object} globalDefaults + * @returns {array} + */ +module.exports = (path, operation, oas, globalDefaults = {}) => { + let hasCircularRefs = false; + + function refLogger() { + hasCircularRefs = true; + } + + function getRequestBody() { + const schema = getSchema(operation, oas); + if (!schema || !schema.schema) return null; + + const type = schema.type === 'application/x-www-form-urlencoded' ? 'formData' : 'body'; + const requestBody = schema.schema; + + // If this schema is completely empty, don't bother processing it. + if (Object.keys(requestBody.schema).length === 0) { + return null; + } - const type = schema.type === 'application/x-www-form-urlencoded' ? 'formData' : 'body'; - const requestBody = schema.schema; + const examples = []; + if ('example' in requestBody) { + examples.push({ example: requestBody.example }); + } else if ('examples' in requestBody) { + examples.push({ examples: requestBody.examples }); + } + + // We're cloning the request schema because we've had issues with request schemas that were dereferenced being + // processed multiple times because their component is also processed. + const requestSchema = cloneObject(requestBody.schema); + const cleanedSchema = toJSONSchema(requestSchema, { globalDefaults, prevSchemas: examples, refLogger }); + + // If this schema is **still** empty, don't bother returning it. + if (Object.keys(cleanedSchema).length === 0) { + return null; + } - // If this schema is completely empty, don't bother processing it. - if (Object.keys(requestBody.schema).length === 0) { - return null; + return { + type, + label: types[type], + schema: cleanedSchema, + }; } - const examples = []; - if ('example' in requestBody) { - examples.push({ example: requestBody.example }); - } else if ('examples' in requestBody) { - examples.push({ examples: requestBody.examples }); + function getCommonParams() { + if (oas && 'paths' in oas && path in oas.paths && 'parameters' in oas.paths[path]) { + return oas.paths[path].parameters; + } + + return []; } - // We're cloning the request, and further below the component, schema because we've had issues with request schemas - // that were dereferenced being processed multiple times because their component is also processed. - const requestSchema = cloneObject(requestBody.schema); - const cleanedSchema = toJSONSchema(requestSchema, { prevSchemas: examples, globalDefaults }); - if (oas.components) { + function getComponents() { + if (!('components' in oas)) { + return false; + } + const components = {}; Object.keys(oas.components).forEach(componentType => { if (typeof oas.components[componentType] === 'object' && !Array.isArray(oas.components[componentType])) { @@ -53,170 +90,164 @@ function getRequestBody(operation, oas, globalDefaults) { Object.keys(oas.components[componentType]).forEach(schemaName => { const componentSchema = cloneObject(oas.components[componentType][schemaName]); - components[componentType][schemaName] = toJSONSchema(componentSchema, { globalDefaults }); + components[componentType][schemaName] = toJSONSchema(componentSchema, { globalDefaults, refLogger }); }); } }); - cleanedSchema.components = components; - } - - // If this schema is **still** empty, don't bother returning it. - if (Object.keys(cleanedSchema).length === 0) { - return null; - } - - return { - type, - label: types[type], - schema: cleanedSchema, - }; -} - -function getCommonParams(path, oas) { - if (oas && 'paths' in oas && path in oas.paths && 'parameters' in oas.paths[path]) { - return oas.paths[path].parameters; + return components; } - return []; -} - -function getParameters(path, operation, oas, globalDefaults) { - let operationParams = operation.parameters || []; - const commonParams = getCommonParams(path, oas); - - if (commonParams.length !== 0) { - const commonParamsNotInParams = commonParams.filter(param => { - return !operationParams.find(param2 => { - if (param.name && param2.name) { - return param.name === param2.name && param.in === param2.in; - } else if (param.$ref && param2.$ref) { - return param.$ref === param2.$ref; - } + function getParameters() { + let operationParams = operation.parameters || []; + const commonParams = getCommonParams(); + + if (commonParams.length !== 0) { + const commonParamsNotInParams = commonParams.filter(param => { + return !operationParams.find(param2 => { + if (param.name && param2.name) { + return param.name === param2.name && param.in === param2.in; + } else if (param.$ref && param2.$ref) { + return param.$ref === param2.$ref; + } - return false; + return false; + }); }); - }); - operationParams = operationParams.concat(commonParamsNotInParams || []); - } + operationParams = operationParams.concat(commonParamsNotInParams || []); + } - return Object.keys(types).map(type => { - const required = []; + return Object.keys(types).map(type => { + const required = []; - const parameters = operationParams.filter(param => param.in === type); - if (parameters.length === 0) { - return null; - } + const parameters = operationParams.filter(param => param.in === type); + if (parameters.length === 0) { + return null; + } - const properties = parameters.reduce((prev, current) => { - let schema = {}; - if ('schema' in current) { - const currentSchema = current.schema ? cloneObject(current.schema) : {}; - - if (current.example) { - // `example` can be present outside of the `schema` block so if it's there we should pull it in so it can be - // handled and returned if it's valid. - currentSchema.example = current.example; - } else if (current.examples) { - // `examples` isn't actually supported here in OAS 3.0, but we might as well support it because `examples` is - // JSON Schema and that's fully supported in OAS 3.1. - currentSchema.examples = current.examples; - } + const properties = parameters.reduce((prev, current) => { + let schema = {}; + if ('schema' in current) { + const currentSchema = current.schema ? cloneObject(current.schema) : {}; + + if (current.example) { + // `example` can be present outside of the `schema` block so if it's there we should pull it in so it can be + // handled and returned if it's valid. + currentSchema.example = current.example; + } else if (current.examples) { + // `examples` isn't actually supported here in OAS 3.0, but we might as well support it because `examples` is + // JSON Schema and that's fully supported in OAS 3.1. + currentSchema.examples = current.examples; + } - schema = { - ...toJSONSchema(currentSchema, { - currentLocation: `/${current.name}`, - globalDefaults, - }), - }; - } else if ('content' in current && typeof current.content === 'object') { - const contentKeys = Object.keys(current.content); - if (contentKeys.length) { - let contentType; - if (contentKeys.length === 1) { - contentType = contentKeys[0]; - } else { - // We should always try to prioritize `application/json` over any other possible content that might be present - // on this schema. - const jsonLikeContentTypes = contentKeys.filter(k => isJSON(k)); - if (jsonLikeContentTypes.length) { - contentType = jsonLikeContentTypes[0]; - } else { + schema = { + ...toJSONSchema(currentSchema, { + currentLocation: `/${current.name}`, + globalDefaults, + refLogger, + }), + }; + } else if ('content' in current && typeof current.content === 'object') { + const contentKeys = Object.keys(current.content); + if (contentKeys.length) { + let contentType; + if (contentKeys.length === 1) { contentType = contentKeys[0]; + } else { + // We should always try to prioritize `application/json` over any other possible content that might be present + // on this schema. + const jsonLikeContentTypes = contentKeys.filter(k => isJSON(k)); + if (jsonLikeContentTypes.length) { + contentType = jsonLikeContentTypes[0]; + } else { + contentType = contentKeys[0]; + } } - } - if (typeof current.content[contentType] === 'object' && 'schema' in current.content[contentType]) { - const currentSchema = current.content[contentType].schema - ? cloneObject(current.content[contentType].schema) - : {}; - - if (current.example) { - // `example` can be present outside of the `schema` block so if it's there we should pull it in so it can be - // handled and returned if it's valid. - currentSchema.example = current.example; - } else if (current.examples) { - // `examples` isn't actually supported here in OAS 3.0, but we might as well support it because `examples` is - // JSON Schema and that's fully supported in OAS 3.1. - currentSchema.examples = current.examples; + if (typeof current.content[contentType] === 'object' && 'schema' in current.content[contentType]) { + const currentSchema = current.content[contentType].schema + ? cloneObject(current.content[contentType].schema) + : {}; + + if (current.example) { + // `example` can be present outside of the `schema` block so if it's there we should pull it in so it can be + // handled and returned if it's valid. + currentSchema.example = current.example; + } else if (current.examples) { + // `examples` isn't actually supported here in OAS 3.0, but we might as well support it because `examples` is + // JSON Schema and that's fully supported in OAS 3.1. + currentSchema.examples = current.examples; + } + + schema = { + ...toJSONSchema(currentSchema, { + currentLocation: `/${current.name}`, + globalDefaults, + refLogger, + }), + }; } - - schema = { - ...toJSONSchema(currentSchema, { - currentLocation: `/${current.name}`, - globalDefaults, - }), - }; } } - } - - // Parameter descriptions don't exist in `current.schema` so `constructSchema` will never have access to it. - if (current.description) { - schema.description = current.description; - } - // If for whatever reason we were unable to ascertain a type for the schema (maybe `schema` and `content` aren't - // present, or they're not in the shape they should be), set it to a string so we can at least make an attempt at - // returning *something* for it. - if (!('type' in schema)) { - // Only add a missing type if this schema isn't a polymorphismified schema. - if (!('allOf' in schema) && !('oneOf' in schema) && !('anyOf' in schema)) { - schema.type = 'string'; + // Parameter descriptions don't exist in `current.schema` so `constructSchema` will never have access to it. + if (current.description) { + schema.description = current.description; } - } - prev[current.name] = schema; + // If for whatever reason we were unable to ascertain a type for the schema (maybe `schema` and `content` aren't + // present, or they're not in the shape they should be), set it to a string so we can at least make an attempt at + // returning *something* for it. + if (!('type' in schema)) { + // Only add a missing type if this schema isn't a polymorphismified schema. + if (!('allOf' in schema) && !('oneOf' in schema) && !('anyOf' in schema)) { + schema.type = 'string'; + } + } - if (current.required) { - required.push(current.name); - } + prev[current.name] = schema; - return prev; - }, {}); + if (current.required) { + required.push(current.name); + } - return { - type, - label: types[type], - schema: { - type: 'object', - properties, - required, - }, - }; - }); -} + return prev; + }, {}); + + return { + type, + label: types[type], + schema: { + type: 'object', + properties, + required, + }, + }; + }); + } -module.exports = (path, operation, oas, globalDefaults = {}) => { const hasRequestBody = !!operation.requestBody; const hasParameters = !!(operation.parameters && operation.parameters.length !== 0); - if (!hasParameters && !hasRequestBody && getCommonParams(path, oas).length === 0) return null; + if (!hasParameters && !hasRequestBody && getCommonParams().length === 0) return null; + + const components = getComponents(); const typeKeys = Object.keys(types); - return [getRequestBody(operation, oas, globalDefaults)] - .concat(...getParameters(path, operation, oas, globalDefaults)) + return [getRequestBody()] + .concat(...getParameters()) .filter(Boolean) + .map(group => { + // Since this library assumes that the schema has already been dereferenced, adding every component here that + // **isn't** circular adds a ton of bloat so it'd be cool if `components` was just the remaining `$ref` pointers + // that are still being referenced. + // @todo + if (hasCircularRefs && components) { + group.schema.components = components; + } + + return group; + }) .sort((a, b) => { return typeKeys.indexOf(a.type) - typeKeys.indexOf(b.type); }); diff --git a/src/operation/get-response-as-json-schema.js b/src/operation/get-response-as-json-schema.js index f92c6f60..e86e6d51 100644 --- a/src/operation/get-response-as-json-schema.js +++ b/src/operation/get-response-as-json-schema.js @@ -3,6 +3,8 @@ const { json: isJSON } = require('../lib/matches-mimetype'); /** * Turn a header map from oas 3.0.3 (and some earlier versions too) into a schema. Does not cover 3.1.0's header format + * + * @link https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#headerObject * @param {object} response * @returns object */ @@ -36,23 +38,6 @@ function buildHeadersSchema(response) { return headersWrapper; } -function getPreferredSchema(content) { - if (!content) { - return null; - } - - const contentTypes = Object.keys(content); - - // eslint-disable-next-line no-plusplus - for (let i = 0; i < contentTypes.length; i++) { - if (isJSON(contentTypes[i])) { - return toJSONSchema(content[contentTypes[i]].schema); - } - } - - return null; -} - /** * Extract all the response schemas, matching the format of get-parameters-as-json-schema. * @@ -71,12 +56,36 @@ module.exports = function getResponseAsJsonSchema(operation, oas, statusCode) { return null; } + let hasCircularRefs = false; + function refLogger() { + hasCircularRefs = true; + } + + function getPreferredSchema(content) { + if (!content) { + return null; + } + + const contentTypes = Object.keys(content); + + // eslint-disable-next-line no-plusplus + for (let i = 0; i < contentTypes.length; i++) { + if (isJSON(contentTypes[i])) { + return toJSONSchema(content[contentTypes[i]].schema, { refLogger }); + } + } + + return null; + } + const foundSchema = getPreferredSchema(response.content); if (foundSchema) { const schemaWrapper = { - // shallow copy so that the upcoming components addition doesn't pass to other uses of this schema - schema: { ...foundSchema }, - type: foundSchema.type, + // If there's no `type` then the root schema is a circular `$ref` that we likely won't be able to render so + // instead of generating a JSON Schema with an `undefined` type we should default to `string` so there's at least + // *something* the end-user can interact with. + type: foundSchema.type || 'string', + schema: JSON.parse(JSON.stringify(foundSchema)), label: 'Response body', }; @@ -84,9 +93,11 @@ module.exports = function getResponseAsJsonSchema(operation, oas, statusCode) { schemaWrapper.description = response.description; } - // Components are included so we can identify the names of refs - // Also so we can do a lookup if we end up with a $ref - if (oas.components && schemaWrapper.schema) { + // Since this library assumes that the schema has already been dereferenced, adding every component here that + // **isn't** circular adds a ton of bloat so it'd be cool if `components` was just the remaining `$ref` pointers + // that are still being referenced. + // @todo + if (hasCircularRefs && oas.components && schemaWrapper.schema) { schemaWrapper.schema.components = oas.components; }