From f3744ad9cdb9b9bca6fe0e5b248c1c038b111793 Mon Sep 17 00:00:00 2001 From: Ahmad Nassri Date: Tue, 30 Mar 2021 17:43:39 -0400 Subject: [PATCH] feat(http-client): default to using isomorphic-unfetch and allow custom clients --- README.md | 232 ++++++++++++++++++++++++++++++----------- docs/README.md | 224 +++++++++++++++++++++++++++++---------- lib/http/config.js | 21 ---- lib/http/decompress.js | 24 ----- lib/http/index.js | 60 ----------- lib/index.js | 49 +++++---- lib/parse-server.js | 9 +- package-lock.json | 40 ++++++- package.json | 4 + test/custom-client.js | 58 +++++++++++ test/index.js | 109 ++++--------------- test/parse-server.js | 22 ++++ test/real.js | 53 ++++++---- 13 files changed, 547 insertions(+), 358 deletions(-) delete mode 100644 lib/http/config.js delete mode 100644 lib/http/decompress.js delete mode 100644 lib/http/index.js create mode 100644 test/custom-client.js diff --git a/README.md b/README.md index 7a9f536..ffa5994 100644 --- a/README.md +++ b/README.md @@ -24,28 +24,29 @@ This library does not concern itself with anything other than constructing an HT - **YAML Support?** This package **does not** natively support OpenAPI Specification YAML format, but you can easily convert to JSON before calling `oas-rqeuest` -
- Example +
+ Example - ###### using [`YAML`](https://www.npmjs.com/package/yaml) + ###### using [`YAML`][] - ```js - const YAML = require('yaml') - const { readFile } = require('fs/promises') + ``` js + const YAML = require('yaml') + const { readFile } = require('fs/promises') - const file = await readFile('openapi.yml', 'utf8') - - const spec = YAML.parse(file) + const file = await readFile('openapi.yml', 'utf8') - const API = require('oas-request')(spec) - ``` + const spec = YAML.parse(file) - ###### using [`apidevtools/swagger-cli`](https://www.npmjs.com/package/@apidevtools/swagger-cli) - - ```bash - npx apidevtools/swagger-cli bundle spec/openapi.yml --outfile spec.json - ``` -
+ const OASRequest = require('oas-request')(spec) + ``` + + ###### using [`apidevtools/swagger-cli`][] + + ``` bash + npx apidevtools/swagger-cli bundle spec/openapi.yml --outfile spec.json + ``` + +
@@ -53,16 +54,14 @@ This library does not concern itself with anything other than constructing an HT Some feature highlights: -- Zero dependencies! -- Lightweight -- Node.js and Browser ready *(browser support coming soon)* - Automatic methods creation - Path Templating +- uses [`isomorphic-unfetch`][] for all HTTP operations ## Usage
-e.g. petstore.json + e.g. petstore.json ``` json { @@ -186,15 +185,15 @@ Some feature highlights: ``` js const spec = require('./petstore.json') -const API = require('oas-request')(spec) +const OASRequest = require('oas-request')(spec) // define root server url -const client = new API({ +const request = new OASRequest({ server: 'http://petstore.swagger.io/v1' }) // or use one from the OpenAPI Specification -const client = new API({ +const request = new OASRequest({ server: { url: spec.servers[0].url // populate values for server (see OpenAPI Specification #4.7.5) @@ -205,14 +204,40 @@ const client = new API({ }) // auto generated methods match OpenAPI Specification "operationId" -await client.listPets() -await client.createPets() -await client.showPetById() +await request.listPets() +await request.createPets() +await request.showPetById() +``` + +
+Advanced Usage + +``` js +const spec = require('./petstore.json') +const OASRequest = require('oas-request')(spec) + +// always use JSON headers +const request = new OASRequest({ + server: 'http://petstore.swagger.io/v1' + headers: { + 'accept': 'application/json', + 'content-type': 'application/json' + } +}) + +// POST with JSON +const body = JSON.stringify(body) +const response = await request.createPets({ body }) +const data = await response.json() + +console.log(data) ``` -### `API(clientOptions)` +
-Construct a new instance of the api client, returns an Object with auto generated method names matching each of the unique OpenAPI Specification [`operationId`][] +### `new OASRequest(APIOptions)` + +Construct a new instance of the API request, returns an Object with auto generated method names matching each of the unique OpenAPI Specification [`operationId`][]
Example @@ -246,28 +271,114 @@ Construct a new instance of the api client, returns an Object with auto generate ###### `app.js` ``` js -const spec = require('./spec.json') -const API = require('oas-request')(spec) +const spec = require('./petstore.json') +const OASRequest = require('oas-request')(spec) // define root server url -const client = new API({ server: 'http://petstore.swagger.io/v1' }) +const request = new OASRequest({ server: 'http://petstore.swagger.io/v1' }) // auto generated methods match OpenAPI Specification "operationId" -await client.listPets() -await client.createPets() -await client.showPetById() +await request.listPets() +await request.createPets() +await request.showPetById() ```
-#### `clientOptions` +#### `APIOptions` -| property | type | required | description | -|---------------|------------------|----------|-------------------------------------------------------------------| -| **`server`** | `String|Object` | ✔ | Root server url, or [`Server Object`][] | -| **`headers`** | `Object` | ✖ | Global HTTP request headers *(used with every request)* | -| **`query`** | `Object` | ✖ | Global Query String *(used with every request)* | -| **`params`** | `Object` | ✖ | Global [Path Templating][] parameters *(used with every request)* | +| property | type | required | default | description | +|---------------|------------------|----------|-------------------|-------------------------------------------------------------------------| +| **`client`** | `Function` | ✗ | [`unfetch`][] | a Function that executes the HTTP request. *(see [`clientFunction`][])* | +| **`server`** | `String|Object` | ✗ | `spec.servers[0]` | Root server url String, or [`Server Object`][] | +| **`headers`** | `Object` | ✗ | `{}` | Global HTTP request headers *(used with every request)* | +| **`query`** | `Object` | ✗ | `{}` | Global Query String *(used with every request)* | +| **`params`** | `Object` | ✗ | `{}` | Global [Path Templating][] parameters *(used with every request)* | + +##### `clientFunction` + +a `Function` with the signature: `Function(url, requestOptions)` to execute the HTTP request, the default built-in function uses [`isomorphic-unfetch`][], you can customize the client to use whatever HTTP library you prefer. + +> **⚠️ Note**: +> +> - `url` is an instance of [`URL`][] +> - `options.query` will be processed to construct the `url`, then deleted. +> - `options.params` will be processed and used in Path Templating, then deleted. + +
+Example: always assume JSON + +``` js +const spec = require('./petstore.json') +const fetch = require('isomorphic-unfetch') +const OASRequest = require('oas-request')(spec) + +const request = new OASRequest({ + client: async function (url, options) { + const response = await fetch(url, { + ...options, + + // always set body to JSON + body: JSON.stringify(options.body), + + headers: { + ...options.headers, + // always set headers to JSON + ...{ + 'accept': 'application/json', + 'content-type': 'application/json' + } + } + }) + + // always parse body as JSON + response.data = await response.json() + + return response + } +}) + +const response = await request.createPet({ + body { + id: 1, + name: 'Ruby' + } +}) + +console.log(response.data) +``` + +
+ +
+Example: using axios + +``` js +const spec = require('./petstore.json') +const axios = require('axios') +const OASRequest = require('oas-request')(spec) + +const request = new OASRequest({ + client: async function (URL, options) { + return axios({ + ...options, + maxRedirects: 10, + url: URL.toString(), + httpsAgent: new https.Agent({ keepAlive: true }) + }) + } +}) + +const response = await request.createPet({ + data: { + id: 1, + name: 'Ruby' + }, + timeout: 1000 +}) +``` + +
##### `ServerObject` @@ -275,23 +386,21 @@ await client.showPetById() | property | type | required | description | |-----------------|----------|----------|------------------------------------------------------| -| **`url`** | `String` | ✔ | Root server url | -| **`variables`** | `Object` | ✖ | Key-value pairs for server URL template substitution | +| **`url`** | `String` | ✓ | Root server url | +| **`variables`** | `Object` | ✗ | Key-value pairs for server URL template substitution | ### `__Operation__(requestOptions)` -Operation method names are generated from the unique OpenAPI Specification [`operationId`][] +- Operation method names are generated from the unique OpenAPI Specification [`operationId`][] +- Operations method will return with a call to the specified [`Client Function`][] #### `requestOptions` -Each generated method accepts a `requestOptions` object with the following properties: +The `requestOptions` Objects maps to [Fetch `init` parameter][] with some special considerations: -| name | type | required | description | -|---------------|----------|----------|----------------------------------------------------------------------| -| **`body`** | `Object` | ✖ | HTTP request body | -| **`headers`** | `Object` | ✖ | HTTP request headers *(inherits from [`clientOptions`][])* | -| **`query`** | `Object` | ✖ | Query String *(inherits from [`clientOptions`][])* | -| **`params`** | `Object` | ✖ | [Path Templating][] parameters *(inherits from [`clientOptions`][])* | +- `method` will always be set based on the OpenAPI Specification method for this operation +- `query` is a special property used to construct the final URL +- `params` is a special property used to construct the final URL Path *(Path Templating)* ## Full Example @@ -300,7 +409,7 @@ const spec = require('./petstore.json') const API = require('oas-request')(spec) // send to httpbin so we can inspect the result -const client = new API({ +const request = new OASRequest({ server: 'http://petstore.swagger.io/v1', headers: { 'user-agent': 'my-awsome-api-client', @@ -308,20 +417,20 @@ const client = new API({ } }) -await client.listPets({ +await request.listPets({ query: { limit: 100 } }) -await client.getPetById({ +await request.getPetById({ params: { petId: 'my-pet' } headers: { 'x-additional-header': 'this operation needs this' } }) -await client.updatePetById({ +await request.updatePetById({ params: { petId: 'my-pet' }, body: { name: "ruby", @@ -330,11 +439,18 @@ await client.updatePetById({ }) ``` + [`YAML`]: https://www.npmjs.com/package/yaml + [`apidevtools/swagger-cli`]: https://www.npmjs.com/package/@apidevtools/swagger-cli + [`isomorphic-unfetch`]: https://www.npmjs.com/package/isomorphic-unfetch [`operationId`]: http://spec.openapis.org/oas/v3.0.3#operation-object - [`Server Object`]: #server-object + [`unfetch`]: #clientFunction + [`clientFunction`]: #clientfunction + [`Server Object`]: #serverobject [Path Templating]: http://spec.openapis.org/oas/v3.0.3#path-templating + [`URL`]: https://developer.mozilla.org/en-US/docs/Web/API/URL [Server Object]: http://spec.openapis.org/oas/v3.0.3#server-object - [`clientOptions`]: #clientoptions + [`Client Function`]: clientFunction + [Fetch `init` parameter]: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters ---- > Author: [Ahmad Nassri](https://www.ahmadnassri.com/) • diff --git a/docs/README.md b/docs/README.md index 93194c7..3c8e901 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,28 +14,29 @@ This library does not concern itself with anything other than constructing an HT - **YAML Support?** This package **does not** natively support OpenAPI Specification YAML format, but you can easily convert to JSON before calling `oas-rqeuest` -
- Example +
+ Example - ###### using [`YAML`](https://www.npmjs.com/package/yaml) + ###### using [`YAML`](https://www.npmjs.com/package/yaml) - ```js - const YAML = require('yaml') - const { readFile } = require('fs/promises') + ```js + const YAML = require('yaml') + const { readFile } = require('fs/promises') - const file = await readFile('openapi.yml', 'utf8') - - const spec = YAML.parse(file) + const file = await readFile('openapi.yml', 'utf8') - const API = require('oas-request')(spec) - ``` + const spec = YAML.parse(file) - ###### using [`apidevtools/swagger-cli`](https://www.npmjs.com/package/@apidevtools/swagger-cli) - - ```bash - npx apidevtools/swagger-cli bundle spec/openapi.yml --outfile spec.json - ``` -
+ const OASRequest = require('oas-request')(spec) + ``` + + ###### using [`apidevtools/swagger-cli`](https://www.npmjs.com/package/@apidevtools/swagger-cli) + + ```bash + npx apidevtools/swagger-cli bundle spec/openapi.yml --outfile spec.json + ``` + +
@@ -43,16 +44,14 @@ This library does not concern itself with anything other than constructing an HT Some feature highlights: -- Zero dependencies! -- Lightweight -- Node.js and Browser ready _(browser support coming soon)_ - Automatic methods creation - Path Templating +- uses [`isomorphic-unfetch`] for all HTTP operations ## Usage
-e.g. petstore.json + e.g. petstore.json ```json { @@ -176,15 +175,15 @@ Some feature highlights: ```js const spec = require('./petstore.json') -const API = require('oas-request')(spec) +const OASRequest = require('oas-request')(spec) // define root server url -const client = new API({ +const request = new OASRequest({ server: 'http://petstore.swagger.io/v1' }) // or use one from the OpenAPI Specification -const client = new API({ +const request = new OASRequest({ server: { url: spec.servers[0].url // populate values for server (see OpenAPI Specification #4.7.5) @@ -195,14 +194,40 @@ const client = new API({ }) // auto generated methods match OpenAPI Specification "operationId" -await client.listPets() -await client.createPets() -await client.showPetById() +await request.listPets() +await request.createPets() +await request.showPetById() ``` -### `API(clientOptions)` +
+Advanced Usage + +```js +const spec = require('./petstore.json') +const OASRequest = require('oas-request')(spec) -Construct a new instance of the api client, returns an Object with auto generated method names matching each of the unique OpenAPI Specification [`operationId`][operation-id] +// always use JSON headers +const request = new OASRequest({ + server: 'http://petstore.swagger.io/v1' + headers: { + 'accept': 'application/json', + 'content-type': 'application/json' + } +}) + +// POST with JSON +const body = JSON.stringify(body) +const response = await request.createPets({ body }) +const data = await response.json() + +console.log(data) +``` + +
+ +### `new OASRequest(APIOptions)` + +Construct a new instance of the API request, returns an Object with auto generated method names matching each of the unique OpenAPI Specification [`operationId`][operation-id]
Example @@ -236,28 +261,114 @@ Construct a new instance of the api client, returns an Object with auto generate ###### `app.js` ```js -const spec = require('./spec.json') -const API = require('oas-request')(spec) +const spec = require('./petstore.json') +const OASRequest = require('oas-request')(spec) // define root server url -const client = new API({ server: 'http://petstore.swagger.io/v1' }) +const request = new OASRequest({ server: 'http://petstore.swagger.io/v1' }) // auto generated methods match OpenAPI Specification "operationId" -await client.listPets() -await client.createPets() -await client.showPetById() +await request.listPets() +await request.createPets() +await request.showPetById() ```
-#### `clientOptions` +#### `APIOptions` + +| property | type | required | default | description | +| ------------- | --------------- | -------- | ---------------------------- | -------------------------------------------------------------------------------------- | +| **`client`** | `Function` | ✗ | [`unfetch`](#clientFunction) | a Function that executes the HTTP request. _(see [`clientFunction`](#clientfunction))_ | +| **`server`** | `String|Object` | ✗ | `spec.servers[0]` | Root server url String, or [`Server Object`](#serverobject) | +| **`headers`** | `Object` | ✗ | `{}` | Global HTTP request headers _(used with every request)_ | +| **`query`** | `Object` | ✗ | `{}` | Global Query String _(used with every request)_ | +| **`params`** | `Object` | ✗ | `{}` | Global [Path Templating][path-templating] parameters _(used with every request)_ | -| property | type | required | description | -| ------------- | --------------- | -------- | -------------------------------------------------------------------------------- | -| **`server`** | `String|Object` | ✔ | Root server url, or [`Server Object`](#server-object) | -| **`headers`** | `Object` | ✖ | Global HTTP request headers _(used with every request)_ | -| **`query`** | `Object` | ✖ | Global Query String _(used with every request)_ | -| **`params`** | `Object` | ✖ | Global [Path Templating][path-templating] parameters _(used with every request)_ | +##### `clientFunction` + +a `Function` with the signature: `Function(url, requestOptions)` to execute the HTTP request, the default built-in function uses [`isomorphic-unfetch`], you can customize the client to use whatever HTTP library you prefer. + +> **⚠️ Note**: +> +> - `url` is an instance of [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) +> - `options.query` will be processed to construct the `url`, then deleted. +> - `options.params` will be processed and used in Path Templating, then deleted. + +
+Example: always assume JSON + +```js +const spec = require('./petstore.json') +const fetch = require('isomorphic-unfetch') +const OASRequest = require('oas-request')(spec) + +const request = new OASRequest({ + client: async function (url, options) { + const response = await fetch(url, { + ...options, + + // always set body to JSON + body: JSON.stringify(options.body), + + headers: { + ...options.headers, + // always set headers to JSON + ...{ + 'accept': 'application/json', + 'content-type': 'application/json' + } + } + }) + + // always parse body as JSON + response.data = await response.json() + + return response + } +}) + +const response = await request.createPet({ + body { + id: 1, + name: 'Ruby' + } +}) + +console.log(response.data) +``` + +
+ +
+Example: using axios + +```js +const spec = require('./petstore.json') +const axios = require('axios') +const OASRequest = require('oas-request')(spec) + +const request = new OASRequest({ + client: async function (URL, options) { + return axios({ + ...options, + maxRedirects: 10, + url: URL.toString(), + httpsAgent: new https.Agent({ keepAlive: true }) + }) + } +}) + +const response = await request.createPet({ + data: { + id: 1, + name: 'Ruby' + }, + timeout: 1000 +}) +``` + +
##### `ServerObject` @@ -265,23 +376,21 @@ await client.showPetById() | property | type | required | description | | --------------- | -------- | -------- | ----------------------------------------------------- | -| **`url`** | `String` | ✔ | Root server url | -| **`variables`** | `Object` | ✖ | Key-value pairs for server URL template substitution | +| **`url`** | `String` | ✓ | Root server url | +| **`variables`** | `Object` | ✗ | Key-value pairs for server URL template substitution | ### `__Operation__(requestOptions)` -Operation method names are generated from the unique OpenAPI Specification [`operationId`][operation-id] +- Operation method names are generated from the unique OpenAPI Specification [`operationId`][operation-id] +- Operations method will return with a call to the specified [`Client Function`](clientFunction) #### `requestOptions` -Each generated method accepts a `requestOptions` object with the following properties: +The `requestOptions` Objects maps to [Fetch `init` parameter](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters) with some special considerations: -| name | type | required | description | -| ------------- | -------- | -------- | ------------------------------------------------------------------------------------------------- | -| **`body`** | `Object` | ✖ | HTTP request body | -| **`headers`** | `Object` | ✖ | HTTP request headers _(inherits from [`clientOptions`](#clientoptions))_ | -| **`query`** | `Object` | ✖ | Query String _(inherits from [`clientOptions`](#clientoptions))_ | -| **`params`** | `Object` | ✖ | [Path Templating][path-templating] parameters _(inherits from [`clientOptions`](#clientoptions))_ | +- `method` will always be set based on the OpenAPI Specification method for this operation +- `query` is a special property used to construct the final URL +- `params` is a special property used to construct the final URL Path _(Path Templating)_ ## Full Example @@ -290,7 +399,7 @@ const spec = require('./petstore.json') const API = require('oas-request')(spec) // send to httpbin so we can inspect the result -const client = new API({ +const request = new OASRequest({ server: 'http://petstore.swagger.io/v1', headers: { 'user-agent': 'my-awsome-api-client', @@ -298,20 +407,20 @@ const client = new API({ } }) -await client.listPets({ +await request.listPets({ query: { limit: 100 } }) -await client.getPetById({ +await request.getPetById({ params: { petId: 'my-pet' } headers: { 'x-additional-header': 'this operation needs this' } }) -await client.updatePetById({ +await request.updatePetById({ params: { petId: 'my-pet' }, body: { name: "ruby", @@ -323,4 +432,7 @@ await client.updatePetById({ [server-object]: http://spec.openapis.org/oas/v3.0.3#server-object [path-templating]: http://spec.openapis.org/oas/v3.0.3#path-templating + [operation-id]: http://spec.openapis.org/oas/v3.0.3#operation-object + +[`isomorphic-unfetch`]: https://www.npmjs.com/package/isomorphic-unfetch diff --git a/lib/http/config.js b/lib/http/config.js deleted file mode 100644 index 41bc449..0000000 --- a/lib/http/config.js +++ /dev/null @@ -1,21 +0,0 @@ -/* istanbul ignore file */ - -module.exports = function config (options = {}) { - // set default options - if (!options.hostname) options.hostname = 'localhost' - if (!options.path) options.path = '/' - if (!options.port) options.port = 443 - if (!options.protocol) options.protocol = 'https' - if (!options.headers) options.headers = {} - - // set standard header values - Object.assign(options.headers, { accept: 'application/json' }) - - // only set content-type header when body is present - if (options.body) Object.assign(options.headers, { 'content-type': 'application/json' }) - - // ensure body is in JSON format - if (options.body) options.body = JSON.stringify(options.body) - - return options -} diff --git a/lib/http/decompress.js b/lib/http/decompress.js deleted file mode 100644 index 3627574..0000000 --- a/lib/http/decompress.js +++ /dev/null @@ -1,24 +0,0 @@ -/* istanbul ignore file */ - -module.exports = function (response) { - const { unzip, brotliDecompress } = require('zlib') - - return new Promise((resolve, reject) => { - const encoding = response.headers['content-encoding'] - - const isBrotli = encoding === 'br' - - // TODO: Remove this when targeting Node.js 12. - if (isBrotli && typeof brotliDecompress !== 'function') { - reject(new Error('Brotli is not supported on Node.js < 12')) - } - - const decompress = isBrotli ? brotliDecompress : unzip - - decompress(response.body, (err, buffer) => { - if (err) reject(new Error('An error occurred:', err)) - - resolve(buffer.toString()) - }) - }) -} diff --git a/lib/http/index.js b/lib/http/index.js deleted file mode 100644 index 44d73ab..0000000 --- a/lib/http/index.js +++ /dev/null @@ -1,60 +0,0 @@ -/* istanbul ignore file */ - -const config = require('./config') -const decompress = require('./decompress') - -module.exports = function (options) { - // configure default options - options = config(options) - - const { request } = require(options.protocol) - - // remove value to avoid clashing with internal property - delete options.protocol - - return new Promise((resolve, reject) => { - // create new request object - const req = request(options) - - // assign request event listeners - // TODO pass structured object here - req.on('error', reject) - - req.on('response', (res) => { - const body = [] - - // assign response event listeners - // TODO pass structured object here - res.on('error', reject) - - res.on('data', (chunk) => body.push(chunk)) - - res.on('end', async () => { - const response = { - headers: res.headers, - statusCode: res.statusCode, - statusMessage: res.statusMessage - } - - response.body = Buffer.concat(body) - - // handle compression - if (/gzip|deflate|br/i.test(response.headers['content-encoding'])) { - response.body = await decompress(response) - } - - // parse json - if (/application\/json/g.test(response.headers['content-type'])) { - response.body = JSON.parse(response.body) - } else { - response.body = response.body.toString() - } - - resolve(response) - }) - }) - - // send request with body - req.end(options.body) - }) -} diff --git a/lib/index.js b/lib/index.js index ae00a9a..f5d2c54 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,8 +1,5 @@ -// node utilities -const querystring = require('querystring') - // modules -const http = require('./http/') +const fetch = require('isomorphic-unfetch') const parseServer = require('./parse-server') const OASRequestError = require('./error') const parsePathTemplate = require('./parse-path-template') @@ -11,10 +8,11 @@ const parsePathTemplate = require('./parse-path-template') module.exports = function (spec) { if (!spec || !spec.paths) throw new OASRequestError('missing argument: spec') - const client = class { + class OASRequest { constructor (options = {}) { // process spec.servers & options.server this.options = { + client: options.client || fetch, server: parseServer(options.server, spec), // global properties headers: options.headers || {}, @@ -23,34 +21,35 @@ module.exports = function (spec) { } } - __request (method, url, options) { + __request (url, options) { // merge params with global defaults const params = { ...this.options.params, ...options.params } + // cleanup + delete options.params + // process path template const urlPath = parsePathTemplate(url, params) // construct final host & url parts - const { protocol, port, hostname, pathname, searchParams } = new URL(`${this.options.server}${urlPath}`) + const WHATWGURL = new URL(`${this.options.server}${urlPath}`) // convert query back to regular object - const searchObj = Object.fromEntries(searchParams.entries()) + WHATWGURL.search = new URLSearchParams({ + ...Object.fromEntries(WHATWGURL.searchParams.entries()), + ...this.options.query, + ...options.query + }) - // overrides - const headers = { ...this.options.headers, ...options.headers } - const query = Object.assign(searchObj, this.options.query, options.query) + // cleanup + delete options.query - // final query string - const search = querystring.stringify(query) + // construct combined headers object + const headers = { ...this.options.headers, ...options.headers } - return http({ - headers, - hostname, - method, - port, - body: options.body, - path: pathname + (search ? `?${search}` : ''), - protocol: protocol.replace(':', '') + return this.options.client(WHATWGURL, { + ...options, + headers // use the already combined headers }) } } @@ -62,15 +61,15 @@ module.exports = function (spec) { // create a method for each operation for (const [method, operation] of withOperationId) { - Object.defineProperty(client.prototype, operation.operationId, { + Object.defineProperty(OASRequest.prototype, operation.operationId, { enumerable: true, writable: false, - value: function ({ headers, params, query, body } = {}) { - return this.__request(method, url, { headers, params, query, body }) + value: function (options) { + return this.__request(url, { ...options, method }) } }) } } - return client + return OASRequest } diff --git a/lib/parse-server.js b/lib/parse-server.js index c4911ae..83761ce 100644 --- a/lib/parse-server.js +++ b/lib/parse-server.js @@ -2,7 +2,14 @@ const OASRequestError = require('./error') const parsePathTemplate = require('./parse-path-template') module.exports = function (server, spec) { - if (!server) throw new OASRequestError('missing argument: server') + // try to find a server from the spec + if (!server && spec && spec.servers) { + server = spec.servers[0] + } + + if (!server) { + throw new OASRequestError('missing argument: server') + } // convert to an object if (typeof server === 'string') { diff --git a/package-lock.json b/package-lock.json index d43472d..5ba7178 100644 --- a/package-lock.json +++ b/package-lock.json @@ -267,6 +267,15 @@ "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", "dev": true }, + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "dev": true, + "requires": { + "follow-redirects": "^1.10.0" + } + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -700,6 +709,12 @@ "vlq": "^0.2.1" } }, + "follow-redirects": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.3.tgz", + "integrity": "sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA==", + "dev": true + }, "foreground-child": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-1.5.6.tgz", @@ -968,6 +983,15 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, + "isomorphic-unfetch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz", + "integrity": "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==", + "requires": { + "node-fetch": "^2.6.1", + "unfetch": "^4.2.0" + } + }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -1363,6 +1387,11 @@ "path-to-regexp": "^1.7.0" } }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + }, "node-modules-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz", @@ -3285,6 +3314,11 @@ "integrity": "sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw==", "dev": true }, + "unfetch": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", + "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==" + }, "unicode-length": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/unicode-length/-/unicode-length-2.0.2.tgz", @@ -3435,9 +3469,9 @@ } }, "y18n": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", - "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.1.tgz", + "integrity": "sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==", "dev": true }, "yallist": { diff --git a/package.json b/package.json index 5a6de92..20c0d7f 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,11 @@ "test:report": "opener coverage/lcov-report/index.html" }, "devDependencies": { + "axios": "^0.21.1", "sinon": "^10.0.0", "tap": "^14.10.8" + }, + "dependencies": { + "isomorphic-unfetch": "^3.1.0" } } diff --git a/test/custom-client.js b/test/custom-client.js new file mode 100644 index 0000000..8c32459 --- /dev/null +++ b/test/custom-client.js @@ -0,0 +1,58 @@ +const { test } = require('tap') + +const spec = require('./fixtures/httpbin.json') +const OASRequest = require('../lib')(spec) + +const fetch = require('isomorphic-unfetch') +const axios = require('axios') + +const JSONRequest = new OASRequest({ + client: async function (url, options) { + const response = await fetch(url, { + ...options, + + body: JSON.stringify(options.body), + + headers: { + ...options.headers, + ...{ + accept: 'application/json', + 'content-type': 'application/json' + } + } + }) + + response.data = await response.json() + + return response + } +}) + +const axiosRequest = new OASRequest({ + client: async function (URL, options) { + return axios({ + ...options, + url: URL.toString() + }) + } +}) + +test('JSONRequest', async assert => { + assert.plan(1) + + const response = await JSONRequest.httpPost({ + body: { foo: 'bar' } + }) + + assert.match(response.data, { data: '{"foo":"bar"}', json: { foo: 'bar' } }) +}) + +test('axiosRequest', async assert => { + assert.plan(1) + + const response = await axiosRequest.httpPost({ + data: { foo: 'bar' } + }) + + assert.match(response.data, { data: '{"foo":"bar"}', json: { foo: 'bar' } }) +}) diff --git a/test/index.js b/test/index.js index f9b46b4..dcee44d 100644 --- a/test/index.js +++ b/test/index.js @@ -2,13 +2,12 @@ const { test } = require('tap') const sinon = require('sinon') // create stub -const http = sinon.stub() +const fetch = sinon.stub() -// delete require cache -delete require.cache[require.resolve('../lib/http/')] +delete require.cache[require.resolve('isomorphic-unfetch')] // override required module -require.cache[require.resolve('../lib/http/')] = { exports: http } +require.cache[require.resolve('isomorphic-unfetch')] = { exports: fetch } const oasRequest = require('..') const spec = require('./fixtures/petstore.json') @@ -21,11 +20,13 @@ test('throws if no spec', assert => { assert.throws(() => oasRequest({}), new OASRequestError('missing argument: spec')) }) -test('throws if no serverOptions', assert => { +test('throws if no server', assert => { assert.plan(1) - const API = oasRequest(spec) - assert.throws(() => new API()) + const API = oasRequest({ + paths: {} + }) + assert.throws(() => new API(), new OASRequestError('missing argument: server')) }) test('generates methods', assert => { @@ -40,17 +41,13 @@ test('generates methods', assert => { }) test('methods are callable', assert => { - assert.plan(1) + assert.plan(2) - http.callsFake(options => { + fetch.callsFake((url, options) => { + assert.match(url, new URL('http://pets.com/pets/%7BpetId%7D')) assert.deepEqual(options, { - protocol: 'https', - port: '', - hostname: 'pets.com', method: 'get', - path: '/pets/%7BpetId%7D', - headers: {}, - body: undefined + headers: {} }) }) @@ -61,17 +58,13 @@ test('methods are callable', assert => { }) test('methods options', assert => { - assert.plan(1) + assert.plan(2) - http.callsFake(options => { + fetch.callsFake((url, options) => { + assert.match(url, new URL('https://pets.com/pets/1')) assert.deepEqual(options, { - protocol: 'https', - port: '', - hostname: 'pets.com', method: 'get', - path: '/pets/1', - headers: {}, - body: undefined + headers: {} }) }) @@ -86,17 +79,13 @@ test('methods options', assert => { }) test('global defaults', assert => { - assert.plan(1) + assert.plan(2) - http.callsFake(options => { + fetch.callsFake((url, options) => { + assert.match(url, new URL('https://pets.com/pets/1?name=ruby&is_good=yes')) assert.deepEqual(options, { - protocol: 'https', - port: '', - hostname: 'pets.com', method: 'get', - path: '/pets/1?name=ruby&is_good=yes', - headers: { 'x-pet-type': 'dog' }, - body: undefined + headers: { 'x-pet-type': 'dog' } }) }) @@ -113,61 +102,3 @@ test('global defaults', assert => { query: { is_good: 'yes' } }) }) - -test('sub path in server', assert => { - assert.plan(1) - - http.callsFake(options => { - assert.deepEqual(options, { - protocol: 'https', - port: '', - hostname: 'pets.com', - method: 'get', - path: '/api/v1-0-0/pets/1?name=ruby&is_good=yes', - headers: { 'x-pet-type': 'dog' }, - body: undefined - }) - }) - - const API = oasRequest(spec) - - const api = new API({ - server: 'https://pets.com/api/v1-0-0', - headers: { 'x-pet-type': 'dog' }, - params: { petId: 1 }, - query: { name: 'ruby' } - }) - - api.showPetById({ - query: { is_good: 'yes' } - }) -}) - -test('sub path in server without slashes', assert => { - assert.plan(1) - - http.callsFake(options => { - assert.deepEqual(options, { - protocol: 'https', - port: '', - hostname: 'pets.com', - method: 'get', - path: '/api/v1-0-0/pets/1?name=ruby&is_good=yes', - headers: { 'x-pet-type': 'dog' }, - body: undefined - }) - }) - - const API = oasRequest(spec) - - const api = new API({ - server: 'https://pets.com/api/v1-0-0/', - headers: { 'x-pet-type': 'dog' }, - params: { petId: 1 }, - query: { name: 'ruby' } - }) - - api.showPetById({ - query: { is_good: 'yes' } - }) -}) diff --git a/test/parse-server.js b/test/parse-server.js index 93f8626..7b25535 100644 --- a/test/parse-server.js +++ b/test/parse-server.js @@ -15,6 +15,28 @@ test('throws if no server.url', assert => { assert.throws(() => parseServer({}), new OASRequestError('missing argument: server.url')) }) +test('uses the first server in the spec if no server provided', assert => { + assert.plan(1) + + const spec = { + servers: [ + { url: 'foo' } + ] + } + + const url = parseServer(undefined, spec) + + assert.equal(url, 'foo') +}) + +test('throws if no server.url', assert => { + assert.plan(1) + const spec = { + servers: [] + } + assert.throws(() => parseServer(undefined, spec), new OASRequestError('missing argument: server')) +}) + test('returns the server.url', assert => { assert.plan(1) diff --git a/test/real.js b/test/real.js index 2757535..decf96a 100644 --- a/test/real.js +++ b/test/real.js @@ -16,26 +16,28 @@ test('generates methods', assert => { }) test('GET /', async assert => { - assert.plan(1) + assert.plan(5) + + const response = await api.httpGet() - const result = await api.httpGet() + const body = await response.json() - assert.match(result, { + assert.ok(response.ok) + assert.equal(response.status, 200) + assert.equal(response.statusText, 'OK') + assert.match(response.headers.raw(), { + connection: ['close'], + 'content-type': ['application/json'], + 'access-control-allow-origin': ['*'], + 'access-control-allow-credentials': ['true'] + }) + + assert.match(body, { + url: 'https://httpbin.org/get', + args: {}, headers: { - 'content-type': 'application/json', - connection: 'close', - 'access-control-allow-origin': '*', - 'access-control-allow-credentials': 'true' - }, - statusCode: 200, - statusMessage: 'OK', - body: { - args: {}, - headers: { - Accept: 'application/json', - Host: 'httpbin.org' - }, - url: 'https://httpbin.org/get' + Accept: '*/*', + Host: 'httpbin.org' } }) }) @@ -43,15 +45,24 @@ test('GET /', async assert => { test('POST plain', async assert => { assert.plan(1) - const result = await api.httpPost({ body: 'foo' }) + const response = await api.httpPost({ body: 'foo' }) - assert.match(result.body, { data: '"foo"' }) + const body = await response.json() + + assert.match(body, { data: 'foo' }) }) test('POST json', async assert => { assert.plan(1) - const result = await api.httpPost({ body: { foo: 'bar' } }) + const response = await api.httpPost({ + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ foo: 'bar' }) + }) + + const body = await response.json() - assert.match(result.body, { data: '{"foo":"bar"}', json: { foo: 'bar' } }) + assert.match(body, { data: '{"foo":"bar"}', json: { foo: 'bar' } }) })