diff --git a/.eslintrc b/.eslintrc index 9011699546..5b558deba7 100644 --- a/.eslintrc +++ b/.eslintrc @@ -7,7 +7,8 @@ }, "globals": { "File": true, - "Blob": true + "Blob": true, + "globalThis": true }, "parserOptions": { "sourceType": "module", diff --git a/src/helpers/apidom/reference/resolve/resolvers/http-swagger-client/index.js b/src/helpers/apidom/reference/resolve/resolvers/http-swagger-client/index.js new file mode 100644 index 0000000000..5e822ac428 --- /dev/null +++ b/src/helpers/apidom/reference/resolve/resolvers/http-swagger-client/index.js @@ -0,0 +1,57 @@ +import 'cross-fetch/polyfill'; +import { ResolverError, HttpResolver } from '@swagger-api/apidom-reference/configuration/empty'; + +import Http from '../../../../../../http/index.js'; + +const HttpResolverSwaggerClient = HttpResolver.compose({ + props: { + name: 'http-swagger-client', + swaggerHTTPClient: Http, + swaggerHTTPClientConfig: {}, + }, + init({ swaggerHTTPClient = this.swaggerHTTPClient } = {}) { + this.swaggerHTTPClient = swaggerHTTPClient; + }, + methods: { + getHttpClient() { + return this.swaggerHTTPClient; + }, + + async read(file) { + const client = this.getHttpClient(); + const controller = new AbortController(); + const { signal } = controller; + const timeoutID = setTimeout(() => { + controller.abort(); + }, this.timeout); + const credentials = + this.getHttpClient().withCredentials || this.withCredentials ? 'include' : 'same-origin'; + const redirects = this.redirects === 0 ? 'error' : 'follow'; + const follow = this.redirects > 0 ? this.redirects : undefined; + + try { + const response = await client({ + url: file.uri, + signal, + userFetch: async (resource, options) => { + const res = await fetch(resource, options); + res.headers.delete('Content-Type'); + return res; + }, + credentials, + redirects, + follow, + ...this.swaggerHTTPClientConfig, + }); + + return response.text.arrayBuffer(); + } catch (error) { + throw new ResolverError(`Error downloading "${file.uri}"`, error); + } finally { + clearTimeout(timeoutID); + } + }, + }, +}); + +export default HttpResolverSwaggerClient; diff --git a/test/.eslintrc b/test/.eslintrc index 16006fff5f..d8f5c61f7f 100644 --- a/test/.eslintrc +++ b/test/.eslintrc @@ -10,6 +10,7 @@ "global-require": 0, // needs to be eliminated in future "import/no-dynamic-require": 0, "max-classes-per-file": 0, - "no-underscore-dangle": 0 + "no-underscore-dangle": 0, + "import/no-extraneous-dependencies": ["error", {"devDependencies": true}] } } diff --git a/test/helpers/apidom/reference/resolve/resolvers/http-swagger-client/fixtures/empty-openapi-3-1-api.json b/test/helpers/apidom/reference/resolve/resolvers/http-swagger-client/fixtures/empty-openapi-3-1-api.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/helpers/apidom/reference/resolve/resolvers/http-swagger-client/fixtures/sample-openapi-3-1-api.json b/test/helpers/apidom/reference/resolve/resolvers/http-swagger-client/fixtures/sample-openapi-3-1-api.json new file mode 100644 index 0000000000..13e368d4ea --- /dev/null +++ b/test/helpers/apidom/reference/resolve/resolvers/http-swagger-client/fixtures/sample-openapi-3-1-api.json @@ -0,0 +1,206 @@ +{ + "openapi": "3.1.0", + "x-top-level": "value", + "info": { + "title": "Sample API", + "unknownFixedField": "value", + "description": "Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML.", + "summary": "example summary", + "termsOfService": "Terms of service", + "version": "0.1.9", + "x-version": "0.1.9-beta", + "license": { + "name": "Apache License 2.0", + "x-fullName": "Apache License 2.0", + "identifier": "Apache License 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0" + }, + "contact": { + "name": "Vladimir Gorej", + "x-username": "char0n", + "url": "https://www.linkedin.com/in/vladimirgorej/", + "email": "vladimir.gorej@gmail.com" + } + }, + "components": { + "x-extension": "value", + "schemas": { + "x-model": { + "type": "object", + "properties": { + "id": { + "type:": "integer" + } + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "profile": { + "$ref": "#/components/schemas/UserProfile", + "summary": "user profile reference summary", + "description": "user profile reference description" + } + } + }, + "UserProfile": { + "type": "object", + "properties": { + "email": { + "type": "string", + "x-nullable": true + } + } + } + }, + "parameters": { + "userId": { + "$ref": "#/components/parameters/userIdRef" + }, + "userIdRef": { + "name": "userId", + "in": "query", + "description": "ID of the user", + "required": true + } + } + }, + "security": [ + {}, + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ], + "servers": [ + { + "url": "http://api.example.com/v1", + "description": "Optional server description, e.g. Main (production) server" + }, + { + "url": "http:{port}//staging-api.example.com", + "description": "Optional server description, e.g. Internal staging server for testing", + "variables": { + "port": { + "enum": [ + "8443", + "443" + ], + "default": "8443", + "description": "Port description" + } + } + } + ], + "paths": { + "/users": { + "summary": "path item summary", + "description": "path item description", + "get": { + "tags": ["tag1", "tag2"], + "summary": "Returns a list of users.", + "description": "Optional extended description in CommonMark or HTML.", + "externalDocs": { + "description": "Find more info here", + "url": "https://example.com" + }, + "operationId": "getUserList", + "parameters": [ + { + "$ref": "#/components/parameters/userId" + } + ], + "requestBody": { + "content": {} + }, + "responses": { + "xxx": {"key": "val"}, + "200": { + "description": "A JSON array of user names", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "201": { + "description": "A response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "callbacks": { + "myCallback": { + "{$request.query.queryUrl}": { + "post": { + "requestBody": { + "description": "Callback payload", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "200": { + "description": "callback successfully processed" + } + } + } + } + } + }, + "deprecated": true, + "security": [ + {}, + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ], + "servers": [ + { + "url": "http://api.example.com/v3", + "description": "Redundant server description, e.g. redundant server" + } + ] + }, + "servers": [ + { + "url": "http://api.example.com/v2", + "description": "Redundant server description, e.g. redundant server" + } + ], + "parameters": [ + { + "name": "userId", + "in": "query", + "description": "ID of the user", + "required": true + } + ] + } + } +} diff --git a/test/helpers/apidom/reference/resolve/resolvers/http-swagger-client/fixtures/unknown-extension.ext b/test/helpers/apidom/reference/resolve/resolvers/http-swagger-client/fixtures/unknown-extension.ext new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/helpers/apidom/reference/resolve/resolvers/http-swagger-client/index.js b/test/helpers/apidom/reference/resolve/resolvers/http-swagger-client/index.js new file mode 100644 index 0000000000..f3aca7d547 --- /dev/null +++ b/test/helpers/apidom/reference/resolve/resolvers/http-swagger-client/index.js @@ -0,0 +1,176 @@ +import path from 'node:path'; +import http from 'node:http'; +import { Buffer } from 'node:buffer'; +import fetchMock from 'fetch-mock'; +import { File, ResolverError } from '@swagger-api/apidom-reference/configuration/empty'; + +import Http from '../../../../../../../src/http/index.js'; +import HttpResolverSwaggerClient from '../../../../../../../src/helpers/apidom/reference/resolve/resolvers/http-swagger-client/index.js'; + +describe('HttpResolverSwaggerClient', () => { + let resolver; + + beforeEach(() => { + resolver = HttpResolverSwaggerClient(); + }); + + describe('canRead', () => { + describe('given valid http URL', () => { + test('should consider it a HTTP URL', () => { + expect(resolver.canRead(File({ uri: 'http://swagger.io/file.txt' }))).toBe(true); + }); + }); + + describe('given valid https URL', () => { + test('should consider it a https URL', () => { + expect(resolver.canRead(File({ uri: 'https://swagger.io/file.txt' }))).toBe(true); + }); + }); + + describe('given URIs with no protocol', () => { + test('should not consider it a http/https URL', () => { + expect(resolver.canRead(File({ uri: '/home/user/file.txt' }))).toBe(false); + expect(resolver.canRead(File({ uri: 'C:\\home\\user\\file.txt' }))).toBe(false); + }); + }); + + describe('given URLs with other known protocols', () => { + test('should not consider it a http/https URL', () => { + expect(resolver.canRead(File({ uri: 'ftp://swagger.io/' }))).toBe(false); + }); + }); + }); + + describe('read', () => { + describe('given HTTP URL', () => { + test('should fetch the URL', async () => { + const url = 'https://httpbin.org/anything'; + const response = new Response(Buffer.from('data')); + fetchMock.get(url, response, { repeat: 1 }); + const content = await resolver.read(File({ uri: url })); + + expect(content).toBeInstanceOf(ArrayBuffer); + expect(Buffer.from(content).toString()).toStrictEqual('data'); + + fetchMock.restore(); + }); + + test('should throw on unexpected status codes', async () => { + const url = 'https://httpbin.org/anything'; + const response = new Response(Buffer.from('data'), { + status: 400, + }); + fetchMock.get(url, response, { repeat: 1 }); + + expect.assertions(2); + try { + await resolver.read(File({ uri: url })); + } catch (error) { + expect(error).toBeInstanceOf(ResolverError); + expect(error).toHaveProperty( + 'message', + 'Error downloading "https://httpbin.org/anything"' + ); + } finally { + fetchMock.restore(); + } + }); + + test('should throw on timeout', async () => { + resolver = HttpResolverSwaggerClient({ timeout: 1 }); + const url = 'http://localhost:8123/local-file.txt'; + const cwd = path.join(__dirname, 'fixtures'); + const server = globalThis.createHTTPServer({ port: 8123, cwd }); + + expect.assertions(3); + try { + await resolver.read(File({ uri: url })); + } catch (error) { + expect(error.cause.message).toStrictEqual('The user aborted a request.'); + expect(error).toBeInstanceOf(ResolverError); + expect(error).toHaveProperty( + 'message', + 'Error downloading "http://localhost:8123/local-file.txt"' + ); + } finally { + await server.terminate(); + } + }); + + describe('given withCredentials option', () => { + test('should allow cross-site Access-Control requests', async () => { + resolver = HttpResolverSwaggerClient({ + withCredentials: true, + }); + const url = 'https://httpbin.org/anything'; + const response = new Response(Buffer.from('data')); + fetchMock.get(url, response, { repeat: 1 }); + + expect.assertions(1); + try { + await resolver.read(File({ uri: url })); + const [, requestInit] = fetchMock.lastCall(url); + + expect(requestInit).toHaveProperty('credentials', 'include'); + } finally { + fetchMock.restore(); + } + }); + }); + + describe('given global withCredentials override', () => { + test('should allow cross-site Access-Control requests', async () => { + const url = 'https://httpbin.org/anything'; + const response = new Response(Buffer.from('data')); + fetchMock.get(url, response, { repeat: 1 }); + const { withCredentials: originalWithCredentials } = Http; + + Http.withCredentials = true; + + expect.assertions(1); + try { + await resolver.read(File({ uri: url })); + const [, requestInit] = fetchMock.lastCall(url); + + expect(requestInit).toHaveProperty('credentials', 'include'); + } finally { + fetchMock.restore(); + Http.withCredentials = originalWithCredentials; + } + }); + }); + + describe('given redirects options', () => { + test('should throw on exceeding redirects', (done) => { + resolver = HttpResolverSwaggerClient({ + redirects: 0, + }); + const url = 'http://localhost:4444/'; + const server = http.createServer((req, res) => { + res.setHeader('Location', '/foo'); + res.statusCode = 302; + res.end(); + }); + + expect.assertions(2); + server.listen(4444, () => { + resolver + .read(File({ uri: url })) + .catch((error) => { + expect(error).toBeInstanceOf(ResolverError); + expect(error.cause).toHaveProperty( + 'message', + 'maximum redirect reached at: http://localhost:4444/foo' + ); + }) + .catch((error) => error) + .then((error) => { + server.close(); + done(error); + }); + }); + }); + }); + }); + }); +}); diff --git a/test/jest.setup.js b/test/jest.setup.js index d400113150..82fb598257 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -1,7 +1,42 @@ +import process from 'node:process'; +import http from 'node:http'; +import path from 'node:path'; +import fs from 'node:fs'; import fetchMock from 'fetch-mock'; import fetch, { Headers, Request, Response } from 'cross-fetch'; +import AbortController from 'abort-controller'; +// configures fetchMock with node-fetch fetchMock.config.fetch = fetch; fetchMock.config.Request = Request; fetchMock.config.Response = Response; fetchMock.config.Headers = Headers; + +// provide AbortController for older Node.js versions +globalThis.AbortController = globalThis.AbortController ?? AbortController; + +// helper for providing HTTP server instance for testing +globalThis.createHTTPServer = ({ port = 8123, cwd = process.cwd() } = {}) => { + const server = http.createServer((req, res) => { + const filePath = path.join(cwd, req.url || '/favicon.ico'); + + if (!fs.existsSync(filePath)) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not found'); + return; + } + + const data = fs.readFileSync(filePath).toString(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(data); + }); + + server.listen(port); + + server.terminate = () => + new Promise((resolve) => { + server.close(() => resolve(server)); + }); + + return server; +};