diff --git a/README.md b/README.md index 73658e59..4060eb4f 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Automatic SDK generation from an OpenAPI definition. * [Authentication](#authentication) * [Parameters and Payloads](#parameters-and-payloads) * [HTTP requests](#http-requests) + * [Server configurations](#server-configurations) * [How does it work?](#how-does-it-work) * [Interested in contributing?](#interested-in-contributing) * [FAQ](#faq) @@ -97,6 +98,24 @@ sdk.get('/pets/{petId}', { petId: 1234 }).then(...) The SDK supports GET, PUT, POST, DELETE, OPTIONS, HEAD, and TRACE requests. +### Server configurations +If the API you're using offers alternate server URLs and server variables in its [`servers`](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#serverObject) definition you can supply this to the SDK with `.server()`: + +```js +sdk.server('https://{region}.api.example.com/{basePath}', { + name: 'eu', + basePath: 'v14', +}); + +sdk.get('/pets').then(...) +``` + +When your request is executed it will be made to `https://eu.api.example.com/v14/pets`. Alternatively if you don't want to deal with URL templates you can opt to pass the full URL in instead: + +```js +sdk.server('https://eu.api.example.com/v14'); +``` + ## How does it work? Behind the scenes, `api` will: @@ -135,3 +154,10 @@ Not yet! The URL that you give the module must be publicy accessible. If it isn' ```js const sdk = require('api')('/path/to/downloaded.json'); ``` + +#### How do I access the Response object (for status and headers)? +By default we parse the response based on the `content-type` header for you. You can disable this by doing the following: + +```js +sdk.config({ parseResponse: false }); +``` diff --git a/package-lock.json b/package-lock.json index ebc714ef..507ff9d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "name": "api-monorepo", + "hasInstallScript": true, "workspaces": [ "./packages/*" ], @@ -4256,9 +4257,9 @@ } }, "node_modules/@readme/oas-examples": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@readme/oas-examples/-/oas-examples-4.0.0.tgz", - "integrity": "sha512-MSm5mJoNa9AJ6PfKFOSO4BBzC3oQn0CdJHfP5l6v1ATVX3LUy/+X2Xgx5RZNcefRkJ7TWlfPoCiphjiEDSaAVQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@readme/oas-examples/-/oas-examples-4.2.0.tgz", + "integrity": "sha512-WS/Yb4yXM2ZlOFOA+vyRO/3MxFZvu2os742dUZ996R0x57bD47i/IP8N0PkkQFEujStrl7dMpPPi3GWoyBdVPQ==", "dev": true }, "node_modules/@readme/oas-extensions": { @@ -20488,7 +20489,7 @@ }, "devDependencies": { "@readme/eslint-config": "^5.0.5", - "@readme/oas-examples": "^4.0.0", + "@readme/oas-examples": "^4.2.0", "eslint": "^7.6.0", "jest": "^26.0.1", "memfs": "^3.2.0", @@ -23943,9 +23944,9 @@ } }, "@readme/oas-examples": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@readme/oas-examples/-/oas-examples-4.0.0.tgz", - "integrity": "sha512-MSm5mJoNa9AJ6PfKFOSO4BBzC3oQn0CdJHfP5l6v1ATVX3LUy/+X2Xgx5RZNcefRkJ7TWlfPoCiphjiEDSaAVQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@readme/oas-examples/-/oas-examples-4.2.0.tgz", + "integrity": "sha512-WS/Yb4yXM2ZlOFOA+vyRO/3MxFZvu2os742dUZ996R0x57bD47i/IP8N0PkkQFEujStrl7dMpPPi3GWoyBdVPQ==", "dev": true }, "@readme/oas-extensions": { @@ -24542,7 +24543,7 @@ "@apidevtools/json-schema-ref-parser": "^9.0.1", "@apidevtools/swagger-parser": "^10.0.1", "@readme/eslint-config": "^5.0.5", - "@readme/oas-examples": "^4.0.0", + "@readme/oas-examples": "^4.2.0", "@readme/oas-to-har": "^13.4.5", "datauri": "^3.0.0", "eslint": "^7.6.0", diff --git a/packages/api/README.md b/packages/api/README.md index 565dba34..4060eb4f 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -9,6 +9,7 @@ Automatic SDK generation from an OpenAPI definition. * [Authentication](#authentication) * [Parameters and Payloads](#parameters-and-payloads) * [HTTP requests](#http-requests) + * [Server configurations](#server-configurations) * [How does it work?](#how-does-it-work) * [Interested in contributing?](#interested-in-contributing) * [FAQ](#faq) @@ -24,7 +25,7 @@ Using `api` is as simple as supplying it an OpenAPI and using the SDK as you wou ```js const sdk = require('api')('https://raw.githubusercontent.com/readmeio/oas/master/packages/examples/3.0/json/petstore.json'); -sdk.listPets().then(res => { +sdk.listPets().then(res => res.json()).then(res => { console.log(`My pets name is ${res[0].name}!`); }); ``` @@ -88,30 +89,33 @@ You can also give it a stream and it'll handle all of the hard work for you. sdk.uploadFile({ file: fs.createReadStream('/path/to/a/file.txt') }).then(...) ``` -### Responses -Since we know the `Content-Type` of the returned response, we automatically parse it for you before returning it. So no more superfluous `.then(res => res.json())` calls. If your API returned with JSON, we'll give you the parsed JSON. +### HTTP requests +If the API you're using doesn't have any documented operation IDs, you can make requests with HTTP verbs instead: + +```js +sdk.get('/pets/{petId}', { petId: 1234 }).then(...) +``` + +The SDK supports GET, PUT, POST, DELETE, OPTIONS, HEAD, and TRACE requests. -If you need access to the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object you can disable our automatic parsing using `.config()` like so: +### Server configurations +If the API you're using offers alternate server URLs and server variables in its [`servers`](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#serverObject) definition you can supply this to the SDK with `.server()`: ```js -sdk.config({ parseResponse: false }); +sdk.server('https://{region}.api.example.com/{basePath}', { + name: 'eu', + basePath: 'v14', +}); -sdk.createPets({ name: 'Buster' }) - .then(res => { - // `res` will be a Response object - // so you can access status and headers - }) +sdk.get('/pets').then(...) ``` -### HTTP requests -If the API you're using doesn't have any documented operation IDs, you can make requests with HTTP verbs instead: +When your request is executed it will be made to `https://eu.api.example.com/v14/pets`. Alternatively if you don't want to deal with URL templates you can opt to pass the full URL in instead: ```js -sdk.get('/pets/{petId}', { petId: 1234 }).then(...) +sdk.server('https://eu.api.example.com/v14'); ``` -The SDK supports GET, PUT, POST, DELETE, OPTIONS, HEAD, and TRACE requests. - ## How does it work? Behind the scenes, `api` will: diff --git a/packages/api/__tests__/__fixtures__/createOas.js b/packages/api/__tests__/__fixtures__/createOas.js deleted file mode 100644 index 7a5dd719..00000000 --- a/packages/api/__tests__/__fixtures__/createOas.js +++ /dev/null @@ -1,20 +0,0 @@ -module.exports = serverUrl => { - return function (method = 'get', path = '/', operation = {}) { - return { - openapi: '3.0.0', - info: { - title: 'OAS test', - }, - servers: [ - { - url: serverUrl, - }, - ], - paths: { - [path]: { - [method]: operation, - }, - }, - }; - }; -}; diff --git a/packages/api/__tests__/__fixtures__/payloads.oas.json b/packages/api/__tests__/__fixtures__/payloads.oas.json new file mode 100644 index 00000000..64cb6649 --- /dev/null +++ b/packages/api/__tests__/__fixtures__/payloads.oas.json @@ -0,0 +1,58 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "OAS test cases", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://api.example.com" + } + ], + "paths": { + "/arraySchema": { + "put": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/primitiveBody": { + "put": { + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} diff --git a/packages/api/__tests__/auth.test.js b/packages/api/__tests__/auth.test.js index af7455dd..0d89dbdb 100644 --- a/packages/api/__tests__/auth.test.js +++ b/packages/api/__tests__/auth.test.js @@ -1,99 +1,63 @@ const nock = require('nock'); const api = require('../src'); -const serverUrl = 'https://api.example.com'; -const createOas = require('./__fixtures__/createOas')(serverUrl); +const securityOas = require('@readme/oas-examples/3.0/json/security.json'); describe('#auth()', () => { - const baseSecurityOas = createOas('get', '/', { - operationId: 'getSomething', - security: [ - { - auth: [], - }, - ], - }); - describe('API Keys', () => { const apiKey = '123457890'; describe('in: query', () => { - const securityOas = { - ...baseSecurityOas, - components: { - securitySchemes: { - auth: { - type: 'apiKey', - name: 'apiKeyParam', - in: 'query', - }, - }, - }, - }; - it.each([ ['should allow you to supply auth', false], ['should allow you to supply auth when unchained from an operation', true], ])('%s', (testCase, chained) => { const sdk = api(securityOas); - const mock = nock(serverUrl).get('/').query({ apiKeyParam: apiKey }).reply(200, {}); + const mock = nock('https://httpbin.org').get('/apiKey').query({ apiKey }).reply(200, {}); if (chained) { return sdk .auth(apiKey) - .getSomething() + .get('/apiKey') .then(() => mock.done()); } sdk.auth(apiKey); - return sdk.getSomething().then(() => mock.done()); + return sdk.get('/apiKey').then(() => mock.done()); }); it('should throw if you supply multiple auth keys', () => { const sdk = api(securityOas); - return expect(sdk.auth(apiKey, apiKey).getSomething()).rejects.toThrow(/only a single key is needed/i); + return expect(sdk.auth(apiKey, apiKey).get('/apiKey')).rejects.toThrow(/only a single key is needed/i); }); }); describe('in: header', () => { - const securityOas = { - ...baseSecurityOas, - components: { - securitySchemes: { - auth: { - type: 'apiKey', - name: 'apiKeyHeader', - in: 'header', - }, - }, - }, - }; - it.each([ ['should allow you to supply auth', false], ['should allow you to supply auth when unchained from an operation', true], ])('%s', (testCase, chained) => { const sdk = api(securityOas); - const mock = nock(serverUrl, { reqheaders: { apiKeyHeader: apiKey } }) - .get('/') + const mock = nock('https://httpbin.org', { reqheaders: { 'X-API-KEY': apiKey } }) + .put('/apiKey') .reply(200, {}); if (chained) { return sdk .auth(apiKey) - .getSomething() + .put('/apiKey') .then(() => mock.done()); } sdk.auth(apiKey); - return sdk.getSomething().then(() => mock.done()); + return sdk.put('/apiKey').then(() => mock.done()); }); it('should throw if you supply multiple auth keys', () => { const sdk = api(securityOas); - return expect(sdk.auth(apiKey, apiKey).getSomething()).rejects.toThrow(/only a single key is needed/i); + return expect(sdk.auth(apiKey, apiKey).put('/apiKey')).rejects.toThrow(/only a single key is needed/i); }); }); }); @@ -102,33 +66,22 @@ describe('#auth()', () => { describe('scheme: basic', () => { const user = 'username'; const pass = 'changeme'; - const securityOas = { - ...baseSecurityOas, - components: { - securitySchemes: { - auth: { - type: 'http', - scheme: 'basic', - }, - }, - }, - }; it.each([ ['should allow you to supply auth', false], ['should allow you to supply auth when unchained from an operation', true], ])('%s', (testCase, chained) => { const sdk = api(securityOas); - const mock = nock(serverUrl, { + const mock = nock('https://httpbin.org', { reqheaders: { authorization: `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}` }, }) - .get('/') + .post('/basic') .reply(200, { id: 1 }); if (chained) { return sdk .auth(user, pass) - .getSomething() + .post('/basic') .then(res => { // eslint-disable-next-line jest/no-conditional-expect expect(res.id).toBe(1); @@ -137,7 +90,7 @@ describe('#auth()', () => { } sdk.auth(user, pass); - return sdk.getSomething().then(res => { + return sdk.post('/basic').then(res => { expect(res.id).toBe(1); mock.done(); }); @@ -145,98 +98,77 @@ describe('#auth()', () => { it('should allow you to not pass in a password', () => { const sdk = api(securityOas); - const mock = nock(serverUrl, { + const mock = nock('https://httpbin.org', { reqheaders: { authorization: `Basic ${Buffer.from(`${user}:`).toString('base64')}` }, }) - .get('/') + .post('/basic') .reply(200, {}); return sdk .auth(user) - .getSomething() + .post('/basic') .then(() => mock.done()); }); }); describe('scheme: bearer', () => { const apiKey = '123457890'; - const securityOas = { - ...baseSecurityOas, - components: { - securitySchemes: { - auth: { - type: 'http', - scheme: 'bearer', - }, - }, - }, - }; it.each([ ['should allow you to supply auth', false], ['should allow you to supply auth when unchained from an operation', true], ])('%s', (testCase, chained) => { const sdk = api(securityOas); - const mock = nock(serverUrl, { reqheaders: { authorization: `Bearer ${apiKey}` } }) - .get('/') + const mock = nock('https://httpbin.org', { reqheaders: { authorization: `Bearer ${apiKey}` } }) + .post('/bearer') .reply(200, {}); if (chained) { return sdk .auth(apiKey) - .getSomething() + .post('/bearer') .then(() => mock.done()); } sdk.auth(apiKey); - return sdk.getSomething().then(() => mock.done()); + return sdk.post('/bearer').then(() => mock.done()); }); it('should throw if you pass in multiple bearer tokens', () => { const sdk = api(securityOas); - return expect(sdk.auth(apiKey, apiKey).getSomething()).rejects.toThrow(/only a single token is needed/i); + return expect(sdk.auth(apiKey, apiKey).post('/bearer')).rejects.toThrow(/only a single token is needed/i); }); }); }); describe('OAuth 2', () => { const apiKey = '123457890'; - const securityOas = { - ...baseSecurityOas, - components: { - securitySchemes: { - auth: { - type: 'oauth2', - }, - }, - }, - }; it.each([ ['should allow you to supply auth', false], ['should allow you to supply auth when unchained from an operation', true], ])('%s', (testCase, chained) => { const sdk = api(securityOas); - const mock = nock(serverUrl, { reqheaders: { authorization: `Bearer ${apiKey}` } }) - .get('/') + const mock = nock('https://httpbin.org', { reqheaders: { authorization: `Bearer ${apiKey}` } }) + .post('/oauth2') .reply(200, {}); if (chained) { return sdk .auth(apiKey) - .getSomething() + .post('/oauth2') .then(() => mock.done()); } sdk.auth(apiKey); - return sdk.getSomething().then(() => mock.done()); + return sdk.post('/oauth2').then(() => mock.done()); }); it('should throw if you pass in multiple bearer tokens', () => { const sdk = api(securityOas); - return expect(sdk.auth(apiKey, apiKey).getSomething()).rejects.toThrow(/only a single token is needed/i); + return expect(sdk.auth(apiKey, apiKey).post('/oauth2')).rejects.toThrow(/only a single token is needed/i); }); }); }); diff --git a/packages/api/__tests__/config.test.js b/packages/api/__tests__/config.test.js index 0de6ac1e..e436f261 100644 --- a/packages/api/__tests__/config.test.js +++ b/packages/api/__tests__/config.test.js @@ -2,29 +2,27 @@ const nock = require('nock'); const api = require('../src'); const { Response } = require('node-fetch'); -const serverUrl = 'https://api.example.com'; -const createOas = require('./__fixtures__/createOas')(serverUrl); +const petstore = require('@readme/oas-examples/3.0/json/petstore.json'); + +let sdk; +const petId = 123; +const response = { + id: petId, + name: 'Buster', +}; describe('#config()', () => { describe('parseResponse', () => { - const petId = 123; - const response = { - id: petId, - name: 'Buster', - }; - - const sdk = api( - createOas('delete', `/pets/${petId}`, { - operationId: 'deletePet', - }) - ); + beforeEach(() => { + sdk = api(petstore); + }); it('should give access to the Response object if `parseResponse` is `false`', () => { - const mock = nock(serverUrl).delete(`/pets/${petId}`).reply(200, response); + const mock = nock('http://petstore.swagger.io/v2').delete(`/pet/${petId}`).reply(200, response); sdk.config({ parseResponse: false }); - return sdk.deletePet({ id: petId }).then(async res => { + return sdk.deletePet({ petId }).then(async res => { expect(res instanceof Response).toBe(true); expect(res.status).toStrictEqual(200); expect(await res.json()).toStrictEqual(response); @@ -33,11 +31,11 @@ describe('#config()', () => { }); it('should parse the response if `parseResponse` is `undefined`', () => { - const mock = nock(serverUrl).delete(`/pets/${petId}`).reply(200, response); + const mock = nock('http://petstore.swagger.io/v2').delete(`/pet/${petId}`).reply(200, response); sdk.config({ unrecognizedConfigParameter: false }); - return sdk.deletePet({ id: petId }).then(res => { + return sdk.deletePet({ petId }).then(res => { expect(res instanceof Response).toBe(false); expect(res).toStrictEqual(response); mock.done(); diff --git a/packages/api/__tests__/index.test.js b/packages/api/__tests__/index.test.js index f384bfc9..f1aa1fb3 100644 --- a/packages/api/__tests__/index.test.js +++ b/packages/api/__tests__/index.test.js @@ -10,9 +10,6 @@ const realFs = jest.requireActual('fs').promises; // eslint-disable-next-line global-require jest.mock('fs', () => require('memfs').fs); -const serverUrl = 'https://api.example.com'; -const createOas = require('./__fixtures__/createOas')(serverUrl); - const examplesDir = path.join(__dirname, 'examples'); let petstoreSdk; @@ -62,7 +59,7 @@ describe('#preloading', () => { // SDK should still not be loaded since we haven't officially called it yet. expect(new Cache(uspto).isCached()).toBe(false); - expect(Object.keys(sdk)).toStrictEqual(['auth', 'config']); + expect(Object.keys(sdk)).toStrictEqual(['auth', 'config', 'server']); await sdk.get('/').then(() => { mock.done(); @@ -73,6 +70,7 @@ describe('#preloading', () => { expect(Object.keys(sdk)).toStrictEqual([ 'auth', 'config', + 'server', 'get', 'put', 'post', @@ -93,7 +91,7 @@ describe('#preloading', () => { }); it('should support supplying a raw JSON OAS object', () => { - const sdk = api(createOas()); + const sdk = api(uspto); expect(typeof sdk.get).toBe('function'); }); }); diff --git a/packages/api/__tests__/lib/prepareParams.test.js b/packages/api/__tests__/lib/prepareParams.test.js index bd11d228..40067a60 100644 --- a/packages/api/__tests__/lib/prepareParams.test.js +++ b/packages/api/__tests__/lib/prepareParams.test.js @@ -3,31 +3,10 @@ const Oas = require('oas/tooling'); const $RefParser = require('@apidevtools/json-schema-ref-parser'); const readmeExample = require('@readme/oas-examples/3.0/json/readme.json'); const usptoExample = require('@readme/oas-examples/3.0/json/uspto.json'); +const payloadExamples = require('../__fixtures__/payloads.oas.json'); -const serverUrl = 'https://api.example.com'; -const createOas = require('../__fixtures__/createOas')(serverUrl); const prepareParams = require('../../src/lib/prepareParams'); -const arraySchema = createOas('put', '/', { - requestBody: { - content: { - 'application/json': { - schema: { - type: 'array', - items: { - type: 'object', - properties: { - name: { - type: 'string', - }, - }, - }, - }, - }, - }, - }, -}); - describe('#prepareParams', () => { let readmeSpec; let usptoSpec; @@ -70,19 +49,7 @@ describe('#prepareParams', () => { }); it('should prepare body if body is a primitive', async () => { - const schema = createOas('put', '/', { - requestBody: { - content: { - 'text/plain': { - schema: { - type: 'string', - }, - }, - }, - }, - }); - - const operation = new Oas(schema).operation('/', 'put'); + const operation = new Oas(payloadExamples).operation('/primitiveBody', 'put'); const body = 'Brie cheeseburger ricotta.'; expect(await prepareParams(operation, body, {})).toStrictEqual({ @@ -91,7 +58,7 @@ describe('#prepareParams', () => { }); it('should prepare body if body is an array', async () => { - const operation = new Oas(arraySchema).operation('/', 'put'); + const operation = new Oas(payloadExamples).operation('/arraySchema', 'put'); const body = [ { name: 'Buster', @@ -181,7 +148,7 @@ describe('#prepareParams', () => { }); it('should prepare just a body if supplied argument is an array', async () => { - const operation = new Oas(arraySchema).operation('/', 'put'); + const operation = new Oas(payloadExamples).operation('/arraySchema', 'put'); const body = [ { name: 'Buster', diff --git a/packages/api/__tests__/server.test.js b/packages/api/__tests__/server.test.js new file mode 100644 index 00000000..b8071185 --- /dev/null +++ b/packages/api/__tests__/server.test.js @@ -0,0 +1,55 @@ +const nock = require('nock'); +const api = require('../src'); + +const serverVariables = require('@readme/oas-examples/3.0/json/server-variables.json'); + +let sdk; +const petId = 123; +const response = { + id: petId, + name: 'Buster', +}; + +describe('#server()', () => { + beforeEach(() => { + sdk = api(serverVariables); + }); + + it('should use server variable defaults if no server or variables are supplied', () => { + const mock = nock('https://demo.example.com:443/v2/').post('/').reply(200, response); + + return sdk.post('/').then(res => { + expect(res).toStrictEqual(response); + mock.done(); + }); + }); + + it('should support supplying a full server url', () => { + const mock = nock('https://buster.example.com:3000/v14').post('/').reply(200, response); + + sdk.server('https://buster.example.com:3000/v14'); + + return sdk.post('/').then(res => { + expect(res).toStrictEqual(response); + mock.done(); + }); + }); + + it('should support supplying a server url with server variables', () => { + const mock = nock('http://dev.local/v14').post('/').reply(200, response); + + sdk.server('http://{name}.local/{basePath}', { + name: 'dev', + basePath: 'v14', + }); + + return sdk.post('/').then(res => { + expect(res).toStrictEqual(response); + mock.done(); + }); + }); + + it.todo('should be able to supply a url on an OAS that has no servers defined'); + + it.todo("should be able to supply a url that doesn't match any defined server"); +}); diff --git a/packages/api/package-lock.json b/packages/api/package-lock.json index c8eb881b..9ae7facb 100644 --- a/packages/api/package-lock.json +++ b/packages/api/package-lock.json @@ -24,7 +24,7 @@ }, "devDependencies": { "@readme/eslint-config": "^5.0.5", - "@readme/oas-examples": "^4.0.0", + "@readme/oas-examples": "^4.2.0", "eslint": "^7.6.0", "jest": "^26.0.1", "memfs": "^3.2.0", @@ -1256,9 +1256,9 @@ } }, "node_modules/@readme/oas-examples": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@readme/oas-examples/-/oas-examples-4.0.0.tgz", - "integrity": "sha512-MSm5mJoNa9AJ6PfKFOSO4BBzC3oQn0CdJHfP5l6v1ATVX3LUy/+X2Xgx5RZNcefRkJ7TWlfPoCiphjiEDSaAVQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@readme/oas-examples/-/oas-examples-4.2.0.tgz", + "integrity": "sha512-WS/Yb4yXM2ZlOFOA+vyRO/3MxFZvu2os742dUZ996R0x57bD47i/IP8N0PkkQFEujStrl7dMpPPi3GWoyBdVPQ==", "dev": true }, "node_modules/@readme/oas-extensions": { @@ -12080,9 +12080,9 @@ } }, "@readme/oas-examples": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@readme/oas-examples/-/oas-examples-4.0.0.tgz", - "integrity": "sha512-MSm5mJoNa9AJ6PfKFOSO4BBzC3oQn0CdJHfP5l6v1ATVX3LUy/+X2Xgx5RZNcefRkJ7TWlfPoCiphjiEDSaAVQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@readme/oas-examples/-/oas-examples-4.2.0.tgz", + "integrity": "sha512-WS/Yb4yXM2ZlOFOA+vyRO/3MxFZvu2os742dUZ996R0x57bD47i/IP8N0PkkQFEujStrl7dMpPPi3GWoyBdVPQ==", "dev": true }, "@readme/oas-extensions": { diff --git a/packages/api/package.json b/packages/api/package.json index 304546ae..5a7ed355 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -39,7 +39,7 @@ }, "devDependencies": { "@readme/eslint-config": "^5.0.5", - "@readme/oas-examples": "^4.0.0", + "@readme/oas-examples": "^4.2.0", "eslint": "^7.6.0", "jest": "^26.0.1", "memfs": "^3.2.0", diff --git a/packages/api/src/index.js b/packages/api/src/index.js index 2db45b83..33f6fa26 100644 --- a/packages/api/src/index.js +++ b/packages/api/src/index.js @@ -5,7 +5,7 @@ const oasToHar = require('@readme/oas-to-har'); const pkg = require('../package.json'); const Cache = require('./cache'); -const { prepareAuth, prepareParams, parseResponse } = require('./lib/index'); +const { parseResponse, prepareAuth, prepareParams, prepareServer } = require('./lib/index'); global.fetch = fetch; global.Request = fetch.Request; @@ -33,6 +33,7 @@ class Sdk { const cache = new Cache(this.uri); const self = this; let config = { parseResponse: true }; + let server = false; let isLoaded = false; let isCached = cache.isCached(); @@ -42,7 +43,18 @@ class Sdk { return new Promise(resolve => { resolve(prepareParams(operation, body, metadata)); }).then(params => { - const har = oasToHar(spec, operation, params, prepareAuth(authKeys, operation)); + const data = { ...params }; + + // If `sdk.server()` has been issued data then we need to do some extra work to figure out how to use that + // supplied server, and also handle any server variables that were sent alongside it. + if (server) { + const preparedServer = prepareServer(spec, server.url, server.variables); + if (preparedServer) { + data.server = preparedServer; + } + } + + const har = oasToHar(spec, operation, data, prepareAuth(authKeys, operation)); return fetchHar(har, self.userAgent).then(res => { if (res.status >= 400 && res.status <= 599) { @@ -142,9 +154,17 @@ class Sdk { return new Proxy(sdk, sdkProxy); }, config: opts => { + // Downside to having `opts` be merged into the existing `config` is that there isn't a clean way to reset your + // current config to the default, so having `opts` assigned directly to the existing config should be okay. config = opts; return new Proxy(sdk, sdkProxy); }, + server: (url, variables = {}) => { + server = { + url, + variables, + }; + }, }; return new Proxy(sdk, sdkProxy); diff --git a/packages/api/src/lib/index.js b/packages/api/src/lib/index.js index 3ff1c716..59d8d852 100644 --- a/packages/api/src/lib/index.js +++ b/packages/api/src/lib/index.js @@ -1,9 +1,11 @@ +const parseResponse = require('./parseResponse'); const prepareAuth = require('./prepareAuth'); const prepareParams = require('./prepareParams'); -const parseResponse = require('./parseResponse'); +const prepareServer = require('./prepareServer'); module.exports = { + parseResponse, prepareAuth, prepareParams, - parseResponse, + prepareServer, }; diff --git a/packages/api/src/lib/prepareServer.js b/packages/api/src/lib/prepareServer.js new file mode 100644 index 00000000..50b44e57 --- /dev/null +++ b/packages/api/src/lib/prepareServer.js @@ -0,0 +1,50 @@ +function stripTrailingSlash(url) { + if (url[url.length - 1] === '/') { + return url.slice(0, -1); + } + + return url; +} + +/** + * With an SDK server config and an instance of OAS we should extract and prepare the server and any server variables + * to be supplied to `@readme/oas-to-har`. + * + * @param {Oas} spec + * @param {String} url + * @param {Object} variables + * @returns {Object|Boolean} + */ +module.exports = (spec, url, variables = {}) => { + let serverIdx; + const sanitizedUrl = stripTrailingSlash(url); + (spec.servers || []).forEach((server, i) => { + if (server.url === sanitizedUrl) { + serverIdx = i; + } + }); + + // If we were able to find the passed in server in the OAS servers, we should use that! If we couldn't + // and server variables were passed in we should try our best to handle that, otherwise we should ignore + // the passed in server and use whever the default from the OAS is. + if (serverIdx) { + return { + selected: serverIdx, + variables, + }; + } else if (Object.keys(variables).length) { + // @todo we should run `oas.replaceUrl(url)` and pass that unto `@readme/oas-to-har` + } else { + const server = spec.splitVariables(url); + if (server) { + return { + selected: server.selected, + variables: server.variables, + }; + } + + // @todo we should pass `url` directly into `@readme/oas-to-har` as the base URL + } + + return false; +}; diff --git a/packages/httpsnippet-client-api/README.md b/packages/httpsnippet-client-api/README.md index 20e90509..60fdffa3 100644 --- a/packages/httpsnippet-client-api/README.md +++ b/packages/httpsnippet-client-api/README.md @@ -31,3 +31,13 @@ console.log( }) ); ``` + +Results in the following: + +```js +const sdk = require('api')('https://example.com/openapi.json'); + +sdk.get('/har') + .then(res => console.log(res)) + .catch(err => console.error(err)); +``` diff --git a/packages/httpsnippet-client-api/__tests__/__fixtures__/output/alternate-server.js b/packages/httpsnippet-client-api/__tests__/__fixtures__/output/alternate-server.js new file mode 100644 index 00000000..cfa267ee --- /dev/null +++ b/packages/httpsnippet-client-api/__tests__/__fixtures__/output/alternate-server.js @@ -0,0 +1,7 @@ +const sdk = require('api')('https://example.com/openapi.json'); + +sdk.auth('123'); +sdk.server('http://dev.local/v2'); +sdk.create({foo: 'bar', hello: 'world'}, {id: '1234'}) + .then(res => console.log(res)) + .catch(err => console.error(err)); diff --git a/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-119.js b/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-119.js index 41aae53c..1c0582d4 100644 --- a/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-119.js +++ b/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-119.js @@ -1,6 +1,6 @@ const sdk = require('api')('https://example.com/openapi.json'); -sdk.auth('123'); +sdk.auth('123'); sdk['find/pets-by-status']({status: 'available'}) .then(res => console.log(res)) .catch(err => console.error(err)); diff --git a/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-128.js b/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-128.js index 0a2d40e9..cd9cf09b 100644 --- a/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-128.js +++ b/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-128.js @@ -1,6 +1,6 @@ const sdk = require('api')('https://example.com/openapi.json'); -sdk.auth('authKey\'With\'Apostrophes'); +sdk.auth('authKey\'With\'Apostrophes'); sdk.getItem({Accept: 'application/xml'}) .then(res => console.log(res)) .catch(err => console.error(err)); diff --git a/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-76.js b/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-76.js index 4f7ccb20..0e030653 100644 --- a/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-76.js +++ b/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-76.js @@ -1,6 +1,6 @@ const sdk = require('api')('https://example.com/openapi.json'); -sdk.auth('a5a220e'); +sdk.auth('a5a220e'); sdk.get('/pet/findByStatus', {status: 'available', Accept: 'application/xml'}) .then(res => console.log(res)) .catch(err => console.error(err)); diff --git a/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-78.js b/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-78.js index 37db9e5d..372f3ea2 100644 --- a/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-78.js +++ b/packages/httpsnippet-client-api/__tests__/__fixtures__/output/issue-78.js @@ -1,5 +1,5 @@ const sdk = require('api')('https://example.com/openapi.json'); -sdk.get('/store/order/1234/tracking/{trackingId}', {Accept: 'application/xml'}) +sdk.get('/store/order/1234/tracking/5678', {Accept: 'application/xml'}) .then(res => console.log(res)) .catch(err => console.error(err)); diff --git a/packages/httpsnippet-client-api/__tests__/__fixtures__/output/petstore.js b/packages/httpsnippet-client-api/__tests__/__fixtures__/output/petstore.js index 2726d563..626d8520 100644 --- a/packages/httpsnippet-client-api/__tests__/__fixtures__/output/petstore.js +++ b/packages/httpsnippet-client-api/__tests__/__fixtures__/output/petstore.js @@ -1,6 +1,6 @@ const sdk = require('api')('https://example.com/openapi.json'); -sdk.auth('123'); +sdk.auth('123'); sdk.findPetsByStatus({status: 'available', Accept: 'application/xml'}) .then(res => console.log(res)) .catch(err => console.error(err)); diff --git a/packages/httpsnippet-client-api/__tests__/__fixtures__/output/query-auth.js b/packages/httpsnippet-client-api/__tests__/__fixtures__/output/query-auth.js index 56c92ead..42e20358 100644 --- a/packages/httpsnippet-client-api/__tests__/__fixtures__/output/query-auth.js +++ b/packages/httpsnippet-client-api/__tests__/__fixtures__/output/query-auth.js @@ -1,6 +1,6 @@ const sdk = require('api')('https://example.com/openapi.json'); -sdk.auth('a5a220e'); +sdk.auth('a5a220e'); sdk.findPetsByStatus({status: 'available', Accept: 'application/xml'}) .then(res => console.log(res)) .catch(err => console.error(err)); diff --git a/packages/httpsnippet-client-api/__tests__/__fixtures__/request/alternate-server/definition.json b/packages/httpsnippet-client-api/__tests__/__fixtures__/request/alternate-server/definition.json new file mode 100644 index 00000000..a4792d24 --- /dev/null +++ b/packages/httpsnippet-client-api/__tests__/__fixtures__/request/alternate-server/definition.json @@ -0,0 +1,104 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Server variables", + "description": "https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#serverVariableObject", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://{name}.example.com:{port}/{basePath}", + "variables": { + "name": { + "default": "demo" + }, + "port": { + "default": "443" + }, + "basePath": { + "default": "v2" + } + } + }, + { + "url": "http://{name}.local/{basePath}", + "variables": { + "name": { + "default": "demo" + }, + "basePath": { + "default": "v2" + } + } + }, + { + "url": "http://{subdomain}.local/{subdomain}", + "variables": { + "subdomain": { + "default": "demo" + } + } + } + ], + "paths": { + "/create/{id}": { + "post": { + "operationId": "create", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "foo": { + "type": "string" + } + }, + "required": [ + "foo" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "petstore_auth": [] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "petstore_auth": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "http://petstore.swagger.io/oauth/dialog", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + } + } + } + } +} diff --git a/packages/httpsnippet-client-api/__tests__/__fixtures__/request/alternate-server/har.json b/packages/httpsnippet-client-api/__tests__/__fixtures__/request/alternate-server/har.json new file mode 100644 index 00000000..76cc19c3 --- /dev/null +++ b/packages/httpsnippet-client-api/__tests__/__fixtures__/request/alternate-server/har.json @@ -0,0 +1,31 @@ +{ + "log": { + "entries": [ + { + "request": { + "method": "POST", + "url": "http://dev.local/v2/create/1234", + "headers": [ + { + "name": "Authorization", + "value": "Bearer 123" + } + ], + "postData": { + "mimeType": "application/x-www-form-urlencoded", + "params": [ + { + "name": "foo", + "value": "bar" + }, + { + "name": "hello", + "value": "world" + } + ] + } + } + } + ] + } +} diff --git a/packages/httpsnippet-client-api/__tests__/__fixtures__/request/issue-78/har.json b/packages/httpsnippet-client-api/__tests__/__fixtures__/request/issue-78/har.json index 82320e25..cafa82b5 100644 --- a/packages/httpsnippet-client-api/__tests__/__fixtures__/request/issue-78/har.json +++ b/packages/httpsnippet-client-api/__tests__/__fixtures__/request/issue-78/har.json @@ -17,7 +17,7 @@ "paramsObj": false, "size": 0 }, - "url": "http://petstore.swagger.io/v2/store/order/1234/tracking/{trackingId}" + "url": "http://petstore.swagger.io/v2/store/order/1234/tracking/5678" } } ] diff --git a/packages/httpsnippet-client-api/__tests__/__snapshots__/index.test.js.snap b/packages/httpsnippet-client-api/__tests__/__snapshots__/index.test.js.snap index 18839cb5..8067b8dd 100644 --- a/packages/httpsnippet-client-api/__tests__/__snapshots__/index.test.js.snap +++ b/packages/httpsnippet-client-api/__tests__/__snapshots__/index.test.js.snap @@ -2,8 +2,8 @@ exports[`auth handling basic should be able to handle basic auth that's just a password 1`] = ` "const sdk = require('api')('https://example.com/openapi.json'); -sdk.auth('', 'pug') +sdk.auth('', 'pug') sdk.getAPISpecification({perPage: '10', page: '1'}) .then(res => console.log(res)) .catch(err => console.error(err));" @@ -11,8 +11,8 @@ sdk.getAPISpecification({perPage: '10', page: '1'}) exports[`auth handling basic should be able to handle basic auth that's just a username 1`] = ` "const sdk = require('api')('https://example.com/openapi.json'); -sdk.auth('buster') +sdk.auth('buster') sdk.getAPISpecification({perPage: '10', page: '1'}) .then(res => console.log(res)) .catch(err => console.error(err));" @@ -20,8 +20,8 @@ sdk.getAPISpecification({perPage: '10', page: '1'}) exports[`auth handling basic should not encode basic auth in the \`.auth()\` call 1`] = ` "const sdk = require('api')('https://example.com/openapi.json'); -sdk.auth('buster', 'pug') +sdk.auth('buster', 'pug') sdk.getAPISpecification({perPage: '10', page: '1'}) .then(res => console.log(res)) .catch(err => console.error(err));" diff --git a/packages/httpsnippet-client-api/__tests__/index.test.js b/packages/httpsnippet-client-api/__tests__/index.test.js index 4950d08d..ebc5ec22 100644 --- a/packages/httpsnippet-client-api/__tests__/index.test.js +++ b/packages/httpsnippet-client-api/__tests__/index.test.js @@ -1,11 +1,8 @@ -/* eslint-disable import/no-dynamic-require, global-require */ - -// Most of this has been copied over from the httpsnippet target unit test file. It'd be ideal if this were in a -// helper library we could use instead. const fs = require('fs').promises; const HTTPSnippet = require('@readme/httpsnippet'); const path = require('path'); const client = require('../src'); +const readme = require('@readme/oas-examples/3.0/json/readme.json'); HTTPSnippet.addTargetClient('node', client); @@ -24,7 +21,7 @@ test('it should have info', () => { }); test('it should error if no apiDefinitionUri was supplied', async () => { - const har = await fs.readFile(path.join(__dirname, `./__fixtures__/request/petstore/har.json`), 'utf8'); + const har = await fs.readFile(path.join(__dirname, './__fixtures__/request/petstore/har.json'), 'utf8'); const snippet = new HTTPSnippet(JSON.parse(har)); expect(() => { @@ -33,7 +30,7 @@ test('it should error if no apiDefinitionUri was supplied', async () => { }); test('it should error if no apiDefinition was supplied', async () => { - const har = await fs.readFile(path.join(__dirname, `./__fixtures__/request/petstore/har.json`), 'utf8'); + const har = await fs.readFile(path.join(__dirname, './__fixtures__/request/petstore/har.json'), 'utf8'); const snippet = new HTTPSnippet(JSON.parse(har)); expect(() => { @@ -45,7 +42,6 @@ test('it should error if no apiDefinition was supplied', async () => { // This test should fail because the url in the HAR is missing `/v1` in the path. test('it should error if no matching operation was found in the apiDefinition', () => { - const definition = require('@readme/oas-examples/3.0/json/readme.json'); const har = { bodySize: 0, cookies: [], @@ -65,7 +61,7 @@ test('it should error if no matching operation was found in the apiDefinition', expect(() => { snippet.convert('node', 'api', { apiDefinitionUri: 'https://example.com/openapi.json', - apiDefinition: definition, + apiDefinition: readme, }); }).toThrow(/unable to locate a matching operation/i); }); @@ -77,7 +73,6 @@ describe('auth handling', () => { ["should be able to handle basic auth that's just a username", 'buster:'], ["should be able to handle basic auth that's just a password", ':pug'], ])('%s', (testCase, authKey) => { - const definition = require('@readme/oas-examples/3.0/json/readme.json'); const har = { bodySize: 0, cookies: [], @@ -99,7 +94,7 @@ describe('auth handling', () => { const code = new HTTPSnippet(har).convert('node', 'api', { apiDefinitionUri: 'https://example.com/openapi.json', - apiDefinition: definition, + apiDefinition: readme, }); expect(code).toMatchSnapshot(); @@ -109,6 +104,7 @@ describe('auth handling', () => { describe('snippets', () => { it.each([ + ['alternate-server'], ['application-form-encoded'], ['application-json'], // ['cookies'], // Cookies test needs to get built out. @@ -132,8 +128,14 @@ describe('snippets', () => { ['short'], ['text-plain'], ])('should generate `%s` snippet', async testCase => { - const har = require(`./__fixtures__/request/${testCase}/har.json`); - const definition = require(`./__fixtures__/request/${testCase}/definition.json`); + const har = JSON.parse( + await fs.readFile(path.join(__dirname, `./__fixtures__/request/${testCase}/har.json`), 'utf8') + ); + + const definition = JSON.parse( + await fs.readFile(path.join(__dirname, `./__fixtures__/request/${testCase}/definition.json`), 'utf8') + ); + const expected = await fs.readFile(path.join(__dirname, `./__fixtures__/output/${testCase}.js`), 'utf8'); const code = new HTTPSnippet(har).convert('node', 'api', { diff --git a/packages/httpsnippet-client-api/src/index.js b/packages/httpsnippet-client-api/src/index.js index 49a02e64..1d23f9d4 100644 --- a/packages/httpsnippet-client-api/src/index.js +++ b/packages/httpsnippet-client-api/src/index.js @@ -4,6 +4,10 @@ const CodeBuilder = require('@readme/httpsnippet/src/helpers/code-builder'); const contentType = require('content-type'); const Oas = require('oas/tooling'); +function stringify(obj, opts = {}) { + return stringifyObject(obj, { indent: ' ', ...opts }); +} + function buildAuthSnippet(authKey) { // Auth key will be an array for Basic auth cases. if (Array.isArray(authKey)) { @@ -86,24 +90,38 @@ module.exports = function (source, options) { const method = source.method.toLowerCase(); const oas = new Oas(opts.apiDefinition); - const operation = oas.getOperation(source.url, method); - - if (!operation) { + const foundOperation = oas.findOperation(source.url, method); + if (!foundOperation) { throw new Error( `Unable to locate a matching operation in the supplied \`apiDefinition\` for: ${source.method} ${source.url}` ); } - // For cases where a server URL in the OAS has a path attached to it, we don't want to include that path with the - // operation path. - const path = source.url.replace(oas.url(), ''); - + const operationSlugs = foundOperation.url.slugs; + const operation = oas.operation(foundOperation.url.nonNormalizedPath, method); + const path = operation.path; const authData = []; const authSources = getAuthSources(operation); const code = new CodeBuilder(opts.indent); code.push(`const sdk = require('api')('${opts.apiDefinitionUri}');`); + code.blank(); + + // If we have multiple servers configured and our source URL differs from the stock URL that we receive from our + // `oas` library then the URL either has server variables contained in it (that don't match the defaults), or the + // OAS offers alternate server URLs and we should expose that in the generated snippet. + const configData = []; + if ((oas.servers || []).length > 1) { + const stockUrl = oas.url(); + const baseUrl = source.url.replace(path, ''); + if (baseUrl !== stockUrl) { + const serverVars = oas.splitVariables(baseUrl); + const serverUrl = serverVars ? oas.url(serverVars.selected, serverVars.variables) : baseUrl; + + configData.push(`sdk.server('${serverUrl}');`); + } + } let metadata = {}; if (Object.keys(source.queryObj).length) { @@ -123,10 +141,14 @@ module.exports = function (source, options) { // If we have path parameters present, we should only add them in if we have an operationId as we don't want metadata // to duplicate what we'll be setting the path in the snippet to. if ('operationId' in operation.schema) { - const pathParams = getParamsInPath(operation, path); + const pathParams = getParamsInPath(operation, operation.path); if (Object.keys(pathParams).length) { Object.keys(pathParams).forEach(param => { - metadata[param] = pathParams[param]; + if (`:${param}` in operationSlugs) { + metadata[param] = operationSlugs[`:${param}`]; + } else { + metadata[param] = pathParams[param]; + } }); } } @@ -220,7 +242,13 @@ module.exports = function (source, options) { if ('operationId' in operation.schema && operation.schema.operationId.length > 0) { accessor = operation.schema.operationId; } else { - args.push(`'${decodeURIComponent(path)}'`); + // Since we're not using an operationId as our primary accessor we need to take the current operation that we're + // working with and transpile back our path parameters on top of it. + const slugs = Object.fromEntries( + Object.keys(operationSlugs).map(slug => [slug.replace(/:(.*)/, '$1'), operationSlugs[slug]]) + ); + + args.push(`'${decodeURIComponent(oas.replaceUrl(path, slugs))}'`); } // If the operation or method accessor is non-alphanumeric, we need to add it to the SDK object as an array key. @@ -235,18 +263,20 @@ module.exports = function (source, options) { // we'll be rendering them in their own lines. const inlineCharacterLimit = typeof body !== 'undefined' && Object.keys(metadata).length > 0 ? 40 : 80; if (typeof body !== 'undefined') { - args.push(stringifyObject(body, { indent: ' ', inlineCharacterLimit })); + args.push(stringify(body, { inlineCharacterLimit })); } if (Object.keys(metadata).length > 0) { - args.push(stringifyObject(metadata, { indent: ' ', inlineCharacterLimit })); + args.push(stringify(metadata, { inlineCharacterLimit })); } if (authData.length) { code.push(authData.join('\n')); } - code.blank(); + if (configData.length) { + code.push(configData.join('\n')); + } code .push(`sdk${accessor}(${args.join(', ')})`)