Skip to content

Commit

Permalink
feat!(oas-normalize): reworking how validate works, new convert m…
Browse files Browse the repository at this point in the history
…ethod
  • Loading branch information
erunion committed Dec 20, 2024
1 parent 0b7f71a commit 6c92197
Show file tree
Hide file tree
Showing 8 changed files with 2,244 additions and 14,612 deletions.
153 changes: 94 additions & 59 deletions packages/oas-normalize/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</p>

<p align="center">
Tooling for converting, validating, and parsing OpenAPI, Swagger, and Postman API definitions
Tooling for converting, validating, and parsing OpenAPI, Swagger, and Postman API definitions.
</p>

<p align="center">
Expand All @@ -27,126 +27,161 @@ npm install oas-normalize

## Usage

```javascript
```ts
import OASNormalize from 'oas-normalize';
// const { default: OASNormalize } = require('oas-normalize'); // If you're using CJS.

const oas = new OASNormalize(
'https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/examples/v3.0/petstore-expanded.yaml',
// ...or a string, path, JSON blob, whatever you've got.
// ...or a JSON object, YAML, a file path, stringified JSON, whatever you have.
);

oas
await oas
.validate()
.then(definition => {
// Definition will always be JSON, and valid.
console.log(definition);
.then(() => {
// The API definition is valid!
})
.catch(err => {
console.log(err);
console.error(err);
});
```

### `#bundle()`
### `.load()`

Load and retrive the API definition that `oas-normalize` was initialized with. Every method of `oas-normalize` utilizes this internally however if you would like to retrieve the original API _definition_ supplied (for example if all you had was a URL, a file path, or a buffer), you can use `.load()` to automatically resolve and return its contents.

```ts
const file = await oas.load();
console.log(file);
```

### `.bundle()`

> **Note**
>
> Because Postman collections don't support `$ref` pointers, this method will automatically upconvert a Postman collection to OpenAPI if supplied one.
Bundle up the given API definition, resolving any external `$ref` pointers in the process.

```js
await oas.bundle().then(definition => {
console.log(definition);
});
```ts
const definition = await oas.bundle();
console.log(definition);
```

### `#deref()`
### `.convert()`

Convert a given API definition into an OpenAPI definition JSON object.

```ts
await oas
.convert()
.then(definition => {
// Definition will always be an OpenAPI JSON object, regardless if a
// Swagger definition, Postman collection, or even YAML was supplied.
console.log(definition);
})
.catch(err => {
console.error(err);
});
```

### `.deref()`

> **Note**
>
> Because Postman collections don't support `$ref` pointers, this method will automatically upconvert a Postman collection to OpenAPI if supplied one.
Dereference the given API definition, resolving all `$ref` pointers in the process.

```js
await oas.deref().then(definition => {
console.log(definition);
});
```ts
const definition = await oas.bundle();
console.log(definition);
```

### `#validate({ convertToLatest?: boolean })`
### `.validate()`

Validate and optionally convert to OpenAPI, a given API definition. This supports Swagger 2.0, OpenAPI 3.x API definitions as well as Postman 2.x collections.
Validate a given API definition. This supports Swagger 2.0 and OpenAPI 3.x API definitions as well as Postman 2.x collections.

Please note that if you've supplied a Postman collection to the library it will **always** be converted to OpenAPI, using [@readme/postman-to-openapi](https://npm.im/@readme/postman-to-openapi), and we will only validate resulting OpenAPI definition.

```js
await oas.validate().then(definition => {
console.log(definition);
});
```ts
try {
await oas.validate();
// The API definition is valid!
} catch (err) {
console.error(err);
}
```

#### Options
#### Error Handling

<!-- prettier-ignore-start -->
| Option | Type | Description |
| :--- | :--- | :--- |
| `convertToLatest` | Boolean | By default `#validate` will not upconvert Swagger API definitions to OpenAPI so if you wish for this to happen, supply `true`. |
<!-- prettier-ignore-end -->
All thrown validation error messages that direct the user to the line(s) where their errors are present:

#### Error Handling
```
OpenAPI schema validation failed.
For validation errors, when available, you'll get back an object:

```js
{
"details": [
// Ajv pathing errors. For example:
/* {
"instancePath": "/components/securitySchemes/tlsAuth",
"schemaPath": "#/properties/securitySchemes/patternProperties/%5E%5Ba-zA-Z0-9%5C.%5C-_%5D%2B%24/oneOf",
"keyword": "oneOf",
"params": { "passingSchemas": null },
"message": "must match exactly one schema in oneOf"
}, */
]
}
REQUIRED must have required property 'url'
7 | },
8 | "servers": [
> 9 | {
| ^ ☹️ url is missing here!
10 | "urll": "http://petstore.swagger.io/v2"
11 | }
12 | ],
```

`message` is almost always there, but `path` is less dependable.
However if you would like to programatically access this information the `SyntaxError` error that is thrown contains a `details` array of [AJV](https://npm.im/ajv) errors:

```json
[
{
"instancePath": "/servers/0",
"schemaPath": "#/required",
"keyword": "required",
"params": { "missingProperty": "url" },
"message": "must have required property 'url'",
},
{
"instancePath": "/servers/0",
"schemaPath": "#/additionalProperties",
"keyword": "additionalProperties",
"params": { "additionalProperty": "urll" },
"message": "must NOT have additional properties",
},
];
```

### `#version()`
### `.version()`

Load and retrieve version information about a supplied API definition.

```js
await oas.version().then(({ specification, version }) => {
console.log(specification); // openapi
console.log(version); // 3.1.0
});
```ts
const { specification, version } = await oas.version();

console.log(specification); // openapi
console.log(version); // 3.1.0
```

### Options

##### Enable local paths

For security reasons, you need to opt into allowing fetching by a local path. To enable it supply the `enablePaths` option to the class instance:
For security reasons, you need to opt into allowing fetching by a local path. To enable this supply the `enablePaths` option to the class instance:

```js
```ts
const oas = new OASNormalize('./petstore.json', { enablePaths: true });
```

##### Colorized errors

If you wish errors from `.validate()` to be styled and colorized, supply `colorizeErrors: true` to your instance of `OASNormalize`:
If you wish errors from `.validate()` to be styled and colorized, supply `colorizeErrors: true` to the class instance:

```js
```ts
const oas = new OASNormalize('https://example.com/petstore.json', {
colorizeErrors: true,
});
```

Error messages will look like such:
When enabled thrown validation error messages will now resemble the following:

<img src="https://user-images.githubusercontent.com/33762/137796648-7e1157c2-cee4-466e-9129-dd2a743dd163.png" width="600" />
1 change: 1 addition & 0 deletions packages/oas-normalize/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
},
"scripts": {
"attw": "attw --pack --format table-flipped",
"bench": "echo 'Please run benchmarks from the root!' && exit 1",
"build": "tsup",
"lint": "npm run lint:types && npm run lint:js",
"lint:js": "eslint . --ext .js,.ts --ignore-path ../../.gitignore",
Expand Down
81 changes: 51 additions & 30 deletions packages/oas-normalize/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as utils from './lib/utils.js';
export default class OASNormalize {
cache: {
bundle?: OpenAPI.Document | false;
convert?: OpenAPI.Document | false;
deref?: OpenAPI.Document | false;
load?: Record<string, unknown> | false;
};
Expand Down Expand Up @@ -40,7 +41,8 @@ export default class OASNormalize {
}

/**
* @private
* Load and return the API definition that `oas-normalize` was initialized with.
*
*/
async load(): Promise<Record<string, unknown>> {
if (this.cache.load) return Promise.resolve(this.cache.load);
Expand Down Expand Up @@ -81,10 +83,7 @@ export default class OASNormalize {
}
}

/**
* @private
*/
static async convertPostmanToOpenAPI(schema: any) {
private static async convertPostmanToOpenAPI(schema: any) {
return postmanToOpenAPI(JSON.stringify(schema), undefined, { outputFormat: 'json', replaceVars: true }).then(
JSON.parse,
);
Expand Down Expand Up @@ -141,19 +140,48 @@ export default class OASNormalize {
}

/**
* Validate, and potentially convert to OpenAPI, a given API definition.
* Convert a given API definition to OpenAPI if it is not already.
*
*/
async convert(): Promise<OpenAPI.Document> {
if (this.cache.convert) return Promise.resolve(this.cache.convert);

return this.load()
.then(async schema => {
// If we have a Postman collection we need to convert it to OpenAPI.
return utils.isPostman(schema) ? OASNormalize.convertPostmanToOpenAPI(schema) : schema;
})
.then(async schema => {
if (!utils.isSwagger(schema) && !utils.isOpenAPI(schema)) {
return Promise.reject(new Error('The supplied API definition is unsupported.'));
} else if (utils.isOpenAPI(schema)) {
return schema;
}

const baseVersion = parseInt(schema.swagger, 10);
if (baseVersion === 1) {
return Promise.reject(new Error('Swagger v1.2 is unsupported.'));
}

return converter
.convertObj(schema, { anchors: true })
.then((options: { openapi: OpenAPI.Document }) => options.openapi)
.catch(err => Promise.reject(err));
});
}

/**
* Validate a given API definition.
*
* If supplied a Postman collection it will be converted to OpenAPI first and then run through
* standard OpenAPI validation.
*
*/
async validate(
opts: {
/**
* Automatically convert the supplied API definition to the latest version of OpenAPI.
*/
convertToLatest?: boolean;
parser?: openapiParser.Options;
} = { convertToLatest: false },
): Promise<OpenAPI.Document> {
const convertToLatest = opts.convertToLatest;
} = {},
): Promise<boolean> {
const parserOptions = opts.parser || {};
if (!parserOptions.validate) {
parserOptions.validate = {};
Expand All @@ -163,11 +191,9 @@ export default class OASNormalize {

return this.load()
.then(async schema => {
if (!utils.isPostman(schema)) {
return schema;
}

return OASNormalize.convertPostmanToOpenAPI(schema);
// Because we don't have something akin to `openapi-parser` for Postman collections we just
// always convert them to OpenAPI.
return utils.isPostman(schema) ? OASNormalize.convertPostmanToOpenAPI(schema) : schema;
})
.then(async schema => {
if (!utils.isSwagger(schema) && !utils.isOpenAPI(schema)) {
Expand All @@ -180,25 +206,20 @@ export default class OASNormalize {
}

/**
* `openapiParser.validate()` dereferences schemas at the same time as validation and does
* not give us an option to disable this. Since all we already have a dereferencing method
* on this library and our `validate()` method here just needs to tell us if the definition
* is valid or not we need to clone it before passing it over to `openapi-parser` so as to
* not run into pass-by-reference problems.
* `openapiParser.validate()` dereferences schemas at the same time as validation, mutating
* the supplied parameter in the process, and does not give us an option to disable this.
* As we already have a dereferencing method on this library, and this method just needs to
* tell us if the API definition is valid or not, we need to clone the schema before
* supplying it to `openapi-parser`.
*/
// eslint-disable-next-line try-catch-failsafe/json-parse
const clonedSchema = JSON.parse(JSON.stringify(schema));

return openapiParser
.validate(clonedSchema, parserOptions)
.then(() => {
if (!convertToLatest || utils.isOpenAPI(schema)) {
return schema;
}

return converter
.convertObj(schema, { anchors: true })
.then((options: { openapi: OpenAPI.Document }) => options.openapi);
// The API definition, whatever its format or specification, is valid.
return Promise.resolve(true);
})
.catch(err => Promise.reject(err));
});
Expand Down
Loading

0 comments on commit 6c92197

Please sign in to comment.