diff --git a/.changeset/purple-onions-shave.md b/.changeset/purple-onions-shave.md new file mode 100644 index 0000000000..c197713c37 --- /dev/null +++ b/.changeset/purple-onions-shave.md @@ -0,0 +1,6 @@ +--- +'@redocly/cli': minor +'@redocly/openapi-core': minor +--- + +Added an experimental `diff` command that compares two API descriptions, reports what was added, removed, and modified, and rates every change by the semver bump it requires (`patch`, `minor`, or `major`). `--fail-on` sets the impact that fails the run and `--check-version` verifies that `info.version` was bumped accordingly. diff --git a/docs/@v2/commands/diff.md b/docs/@v2/commands/diff.md new file mode 100644 index 0000000000..eea4a78b40 --- /dev/null +++ b/docs/@v2/commands/diff.md @@ -0,0 +1,162 @@ +# `diff` + +## Introduction + +The `diff` command compares two API descriptions and lists the changes between them. +The command gives each change an impact: the semver part that a release with this change must bump. + +- `major`: the change can break a client, for example a removed operation. +- `minor`: the change adds to the API, for example a new path. +- `patch`: the change does not break or add anything, for example a new description. + +Use the command in CI to stop breaking changes, or to get the next version of your API. + +{% admonition type="warning" name="Experimental" %} +This is an experimental feature. +Its behavior, options, rule names, and output formats can change in future releases. +{% /admonition %} + +## Usage + +```bash +redocly diff +redocly diff [--fail-on=] [--check-version] +redocly diff [--format=] [--output=] +redocly diff [--config=] [--skip-rule=]... +``` + +## Options + +| Option | Type | Description | +| --------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| base | string | **REQUIRED.** Path, URL, or [alias](../configuration/reference/apis.md) of the older API description. | +| revision | string | **REQUIRED.** Path, URL, or alias of the newer API description. | +| --check-version | boolean | Fail if `info.version` of the revision is lower than the version that the changes require. See [Check the version](#check-the-version). Default value is `false`. | +| --config | string | Specify path to the [configuration file](../configuration/index.md). | +| --fail-on | string | Exit with code `1` if a change has this impact or a higher impact.
**Possible values:** `major`, `minor`, `patch`, `none`. Default value is `major`. | +| --format | string | Format for the output. See [Output formats](#output-formats).
**Possible values:** `stylish`, `json`, `markdown`, `html`, `github-actions`, `next-version`. Default value is `stylish`. | +| --help | boolean | Show help. | +| --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | +| --output, -o | string | Write the report to this file. All formats support this option, except `github-actions`. | +| --skip-rule | [string] | Turn off these [diff rules](../rules/diff-rules.md) for this run. | +| --version | boolean | Show version number. | + +## How the command compares descriptions + +- The [diff rules](../rules/diff-rules.md) find the changes that can break a client and set their impact. + A change that no rule reports is `minor` if you add something, and `patch` in all other cases. +- A new component is `patch`, because it has no effect until something uses it. +- The command follows `$ref`s, also into other files. + It shows a change in a component at the first place that uses the component, with the line of the change in its file. +- If you rename an item, such as a path, a property, or a parameter, and the new item is similar to the old item, the command shows one changed item. + It does not show a removed item and an added item. +- A new order of list items, such as parameters or `oneOf` subschemas, is not a change. +- The command applies the preprocessors from your configuration, but not the [decorators](../decorators.md). + To compare the descriptions that you publish, bundle them with your decorators first. +- The diff rules support OpenAPI 3.x and AsyncAPI 3. + For other description types, the command lists the changes, but all changes are `minor` or `patch`. +- The command cannot compare descriptions of different types, for example OpenAPI 2.0 and OpenAPI 3.1. + +## Output formats + +The command prints the report to stdout and all other messages to stderr. + +| Format | Output | +| ---------------- | ----------------------------------------------------------------------------------------------------------- | +| `stylish` | The changes grouped by path, webhook, channel, operation, or server, with rule messages and file locations. | +| `json` | All data of each change. Use this format in scripts. | +| `markdown` | A table of the changes for a pull request comment. | +| `html` | A standalone page that you open in a browser. | +| `github-actions` | An error annotation for each rule message of a `major` change. | +| `next-version` | Only the next version, for example `2.0.0`. See [Get the next version](#get-the-next-version). | + +## Examples + +### Stop breaking changes in CI + +By default, the command exits with code `1` if it finds a `major` change: + +```bash +redocly diff main/openapi.yaml openapi.yaml +``` + +To also fail on new features, use `--fail-on=minor`. +To never fail, use `--fail-on=none`. + +### Check the version + +With `--check-version`, the command also fails if `info.version` of the revision is lower than the version that the changes require: + +```bash +redocly diff main/openapi.yaml openapi.yaml --check-version +``` + +For example, if the base version is `1.2.0` and a change is `major`, the revision version must be `2.0.0` or higher: + +```text +❌ info.version went 1.2.0 → 1.3.0, but these changes require a major bump (2.0.0). +``` + +- Below version `1.0.0`, semver allows a `minor` release to break the API. + Thus, a `major` change from `0.4.2` requires `0.5.0` or higher. +- The command compares only the `major.minor.patch` numbers. + It ignores pre-release and build labels. +- A change to `info.version` does not require a bump. +- If the changes require a bump and `info.version` is not a semver string, the check fails. + +### Get the next version + +The `next-version` format prints the base version, bumped as the changes require: + +```bash +NEXT_VERSION=$(redocly diff main/openapi.yaml openapi.yaml --format=next-version --fail-on=none) +``` + +If no change requires a bump, the command prints the base version. +Use `--fail-on=none`, so that a `major` change does not stop your script. + +### Comment on a pull request + +Write the report to a file, and attach the file to a pull request comment: + +```bash +redocly diff main/openapi.yaml openapi.yaml --format=markdown -o diff.md +``` + +To show each breaking change on the changed line, use the `github-actions` format in a GitHub Actions workflow: + +```bash +redocly diff main/openapi.yaml openapi.yaml --format=github-actions +``` + +### Compare two APIs by their aliases + +If the `apis` section of `redocly.yaml` has both descriptions, use their aliases: + +```yaml +apis: + cafe@v1: + root: v1/openapi.yaml + cafe@v2: + root: v2/openapi.yaml +``` + +```bash +redocly diff cafe@v1 cafe@v2 +``` + +### Change the impact of a rule + +To change the impact of a rule, or to turn it off, add a [`diff` section](../configuration/reference/diff.md) to `redocly.yaml`: + +```yaml +extends: + - diff-recommended +diff: + enum-values-added: minor +``` + +## Resources + +- [Diff rules](../rules/diff-rules.md) +- [`diff` configuration](../configuration/reference/diff.md) diff --git a/docs/@v2/commands/index.md b/docs/@v2/commands/index.md index fa93d04a3e..2c1d02eaa7 100644 --- a/docs/@v2/commands/index.md +++ b/docs/@v2/commands/index.md @@ -24,6 +24,7 @@ Docs linting commands: CI and delivery commands: - [`bundle`](bundle.md) Bundle an API description. +- [`diff`](diff.md) Compare two API descriptions and rate each change by the semver bump it requires [experimental feature]. - [`join`](join.md) Join API descriptions [experimental feature]. API testing and drift commands: diff --git a/docs/@v2/configuration/index.md b/docs/@v2/configuration/index.md index 5ba7d37a2f..0a559745ec 100644 --- a/docs/@v2/configuration/index.md +++ b/docs/@v2/configuration/index.md @@ -112,6 +112,19 @@ recheck: The `lint` command ignores the Recheck presets and the `recheck` block, and the `recheck` command ignores API rulesets. For more information, visit the [`recheck` block reference](./reference/recheck.md) and the [Markdown and prose linting](../recheck/index.md) section. +### Configure `diff` rules + +In the `diff` section, set the impact that each [diff rule](../rules/diff-rules.md) gives to a change: `major`, `minor`, `patch`, or `off`. +The [`diff` command](../commands/diff.md) uses these rules to compare two versions of an API. +See the [`diff` reference](./reference/diff.md) for all options. + +```yaml +extends: + - diff-recommended +diff: + enum-values-added: minor +``` + ### Configure OpenAPI features and documentation styles diff --git a/docs/@v2/configuration/reference/diff.md b/docs/@v2/configuration/reference/diff.md new file mode 100644 index 0000000000..fa0c4589fa --- /dev/null +++ b/docs/@v2/configuration/reference/diff.md @@ -0,0 +1,69 @@ +# `diff` + +## Introduction + +The `diff` section sets the impact of each [diff rule](../../rules/diff-rules.md). +The [`diff` command](../../commands/diff.md) uses these impacts. + +Use the `diff` section only at the root of a configuration file. +[API-specific sections](./apis.md) do not support it. + +{% admonition type="warning" name="Experimental" %} +The `diff` section is an experimental feature. +It can change in future releases. +{% /admonition %} + +## Options + +{% table %} + +- Option +- Type +- Description + +--- + +- {rule name} +- string +- **REQUIRED**. The impact of the changes that the rule reports. + Possible values: `major`, `minor`, `patch`, `off`. + +{% /table %} + +To set an impact for one specification version, use one of these sections. +They have the same options. + +- `oas3_0Diff` +- `oas3_1Diff` +- `oas3_2Diff` +- `async3Diff` + +## Examples + +This example adds the `diff-recommended` ruleset, sets a lower impact for one rule, and turns off another rule: + +```yaml +extends: + - diff-recommended +diff: + enum-values-added: minor + schema-constraint-changed: off +``` + +This example changes the impact of a rule for OpenAPI 3.1 descriptions only: + +```yaml +extends: + - diff-recommended +oas3_1Diff: + schema-type-changed: minor +``` + +## Related options + +- [extends](./extends.md) adds the `diff-recommended` ruleset. + +## Resources + +- [Diff rules](../../rules/diff-rules.md) +- [`diff` command](../../commands/diff.md) diff --git a/docs/@v2/rules.md b/docs/@v2/rules.md index 0ffcf2dbbd..40d0cf569b 100644 --- a/docs/@v2/rules.md +++ b/docs/@v2/rules.md @@ -23,6 +23,7 @@ Rulesets are groups of rules that are applied together, and APIs can be checked - A [recommended](./rules/recommended.md) ruleset is our unabashedly opinionated recommendation of what we think a good API looks like. It's a great place to start, before adapting to your own context. - A [recommended-strict](./rules/recommended.md#recommended-strict-ruleset) ruleset is identical to the `recommended`, except it elevates all warnings to errors. It's the ideal option for those who don't want to miss anything. - A [minimal](./rules/minimal.md) ruleset is a good starting point for an existing API that doesn't currently conform to any standard. It has fewer rules that cause an error, with others either downgraded to a warning or turned off completely. +- The [diff-recommended](./rules/diff-rules.md#diff-recommended-ruleset) ruleset holds the rules that the `diff` command uses to find breaking changes between two versions of an API. Enable a ruleset by adding a block like this to the Redocly configuration file: diff --git a/docs/@v2/rules/diff-rules.md b/docs/@v2/rules/diff-rules.md new file mode 100644 index 0000000000..62b842e58b --- /dev/null +++ b/docs/@v2/rules/diff-rules.md @@ -0,0 +1,141 @@ +--- +slug: /docs/cli/rules/diff-rules +--- + +# Diff rules + +The [`diff` command](../commands/diff.md) uses diff rules to find the changes that can break a client. +Each rule finds one type of change, for example a removed operation. +When a rule finds a change, the rule shows a message, and the change gets the impact that you set for the rule. + +{% admonition type="warning" name="Experimental" %} +Diff rules are an experimental feature. +Their names, messages, and configuration can change in future releases. +{% /admonition %} + +## `diff-recommended` ruleset + +The `diff-recommended` ruleset turns on all diff rules with the `major` impact. +The `diff` command uses this ruleset if you do not have a `redocly.yaml` file. + +If you have a `redocly.yaml` file, only the rules from that file run. +To use the ruleset, add it to `extends`: + +```yaml +extends: + - recommended + - diff-recommended +``` + +## Configure diff rules + +Set the impact of a rule in the [`diff` section](../configuration/reference/diff.md) of `redocly.yaml`. +The impact is `major`, `minor`, `patch`, or `off`: + +```yaml +extends: + - diff-recommended +diff: + enum-values-added: minor + schema-constraint-changed: off +``` + +To turn off rules for one run, use the `--skip-rule` option: + +```bash +redocly diff v1.yaml v2.yaml --skip-rule=enum-values-added +``` + +## Requests and responses + +Many rules check the direction of the data, because a change can break one direction only. +For example, a new `enum` value is safe in a request, but it can break a client that reads a response. + +- OpenAPI: parameters and request bodies are requests, and responses are responses. + In `callbacks` and `webhooks`, the API sends the request, so the directions are opposite. +- AsyncAPI 3: the messages of an operation with `action: receive` are requests, and the messages of an operation with `action: send` are responses. + A `reply` has the opposite direction. +- A schema with `readOnly: true` is response data, and a schema with `writeOnly: true` is request data. +- A component has the directions of all places that use it. + If a request and a response use the same schema, the rules check its changes in both directions. + A component that nothing uses has no direction. + +## Rules + +The rules apply to OpenAPI 3.x and AsyncAPI 3, except the rules that name one of them. + +### Paths, operations, and servers + +| Rule | Reports | +| -------------------------- | -------------------------------------------------------------------------------------------- | +| `path-removed` | A removed or renamed path, or a removed webhook, callback, or callback URL. OpenAPI only. | +| `operation-removed` | A removed operation. | +| `operation-id-changed` | A changed or removed `operationId`. Generated clients use it as a method name. OpenAPI only. | +| `operation-action-changed` | A changed `action` of an operation. AsyncAPI only. | +| `server-removed` | A removed server, or a changed `url`, `host`, `pathname`, or `protocol` of a server. | + +### Channels and messages + +These rules apply to AsyncAPI only. + +| Rule | Reports | +| ------------------------------ | ------------------------------------- | +| `channel-removed` | A removed channel. | +| `channel-address-changed` | A changed `address` of a channel. | +| `message-removed` | A removed message. | +| `message-content-type-changed` | A changed `contentType` of a message. | + +### Parameters, request bodies, and responses + +These rules apply to OpenAPI only. + +| Rule | Reports | +| --------------------------------- | ----------------------------------------------------------------------------------- | +| `parameter-removed` | A removed or renamed parameter, or a changed `in` of a parameter. | +| `parameter-became-required` | A new required parameter, or a parameter that became required. | +| `parameter-serialization-changed` | A changed `style`, `explode`, `allowReserved`, or `allowEmptyValue` of a parameter. | +| `request-body-removed` | A removed request body. | +| `request-body-became-required` | A request body that became required. | +| `response-removed` | A removed response, or a changed status code of a response. | +| `response-header-removed` | A removed or renamed response header. | +| `media-type-removed` | A removed or renamed media type. | + +### Schemas + +| Rule | Reports | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `property-removed` | A removed or renamed property in a response, or in a request whose schema rejects unlisted properties (`additionalProperties: false` or `unevaluatedProperties: false`). | +| `required-properties-added` | Properties that became required in a request. | +| `required-properties-removed` | Properties that are no longer required in a response. | +| `enum-values-removed` | Removed `enum` values, or a new `enum`, in a request. | +| `enum-values-added` | New `enum` values, or a removed `enum`, in a response. | +| `schema-type-changed` | A changed `type` or `nullable` that accepts fewer types in a request, or more types in a response. | +| `schema-constraint-changed` | A changed constraint that accepts fewer values in a request, or more values in a response. | +| `schema-combinator-changed` | An added or removed subschema of `allOf`, `anyOf`, or `oneOf`, or an added or removed list of them, that accepts fewer values in a request, or more values in a response. OpenAPI only. | + +`schema-constraint-changed` checks these keywords: + +- Limits: `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `minLength`, `maxLength`, `minItems`, `maxItems`, `minProperties`, `maxProperties`. +- Values: `multipleOf`, `pattern`, `format`, `const`, `contentEncoding`, `contentMediaType`. + The rule cannot compare two values of these keywords, so it reports a changed value in both directions. +- Flags: `uniqueItems: true`, and `false` for `additionalProperties`, `items`, `unevaluatedItems`, or `unevaluatedProperties`. +- Subschemas: `not`, `then`, `else`, `contains`, `propertyNames`, `items`, `prefixItems`, `additionalProperties`, `unevaluatedItems`, `unevaluatedProperties`, `dependentSchemas`, `dependentRequired`. + +### Security + +These rules apply to OpenAPI only. + +| Rule | Reports | +| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `security-requirement-changed` | A `security` change that a client with the old credentials cannot pass: new authentication, a new scheme or scope, or a removed requirement. | +| `security-scheme-changed` | A removed security scheme, or a changed `type`, `scheme`, `in`, `name`, `bearerFormat`, or `openIdConnectUrl` of a security scheme. | + +## Changes that the rules do not report + +These changes do not break a client: + +- A renamed path variable, for example `/menu/{id}` to `/menu/{itemId}`, and the renamed path parameter. +- A renamed webhook. Its key is only a name for the documentation. +- A changed case of a header name. +- A removed `servers` list of a path or an operation. The path or operation then uses the servers of the document. +- A new `operationId` where there was none. diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index 534c941845..bfcdfeef93 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -14,6 +14,8 @@ page: commands/bundle.md - label: check-config page: commands/check-config.md + - label: diff + page: commands/diff.md - label: drift page: commands/drift.md - label: eject @@ -206,6 +208,7 @@ - page: rules/arazzo/workflow-dependsOn.md - page: rules/arazzo/workflowId-unique.md + - page: rules/diff-rules.md - page: rules/configure-rules.md label: Rule configuration - page: rules/configurable-rules.md @@ -246,6 +249,7 @@ - page: configuration/reference/apis.md - page: configuration/reference/client.md - page: configuration/reference/decorators.md + - page: configuration/reference/diff.md - page: configuration/reference/extends.md - page: configuration/reference/plugins.md - page: configuration/reference/preprocessors.md diff --git a/package-lock.json b/package-lock.json index da00fbcbbc..979868a72a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4455,12 +4455,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/js-levenshtein": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@types/js-levenshtein/-/js-levenshtein-1.1.1.tgz", - "integrity": "sha512-qC4bCqYGy1y/NP7dDVr7KJarn+PbX1nSpwA7JXdu0HxT3QYjO8MJ+cntENtHFVy2dRAyBV23OZ6MxsW1AM1L8g==", - "dev": true - }, "node_modules/@types/js-yaml": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", @@ -7039,6 +7033,15 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -7950,6 +7953,7 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -13688,8 +13692,8 @@ "ajv": "npm:@redocly/ajv@^8.18.3", "ajv-formats": "^3.0.1", "colorette": "^1.2.0", + "fastest-levenshtein": "^1.0.16", "graphql": "^16.14.1", - "js-levenshtein": "^1.1.6", "js-yaml": "^5.2.2", "json-schema-to-ts": "^3.1.0", "jsonpath-rfc9535": "1.3.0", @@ -13698,7 +13702,6 @@ "yaml-ast-parser": "0.0.43" }, "devDependencies": { - "@types/js-levenshtein": "^1.1.0", "@types/picomatch": "^4.0.2", "@types/pluralize": "^0.0.29", "typescript": "6.0.2" diff --git a/packages/cli/src/__tests__/fixtures/config.ts b/packages/cli/src/__tests__/fixtures/config.ts index a871382e36..8c138c29bc 100644 --- a/packages/cli/src/__tests__/fixtures/config.ts +++ b/packages/cli/src/__tests__/fixtures/config.ts @@ -16,7 +16,16 @@ export const configFixture: Config = { skipPreprocessors: vi.fn(), saveIgnore: vi.fn(), skipDecorators: vi.fn(), + skipDiffRules: vi.fn(), + getDiffRulesForSpecVersion: vi.fn(() => []), + getDiffImpact: vi.fn(() => 'off' as const), ignore: {}, + diff: { + oas3_0: {}, + oas3_1: {}, + oas3_2: {}, + async3: {}, + }, decorators: { oas2: {}, oas3_0: {}, diff --git a/packages/cli/src/commands/diff/__tests__/check-version.test.ts b/packages/cli/src/commands/diff/__tests__/check-version.test.ts new file mode 100644 index 0000000000..8395de9a12 --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/check-version.test.ts @@ -0,0 +1,52 @@ +import { checkVersion, nextVersion } from '../check-version.js'; + +describe('checkVersion', () => { + it('passes when the declared bump matches or exceeds the required one', () => { + expect(checkVersion({ base: '1.2.0', revision: '1.3.0', required: 'minor' })).toBeUndefined(); + expect(checkVersion({ base: '1.2.0', revision: '2.0.0', required: 'patch' })).toBeUndefined(); + }); + + it('fails when the version moved less than the changes require', () => { + expect(checkVersion({ base: '1.2.0', revision: '1.3.0', required: 'major' })).toBe( + '❌ info.version went 1.2.0 → 1.3.0, but these changes require a major bump (2.0.0).' + ); + }); + + it('fails when the version did not move although something changed', () => { + expect(checkVersion({ base: '1.2.0', revision: '1.2.0', required: 'patch' })).toBe( + '❌ info.version stayed 1.2.0, but these changes require a patch bump (1.2.1).' + ); + }); + + it('lets a minor stand in for a major while below 1.0', () => { + expect(checkVersion({ base: '0.3.1', revision: '0.4.0', required: 'major' })).toBeUndefined(); + expect(checkVersion({ base: '0.3.1', revision: '0.3.2', required: 'major' })).toBe( + '❌ info.version went 0.3.1 → 0.3.2, but these changes require a minor bump (0.4.0) while below 1.0.' + ); + }); + + it('fails when either version is not semver', () => { + expect(checkVersion({ base: 'v1', revision: '1.0.0', required: 'patch' })).toBe( + '❌ info.version is not a semver string, so --check-version cannot compare it.' + ); + }); + + it('passes when nothing changed', () => { + expect(checkVersion({ base: 'v1', revision: 'v1', required: undefined })).toBeUndefined(); + }); +}); + +describe('nextVersion', () => { + it('should bump the base version as the changes require, a minor for a major below 1.0', () => { + expect(nextVersion('1.4.2', 'major')).toBe('2.0.0'); + expect(nextVersion('0.4.2', 'major')).toBe('0.5.0'); + }); + + it('should keep the base version when nothing requires a bump', () => { + expect(nextVersion('1.4.2', undefined)).toBe('1.4.2'); + }); + + it('should give nothing for a base version that is not semver', () => { + expect(nextVersion('v1', 'patch')).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/diff/__tests__/fail-on.test.ts b/packages/cli/src/commands/diff/__tests__/fail-on.test.ts new file mode 100644 index 0000000000..78697fcb36 --- /dev/null +++ b/packages/cli/src/commands/diff/__tests__/fail-on.test.ts @@ -0,0 +1,24 @@ +import { getDiffFailure } from '../fail-on.js'; + +const summary = { major: 2, minor: 1, patch: 3 }; + +describe('getDiffFailure', () => { + it.each([ + ['major', '❌ Diff failed with 2 major changes.'], + ['minor', '❌ Diff failed with 2 major and 1 minor changes.'], + ['patch', '❌ Diff failed with 2 major and 1 minor and 3 patch changes.'], + ['none', undefined], + ] as const)('with --fail-on=%s reports %s', (failOn, expected) => { + expect(getDiffFailure(summary, failOn)).toBe(expected); + }); + + it('passes when nothing reaches the threshold', () => { + expect(getDiffFailure({ major: 0, minor: 0, patch: 3 }, 'minor')).toBeUndefined(); + }); + + it('uses the singular for one change', () => { + expect(getDiffFailure({ major: 1, minor: 0, patch: 0 }, 'major')).toBe( + '❌ Diff failed with 1 major change.' + ); + }); +}); diff --git a/packages/cli/src/commands/diff/check-version.ts b/packages/cli/src/commands/diff/check-version.ts new file mode 100644 index 0000000000..447a58ae96 --- /dev/null +++ b/packages/cli/src/commands/diff/check-version.ts @@ -0,0 +1,54 @@ +import type { Impact } from '@redocly/openapi-core'; +import semver from 'semver'; + +// Pre-release and build metadata are accepted and ignored: only the three numbers are compared. +function releaseOf(version: unknown): string | undefined { + const parsed = typeof version === 'string' ? semver.parse(version) : null; + + return parsed ? `${parsed.major}.${parsed.minor}.${parsed.patch}` : undefined; +} + +// Semver lets a 0.x line break in a minor release, so below 1.0 a minor stands in for a major. +function bumpFor(base: string, required: Impact): Impact { + return semver.major(base) === 0 && required === 'major' ? 'minor' : required; +} + +/** + * The version a release of these changes takes: the base version bumped as the changes require, + * or unchanged when they require nothing; `undefined` when the base version is not semver. + */ +export function nextVersion( + baseVersion: unknown, + required: Impact | undefined +): string | undefined { + const base = releaseOf(baseVersion); + if (!base || required === undefined) return base; + // `base` is a valid release, so incrementing it cannot fail. + return semver.inc(base, bumpFor(base, required))!; +} + +export function checkVersion(opts: { + base: unknown; + revision: unknown; + required: Impact | undefined; +}): string | undefined { + const { required } = opts; + if (required === undefined) return undefined; + + const base = releaseOf(opts.base); + const revision = releaseOf(opts.revision); + + if (!base || !revision) { + return '❌ info.version is not a semver string, so --check-version cannot compare it.'; + } + + const bump = bumpFor(base, required); + const next = nextVersion(base, required)!; + + if (semver.gte(revision, next)) return undefined; + + const moved = base === revision ? `stayed ${base}` : `went ${base} → ${revision}`; + + const belowOne = bump === required ? '' : ' while below 1.0'; + return `❌ info.version ${moved}, but these changes require a ${bump} bump (${next})${belowOne}.`; +} diff --git a/packages/cli/src/commands/diff/fail-on.ts b/packages/cli/src/commands/diff/fail-on.ts new file mode 100644 index 0000000000..b20283769a --- /dev/null +++ b/packages/cli/src/commands/diff/fail-on.ts @@ -0,0 +1,18 @@ +import { impactRank, impacts, pluralize, type DiffSummary } from '@redocly/openapi-core'; + +import type { DiffFailOn } from './types.js'; + +export function getDiffFailure(summary: DiffSummary, failOn: DiffFailOn): string | undefined { + if (failOn === 'none') return undefined; + + const failing = [...impacts] + .reverse() + .filter((impact) => impactRank(impact) >= impactRank(failOn) && summary[impact] > 0); + + if (!failing.length) return undefined; + + const total = failing.reduce((sum, impact) => sum + summary[impact], 0); + const counted = failing.map((impact) => `${summary[impact]} ${impact}`).join(' and '); + + return `❌ Diff failed with ${counted} ${pluralize('change', total)}.`; +} diff --git a/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap b/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap new file mode 100644 index 0000000000..4e8e367258 --- /dev/null +++ b/packages/cli/src/commands/diff/format/__tests__/__snapshots__/format.test.ts.snap @@ -0,0 +1,449 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`htmlDiff > should render a self-contained page 1`] = ` +" + + + +API diff + + + +

API diff: base.yaml → revision.yaml

+

+ 4 major + 0 minor + 2 patch + requires a major bump + OpenAPI 3.1 +

+ +
+ + patch + modified + #/paths/~1menu/get · summary + + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1menu/get/summary",
+    "line": 6,
+    "col": 16,
+    "value": "List the menu"
+  },
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/paths/~1menu/get/summary",
+    "line": 6,
+    "col": 16,
+    "value": "List the whole menu"
+  }
+}
+
+ +
+ + major + modified + #/paths/~1menu/get/parameters/0 · required + \`limit\` query parameter became required. parameter-became-required + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1menu/get/parameters/0",
+    "line": 8,
+    "col": 11
+  },
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/paths/~1menu/get/parameters/0/required",
+    "line": 8,
+    "col": 47,
+    "value": true
+  }
+}
+
+ +
+ + major + modified + #/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note · pattern + \`pattern\` of \`note\` changed from 'a' to 'a|b'. schema-constraint-changed + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/pattern",
+    "line": 15,
+    "col": 68,
+    "value": "a"
+  },
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/pattern",
+    "line": 15,
+    "col": 68,
+    "value": "a|b"
+  }
+}
+
+ +
+ + major + removed + #/paths/~1orders/delete + Operation \`DELETE /orders\` was removed. operation-removed + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1orders/delete",
+    "line": 18,
+    "col": 7,
+    "value": {
+      "summary": "<script>alert(1)</script>",
+      "responses": {
+        "204": {
+          "description": "Cancelled"
+        }
+      }
+    }
+  }
+}
+
+ +
+ + major + removed + #/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta + Property \`eta\` was removed. property-removed + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta",
+    "line": 26,
+    "col": 42,
+    "value": {
+      "type": "string"
+    }
+  }
+}
+
+ +
+ + patch + added + #/components + + +
{
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/components",
+    "line": 26,
+    "col": 3,
+    "value": {
+      "schemas": {
+        "Dessert": {
+          "type": "object"
+        }
+      }
+    }
+  }
+}
+
+ +" +`; + +exports[`jsonDiff > should render the result with each change located in its file 1`] = ` +"{ + "reportVersion": "1", + "files": { + "base": "base.yaml", + "revision": "revision.yaml" + }, + "specVersions": { + "base": "oas3_1", + "revision": "oas3_1" + }, + "infoVersions": { + "base": "1.0.0", + "revision": "1.0.0" + }, + "summary": { + "major": 4, + "minor": 0, + "patch": 2 + }, + "bump": "major", + "changes": [ + { + "key": "#/paths/~1menu/get", + "typeName": "Operation", + "kind": "modified", + "impact": "patch", + "verdicts": [], + "property": "summary", + "base": { + "file": "base.yaml", + "pointer": "#/paths/~1menu/get/summary", + "line": 6, + "col": 16, + "value": "List the menu" + }, + "revision": { + "file": "revision.yaml", + "pointer": "#/paths/~1menu/get/summary", + "line": 6, + "col": 16, + "value": "List the whole menu" + } + }, + { + "key": "#/paths/~1menu/get/parameters/0", + "typeName": "Parameter", + "kind": "modified", + "impact": "major", + "verdicts": [ + { + "ruleId": "parameter-became-required", + "impact": "major", + "message": "\`limit\` query parameter became required.", + "location": { + "file": "revision.yaml", + "pointer": "#/paths/~1menu/get/parameters/0/required", + "line": 8, + "col": 47 + } + } + ], + "property": "required", + "base": { + "file": "base.yaml", + "pointer": "#/paths/~1menu/get/parameters/0", + "line": 8, + "col": 11 + }, + "revision": { + "file": "revision.yaml", + "pointer": "#/paths/~1menu/get/parameters/0/required", + "line": 8, + "col": 47, + "value": true + } + }, + { + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "typeName": "Schema", + "kind": "modified", + "impact": "major", + "verdicts": [ + { + "ruleId": "schema-constraint-changed", + "impact": "major", + "message": "\`pattern\` of \`note\` changed from 'a' to 'a|b'.", + "location": { + "file": "revision.yaml", + "pointer": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/pattern", + "line": 15, + "col": 68 + } + } + ], + "property": "pattern", + "base": { + "file": "base.yaml", + "pointer": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/pattern", + "line": 15, + "col": 68, + "value": "a" + }, + "revision": { + "file": "revision.yaml", + "pointer": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/pattern", + "line": 15, + "col": 68, + "value": "a|b" + } + }, + { + "key": "#/paths/~1orders/delete", + "typeName": "Operation", + "kind": "removed", + "impact": "major", + "verdicts": [ + { + "ruleId": "operation-removed", + "impact": "major", + "message": "Operation \`DELETE /orders\` was removed.", + "location": { + "file": "base.yaml", + "pointer": "#/paths/~1orders/delete", + "line": 18, + "col": 7 + } + } + ], + "base": { + "file": "base.yaml", + "pointer": "#/paths/~1orders/delete", + "line": 18, + "col": 7, + "value": { + "summary": "", + "responses": { + "204": { + "description": "Cancelled" + } + } + } + } + }, + { + "key": "#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta", + "typeName": "Schema", + "kind": "removed", + "impact": "major", + "verdicts": [ + { + "ruleId": "property-removed", + "impact": "major", + "message": "Property \`eta\` was removed.", + "location": { + "file": "base.yaml", + "pointer": "#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta", + "line": 26, + "col": 42 + } + } + ], + "base": { + "file": "base.yaml", + "pointer": "#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta", + "line": 26, + "col": 42, + "value": { + "type": "string" + } + } + }, + { + "key": "#/components", + "typeName": "Components", + "kind": "added", + "impact": "patch", + "verdicts": [], + "revision": { + "file": "revision.yaml", + "pointer": "#/components", + "line": 26, + "col": 3, + "value": { + "schemas": { + "Dessert": { + "type": "object" + } + } + } + } + } + ] +}" +`; + +exports[`markdownDiff > should render one table row per change 1`] = ` +"## API diff: base.yaml → revision.yaml + +**4** major · **0** minor · **2** patch · requires a **major** bump + +| Impact | Change | Pointer | Location | Details | +| --- | --- | --- | --- | --- | +| ⚪ patch | modified | \`#/paths/~1menu/get · summary\` | revision.yaml:6:16 | | +| 🔴 major | modified | \`#/paths/~1menu/get/parameters/0 · required\` | revision.yaml:8:47 | \\\`limit\\\` query parameter became required. \`parameter-became-required\` | +| 🔴 major | modified | \`#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note · pattern\` | revision.yaml:15:68 | \\\`pattern\\\` of \\\`note\\\` changed from 'a' to 'a\\|b'. \`schema-constraint-changed\` | +| 🔴 major | removed | \`#/paths/~1orders/delete\` | base.yaml:18:7 | Operation \\\`DELETE /orders\\\` was removed. \`operation-removed\` | +| 🔴 major | removed | \`#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/eta\` | base.yaml:26:42 | Property \\\`eta\\\` was removed. \`property-removed\` | +| ⚪ patch | added | \`#/components\` | revision.yaml:26:3 | |" +`; + +exports[`nextVersionDiff > should bump the base version as far as the changes require 1`] = `"2.0.0"`; + +exports[`printGithubActions > should annotate each verdict of a breaking change as an error and skip the rest 1`] = ` +"::error title=parameter-became-required,file=revision.yaml,line=8,col=47,endLine=8,endColumn=51::\`limit\` query parameter became required. +::error title=schema-constraint-changed,file=revision.yaml,line=15,col=68,endLine=15,endColumn=73::\`pattern\` of \`note\` changed from 'a' to 'a|b'. +::error title=operation-removed,file=base.yaml,line=18,col=7,endLine=19,endColumn=55::Operation \`DELETE /orders\` was removed. +::error title=property-removed,file=base.yaml,line=26,col=42,endLine=26,endColumn=58::Property \`eta\` was removed. +" +`; + +exports[`stylishDiff > should group AsyncAPI changes per channel, operation and server 1`] = ` +"server staging + ✖ major removed + Server \`staging\` was removed. (server-removed) + at base.yaml:5:12 + +channel orderPlaced + ✖ major modified address + \`address\` of channel \`orderPlaced\` changed from 'orders/placed' to 'orders/new'. (channel-address-changed) + at revision.yaml:6:27 + +operation sendOrderReady + ✖ major modified action + \`action\` of operation \`sendOrderReady\` changed from 'send' to 'receive'. (operation-action-changed) + at revision.yaml:8:29 + +3 major, 0 minor, 0 patch." +`; + +exports[`stylishDiff > should group the changes per endpoint and webhook, worst first, with their verdicts and location 1`] = ` +"GET /menu + ✖ major modified parameters/0 · required + \`limit\` query parameter became required. (parameter-became-required) + at revision.yaml:8:47 + · patch modified summary + at revision.yaml:6:16 + +POST /orders + ✖ major modified requestBody/content/application/json/schema/properties/note · pattern + \`pattern\` of \`note\` changed from 'a' to 'a|b'. (schema-constraint-changed) + at revision.yaml:15:68 + +DELETE /orders + ✖ major removed + Operation \`DELETE /orders\` was removed. (operation-removed) + at base.yaml:18:7 + +POST orderReady (webhook) + ✖ major removed requestBody/content/application/json/schema/properties/eta + Property \`eta\` was removed. (property-removed) + at base.yaml:26:42 + +components + · patch added + at revision.yaml:26:3 + +4 major, 0 minor, 2 patch." +`; diff --git a/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/base.yaml b/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/base.yaml new file mode 100644 index 0000000000..b9cbd41248 --- /dev/null +++ b/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/base.yaml @@ -0,0 +1,27 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.0.0 } +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + responses: { '200': { description: OK } } + /orders: + post: + requestBody: + content: + application/json: + schema: { properties: { note: { type: string, pattern: 'a' } } } + responses: { '201': { description: Created } } + delete: + summary: + responses: { '204': { description: Cancelled } } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: { properties: { eta: { type: string } } } + responses: { '200': { description: OK } } diff --git a/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/revision.yaml b/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/revision.yaml new file mode 100644 index 0000000000..25c1690c30 --- /dev/null +++ b/packages/cli/src/commands/diff/format/__tests__/fixtures/cafe/revision.yaml @@ -0,0 +1,27 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.0.0 } +paths: + /menu: + get: + summary: List the whole menu + parameters: + - { name: limit, in: query, required: true, schema: { type: integer } } + responses: { '200': { description: OK } } + /orders: + post: + requestBody: + content: + application/json: + schema: { properties: { note: { type: string, pattern: 'a|b' } } } + responses: { '201': { description: Created } } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: { properties: {} } + responses: { '200': { description: OK } } +components: + schemas: + Dessert: { type: object } diff --git a/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/base.yaml b/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/base.yaml new file mode 100644 index 0000000000..83be825107 --- /dev/null +++ b/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/base.yaml @@ -0,0 +1,9 @@ +asyncapi: 3.0.0 +info: { title: Cafe kitchen, version: 1.0.0 } +servers: + production: { host: kitchen.cafe.example, protocol: mqtt } + staging: { host: staging.cafe.example, protocol: mqtt } +channels: + orderPlaced: { address: orders/placed } +operations: + sendOrderReady: { action: send, channel: { $ref: '#/channels/orderPlaced' } } diff --git a/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/revision.yaml b/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/revision.yaml new file mode 100644 index 0000000000..5977a6a47c --- /dev/null +++ b/packages/cli/src/commands/diff/format/__tests__/fixtures/kitchen/revision.yaml @@ -0,0 +1,8 @@ +asyncapi: 3.0.0 +info: { title: Cafe kitchen, version: 1.0.0 } +servers: + production: { host: kitchen.cafe.example, protocol: mqtt } +channels: + orderPlaced: { address: orders/new } +operations: + sendOrderReady: { action: receive, channel: { $ref: '#/channels/orderPlaced' } } diff --git a/packages/cli/src/commands/diff/format/__tests__/format.test.ts b/packages/cli/src/commands/diff/format/__tests__/format.test.ts new file mode 100644 index 0000000000..f7e066d113 --- /dev/null +++ b/packages/cli/src/commands/diff/format/__tests__/format.test.ts @@ -0,0 +1,123 @@ +import { createConfig, diffDocuments, makeDocumentFromString } from '@redocly/openapi-core'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { stripVTControlCharacters } from 'node:util'; +import { outdent } from 'outdent'; + +import { printGithubActions } from '../github-actions.js'; +import { htmlDiff } from '../html.js'; +import { jsonDiff } from '../json.js'; +import { markdownDiff } from '../markdown.js'; +import { nextVersionDiff } from '../next-version.js'; +import { stylishDiff } from '../stylish.js'; + +const documentOf = (fixture: string, side: 'base' | 'revision') => + makeDocumentFromString( + readFileSync(join(import.meta.dirname, 'fixtures', fixture, `${side}.yaml`), 'utf8'), + `${side}.yaml` + ); + +const config = await createConfig({ extends: ['diff-recommended'] }); + +// Every shape a report has to survive: a removal found in the base document, changes found in +// the revision, an addition, a change no rule judges, a webhook — and content that fights the +// output format, so the escaping shows up in the snapshots. +const cafe = await diffDocuments({ + base: documentOf('cafe', 'base'), + revision: documentOf('cafe', 'revision'), + config, +}); + +const kitchen = await diffDocuments({ + base: documentOf('kitchen', 'base'), + revision: documentOf('kitchen', 'revision'), + config, +}); + +// vitest runs with FORCE_COLOR=1, so the stylish report is snapshotted without its colours. +describe('stylishDiff', () => { + it('should group the changes per endpoint and webhook, worst first, with their verdicts and location', () => { + expect(stripVTControlCharacters(stylishDiff(cafe))).toMatchSnapshot(); + }); + + it('should group AsyncAPI changes per channel, operation and server', () => { + expect(stripVTControlCharacters(stylishDiff(kitchen))).toMatchSnapshot(); + }); + + it('should head the changes of a path by its path when its $ref points to another path item', async () => { + const menu = (pathItem: string, side: 'base' | 'revision') => + makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: { $ref: '#/components/pathItems/${pathItem}' } + components: + pathItems: + Menu: + get: { responses: { '200': { description: OK } } } + delete: { responses: { '204': { description: Removed } } } + Dishes: + get: { responses: { '200': { description: OK } } } + `, + `${side}.yaml` + ); + + const result = await diffDocuments({ + base: menu('Menu', 'base'), + revision: menu('Dishes', 'revision'), + config, + }); + + expect(stripVTControlCharacters(stylishDiff(result))).toMatchInlineSnapshot(` + "DELETE /menu + ✖ major removed + Operation \`DELETE /menu\` was removed. (operation-removed) + at base.yaml:9:15 + + 1 major, 0 minor, 0 patch." + `); + }); +}); + +describe('markdownDiff', () => { + it('should render one table row per change', () => { + expect(markdownDiff(cafe)).toMatchSnapshot(); + }); +}); + +describe('htmlDiff', () => { + it('should render a self-contained page', () => { + const output = htmlDiff(cafe); + + expect(output).toMatchSnapshot(); + // The report is opened straight from disk, so it must pull in nothing. + expect(output).not.toMatch(/src="http|href="http/); + }); +}); + +describe('jsonDiff', () => { + it('should render the result with each change located in its file', () => { + expect(jsonDiff(cafe)).toMatchSnapshot(); + }); +}); + +describe('printGithubActions', () => { + it('should annotate each verdict of a breaking change as an error and skip the rest', () => { + let output = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((text: string | Uint8Array) => { + output += text; + return true; + }); + + printGithubActions(cafe); + + expect(output).toMatchSnapshot(); + }); +}); + +describe('nextVersionDiff', () => { + it('should bump the base version as far as the changes require', () => { + expect(nextVersionDiff(cafe)).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/commands/diff/format/github-actions.ts b/packages/cli/src/commands/diff/format/github-actions.ts new file mode 100644 index 0000000000..1eee4a22e1 --- /dev/null +++ b/packages/cli/src/commands/diff/format/github-actions.ts @@ -0,0 +1,42 @@ +import { + displaySide, + formatProblems, + getTotals, + type DiffResult, + type Impact, + type JudgedChange, + type NormalizedProblem, + type ProblemSeverity, +} from '@redocly/openapi-core'; + +// Only a breaking change is annotated, as an error: a GitHub warning would flag a compatible +// change as something to fix. This is what lets the format come from core's formatProblems. +const SEVERITIES: Partial> = { major: 'error' }; + +export function printGithubActions(result: DiffResult): void { + const problems: NormalizedProblem[] = result.changes.flatMap((change) => { + // A change no rule judged is reported on its own, under the command's name. + const verdicts: JudgedChange['verdicts'] = change.verdicts.length + ? change.verdicts + : [ + { + ruleId: 'diff', + impact: change.impact, + message: `${change.kind} ${change.key}`, + location: displaySide(change).location, + }, + ]; + + return verdicts.flatMap(({ ruleId, impact, message, location }) => { + const severity = SEVERITIES[impact]; + if (!severity) return []; + return [{ message, ruleId, severity, location: [location], suggest: [] }]; + }); + }); + + formatProblems(problems, { + format: 'github-actions', + totals: getTotals(problems), + maxProblems: problems.length, + }); +} diff --git a/packages/cli/src/commands/diff/format/html.ts b/packages/cli/src/commands/diff/format/html.ts new file mode 100644 index 0000000000..fa581d2648 --- /dev/null +++ b/packages/cli/src/commands/diff/format/html.ts @@ -0,0 +1,91 @@ +import type { DiffResult, JudgedChange, SpecVersion } from '@redocly/openapi-core'; + +import { formatPath } from '../../../utils/miscellaneous.js'; +import { toJsonChange } from './json.js'; + +const SPEC_NAMES: Record = { + oas2: 'OpenAPI 2.0', + oas3_0: 'OpenAPI 3.0', + oas3_1: 'OpenAPI 3.1', + oas3_2: 'OpenAPI 3.2', + async2: 'AsyncAPI 2', + async3: 'AsyncAPI 3', + arazzo1: 'Arazzo 1.0', + arazzo1_1: 'Arazzo 1.1', + overlay1: 'Overlay 1', + openrpc1: 'OpenRPC 1', + graphql: 'GraphQL', +}; + +function escapeHtml(value: unknown): string { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function renderChange(change: JudgedChange): string { + const address = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key; + const { base, revision } = toJsonChange(change); + return ` +
+ + ${escapeHtml(change.impact)} + ${escapeHtml(change.kind)} + ${escapeHtml(address)} + ${change.verdicts + .map( + (verdict) => + `${escapeHtml(verdict.message)} ${escapeHtml( + verdict.ruleId + )}` + ) + .join(' ')} + +
${escapeHtml(JSON.stringify({ base, revision }, null, 2))}
+
`; +} + +export function htmlDiff(result: DiffResult): string { + const { major, minor, patch } = result.summary; + const { base, revision } = result.specVersions; + const specs = + base === revision ? SPEC_NAMES[base] : `${SPEC_NAMES[base]} → ${SPEC_NAMES[revision]}`; + const bumpSpan = result.bump + ? `requires a ${escapeHtml(result.bump)} bump` + : ''; + return ` + + + +API diff + + + +

API diff: ${escapeHtml(formatPath(result.files.base))} → ${escapeHtml(formatPath(result.files.revision))}

+

+ ${major} major + ${minor} minor + ${patch} patch + ${bumpSpan} + ${escapeHtml(specs)} +

+${result.changes.map(renderChange).join('\n')} + +`; +} diff --git a/packages/cli/src/commands/diff/format/index.ts b/packages/cli/src/commands/diff/format/index.ts new file mode 100644 index 0000000000..155f94c65f --- /dev/null +++ b/packages/cli/src/commands/diff/format/index.ts @@ -0,0 +1,17 @@ +import type { DiffResult } from '@redocly/openapi-core'; + +import { htmlDiff } from './html.js'; +import { jsonDiff } from './json.js'; +import { markdownDiff } from './markdown.js'; +import { nextVersionDiff } from './next-version.js'; +import { stylishDiff } from './stylish.js'; + +export const diffReportFormats = { + stylish: stylishDiff, + json: jsonDiff, + markdown: markdownDiff, + html: htmlDiff, + 'next-version': nextVersionDiff, +} satisfies Record string>; + +export type DiffReportFormat = keyof typeof diffReportFormats; diff --git a/packages/cli/src/commands/diff/format/json.ts b/packages/cli/src/commands/diff/format/json.ts new file mode 100644 index 0000000000..a2a243890b --- /dev/null +++ b/packages/cli/src/commands/diff/format/json.ts @@ -0,0 +1,50 @@ +import { + typeOf, + type DiffResult, + type JudgedChange, + type Location, + type LocatedNode, +} from '@redocly/openapi-core'; + +import { formatPath } from '../../../utils/miscellaneous.js'; +import { lineColOf } from './location.js'; + +function toJsonLocation(location: Location) { + const { file, line, col } = lineColOf(location); + return { file, pointer: location.pointer, line, col }; +} + +function toJsonNode({ location, value }: LocatedNode) { + return { ...toJsonLocation(location), value }; +} + +// Fields a change does not have stay undefined, and JSON.stringify leaves them out. +export function toJsonChange(change: JudgedChange) { + return { + key: change.key, + typeName: typeOf(change.node), + kind: change.kind, + impact: change.impact, + verdicts: change.verdicts.map(({ location, ...verdict }) => ({ + ...verdict, + location: toJsonLocation(location), + })), + property: change.kind === 'modified' ? change.property : undefined, + base: 'base' in change ? toJsonNode(change.base) : undefined, + revision: 'revision' in change ? toJsonNode(change.revision) : undefined, + }; +} + +// The problems met while preparing the documents are printed apart from the report. +export function jsonDiff(result: DiffResult): string { + const report = { + reportVersion: '1', + files: { base: formatPath(result.files.base), revision: formatPath(result.files.revision) }, + specVersions: result.specVersions, + infoVersions: result.infoVersions, + summary: result.summary, + bump: result.bump, + changes: result.changes.map(toJsonChange), + }; + return JSON.stringify(report, null, 2); +} diff --git a/packages/cli/src/commands/diff/format/location.ts b/packages/cli/src/commands/diff/format/location.ts new file mode 100644 index 0000000000..a4dd01c2cf --- /dev/null +++ b/packages/cli/src/commands/diff/format/location.ts @@ -0,0 +1,10 @@ +import { getLineColLocation, type Location } from '@redocly/openapi-core'; + +import { formatPath } from '../../../utils/miscellaneous.js'; + +// A node that a preprocessor or decorator added is not in the text of its file; +// getLineColLocation falls back to 1:1 for such pointers. +export function lineColOf(location: Location): { file: string; line: number; col: number } { + const { start } = getLineColLocation(location); + return { file: formatPath(location.source.absoluteRef), line: start.line, col: start.col }; +} diff --git a/packages/cli/src/commands/diff/format/markdown.ts b/packages/cli/src/commands/diff/format/markdown.ts new file mode 100644 index 0000000000..3309e26e48 --- /dev/null +++ b/packages/cli/src/commands/diff/format/markdown.ts @@ -0,0 +1,50 @@ +import { displaySide, type DiffResult, type Impact } from '@redocly/openapi-core'; + +import { formatPath } from '../../../utils/miscellaneous.js'; +import { lineColOf } from './location.js'; + +const IMPACT_LABELS: Record = { + major: '🔴 major', + minor: '🟢 minor', + patch: '⚪ patch', +}; + +// A cell is rendered inside a code span, so a backtick from the description would +// close it early and let the rest of the row be read as markup. Newlines and pipes +// would break out of the row itself. +function escapeCell(value: string): string { + return value + .replaceAll('|', '\\|') + .replaceAll('`', '\\`') + .split('\n') + .map((line) => line.trim()) + .join(' '); +} + +export function markdownDiff(result: DiffResult): string { + const { major, minor, patch } = result.summary; + const required = result.bump ? ` · requires a **${result.bump}** bump` : ''; + const lines = [ + `## API diff: ${formatPath(result.files.base)} → ${formatPath(result.files.revision)}`, + '', + `**${major}** major · **${minor}** minor · **${patch}** patch${required}`, + '', + '| Impact | Change | Pointer | Location | Details |', + '| --- | --- | --- | --- | --- |', + ]; + + for (const change of result.changes) { + const address = change.kind === 'modified' ? `${change.key} · ${change.property}` : change.key; + const { file, line, col } = lineColOf(displaySide(change).location); + // Only the message comes from the compared document and needs escaping. A rule id is + // lowercase letters and hyphens, and the backticks around it are ours to keep. + const details = change.verdicts + .map((verdict) => `${escapeCell(verdict.message)} \`${verdict.ruleId}\``) + .join('
'); + lines.push( + `| ${IMPACT_LABELS[change.impact]} | ${change.kind} | \`${escapeCell(address)}\` | ${escapeCell(`${file}:${line}:${col}`)} | ${details} |` + ); + } + + return lines.join('\n'); +} diff --git a/packages/cli/src/commands/diff/format/next-version.ts b/packages/cli/src/commands/diff/format/next-version.ts new file mode 100644 index 0000000000..2f4c01e420 --- /dev/null +++ b/packages/cli/src/commands/diff/format/next-version.ts @@ -0,0 +1,13 @@ +import { HandledError, type DiffResult } from '@redocly/openapi-core'; + +import { nextVersion } from '../check-version.js'; + +export function nextVersionDiff(result: DiffResult): string { + const next = nextVersion(result.infoVersions.base, result.bump); + if (next === undefined) { + throw new HandledError( + 'info.version of the base description is not a semver string, so the next version cannot be computed.' + ); + } + return next; +} diff --git a/packages/cli/src/commands/diff/format/stylish.ts b/packages/cli/src/commands/diff/format/stylish.ts new file mode 100644 index 0000000000..777d8fa437 --- /dev/null +++ b/packages/cli/src/commands/diff/format/stylish.ts @@ -0,0 +1,110 @@ +import { + displaySide, + impactRank, + impacts, + nameOf, + parsePointer, + typeOf, + type Change, + type DiffNode, + type DiffResult, + type Impact, + type JudgedChange, +} from '@redocly/openapi-core'; +import { blue, bold, gray, green, red } from 'colorette'; + +import { lineColOf } from './location.js'; + +const IMPACT_COLORS: Record string> = { + major: red, + minor: green, + patch: gray, +}; + +const IMPACT_GLYPHS: Record = { + major: red('✖ major'), + minor: green('✔ minor'), + patch: gray('· patch'), +}; + +// `parsePointer` unescapes every segment and keeps the leading `#`, which names the document. +function segmentsOf(pointer: string): string[] { + const [, ...segments] = parsePointer(pointer); + return segments; +} + +// The items that get a heading of their own, by the type of the map that holds them. +const ITEM_HEADINGS: Partial string>> = { + Paths: (path) => path, + WebhooksMap: (webhook) => `${webhook} (webhook)`, + NamedChannels: (channel) => `channel ${channel}`, + NamedOperations: (operation) => `operation ${operation}`, + ServerMap: (server) => `server ${server}`, +}; + +/** + * The heading a change is listed under — its endpoint, webhook, channel, operation or server, + * or else the section it sits in — and how many levels below the root that heading names. The + * names come from the matched nodes, so a renamed path keeps one heading for all its changes. + */ +function headingOf(change: Change): { heading: string; depth: number } { + const ancestors: DiffNode[] = []; + for (let node: DiffNode | null = change.node; node; node = node.parent) ancestors.unshift(node); + const [, section, item, operation] = ancestors; + + if (!section) return { heading: 'document', depth: 0 }; + const itemHeading = ITEM_HEADINGS[typeOf(section)]; + if (!item || !itemHeading) return { heading: nameOf(section), depth: 1 }; + + const heading = itemHeading(nameOf(item)); + const isOperation = operation && typeOf(operation) === 'Operation' && typeOf(item) === 'PathItem'; + if (isOperation) return { heading: `${nameOf(operation).toUpperCase()} ${heading}`, depth: 3 }; + return { heading, depth: 2 }; +} + +// The label leaves out what the heading names, so a change on the heading's own node has only +// its property, if any. Each segment is unescaped, so a `/` inside a name reads as one. +function labelOf(change: JudgedChange): string { + const below = segmentsOf(change.key).slice(headingOf(change).depth).join('/'); + if (change.kind !== 'modified') return below; + return below ? `${below} · ${change.property}` : change.property; +} + +export function stylishDiff(result: DiffResult): string { + const groups = new Map(); + for (const change of result.changes) { + const { heading } = headingOf(change); + const group = groups.get(heading) ?? []; + group.push(change); + groups.set(heading, group); + } + + const lines: string[] = []; + // The changes come sorted by key, so the headings follow the document's structure. + for (const [heading, changes] of groups) { + lines.push(bold(blue(heading))); + // Sorting is stable, so changes of one impact keep the order the report lists them in. + const sorted = changes.toSorted( + (left, right) => impactRank(right.impact) - impactRank(left.impact) + ); + for (const change of sorted) { + const label = labelOf(change); + const kind = bold(label ? change.kind.padEnd(8) : change.kind); + lines.push(` ${IMPACT_GLYPHS[change.impact]} ${kind}${label ? ` ${label}` : ''}`); + for (const verdict of change.verdicts) { + lines.push(gray(` ${verdict.message} (${verdict.ruleId})`)); + } + const { file, line, col } = lineColOf(displaySide(change).location); + lines.push(gray(` at ${file}:${line}:${col}`)); + } + lines.push(''); + } + + // A zero is not a finding, so it is not coloured like one. + const counts = impacts.toReversed().map((impact) => { + const count = result.summary[impact]; + return (count ? IMPACT_COLORS[impact] : gray)(`${count} ${impact}`); + }); + lines.push(`${counts.join(', ')}.`); + return lines.join('\n'); +} diff --git a/packages/cli/src/commands/diff/index.ts b/packages/cli/src/commands/diff/index.ts new file mode 100644 index 0000000000..0485ff1815 --- /dev/null +++ b/packages/cli/src/commands/diff/index.ts @@ -0,0 +1,100 @@ +import { + AbortFlowError, + BaseResolver, + diffDocuments, + HandledError, + formatProblems, + getTotals, + logger, + type JudgedChange, +} from '@redocly/openapi-core'; +import { green } from 'colorette'; +import { writeFileSync } from 'node:fs'; + +import { getFallbackApisOrExit, printExecutionTime } from '../../utils/miscellaneous.js'; +import type { CommandArgs } from '../../wrapper.js'; +import { checkVersion } from './check-version.js'; +import { getDiffFailure } from './fail-on.js'; +import { printGithubActions } from './format/github-actions.js'; +import { diffReportFormats } from './format/index.js'; +import type { DiffArgv } from './types.js'; + +export async function handleDiff({ argv, config, collectSpecData }: CommandArgs) { + if (argv.output && argv.format === 'github-actions') { + throw new HandledError( + `The github-actions format prints to stdout only. To write a report to a file, use one of these formats: ${Object.keys(diffReportFormats).join(', ')}.` + ); + } + + const startedAt = performance.now(); + + config.skipDiffRules(argv['skip-rule']); + + const [{ path: basePath }] = await getFallbackApisOrExit([argv.base], config); + const [{ path: revisionPath }] = await getFallbackApisOrExit([argv.revision], config); + + // One resolver loads both documents and resolves their `$ref`s, as lint does. + const externalRefResolver = new BaseResolver(config.resolve); + const load = async (documentPath: string) => { + const document = await externalRefResolver.resolveDocument(null, documentPath, true); + if (document instanceof Error) throw document; + return document; + }; + const [base, revision] = await Promise.all([load(basePath), load(revisionPath)]); + + collectSpecData?.(revision); + + const result = await diffDocuments({ base, revision, config, externalRefResolver }); + + if (result.problems.length) { + formatProblems(result.problems, { + format: 'codeframe', + totals: getTotals(result.problems), + command: 'bundle', + }); + logger.warn( + '⚠️ The problems above leave parts of the descriptions unresolved, so the diff may miss changes there or judge them without knowing whether they are in a request or a response.\n' + ); + } + + result.changes.sort(byKeyAndProperty); + + if (argv.format === 'github-actions') { + printGithubActions(result); + } else if (argv.output) { + writeFileSync(argv.output, diffReportFormats[argv.format](result)); + logger.info(`Diff report written to ${argv.output}.\n`); + } else { + logger.output(diffReportFormats[argv.format](result) + '\n'); + } + + printExecutionTime('diff', startedAt, `${basePath} vs ${revisionPath}`); + + const failures = [ + getDiffFailure(result.summary, argv['fail-on']), + argv['check-version'] && + checkVersion({ + base: result.infoVersions.base, + revision: result.infoVersions.revision, + required: result.bump, + }), + ].filter((failure) => typeof failure === 'string'); + + if (failures.length) { + for (const failure of failures) logger.error(`${failure}\n`); + + throw new AbortFlowError('Diff failed.'); + } + + logger.info(green('✅ Diff passed.\n')); +} + +function byKeyAndProperty(left: JudgedChange, right: JudgedChange): number { + if (left.key !== right.key) return left.key < right.key ? -1 : 1; + + const leftProperty = left.kind === 'modified' ? left.property : ''; + const rightProperty = right.kind === 'modified' ? right.property : ''; + if (leftProperty !== rightProperty) return leftProperty < rightProperty ? -1 : 1; + + return 0; +} diff --git a/packages/cli/src/commands/diff/types.ts b/packages/cli/src/commands/diff/types.ts new file mode 100644 index 0000000000..11dfe31fca --- /dev/null +++ b/packages/cli/src/commands/diff/types.ts @@ -0,0 +1,18 @@ +import type { Impact } from '@redocly/openapi-core'; + +import type { VerifyConfigOptions } from '../../types.js'; +import type { DiffReportFormat } from './format/index.js'; + +export type DiffOutputFormat = DiffReportFormat | 'github-actions'; + +export type DiffFailOn = Impact | 'none'; + +export type DiffArgv = { + base: string; + revision: string; + format: DiffOutputFormat; + output?: string; + 'fail-on': DiffFailOn; + 'check-version': boolean; + 'skip-rule'?: string[]; +} & VerifyConfigOptions; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 309e596b2e..0daf15add7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,6 +13,7 @@ import yargs, { type Arguments } from 'yargs'; import { hideBin } from 'yargs/helpers'; import type { BuildDocsArgv } from './commands/build-docs/types.js'; +import type { DiffArgv, DiffFailOn, DiffOutputFormat } from './commands/diff/types.js'; import type { ReportFormat } from './commands/drift/engine/reporter.js'; import { type DriftArgv } from './commands/drift/index.js'; import type { FindingSeverity, MatchMode, TrafficFormat } from './commands/drift/types/index.js'; @@ -134,6 +135,61 @@ yargs(hideBin(process.argv)) commandWrapper(handleInspectNodeTypes)(argv); } ) + .command( + 'diff ', + 'Compare two API descriptions and rate every change by the semver bump it requires [experimental].', + (yargs) => + yargs + .env('REDOCLY_CLI_DIFF') + .positional('base', { type: 'string', demandOption: true }) + .positional('revision', { type: 'string', demandOption: true }) + .option({ + config: { description: 'Path to the config file.', type: 'string' }, + 'lint-config': { + description: 'Severity level for config file linting.', + choices: ['warn', 'error', 'off'] as ReadonlyArray, + default: 'warn' as RuleSeverity, + }, + format: { + description: 'Use a specific output format.', + choices: [ + 'stylish', + 'json', + 'markdown', + 'html', + 'github-actions', + 'next-version', + ] as ReadonlyArray, + default: 'stylish' as const, + }, + output: { + description: 'Write the diff report to a file.', + type: 'string', + alias: 'o', + }, + 'fail-on': { + description: + 'Exit with a non-zero code when changes of this impact or higher are found.', + choices: ['major', 'minor', 'patch', 'none'] as ReadonlyArray, + default: 'major' as const, + }, + 'check-version': { + description: + 'Fail when info.version was not bumped to match the impact of the changes.', + type: 'boolean', + default: false, + }, + 'skip-rule': { + description: 'Ignore certain rules.', + array: true, + type: 'string', + }, + }), + async (argv) => { + const { handleDiff } = await import('./commands/diff/index.js'); + commandWrapper(handleDiff)(argv as Arguments); + } + ) .command( 'score [api]', 'Score an API description for integration simplicity and agent readiness.', diff --git a/packages/core/__tests__/utils.ts b/packages/core/__tests__/utils.ts index 18ad19e1a9..9310000f31 100644 --- a/packages/core/__tests__/utils.ts +++ b/packages/core/__tests__/utils.ts @@ -1,7 +1,10 @@ import * as path from 'path'; import { + BaseResolver, + type Change, type Document, + type JudgedChange, Source, type NormalizedProblem, parseYaml, @@ -15,6 +18,40 @@ export function parseYamlToDocument(body: string, absoluteRef: string = ''): Doc }; } +// A resolver that finds these files, by paths relative to the working directory, in its cache, so +// a test that needs several files writes none to disk. +export function resolverWithFiles(files: Record): BaseResolver { + const resolver = new BaseResolver(); + for (const [file, body] of Object.entries(files)) { + const absoluteRef = path.resolve(file); + resolver.cache.set(absoluteRef, Promise.resolve(parseYamlToDocument(body, absoluteRef))); + } + return resolver; +} + +export function replaceSourceWithRefInChanges(changes: Array) { + return changes.map(({ node: _node, ...change }) => { + const mapped: Record = { ...change }; + + if ('base' in change) { + mapped.base = { ...change.base, location: change.base.location.absolutePointer }; + } + + if ('revision' in change) { + mapped.revision = { ...change.revision, location: change.revision.location.absolutePointer }; + } + + if ('verdicts' in change) { + mapped.verdicts = change.verdicts.map((verdict) => ({ + ...verdict, + location: verdict.location.absolutePointer, + })); + } + + return mapped; + }); +} + export function replaceSourceWithRef(results: NormalizedProblem[], cwd?: string) { const cwdRegexp = cwd ? new RegExp(cwd + path.sep, 'g') : /$^/; return results.map((r) => { diff --git a/packages/core/package.json b/packages/core/package.json index e0efa2a23d..db13ae7cb5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -57,8 +57,8 @@ "ajv": "npm:@redocly/ajv@^8.18.3", "ajv-formats": "^3.0.1", "colorette": "^1.2.0", + "fastest-levenshtein": "^1.0.16", "graphql": "^16.14.1", - "js-levenshtein": "^1.1.6", "js-yaml": "^5.2.2", "json-schema-to-ts": "^3.1.0", "jsonpath-rfc9535": "1.3.0", @@ -67,7 +67,6 @@ "yaml-ast-parser": "0.0.43" }, "devDependencies": { - "@types/js-levenshtein": "^1.1.0", "@types/picomatch": "^4.0.2", "@types/pluralize": "^0.0.29", "typescript": "6.0.2" diff --git a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap index 2d43b299e4..3218db61af 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -471,9 +471,11 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "async2Preprocessors": "Preprocessors", "async2Rules": "Rules", "async3Decorators": "Decorators", + "async3Diff": "DiffRules", "async3Preprocessors": "Preprocessors", "async3Rules": "Rules", "decorators": "Decorators", + "diff": "DiffRules", "extends": { "description": "Use extends to inherit rules and their configurations from other rulesets.", "documentationLink": "https://redocly.com/docs/cli/configuration/reference/extends#extends", @@ -486,12 +488,15 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "oas2Preprocessors": "Preprocessors", "oas2Rules": "Rules", "oas3_0Decorators": "Decorators", + "oas3_0Diff": "DiffRules", "oas3_0Preprocessors": "Preprocessors", "oas3_0Rules": "Rules", "oas3_1Decorators": "Decorators", + "oas3_1Diff": "DiffRules", "oas3_1Preprocessors": "Preprocessors", "oas3_1Rules": "Rules", "oas3_2Decorators": "Decorators", + "oas3_2Diff": "DiffRules", "oas3_2Preprocessors": "Preprocessors", "oas3_2Rules": "Rules", "openrpc1Decorators": "Decorators", @@ -541,6 +546,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "async2Preprocessors": "Preprocessors", "async2Rules": "Rules", "async3Decorators": "Decorators", + "async3Diff": "DiffRules", "async3Preprocessors": "Preprocessors", "async3Rules": "Rules", "asyncapi": "rootRedoclyConfigSchema.asyncapi", @@ -559,6 +565,7 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "corsProxy": "rootRedoclyConfigSchema.corsProxy", "decorators": "Decorators", "developerOnboarding": "rootRedoclyConfigSchema.developerOnboarding", + "diff": "DiffRules", "entitiesCatalog": "rootRedoclyConfigSchema.entitiesCatalog", "env": "rootRedoclyConfigSchema.env", "extends": { @@ -609,12 +616,15 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "oas2Preprocessors": "Preprocessors", "oas2Rules": "Rules", "oas3_0Decorators": "Decorators", + "oas3_0Diff": "DiffRules", "oas3_0Preprocessors": "Preprocessors", "oas3_0Rules": "Rules", "oas3_1Decorators": "Decorators", + "oas3_1Diff": "DiffRules", "oas3_1Preprocessors": "Preprocessors", "oas3_1Rules": "Rules", "oas3_2Decorators": "Decorators", + "oas3_2Diff": "DiffRules", "oas3_2Preprocessors": "Preprocessors", "oas3_2Rules": "Rules", "openapi": "rootRedoclyConfigSchema.openapi", @@ -771,6 +781,11 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] }, "properties": {}, }, + "DiffRules": { + "additionalProperties": [Function], + "description": "The \`diff\` block sets the semver impact of each diff rule: \`off\`, \`patch\`, \`minor\`, or \`major\`.", + "properties": {}, + }, "Discriminator": { "description": "When request bodies or response payloads may be one of a number of different schemas, a discriminator object can be used to aid in serialization, deserialization, and validation. The discriminator is a specific object in a schema which is used to inform the consumer of the document of an alternative schema based on the value associated with it.", "documentationLink": "https://redocly.com/learn/openapi/openapi-visual-reference/discriminator#discriminator-object", @@ -1351,12 +1366,14 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "async2Preprocessors": "Preprocessors", "async2Rules": "Rules", "async3Decorators": "Decorators", + "async3Diff": "DiffRules", "async3Preprocessors": "Preprocessors", "async3Rules": "Rules", "color": { "type": "string", }, "decorators": "Decorators", + "diff": "DiffRules", "extends": { "description": "Use extends to inherit rules and their configurations from other rulesets.", "documentationLink": "https://redocly.com/docs/cli/configuration/reference/extends#extends", @@ -1372,12 +1389,15 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "oas2Preprocessors": "Preprocessors", "oas2Rules": "Rules", "oas3_0Decorators": "Decorators", + "oas3_0Diff": "DiffRules", "oas3_0Preprocessors": "Preprocessors", "oas3_0Rules": "Rules", "oas3_1Decorators": "Decorators", + "oas3_1Diff": "DiffRules", "oas3_1Preprocessors": "Preprocessors", "oas3_1Rules": "Rules", "oas3_2Decorators": "Decorators", + "oas3_2Diff": "DiffRules", "oas3_2Preprocessors": "Preprocessors", "oas3_2Rules": "Rules", "openrpc1Decorators": "Decorators", diff --git a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap index 7d7c3f1a73..1678eac532 100644 --- a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap @@ -80,6 +80,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -101,6 +102,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "warn", "type-description": "off", @@ -156,6 +158,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -224,6 +227,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -289,6 +293,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", @@ -535,6 +540,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -556,6 +562,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "warn", "type-description": "off", @@ -611,6 +618,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -679,6 +687,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -744,6 +753,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", diff --git a/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap index 764a4fb8cc..af326c6d63 100644 --- a/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/config.test.ts.snap @@ -19,6 +19,12 @@ Config { "openrpc1": {}, "overlay1": {}, }, + "diff": { + "async3": {}, + "oas3_0": {}, + "oas3_1": {}, + "oas3_2": {}, + }, "doNotResolveExamples": false, "document": undefined, "ignore": { diff --git a/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap index 449604b428..fd001f14b1 100644 --- a/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/load.test.ts.snap @@ -19,6 +19,12 @@ Config { "openrpc1": {}, "overlay1": {}, }, + "diff": { + "async3": {}, + "oas3_0": {}, + "oas3_1": {}, + "oas3_2": {}, + }, "doNotResolveExamples": false, "document": undefined, "ignore": {}, @@ -58,20 +64,25 @@ Config { "async2Preprocessors": {}, "async2Rules": {}, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": {}, "decorators": {}, + "diff": {}, "graphqlRules": {}, "oas2Decorators": {}, "oas2Preprocessors": {}, "oas2Rules": {}, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": {}, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": {}, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": {}, "openrpc1Decorators": {}, diff --git a/packages/core/src/config/__tests__/config.test.ts b/packages/core/src/config/__tests__/config.test.ts index 5b4b2b2ecf..46b2688f34 100644 --- a/packages/core/src/config/__tests__/config.test.ts +++ b/packages/core/src/config/__tests__/config.test.ts @@ -43,6 +43,12 @@ describe('Config.forAlias', () => { "openrpc1": {}, "overlay1": {}, }, + "diff": { + "async3": {}, + "oas3_0": {}, + "oas3_1": {}, + "oas3_2": {}, + }, "doNotResolveExamples": false, "document": { "parsed": { @@ -106,20 +112,25 @@ describe('Config.forAlias', () => { "async2Preprocessors": {}, "async2Rules": {}, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": {}, "decorators": {}, + "diff": {}, "graphqlRules": {}, "oas2Decorators": {}, "oas2Preprocessors": {}, "oas2Rules": {}, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": {}, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": {}, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": {}, "openrpc1Decorators": {}, diff --git a/packages/core/src/config/__tests__/load.test.ts b/packages/core/src/config/__tests__/load.test.ts index b40067b24c..47b194a432 100644 --- a/packages/core/src/config/__tests__/load.test.ts +++ b/packages/core/src/config/__tests__/load.test.ts @@ -203,6 +203,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -224,6 +225,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "off", "type-description": "off", @@ -282,6 +284,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -352,6 +355,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -419,6 +423,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", @@ -595,6 +600,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -616,6 +622,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "warn", "type-description": "off", @@ -672,6 +679,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -740,6 +748,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -805,6 +814,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", @@ -992,6 +1002,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -1013,6 +1024,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "off", "type-description": "off", @@ -1069,6 +1081,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -1137,6 +1150,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -1202,6 +1216,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", @@ -1473,6 +1488,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -1494,6 +1510,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "off", "type-description": "off", @@ -1552,6 +1569,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -1622,6 +1640,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -1689,6 +1708,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", @@ -1865,6 +1885,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -1886,6 +1907,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "warn", "type-description": "off", @@ -1942,6 +1964,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -2010,6 +2033,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -2075,6 +2099,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", @@ -2262,6 +2287,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "async3Decorators": {}, + "async3Diff": {}, "async3Preprocessors": {}, "async3Rules": { "channels-kebab-case": "off", @@ -2283,6 +2309,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "decorators": {}, + "diff": {}, "graphqlRules": { "no-unused-types": "off", "type-description": "off", @@ -2339,6 +2366,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_0Decorators": {}, + "oas3_0Diff": {}, "oas3_0Preprocessors": {}, "oas3_0Rules": { "array-parameter-serialization": "off", @@ -2407,6 +2435,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_1Decorators": {}, + "oas3_1Diff": {}, "oas3_1Preprocessors": {}, "oas3_1Rules": { "array-parameter-serialization": "off", @@ -2472,6 +2501,7 @@ describe('loadConfig', () => { "tags-alphabetical": "off", }, "oas3_2Decorators": {}, + "oas3_2Diff": {}, "oas3_2Preprocessors": {}, "oas3_2Rules": { "array-parameter-serialization": "off", diff --git a/packages/core/src/config/builtIn.ts b/packages/core/src/config/builtIn.ts index 6141c1df8d..c8f507609b 100644 --- a/packages/core/src/config/builtIn.ts +++ b/packages/core/src/config/builtIn.ts @@ -5,6 +5,7 @@ import { decorators as oas2Decorators } from '../decorators/oas2/index.js'; import { decorators as oas3Decorators } from '../decorators/oas3/index.js'; import { decorators as openrpc1Decorators } from '../decorators/openrpc/index.js'; import { decorators as overlay1Decorators } from '../decorators/overlay1/index.js'; +import { diffRuleSets } from '../diff/rules/index.js'; import { rules as arazzo1Rules, preprocessors as arazzoPreprocessors, @@ -29,6 +30,7 @@ import { preprocessors as overlay1Preprocessors, } from '../rules/overlay1/index.js'; import all from './all.js'; +import diffRecommended from './diff-recommended.js'; import minimal from './minimal.js'; import recommendedStrict from './recommended-strict.js'; import recommended from './recommended.js'; @@ -36,11 +38,18 @@ import spec from './spec.js'; import { type Plugin, type RawGovernanceConfig } from './types.js'; export const builtInConfigs: Record = { + /** + * lint presets + */ recommended, 'recommended-strict': recommendedStrict, minimal, all, spec, + /** + * diff presets + */ + 'diff-recommended': diffRecommended, }; export const defaultPlugin: Plugin<'built-in'> = { @@ -73,5 +82,6 @@ export const defaultPlugin: Plugin<'built-in'> = { overlay1: overlay1Decorators, openrpc1: openrpc1Decorators, }, + diff: diffRuleSets, configs: builtInConfigs, }; diff --git a/packages/core/src/config/config-resolvers.ts b/packages/core/src/config/config-resolvers.ts index 0830b21f46..ed07aeee63 100644 --- a/packages/core/src/config/config-resolvers.ts +++ b/packages/core/src/config/config-resolvers.ts @@ -499,6 +499,19 @@ export async function resolvePlugins( plugin.assertions = pluginInstance.assertions; } + if (pluginInstance.diff) { + if (!pluginInstance.diff.oas3 && !pluginInstance.diff.async3) { + throw new Error(`Plugin diff rules must have \`oas3\` or \`async3\` rules "${p}.`); + } + plugin.diff = {}; + if (pluginInstance.diff.oas3) { + plugin.diff.oas3 = prefixRules(pluginInstance.diff.oas3, id); + } + if (pluginInstance.diff.async3) { + plugin.diff.async3 = prefixRules(pluginInstance.diff.async3, id); + } + } + return { ...pluginInstance, ...plugin, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ebe4ba0be0..666f5ffe70 100755 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2,6 +2,8 @@ import type { RecheckConfig } from '@redocly/config'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { isDiffFamily } from '../diff/rules/index.js'; +import type { DiffRule, Impact } from '../diff/types.js'; import { stringifyYaml } from '../js-yaml/index.js'; import { type Oas2RuleSet, @@ -19,6 +21,7 @@ import { import { isAbsoluteUrl } from '../ref-utils.js'; import type { Document, ResolvedRefMap } from '../resolve.js'; import type { NodeType } from '../types/index.js'; +import { isDefined } from '../utils/is-defined.js'; import { isPlainObject } from '../utils/is-plain-object.js'; import { omit } from '../utils/omit.js'; import { slash } from '../utils/slash.js'; @@ -52,6 +55,8 @@ export class Config { preprocessors: Record>; decorators: Record>; recheck: RecheckConfig; + /** Only the specifications the diff rules cover; the rest are absent, like in `DIRECTIONS`. */ + diff: Partial>>; private _usedRules: Set = new Set(); private _usedVersions: Set = new Set(); @@ -161,6 +166,13 @@ export class Config { }; this.recheck = resolvedConfig.recheck ?? { rules: {} }; + this.diff = { + oas3_0: { ...resolvedConfig.diff, ...resolvedConfig.oas3_0Diff }, + oas3_1: { ...resolvedConfig.diff, ...resolvedConfig.oas3_1Diff }, + oas3_2: { ...resolvedConfig.diff, ...resolvedConfig.oas3_2Diff }, + async3: { ...resolvedConfig.diff, ...resolvedConfig.async3Diff }, + }; + this.ignore = opts.ignore ?? {}; } @@ -346,6 +358,12 @@ export class Config { } // TODO: add rules for redocly.yaml / entities? + /** The diff rule sets every plugin offers for that specification family. */ + getDiffRulesForSpecVersion(family: SpecMajorVersion): Record[] { + if (!isDiffFamily(family)) return []; + return this.plugins.map((plugin) => plugin.diff?.[family]).filter(isDefined); + } + getRulesForSpecVersion(version: SpecMajorVersion) { switch (version) { case 'oas3': { @@ -436,6 +454,10 @@ export class Config { } } + getDiffImpact(ruleId: string, specVersion: SpecVersion): Impact | 'off' { + return this.diff[specVersion]?.[ruleId] || 'off'; + } + skipRules(rules?: string[]) { for (const ruleId of rules || []) { for (const version of specVersions) { @@ -472,4 +494,14 @@ export class Config { } } } + + skipDiffRules(rules?: string[]) { + for (const ruleId of rules || []) { + for (const impacts of Object.values(this.diff)) { + if (impacts[ruleId]) { + impacts[ruleId] = 'off'; + } + } + } + } } diff --git a/packages/core/src/config/constants.ts b/packages/core/src/config/constants.ts index aa8853a751..c35144bd65 100644 --- a/packages/core/src/config/constants.ts +++ b/packages/core/src/config/constants.ts @@ -1,6 +1,6 @@ export const CONFIG_FILE_NAME = 'redocly.yaml'; -export const DEFAULT_CONFIG = { extends: ['recommended'] }; +export const DEFAULT_CONFIG = { extends: ['recommended', 'diff-recommended'] }; export const IGNORE_FILE = '.redocly.lint-ignore.yaml'; export const IGNORE_BANNER = diff --git a/packages/core/src/config/diff-recommended.ts b/packages/core/src/config/diff-recommended.ts new file mode 100644 index 0000000000..8be9af7057 --- /dev/null +++ b/packages/core/src/config/diff-recommended.ts @@ -0,0 +1,39 @@ +import type { DiffRuleId } from '../diff/rules/index.js'; +import type { Impact } from '../diff/types.js'; +import { type RawGovernanceConfig } from './types.js'; + +export const defaultDiffRules: Record = { + 'channel-address-changed': 'major', + 'channel-removed': 'major', + 'enum-values-added': 'major', + 'enum-values-removed': 'major', + 'media-type-removed': 'major', + 'message-content-type-changed': 'major', + 'message-removed': 'major', + 'operation-action-changed': 'major', + 'operation-id-changed': 'major', + 'operation-removed': 'major', + 'parameter-became-required': 'major', + 'parameter-removed': 'major', + 'parameter-serialization-changed': 'major', + 'path-removed': 'major', + 'property-removed': 'major', + 'request-body-became-required': 'major', + 'request-body-removed': 'major', + 'required-properties-added': 'major', + 'required-properties-removed': 'major', + 'response-header-removed': 'major', + 'response-removed': 'major', + 'schema-combinator-changed': 'major', + 'schema-constraint-changed': 'major', + 'schema-type-changed': 'major', + 'security-requirement-changed': 'major', + 'security-scheme-changed': 'major', + 'server-removed': 'major', +}; + +const diffRecommended: RawGovernanceConfig<'built-in'> = { + diff: defaultDiffRules, +}; + +export default diffRecommended; diff --git a/packages/core/src/config/rules.ts b/packages/core/src/config/rules.ts index 85ae65212b..ca87333f93 100644 --- a/packages/core/src/config/rules.ts +++ b/packages/core/src/config/rules.ts @@ -1,3 +1,4 @@ +import type { DiffRule, DiffVisitor, Impact } from '../diff/types.js'; import type { Arazzo1RuleSet, Async2RuleSet, @@ -73,3 +74,27 @@ export function initRules( .flatMap((visitor) => visitor) .filter(isDefined); } + +export type InitializedDiffRule = { + impact: Impact; + ruleId: string; + visitor: DiffVisitor; +}; + +export function initDiffRules( + rules: Record[], + config: Config, + specVersion: SpecVersion +): InitializedDiffRule[] { + return rules.flatMap((ruleset) => + Object.keys(ruleset).flatMap((ruleId) => { + const impact = config.getDiffImpact(ruleId, specVersion); + + if (impact === 'off') { + return []; + } + + return [{ impact, ruleId, visitor: ruleset[ruleId]() }]; + }) + ); +} diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 5cd93f7faa..147b21e03f 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -1,6 +1,8 @@ import type { ApiConfig, RecheckConfig, RedoclyConfig } from '@redocly/config'; import type { JSONSchema } from 'json-schema-to-ts'; +import type { DiffRuleId, DiffRuleSets } from '../diff/rules/index.js'; +import type { DiffRule, Impact } from '../diff/types.js'; import type { SpecMajorVersion, Oas3DecoratorsSet, @@ -144,6 +146,11 @@ export type RawGovernanceConfig = openrpc1Decorators?: Record; recheck?: RecheckConfig; + diff?: RuleMap; + oas3_0Diff?: RuleMap; + oas3_1Diff?: RuleMap; + oas3_2Diff?: RuleMap; + async3Diff?: RuleMap; }; export type ResolvedGovernanceConfig = Omit; @@ -191,6 +198,10 @@ export type RulesConfig = { export type CustomRulesConfig = RulesConfig; +export type DiffRulesConfig = { + [Family in keyof DiffRuleSets]?: RuleMap; +}; + export type AssertionContext = Partial & SkipFunctionContext & { node: any }; export type AssertResult = { message?: string; location?: Location }; @@ -207,10 +218,11 @@ export type Plugin = { configs?: Record; rules?: RulesConfig; + assertions?: AssertionsConfig; preprocessors?: PreprocessorsConfig; decorators?: DecoratorsConfig; + diff?: DiffRulesConfig; typeExtension?: TypeExtensionsConfig; - assertions?: AssertionsConfig; // Realm properties path?: string; diff --git a/packages/core/src/config/utils.ts b/packages/core/src/config/utils.ts index e3429b1fd7..9bb845bddd 100644 --- a/packages/core/src/config/utils.ts +++ b/packages/core/src/config/utils.ts @@ -86,6 +86,11 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { openrpc1Decorators: {}, recheck, + diff: {}, + oas3_0Diff: {}, + oas3_1Diff: {}, + oas3_2Diff: {}, + async3Diff: {}, }; for (const rulesConf of rulesConfList) { @@ -164,6 +169,15 @@ export function mergeExtends(rulesConfList: ResolvedGovernanceConfig[]) { assignOnlyExistingConfig(result.openrpc1Decorators, rulesConf.decorators); recheck = mergeRecheckBlocks(recheck, rulesConf.recheck); + assignConfig(result.diff, rulesConf.diff); + assignConfig(result.oas3_0Diff, rulesConf.oas3_0Diff); + assignOnlyExistingConfig(result.oas3_0Diff, rulesConf.diff); + assignConfig(result.oas3_1Diff, rulesConf.oas3_1Diff); + assignOnlyExistingConfig(result.oas3_1Diff, rulesConf.diff); + assignConfig(result.oas3_2Diff, rulesConf.oas3_2Diff); + assignOnlyExistingConfig(result.oas3_2Diff, rulesConf.diff); + assignConfig(result.async3Diff, rulesConf.async3Diff); + assignOnlyExistingConfig(result.async3Diff, rulesConf.diff); } result.recheck = recheck; diff --git a/packages/core/src/diff/__tests__/changes.test.ts b/packages/core/src/diff/__tests__/changes.test.ts new file mode 100644 index 0000000000..8fab0d2a69 --- /dev/null +++ b/packages/core/src/diff/__tests__/changes.test.ts @@ -0,0 +1,205 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../__tests__/utils.js'; +import { compareTrees } from '../diff-tree.js'; +import { nodeTreeOf, treeOf } from './utils.js'; + +const cafe = (menuItem: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: {} + components: + schemas: + MenuItem: ${menuItem} +`; + +describe('changesOf', () => { + it('should report every property that differs, at its escaped pointer on each side', async () => { + const base = await nodeTreeOf( + cafe("{ type: integer, x-price/unit: cents, description: 'A drink' }") + ); + const revision = await nodeTreeOf( + cafe("{ type: number, x-price/unit: euros, description: 'A drink' }") + ); + + const changes = compareTrees(base, revision); + + expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "api.yaml#/components/schemas/MenuItem/type", + "value": "integer", + }, + "key": "#/components/schemas/MenuItem", + "kind": "modified", + "property": "type", + "revision": { + "location": "api.yaml#/components/schemas/MenuItem/type", + "value": "number", + }, + }, + { + "base": { + "location": "api.yaml#/components/schemas/MenuItem/x-price~1unit", + "value": "cents", + }, + "key": "#/components/schemas/MenuItem", + "kind": "modified", + "property": "x-price/unit", + "revision": { + "location": "api.yaml#/components/schemas/MenuItem/x-price~1unit", + "value": "euros", + }, + }, + ] + `); + }); + + it('should locate a property one side does not have at the node itself', async () => { + const base = await nodeTreeOf(cafe('{ type: object }')); + const revision = await nodeTreeOf(cafe('{ type: object, minProperties: 1 }')); + + const changes = compareTrees(base, revision); + + expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "api.yaml#/components/schemas/MenuItem", + "value": undefined, + }, + "key": "#/components/schemas/MenuItem", + "kind": "modified", + "property": "minProperties", + "revision": { + "location": "api.yaml#/components/schemas/MenuItem/minProperties", + "value": 1, + }, + }, + ] + `); + }); + + it('should report a removed node once, with its whole value, and nothing below it', async () => { + const base = await nodeTreeOf(cafe('{ properties: { price: { type: number, minimum: 0 } } }')); + const revision = await nodeTreeOf(cafe('{ properties: {} }')); + + const changes = compareTrees(base, revision); + + expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "api.yaml#/components/schemas/MenuItem/properties/price", + "value": { + "minimum": 0, + "type": "number", + }, + }, + "key": "#/components/schemas/MenuItem/properties/price", + "kind": "removed", + }, + ] + `); + }); + + it('should report a node of another type in the same place as a removal and an addition', () => { + // The walker gives a place its type from the document, so the two sides can disagree. + const base = treeOf(` + #/ Root + #/components Components + #/components/examples NamedExamples + #/components/examples/latte Example + `); + const revision = treeOf(` + #/ Root + #/components Components + #/components/examples NamedExamples + #/components/examples/latte Schema + `); + + const changes = compareTrees(base.get('#/')!, revision.get('#/')!); + + expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "tree.yaml#/components/examples/latte", + "value": {}, + }, + "key": "#/components/examples/latte", + "kind": "removed", + }, + { + "key": "#/components/examples/latte", + "kind": "added", + "revision": { + "location": "tree.yaml#/components/examples/latte", + "value": {}, + }, + }, + ] + `); + }); + + it('should compare a $ref that does not resolve by where it points', async () => { + const base = await nodeTreeOf(cafe("{ $ref: '#/components/schemas/Beverage' }")); + const revision = await nodeTreeOf(cafe("{ $ref: '#/components/schemas/Dessert' }")); + + const changes = compareTrees(base, revision); + + expect(replaceSourceWithRefInChanges(changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "api.yaml#/components/schemas/MenuItem/$ref", + "value": "#/components/schemas/Beverage", + }, + "key": "#/components/schemas/MenuItem", + "kind": "modified", + "property": "$ref", + "revision": { + "location": "api.yaml#/components/schemas/MenuItem/$ref", + "value": "#/components/schemas/Dessert", + }, + }, + ] + `); + }); + + it('should compare an empty array that is not a node of its own as a value', async () => { + const orders = (security: string) => + nodeTreeOf( + `openapi: 3.1.0\ninfo: { title: Cafe, version: 1.0.0 }\npaths: {}\nsecurity: ${security}` + ); + + const changes = compareTrees( + await orders('[{ ApiKey: [] }]'), + await orders('[{ ApiKey: [], OAuth: [] }]') + ); + + expect(changes).toMatchObject([ + { key: '#/security/0', kind: 'modified', property: 'OAuth', revision: { value: [] } }, + ]); + }); + + it('should report a list that the walker records as a node once, not as a value too', async () => { + const orders = (security: string) => + nodeTreeOf( + `openapi: 3.1.0\ninfo: { title: Cafe, version: 1.0.0 }\npaths: {}\nsecurity: ${security}` + ); + + const changes = compareTrees(await orders('[]'), await orders('[{ ApiKey: [] }]')); + + expect(changes).toMatchObject([{ key: '#/security/0', kind: 'added' }]); + }); + + it('should report nothing when the documents are the same', async () => { + const root = await nodeTreeOf(cafe('{ type: string, enum: [coffee, tea] }')); + + const changes = compareTrees(root, root); + + expect(changes).toEqual([]); + }); +}); diff --git a/packages/core/src/diff/__tests__/diff-tree.test.ts b/packages/core/src/diff/__tests__/diff-tree.test.ts new file mode 100644 index 0000000000..c918e1e613 --- /dev/null +++ b/packages/core/src/diff/__tests__/diff-tree.test.ts @@ -0,0 +1,373 @@ +import * as path from 'node:path'; +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges, resolverWithFiles } from '../../../__tests__/utils.js'; +import { pointerOf } from '../diff-node.js'; +import { compareTrees } from '../diff-tree.js'; +import { nodeTreeOf } from './utils.js'; + +const cafe = (body: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + ${body} +`; + +describe('compareTrees', () => { + const menu = (parameters: string) => + cafe(outdent` + paths: + /menu: + get: + parameters: ${parameters} + responses: {} + `); + const compare = async (base: string, revision: string) => + compareTrees(await nodeTreeOf(base), await nodeTreeOf(revision)); + + it('should compare each child with the child it pairs with', async () => { + const changes = await compare( + menu('[{ name: limit, in: query }, { name: offset, in: query }]'), + menu('[{ name: offset, in: query }, { name: limit, in: query, description: How many }]') + ); + + expect(changes).toMatchObject([ + { key: '#/paths/~1menu/get/parameters/1', kind: 'modified', property: 'description' }, + ]); + }); + + it('should pair a map entry renamed to a similar key and report the key as changed', async () => { + const changes = await compare( + cafe('paths: { /order: { get: { responses: {} } } }'), + cafe('paths: { /orders: { get: { responses: {} } } }') + ); + + expect(changes).toMatchObject([ + { + key: '#/paths/~1orders', + kind: 'modified', + property: 'key', + base: { value: '/order' }, + revision: { value: '/orders' }, + }, + ]); + }); + + it('should tell fixed fields from map entries by the node types', async () => { + const changes = await compare( + cafe( + 'paths: {}\ncomponents: { schemas: { Order: { unevaluatedProperties: { type: string } } } }' + ), + cafe('paths: {}\ncomponents: { schemas: { Order: { unevaluatedItems: { type: string } } } }') + ); + + expect(changes).toMatchObject([ + { key: '#/components/schemas/Order/unevaluatedProperties', kind: 'removed' }, + { key: '#/components/schemas/Order/unevaluatedItems', kind: 'added' }, + ]); + }); +}); + +describe('compareTrees through $refs', () => { + const menu = (schema: string, schemas: string) => + cafe(outdent` + paths: + /menu: + get: + responses: + '200': + description: OK + content: + application/json: + schema: ${schema} + components: + schemas: ${schemas} + `); + const item = (nameType: string) => + `{ type: object, properties: { name: { type: ${nameType} } } }`; + const itemRef = "{ $ref: '#/components/schemas/Item' }"; + const menuItemRef = "{ $ref: '#/components/schemas/MenuItem' }"; + + // Each side has a folder of its own, so both roots reference their files by the same paths. + async function nodeTreesOfFiles(sides: Record<'base' | 'revision', Record>) { + const load = (side: 'base' | 'revision') => { + const files = Object.entries(sides[side]).map(([file, body]) => [ + path.join(side, file), + body, + ]); + return nodeTreeOf( + sides[side]['openapi.yaml'], + path.resolve(side, 'openapi.yaml'), + resolverWithFiles(Object.fromEntries(files)) + ); + }; + return { base: await load('base'), revision: await load('revision') }; + } + + it('should compare $refs to different targets by what the targets describe', async () => { + const schemas = `{ Item: ${item('string')}, MenuItem: ${item('integer')} }`; + const base = await nodeTreeOf(menu(itemRef, schemas)); + const revision = await nodeTreeOf(menu(menuItemRef, schemas)); + + expect(replaceSourceWithRefInChanges(compareTrees(base, revision))).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "api.yaml#/components/schemas/Item/properties/name/type", + "value": "string", + }, + "key": "#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/name", + "kind": "modified", + "property": "type", + "revision": { + "location": "api.yaml#/components/schemas/MenuItem/properties/name/type", + "value": "integer", + }, + }, + ] + `); + }); + + it('should find no change where an inline schema moved into a component that reads the same', async () => { + const base = await nodeTreeOf(menu(item('string'), '{}')); + const revision = await nodeTreeOf(menu(itemRef, `{ Item: ${item('string')} }`)); + + expect(compareTrees(base, revision)).toMatchObject([ + { key: '#/components/schemas/Item', kind: 'added' }, + ]); + }); + + it('should compare a component that both documents reference under the place that references it', async () => { + const base = await nodeTreeOf(menu(itemRef, `{ Item: ${item('string')} }`)); + const revision = await nodeTreeOf(menu(itemRef, `{ Item: ${item('integer')} }`)); + + expect(compareTrees(base, revision).map((change) => change.key)).toEqual([ + '#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/name', + ]); + }); + + it('should compare a pair of targets once, under the first place that references both', async () => { + const orders = (ref: string) => + cafe(outdent` + paths: + /menu: + get: + responses: + '200': { description: OK, content: { application/json: { schema: ${ref} } } } + post: + requestBody: { content: { application/json: { schema: ${ref} } } } + responses: {} + components: + schemas: { Item: ${item('string')}, MenuItem: ${item('integer')} } + `); + const base = await nodeTreeOf(orders(itemRef)); + const revision = await nodeTreeOf(orders(menuItemRef)); + + expect(compareTrees(base, revision).map((change) => change.key)).toEqual([ + '#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/name', + ]); + }); + + it('should note the other places that reach a pair it has compared', async () => { + const twoUses = (schemas: string) => + cafe(outdent` + paths: + /menu: + get: + responses: + '200': { description: OK, content: { application/json: { schema: ${itemRef} } } } + post: + requestBody: { content: { application/json: { schema: ${itemRef} } } } + responses: {} + components: + schemas: ${schemas} + `); + const base = await nodeTreeOf(twoUses(`{ Item: ${item('string')} }`)); + const revision = await nodeTreeOf(twoUses(`{ Item: ${item('integer')} }`)); + + const [nameChanged] = compareTrees(base, revision); + const itemPair = nameChanged.node.parent!.parent!; + + expect(pointerOf(itemPair)).toBe( + '#/paths/~1menu/get/responses/200/content/application~1json/schema' + ); + expect(itemPair.referencedBy.map(pointerOf)).toEqual([ + '#/paths/~1menu/post/requestBody/content/application~1json/schema', + '#/components/schemas/Item', + ]); + }); + + it('should end the comparison of components that reference themselves', async () => { + const related = (name: string, description: string) => + `{ description: ${description}, properties: { related: { $ref: '#/components/schemas/${name}' } } }`; + const schemas = `{ Item: ${related('Item', 'An item')}, MenuItem: ${related('MenuItem', 'A dish')} }`; + const base = await nodeTreeOf(menu(itemRef, schemas)); + const revision = await nodeTreeOf(menu(menuItemRef, schemas)); + + expect(compareTrees(base, revision)).toMatchObject([ + { + key: '#/paths/~1menu/get/responses/200/content/application~1json/schema', + kind: 'modified', + property: 'description', + }, + ]); + }); + + it('should locate a change in another file in the file of each side', async () => { + const files = (nameType: string) => ({ + 'openapi.yaml': menu('{ $ref: schemas/item.yaml }', '{}'), + 'schemas/item.yaml': item(nameType), + }); + const { base, revision } = await nodeTreesOfFiles({ + base: files('string'), + revision: files('integer'), + }); + + expect(compareTrees(base, revision)).toMatchObject([ + { + key: '#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/name', + kind: 'modified', + property: 'type', + base: { + location: { + source: { absoluteRef: path.resolve('base/schemas/item.yaml') }, + pointer: '#/properties/name/type', + }, + }, + revision: { + location: { + source: { absoluteRef: path.resolve('revision/schemas/item.yaml') }, + pointer: '#/properties/name/type', + }, + }, + }, + ]); + }); + + it('should match $refs to other files in a list by their files, in any order', async () => { + const files = (oneOf: string) => ({ + 'openapi.yaml': menu(`{ oneOf: ${oneOf} }`, '{}'), + 'coffee.yaml': '{ type: object, properties: { roast: { type: string } } }', + 'tea.yaml': '{ type: object, properties: { leaf: { type: integer } } }', + }); + const { base, revision } = await nodeTreesOfFiles({ + base: files('[{ $ref: coffee.yaml }, { $ref: tea.yaml }]'), + revision: files('[{ $ref: tea.yaml }, { $ref: coffee.yaml }]'), + }); + + expect(compareTrees(base, revision)).toEqual([]); + }); + + it('should compare the keys written next to a $ref at its place and its targets under $ref', async () => { + const order = (ref: string, extraType: string) => + menu( + `{ ${ref}, properties: { extra: { type: ${extraType} } } }`, + `{ Item: ${item('string')}, MenuItem: ${item('integer')} }` + ); + const base = await nodeTreeOf(order("$ref: '#/components/schemas/Item'", 'string')); + const revision = await nodeTreeOf(order("$ref: '#/components/schemas/MenuItem'", 'integer')); + + expect(compareTrees(base, revision).map((change) => change.key)).toEqual([ + '#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/extra', + '#/paths/~1menu/get/responses/200/content/application~1json/schema/$ref/properties/name', + ]); + }); + + it('should keep a $ref on its pair and locate its changes in the target', async () => { + const base = await nodeTreeOf(menu(itemRef, `{ Item: ${item('string')} }`)); + const revision = await nodeTreeOf(menu(itemRef, `{ Item: ${item('integer')} }`)); + + const [nameChanged] = compareTrees(base, revision); + const schemaPair = nameChanged.node.parent!.parent!; + + expect(schemaPair.revision).toMatchObject({ + value: { $ref: '#/components/schemas/Item' }, + resolved: { key: 'Item' }, + }); + expect(nameChanged).toMatchObject({ + key: '#/paths/~1menu/get/responses/200/content/application~1json/schema/properties/name', + revision: { location: { pointer: '#/components/schemas/Item/properties/name/type' } }, + }); + }); + + it('should compare a description added next to a $ref at the place, not the target against it', async () => { + const schemas = `{ Item: ${item('string')} }`; + const base = await nodeTreeOf(menu(itemRef, schemas)); + const revision = await nodeTreeOf( + menu("{ $ref: '#/components/schemas/Item', description: An item }", schemas) + ); + + expect(compareTrees(base, revision)).toMatchObject([ + { + key: '#/paths/~1menu/get/responses/200/content/application~1json/schema', + kind: 'modified', + property: 'description', + base: { value: undefined }, + revision: { value: 'An item' }, + }, + ]); + }); + + it('should follow the target of a component written as a $ref with keys next to it, where a path first reaches it', async () => { + const files = (nameType: string) => ({ + 'openapi.yaml': menu( + "{ $ref: '#/components/schemas/A' }", + '{ A: { $ref: ./b.yaml, description: An item } }' + ), + 'b.yaml': item(nameType), + }); + const { base, revision } = await nodeTreesOfFiles({ + base: files('string'), + revision: files('integer'), + }); + + expect(compareTrees(base, revision)).toMatchObject([ + { + key: '#/paths/~1menu/get/responses/200/content/application~1json/schema/$ref/properties/name', + kind: 'modified', + property: 'type', + }, + ]); + }); + + it('should compare the one target of $refs with keys next to them under $ref of the place', async () => { + const schema = "{ $ref: '#/components/schemas/Item', description: A dish }"; + const base = await nodeTreeOf(menu(schema, `{ Item: ${item('string')} }`)); + const revision = await nodeTreeOf(menu(schema, `{ Item: ${item('integer')} }`)); + + expect(compareTrees(base, revision).map((change) => change.key)).toEqual([ + '#/paths/~1menu/get/responses/200/content/application~1json/schema/$ref/properties/name', + ]); + }); + + it('should report a renamed path by the keys of the places, not of their targets', async () => { + const menuItem = (template: string, pathItem: string) => + cafe(outdent` + paths: + /menu/{${template}}: { $ref: '#/components/pathItems/${pathItem}' } + components: + pathItems: + Item: { get: { responses: {} } } + MenuItem: { get: { responses: {} } } + `); + const base = await nodeTreeOf(menuItem('id', 'Item')); + const revision = await nodeTreeOf(menuItem('itemId', 'MenuItem')); + + expect(replaceSourceWithRefInChanges(compareTrees(base, revision))).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "api.yaml#/paths/~1menu~1{id}", + "value": "/menu/{id}", + }, + "key": "#/paths/~1menu~1{itemId}", + "kind": "modified", + "property": "key", + "revision": { + "location": "api.yaml#/paths/~1menu~1{itemId}", + "value": "/menu/{itemId}", + }, + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/__tests__/direction.test.ts b/packages/core/src/diff/__tests__/direction.test.ts new file mode 100644 index 0000000000..bbcd51d9c0 --- /dev/null +++ b/packages/core/src/diff/__tests__/direction.test.ts @@ -0,0 +1,235 @@ +import { directionsOf } from '../direction.js'; +import { diffTreeOf, withReferences } from './utils.js'; + +describe('directionsOf for OpenAPI 3', () => { + it('should read the direction off the place a node sits in', () => { + const cafe = diffTreeOf(` + #/ Root + #/info Info + #/paths Paths + #/paths/~1orders PathItem + #/paths/~1orders/get Operation + #/paths/~1orders/get/parameters ParameterList + #/paths/~1orders/get/parameters/0 Parameter + #/paths/~1orders/get/responses Responses + #/paths/~1orders/get/responses/200 Response + #/paths/~1orders/post Operation + #/paths/~1orders/post/requestBody RequestBody + `); + + expect(directionsOf(cafe.get('#/paths/~1orders/get/parameters/0')!)).toEqual(['request']); + expect(directionsOf(cafe.get('#/paths/~1orders/get/responses/200')!)).toEqual(['response']); + expect(directionsOf(cafe.get('#/paths/~1orders/post/requestBody')!)).toEqual(['request']); + expect(directionsOf(cafe.get('#/info')!)).toEqual([]); + }); + + it('should turn the direction round under a callback or a webhook, which the API sends', () => { + const cafe = diffTreeOf(` + #/ Root + #/paths Paths + #/paths/~1orders PathItem + #/paths/~1orders/post Operation + #/paths/~1orders/post/callbacks CallbacksMap + #/paths/~1orders/post/callbacks/orderReady Callback + #/paths/~1orders/post/callbacks/orderReady/url PathItem + #/paths/~1orders/post/callbacks/orderReady/url/post Operation + #/paths/~1orders/post/callbacks/orderReady/url/post/requestBody RequestBody + #/webhooks WebhooksMap + #/webhooks/menuChanged PathItem + #/webhooks/menuChanged/post Operation + #/webhooks/menuChanged/post/responses Responses + `); + const orderReadySent = cafe.get( + '#/paths/~1orders/post/callbacks/orderReady/url/post/requestBody' + )!; + const menuChangedAnswer = cafe.get('#/webhooks/menuChanged/post/responses')!; + + expect(directionsOf(orderReadySent)).toEqual(['response']); + expect(directionsOf(menuChangedAnswer)).toEqual(['request']); + }); + + it('should give a component the directions of the places that reach it, through other components', () => { + const cafe = withReferences( + diffTreeOf(` + #/ Root + #/paths Paths + #/paths/~1orders PathItem + #/paths/~1orders/get Operation + #/paths/~1orders/get/responses Responses + #/paths/~1orders/get/responses/200 Response + #/paths/~1orders/post Operation + #/paths/~1orders/post/requestBody RequestBody + #/paths/~1menu PathItem + #/paths/~1menu/get Operation + #/paths/~1menu/get/responses Responses + #/paths/~1menu/get/responses/200 Response + #/components Components + #/components/schemas NamedSchemas + #/components/schemas/Order Schema + #/components/schemas/Order/items Schema + #/components/schemas/OrderItem Schema + #/components/schemas/OrderItem/order Schema + #/components/schemas/MenuItem Schema + #/components/schemas/Dessert Schema + `), + [ + ['#/paths/~1orders/get/responses/200', '#/components/schemas/Order'], + ['#/paths/~1orders/post/requestBody', '#/components/schemas/Order'], + ['#/components/schemas/Order/items', '#/components/schemas/OrderItem'], + ['#/components/schemas/OrderItem/order', '#/components/schemas/Order'], + ['#/paths/~1menu/get/responses/200', '#/components/schemas/MenuItem'], + ] + ); + + expect(directionsOf(cafe.get('#/components/schemas/Order')!)).toEqual(['request', 'response']); + expect(directionsOf(cafe.get('#/components/schemas/OrderItem')!)).toEqual([ + 'request', + 'response', + ]); + expect(directionsOf(cafe.get('#/components/schemas/MenuItem')!)).toEqual(['response']); + expect(directionsOf(cafe.get('#/components/schemas/Dessert')!)).toEqual([]); + }); + + it('should read the direction off the place of a $ref when the component it points at says nothing', () => { + const cafe = withReferences( + diffTreeOf(` + #/ Root + #/paths Paths + #/paths/~1orders PathItem + #/paths/~1orders/post Operation + #/paths/~1orders/post/requestBody RequestBody + #/components Components + #/components/requestBodies NamedRequestBodies + #/components/requestBodies/NewOrder RequestBody + `), + [['#/paths/~1orders/post/requestBody', '#/components/requestBodies/NewOrder']] + ); + + expect(directionsOf(cafe.get('#/components/requestBodies/NewOrder')!)).toEqual(['request']); + }); + + it('should take readOnly as response data and writeOnly as request data wherever the schema is', () => { + const cafe = diffTreeOf(` + #/ Root + #/paths Paths + #/paths/~1orders PathItem + #/paths/~1orders/post Operation + #/paths/~1orders/post/requestBody RequestBody + #/paths/~1orders/post/requestBody/schema Schema + #/paths/~1orders/post/requestBody/schema/properties SchemaProperties + #/paths/~1orders/post/requestBody/schema/properties/id Schema readOnly=true + #/paths/~1orders/get Operation + #/paths/~1orders/get/responses Responses + #/paths/~1orders/get/responses/200 Response + #/paths/~1orders/get/responses/200/schema Schema + #/paths/~1orders/get/responses/200/schema/properties SchemaProperties + #/paths/~1orders/get/responses/200/schema/properties/secret Schema writeOnly=true + `); + + expect(directionsOf(cafe.get('#/paths/~1orders/post/requestBody/schema')!)).toEqual([ + 'request', + ]); + expect( + directionsOf(cafe.get('#/paths/~1orders/post/requestBody/schema/properties/id')!) + ).toEqual(['response']); + expect(directionsOf(cafe.get('#/paths/~1orders/get/responses/200/schema')!)).toEqual([ + 'response', + ]); + expect( + directionsOf(cafe.get('#/paths/~1orders/get/responses/200/schema/properties/secret')!) + ).toEqual(['request']); + }); +}); + +describe('directionsOf for AsyncAPI 3', () => { + it('should judge what an operation receives as a request and what it sends as a response', () => { + const kitchen = withReferences( + diffTreeOf(` + #/ Root + #/channels NamedChannels + #/channels/orderPlaced Channel + #/channels/orderReady Channel + #/channels/menuChanged Channel + #/operations NamedOperations + #/operations/onOrderPlaced Operation action=receive + #/operations/onOrderPlaced/channel Channel + #/operations/sendOrderReady Operation action=send + #/operations/sendOrderReady/channel Channel + `), + [ + ['#/operations/onOrderPlaced/channel', '#/channels/orderPlaced'], + ['#/operations/sendOrderReady/channel', '#/channels/orderReady'], + ] + ); + + expect(directionsOf(kitchen.get('#/channels/orderPlaced')!)).toEqual(['request']); + expect(directionsOf(kitchen.get('#/channels/orderReady')!)).toEqual(['response']); + expect(directionsOf(kitchen.get('#/channels/menuChanged')!)).toEqual([]); + }); + + it('should turn the direction round for the channel a reply travels on', () => { + const kitchen = withReferences( + diffTreeOf(` + #/ Root + #/channels NamedChannels + #/channels/orderConfirmed Channel + #/operations NamedOperations + #/operations/onOrderPlaced Operation action=receive + #/operations/onOrderPlaced/reply OperationReply + #/operations/onOrderPlaced/reply/channel Channel + `), + [['#/operations/onOrderPlaced/reply/channel', '#/channels/orderConfirmed']] + ); + + expect(directionsOf(kitchen.get('#/channels/orderConfirmed')!)).toEqual(['response']); + }); + + it("should judge what the revision has in the revision's directions, and what only the base has in the base's", () => { + const kitchen = withReferences( + diffTreeOf(` + #/ Root + #/channels NamedChannels + #/channels/orderReady Channel + #/channels/orderReady/messages NamedMessages + #/channels/orderReady/messages/orderReady Message + #/operations NamedOperations + #/operations/sendOrderReady Operation action=send + #/operations/sendOrderReady/channel Channel + `), + [['#/operations/sendOrderReady/channel', '#/channels/orderReady']] + ); + const sendOrderReady = kitchen.get('#/operations/sendOrderReady')!; + sendOrderReady.revision = { ...sendOrderReady.revision!, value: { action: 'receive' } }; + const orderReady = kitchen.get('#/channels/orderReady/messages/orderReady')!; + + expect(directionsOf({ ...orderReady, revision: undefined })).toEqual(['response']); + expect(directionsOf(orderReady)).toEqual(['request']); + }); + + it('should give a message the directions of both its channel and itself', () => { + const kitchen = withReferences( + diffTreeOf(` + #/ Root + #/channels NamedChannels + #/channels/orders Channel + #/channels/orders/messages NamedMessages + #/channels/orders/messages/orderPlaced Message + #/operations NamedOperations + #/operations/onOrder Operation action=receive + #/operations/onOrder/channel Channel + #/operations/sendOrderPlaced Operation action=send + #/operations/sendOrderPlaced/messages MessageList + #/operations/sendOrderPlaced/messages/0 Message + `), + [ + ['#/operations/onOrder/channel', '#/channels/orders'], + ['#/operations/sendOrderPlaced/messages/0', '#/channels/orders/messages/orderPlaced'], + ] + ); + + expect(directionsOf(kitchen.get('#/channels/orders/messages/orderPlaced')!)).toEqual([ + 'request', + 'response', + ]); + }); +}); diff --git a/packages/core/src/diff/__tests__/impact.test.ts b/packages/core/src/diff/__tests__/impact.test.ts new file mode 100644 index 0000000000..83b6866568 --- /dev/null +++ b/packages/core/src/diff/__tests__/impact.test.ts @@ -0,0 +1,68 @@ +import { pointerOf } from '../diff-node.js'; +import { defaultImpact, highestImpact, impactRank } from '../impact.js'; +import type { Change, DiffNode } from '../types.js'; +import { diffTreeOf } from './utils.js'; + +const cafe = diffTreeOf(` + #/ Root + #/paths Paths + #/components Components + #/components/schemas NamedSchemas + #/components/schemas/MenuItem Schema + #/components/schemas/MenuItem/properties SchemaProperties +`); + +function addition(node: DiffNode): Change { + return { + key: pointerOf(node), + kind: 'added', + node, + revision: { location: node.revision!.location, value: {} }, + }; +} + +describe('impact', () => { + it('should rank patch below minor below major', () => { + expect(impactRank('patch')).toBeLessThan(impactRank('minor')); + expect(impactRank('minor')).toBeLessThan(impactRank('major')); + }); + + it('should pick the highest impact, and none from no impacts', () => { + expect(highestImpact(['patch', 'major', 'minor'])).toBe('major'); + expect(highestImpact([])).toBeUndefined(); + }); + + it('should take an unjudged addition for a minor and anything else for a patch', () => { + const paths = cafe.get('#/paths')!; + const location = paths.base!.location; + + expect(defaultImpact(addition(paths))).toBe('minor'); + expect( + defaultImpact({ + key: pointerOf(paths), + kind: 'removed', + node: paths, + base: { location, value: {} }, + }) + ).toBe('patch'); + expect( + defaultImpact({ + key: pointerOf(paths), + kind: 'modified', + property: 'x-internal', + node: paths, + base: { location, value: false }, + revision: { location, value: true }, + }) + ).toBe('patch'); + }); + + it('should take a new component, or a new map of them, for a patch until something uses it', () => { + expect(defaultImpact(addition(cafe.get('#/components')!))).toBe('patch'); + expect(defaultImpact(addition(cafe.get('#/components/schemas')!))).toBe('patch'); + expect(defaultImpact(addition(cafe.get('#/components/schemas/MenuItem')!))).toBe('patch'); + expect(defaultImpact(addition(cafe.get('#/components/schemas/MenuItem/properties')!))).toBe( + 'minor' + ); + }); +}); diff --git a/packages/core/src/diff/__tests__/index.test.ts b/packages/core/src/diff/__tests__/index.test.ts new file mode 100644 index 0000000000..08b61b27eb --- /dev/null +++ b/packages/core/src/diff/__tests__/index.test.ts @@ -0,0 +1,750 @@ +import { outdent } from 'outdent'; + +import { + replaceSourceWithRef, + replaceSourceWithRefInChanges, + resolverWithFiles, +} from '../../../__tests__/utils.js'; +import { createConfig, type Config } from '../../config/index.js'; +import { makeDocumentFromString, type Document } from '../../resolve.js'; +import { HandledError } from '../../utils/error.js'; +import type { Oas3Preprocessor } from '../../visitors.js'; +import { diffDocuments } from '../index.js'; + +const orderStatus = (paths: string, statuses: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: ${paths} + components: + schemas: + OrderStatus: { type: string, enum: ${statuses} } +`; + +const menu = (parameters: string, description = 'OK') => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + parameters: ${parameters} + responses: + '200': { description: ${description} } +`; + +const menuItem = (template: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu/{${template}}: + get: + parameters: + - { name: ${template}, in: path, required: true, schema: { type: string } } + responses: + '200': { description: OK } +`; + +describe('diffDocuments', () => { + it('should not judge a component by a place that only the revision has', async () => { + const placeOrder = outdent` + { /orders: { post: { + requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/OrderStatus' } } } }, + responses: { 201: { description: Created } } } } } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(orderStatus('{}', '[placed, preparing, ready]'), 'base.yaml'), + revision: makeDocumentFromString(orderStatus(placeOrder, '[placed, ready]'), 'revision.yaml'), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "minor", + "key": "#/paths/~1orders", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders", + "value": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderStatus", + }, + }, + }, + }, + "responses": { + "201": { + "description": "Created", + }, + }, + }, + }, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/components/schemas/OrderStatus/enum", + "value": [ + "placed", + "preparing", + "ready", + ], + }, + "impact": "patch", + "key": "#/components/schemas/OrderStatus", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/components/schemas/OrderStatus/enum", + "value": [ + "placed", + "ready", + ], + }, + "verdicts": [], + }, + ] + `); + }); + + it('should judge a part only the base has by the base, not by what the revision marks above it', async () => { + const orders = (meta: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + get: + responses: + '200': + description: OK + content: + application/json: + schema: { type: object, properties: { meta: ${meta} } } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString( + orders('{ type: object, properties: { a: { type: string }, b: { type: string } } }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + orders('{ type: object, writeOnly: true, properties: { a: { type: string } } }'), + 'revision.yaml' + ), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(result.changes.find((change) => change.kind === 'removed')).toMatchObject({ + key: '#/paths/~1orders/get/responses/200/content/application~1json/schema/properties/meta/properties/b', + impact: 'major', + verdicts: [{ ruleId: 'property-removed' }], + }); + }); + + it('should judge a component under a $ref with keys next to it in the direction of its place', async () => { + const placeOrder = outdent` + { /orders: { post: { + requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/OrderStatus', description: The first status } } } }, + responses: { 201: { description: Created } } } } } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString( + orderStatus(placeOrder, '[placed, preparing, ready]'), + 'base.yaml' + ), + revision: makeDocumentFromString(orderStatus(placeOrder, '[placed, ready]'), 'revision.yaml'), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(result.changes).toMatchObject([ + { + kind: 'modified', + property: 'enum', + impact: 'major', + verdicts: [{ ruleId: 'enum-values-removed' }], + }, + ]); + }); + + it('should pair reordered parameters by their content and judge only what changed', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + menu( + '[{ name: limit, in: query, schema: { type: integer } }, { name: search, in: query }]' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + menu( + '[{ name: search, in: query }, { name: limit, in: query, required: true, schema: { type: number } }]', + 'Menu items' + ), + 'revision.yaml' + ), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/1", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/1/required", + "value": true, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/1/required", + "message": "\`limit\` query parameter became required.", + "ruleId": "parameter-became-required", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0/schema/type", + "value": "integer", + }, + "impact": "patch", + "key": "#/paths/~1menu/get/parameters/1/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/1/schema/type", + "value": "number", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1menu/get/responses/200/description", + "value": "OK", + }, + "impact": "patch", + "key": "#/paths/~1menu/get/responses/200", + "kind": "modified", + "property": "description", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/responses/200/description", + "value": "Menu items", + }, + "verdicts": [], + }, + ] + `); + expect(result.summary).toEqual({ major: 1, minor: 0, patch: 2 }); + expect(result.bump).toBe('major'); + }); + + it('should report a renamed path parameter as a change of key and name, not a removal', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(menuItem('id'), 'base.yaml'), + revision: makeDocumentFromString(menuItem('menuItemId'), 'revision.yaml'), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu~1{id}", + "value": "/menu/{id}", + }, + "impact": "patch", + "key": "#/paths/~1menu~1{menuItemId}", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1menu~1{menuItemId}", + "value": "/menu/{menuItemId}", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1menu~1{id}/get/parameters/0/name", + "value": "id", + }, + "impact": "patch", + "key": "#/paths/~1menu~1{menuItemId}/get/parameters/0", + "kind": "modified", + "property": "name", + "revision": { + "location": "revision.yaml#/paths/~1menu~1{menuItemId}/get/parameters/0/name", + "value": "menuItemId", + }, + "verdicts": [], + }, + ] + `); + expect(result.bump).toBe('patch'); + }); + + it('should find no change where an inline schema moved into a component that a $ref with a description points to', async () => { + const cafe = (schema: string, schemas: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + responses: + '200': + description: OK + content: + application/json: + schema: ${schema} + components: + schemas: ${schemas} + `; + const item = '{ type: object, properties: { name: { type: string } } }'; + + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(item, '{}'), 'base.yaml'), + revision: makeDocumentFromString( + cafe("{ $ref: '#/components/schemas/Item', description: A dish }", `{ Item: ${item} }`), + 'revision.yaml' + ), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(result.changes.map(({ key, kind, impact }) => ({ key, kind, impact }))).toEqual([ + { key: '#/components/schemas/Item', kind: 'added', impact: 'patch' }, + ]); + }); + + it('should leave the change of info.version out of the required bump', async () => { + const cafe = (version: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: ${version} } + paths: {} + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('1.0.0'), 'base.yaml'), + revision: makeDocumentFromString(cafe('2.0.0'), 'revision.yaml'), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(result.changes).toHaveLength(1); + expect(result.bump).toBeUndefined(); + }); + + it('should compare a specification without diff rules by its structure alone', async () => { + const swagger = (paths: string) => outdent` + swagger: '2.0' + info: { title: Cafe, version: 1.0.0 } + paths: ${paths} + `; + + const result = await diffDocuments({ + base: makeDocumentFromString( + swagger('{ /menu: { get: { responses: { 200: { description: OK } } } } }'), + 'base.yaml' + ), + revision: makeDocumentFromString(swagger('{}'), 'revision.yaml'), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu", + "value": { + "get": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + "impact": "patch", + "key": "#/paths/~1menu", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); + + it('should name a header added to a Swagger 2 response by its key in the headers map', async () => { + const swagger = (headers: string) => outdent` + swagger: '2.0' + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + responses: + '200': { description: OK, headers: ${headers} } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(swagger('{ X-Request-Id: { type: string } }'), 'base.yaml'), + revision: makeDocumentFromString( + swagger('{ X-Request-Id: { type: string }, X-Rate-Limit: { type: integer } }'), + 'revision.yaml' + ), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(result.changes).toMatchObject([ + { + key: '#/paths/~1menu/get/responses/200/headers/X-Rate-Limit', + kind: 'added', + node: { revision: { key: 'X-Rate-Limit' } }, + }, + ]); + }); + + it('should judge the request body of a webhook in a file of its own as data the API sends', async () => { + const orderReady = (properties: string) => outdent` + post: + requestBody: + content: + application/json: + schema: { type: object, properties: ${properties} } + responses: + '200': { description: OK } + `; + const root = outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + webhooks: + orderReady: { $ref: webhooks/order-ready.yaml } + `; + const resolver = resolverWithFiles({ + 'base/openapi.yaml': root, + 'base/webhooks/order-ready.yaml': orderReady( + '{ id: { type: string }, note: { type: string } }' + ), + 'revision/openapi.yaml': root, + 'revision/webhooks/order-ready.yaml': orderReady('{ id: { type: string } }'), + }); + const load = async (side: 'base' | 'revision') => + (await resolver.resolveDocument(null, `${side}/openapi.yaml`, true)) as Document; + + const result = await diffDocuments({ + base: await load('base'), + revision: await load('revision'), + config: await createConfig({ extends: ['diff-recommended'] }), + externalRefResolver: resolver, + }); + + expect(result.changes).toMatchObject([ + { + key: '#/webhooks/orderReady/post/requestBody/content/application~1json/schema/properties/note', + kind: 'removed', + impact: 'major', + verdicts: [{ ruleId: 'property-removed' }], + }, + ]); + }); + + it('should judge the target of a chain of $refs with keys next to them in the direction of the path that uses it', async () => { + const item = (type: string) => outdent` + get: + responses: + '200': + description: OK + content: + application/json: + schema: { type: ${type} } + callbacks: + onEvent: + '{$request.body#/url}': { $ref: '#' } + `; + const root = outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: { $ref: ./menu.yaml } + `; + const menu = '{ $ref: ./item.yaml, summary: Menu }'; + const resolver = resolverWithFiles({ + 'base/openapi.yaml': root, + 'base/menu.yaml': menu, + 'base/item.yaml': item('string'), + 'revision/openapi.yaml': root, + 'revision/menu.yaml': menu, + 'revision/item.yaml': item('integer'), + }); + const load = async (side: 'base' | 'revision') => + (await resolver.resolveDocument(null, `${side}/openapi.yaml`, true)) as Document; + + const result = await diffDocuments({ + base: await load('base'), + revision: await load('revision'), + config: await createConfig({ extends: ['diff-recommended'] }), + externalRefResolver: resolver, + }); + + expect(result.changes).toMatchObject([ + { + key: '#/paths/~1menu/$ref/get/responses/200/content/application~1json/schema', + kind: 'modified', + property: 'type', + impact: 'major', + verdicts: [ + { ruleId: 'schema-type-changed', message: "Type widened from 'string' to 'integer'." }, + ], + }, + ]); + }); + + it("should judge a callback's $ref to a path's request body as a request", async () => { + const cafe = (required: string) => outdent` + openapi: 3.0.3 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + responses: { '200': { description: OK } } + callbacks: + done: + '{$request.body#/url}': + post: + requestBody: { $ref: '#/paths/~1receipts/post/requestBody' } + responses: { '200': { description: OK } } + /receipts: + post: + requestBody: + content: + application/json: + schema: + type: object + required: ${required} + properties: { id: { type: string }, total: { type: number } } + responses: { '200': { description: OK } } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('[id]'), 'base.yaml'), + revision: makeDocumentFromString(cafe('[id, total]'), 'revision.yaml'), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(result.changes).toMatchObject([ + { + key: '#/paths/~1orders/post/callbacks/done/{$request.body#~1url}/post/requestBody/content/application~1json/schema', + kind: 'modified', + property: 'required', + impact: 'major', + verdicts: [{ ruleId: 'required-properties-added' }], + }, + ]); + }); + + it('should refuse to compare documents of different specifications', async () => { + const swagger = outdent` + swagger: '2.0' + info: { title: Cafe, version: 1.0.0 } + paths: {} + `; + const config = await createConfig({ extends: ['diff-recommended'] }); + + await expect( + diffDocuments({ + base: makeDocumentFromString(swagger, 'base.yaml'), + revision: makeDocumentFromString(menu('[]'), 'revision.yaml'), + config, + }) + ).rejects.toThrow(HandledError); + }); + + it('should report a $ref that does not resolve as an error', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(menu('[]'), 'base.yaml'), + revision: makeDocumentFromString( + menu("[{ $ref: '#/components/parameters/Limit' }]"), + 'revision.yaml' + ), + config: await createConfig({ extends: ['diff-recommended'] }), + }); + + expect(replaceSourceWithRef(result.problems)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/paths/~1menu/get/parameters/0", + "reportOnKey": false, + "source": "revision.yaml", + }, + ], + "message": "Can't resolve $ref", + "reference": "https://redocly.com/docs/cli/rules/oas/no-unresolved-refs", + "ruleId": "no-unresolved-refs", + "severity": "error", + "suggest": [], + }, + ] + `); + }); +}); + +describe('diffDocuments configuration', () => { + const orders = (operations: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders/{orderId}: ${operations} + `; + const get = 'get: { responses: { 200: { description: OK } } }'; + const cancel = 'delete: { responses: { 204: { description: Cancelled } } }'; + const base = makeDocumentFromString(orders(`{ ${get}, ${cancel} }`), 'base.yaml'); + const revision = makeDocumentFromString(orders(`{ ${get} }`), 'revision.yaml'); + + it('should take the impacts of diff-recommended', async () => { + const config = await createConfig({ extends: ['diff-recommended'] }); + + const [cancelRemoved] = (await diffDocuments({ base, revision, config })).changes; + + expect(cancelRemoved.impact).toBe('major'); + }); + + it('should let the diff map override a preset', async () => { + const config = await createConfig({ + extends: ['diff-recommended'], + diff: { 'operation-removed': 'minor' }, + }); + + const [cancelRemoved] = (await diffDocuments({ base, revision, config })).changes; + + expect(cancelRemoved.impact).toBe('minor'); + }); + + it('should let a specification block override a preset', async () => { + const config = await createConfig({ + extends: ['diff-recommended'], + oas3_1Diff: { 'operation-removed': 'minor' }, + }); + + const [cancelRemoved] = (await diffDocuments({ base, revision, config })).changes; + + expect(cancelRemoved.impact).toBe('minor'); + }); + + it('should leave a change unjudged when its rule is off', async () => { + const config = await createConfig({ + extends: ['diff-recommended'], + diff: { 'operation-removed': 'off' }, + }); + + const [cancelRemoved] = (await diffDocuments({ base, revision, config })).changes; + + expect(cancelRemoved.impact).toBe('patch'); + }); + + it('should run only the listed rules when no preset is extended', async () => { + const config = await createConfig({ diff: { 'path-removed': 'major' } }); + + const [cancelRemoved] = (await diffDocuments({ base, revision, config })).changes; + + expect(cancelRemoved.impact).toBe('patch'); + }); + + it('should compare the descriptions as written, without the configured decorators', async () => { + const config = await createConfig({ + extends: ['diff-recommended'], + decorators: { 'remove-x-internal': 'on' }, + }); + const withKitchen = makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders/{orderId}: { ${get}, ${cancel} } + /kitchen: + x-internal: true + get: { responses: { 200: { description: OK } } } + `, + 'revision.yaml' + ); + + const result = await diffDocuments({ base, revision: withKitchen, config }); + + expect(result.changes.map(({ key, kind }) => ({ key, kind }))).toEqual([ + { key: '#/paths/~1kitchen', kind: 'added' }, + ]); + }); +}); + +describe('diffDocuments of a document that another file refers back into', () => { + const cafe = (costType: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: schemas/item.yaml } + components: + schemas: + Price: + type: object + properties: + amount: { type: number } + cost: { type: ${costType} } + `; + + // `schemas/item.yaml` uses `Price` from the root; the revision changes `Price.cost`. + async function diffCafes(config: Config) { + const item = "properties: { price: { $ref: '../openapi.yaml#/components/schemas/Price' } }"; + const resolver = resolverWithFiles({ + 'base/openapi.yaml': cafe('number'), + 'base/schemas/item.yaml': item, + 'revision/openapi.yaml': cafe('string'), + 'revision/schemas/item.yaml': item, + }); + const load = async (side: 'base' | 'revision') => + (await resolver.resolveDocument(null, `${side}/openapi.yaml`, true)) as Document; + + const result = await diffDocuments({ + base: await load('base'), + revision: await load('revision'), + config, + externalRefResolver: resolver, + }); + return result; + } + + it('should compare what the preprocessors left of it', async () => { + // It changes `Price` from the components map, which the `$ref` in `item.yaml` does not pass through. + const removeCost: Oas3Preprocessor = () => ({ + NamedSchemas: { + leave(schemas) { + delete schemas.Price?.properties?.cost; + }, + }, + }); + + const result = await diffCafes( + await createConfig({ + plugins: [{ id: 'cafe', preprocessors: { oas3: { 'remove-cost': removeCost } } }], + preprocessors: { 'cafe/remove-cost': 'on' }, + }) + ); + + expect(result.changes).toEqual([]); + }); +}); diff --git a/packages/core/src/diff/__tests__/judge.test.ts b/packages/core/src/diff/__tests__/judge.test.ts new file mode 100644 index 0000000000..c7d976bd05 --- /dev/null +++ b/packages/core/src/diff/__tests__/judge.test.ts @@ -0,0 +1,212 @@ +import type { InitializedDiffRule } from '../../config/rules.js'; +import type { Location } from '../../ref-utils.js'; +import { appliesTo, judgeChanges } from '../judge.js'; +import type { Change, DiffRule } from '../types.js'; +import { diffTreeOf } from './utils.js'; + +const rulesOf = (rule: DiffRule): InitializedDiffRule[] => [ + { ruleId: 'my-rule', impact: 'major', visitor: rule() }, +]; +const judgeWith = (rule: DiffRule, change: Change) => + judgeChanges([change], rulesOf(rule), 'oas3_1')[0]; + +describe('judgeChanges', () => { + it('should run a nested handler for a member only and an any handler for every change', () => { + const cafe = diffTreeOf(` + #/Order Schema + #/Order/properties SchemaProperties + #/Order/properties/items Schema + `); + const order = cafe.get('#/Order')!; + const items = cafe.get('#/Order/properties/items')!; + const orderRemoved: Change = { + key: '#/Order', + kind: 'removed', + node: order, + base: { location: order.base!.location, value: {} }, + }; + const itemsRemoved: Change = { + key: '#/Order/properties/items', + kind: 'removed', + node: items, + base: { location: items.base!.location, value: {} }, + }; + const rule: DiffRule = () => ({ + any(_change, { report }) { + report({ message: 'Something changed.' }); + }, + SchemaProperties: { + Schema(_change, { report }) { + report({ message: 'A property changed.' }); + }, + }, + }); + + expect(judgeWith(rule, orderRemoved).verdicts).toMatchObject([ + { message: 'Something changed.' }, + ]); + expect(judgeWith(rule, itemsRemoved).verdicts).toMatchObject([ + { message: 'Something changed.' }, + { message: 'A property changed.' }, + ]); + }); + + it('should hand the rule the location of the changed side and report there unless told otherwise', () => { + const cafe = diffTreeOf(` + #/Order Schema + `); + const order = cafe.get('#/Order')!; + const orderRemoved: Change = { + key: '#/Order', + kind: 'removed', + node: order, + base: { location: order.base!.location, value: {} }, + }; + const seen: Location[] = []; + const elsewhere = order.base!.location.child(['type']); + const rule: DiffRule = () => ({ + Schema(_change, { report, location }) { + seen.push(location); + report({ message: 'Removed.' }); + report({ message: 'Elsewhere.', location: elsewhere }); + }, + }); + + const { impact, verdicts } = judgeWith(rule, orderRemoved); + + expect(seen).toEqual([order.base!.location]); + expect(verdicts).toEqual([ + { ruleId: 'my-rule', impact: 'major', message: 'Removed.', location: order.base!.location }, + { ruleId: 'my-rule', impact: 'major', message: 'Elsewhere.', location: elsewhere }, + ]); + expect(impact).toBe('major'); + }); + + it('should give a change no rule speaks about the default impact', () => { + const cafe = diffTreeOf(` + #/Order Schema + `); + const order = cafe.get('#/Order')!; + const orderAdded: Change = { + key: '#/Order', + kind: 'added', + node: order, + revision: { location: order.revision!.location, value: {} }, + }; + + expect(judgeWith(() => ({}), orderAdded)).toMatchObject({ impact: 'minor', verdicts: [] }); + }); + + it('should give a rule the directions of the change when it asks', () => { + const cafe = diffTreeOf(` + #/ Root + #/paths Paths + #/paths/~1orders PathItem + #/paths/~1orders/get Operation + #/paths/~1orders/get/parameters ParameterList + #/paths/~1orders/get/parameters/0 Parameter + `); + const limit = cafe.get('#/paths/~1orders/get/parameters/0')!; + const limitRemoved: Change = { + key: '#/paths/~1orders/get/parameters/0', + kind: 'removed', + node: limit, + base: { location: limit.base!.location, value: {} }, + }; + const rule: DiffRule = () => ({ + Parameter(_change, { report, getDirections }) { + report({ message: `Travels as ${getDirections().join(' and ')}.` }); + }, + }); + + expect(judgeWith(rule, limitRemoved).verdicts).toMatchObject([ + { message: 'Travels as request.' }, + ]); + }); + + it('should reject a lint hook, which a diff rule has no use for', () => { + const withEnter: DiffRule = () => ({ Schema: { enter() {} } }); + + expect(() => judgeChanges([], rulesOf(withEnter), 'oas3_1')).toThrow( + "Diff rule 'my-rule' uses 'enter' where diff visitors do not have it." + ); + }); + + it('should reject an any handler nested under a type', () => { + const withNestedAny: DiffRule = () => ({ Schema: { any() {} } }); + + expect(() => judgeChanges([], rulesOf(withNestedAny), 'oas3_1')).toThrow( + "Diff rule 'my-rule' uses 'any' where diff visitors do not have it." + ); + }); +}); + +describe('appliesTo', () => { + it('should apply a nested path to a member of the map, not to the schema that owns it', () => { + const cafe = diffTreeOf(` + #/Order Schema + #/Order/properties SchemaProperties + #/Order/properties/items Schema + `); + const order = cafe.get('#/Order')!; + const items = cafe.get('#/Order/properties/items')!; + + expect(appliesTo(['SchemaProperties', 'Schema'], items)).toBe(true); + expect(appliesTo(['SchemaProperties', 'Schema'], order)).toBe(false); + }); + + it('should not apply a nested path to a schema further down inside a member', () => { + const cafe = diffTreeOf(` + #/Order Schema + #/Order/properties SchemaProperties + #/Order/properties/items Schema + #/Order/properties/items/items Schema + `); + const orderLine = cafe.get('#/Order/properties/items/items')!; + + expect(appliesTo(['SchemaProperties', 'Schema'], orderLine)).toBe(false); + }); + + it('should apply a nested path to a member of a map at any depth', () => { + const cafe = diffTreeOf(` + #/Order Schema + #/Order/properties SchemaProperties + #/Order/properties/payment Schema + #/Order/properties/payment/oneOf OneOf + #/Order/properties/payment/oneOf/0 Schema + #/Order/properties/payment/oneOf/0/properties SchemaProperties + #/Order/properties/payment/oneOf/0/properties/cardNumber Schema + `); + const cardNumber = cafe.get('#/Order/properties/payment/oneOf/0/properties/cardNumber')!; + + expect(appliesTo(['SchemaProperties', 'Schema'], cardNumber)).toBe(true); + }); + + it('should apply OneOf › Schema to an alternative, not to a property of it', () => { + const cafe = diffTreeOf(` + #/Payment Schema + #/Payment/oneOf OneOf + #/Payment/oneOf/0 Schema + #/Payment/oneOf/0/properties SchemaProperties + #/Payment/oneOf/0/properties/cardNumber Schema + `); + const card = cafe.get('#/Payment/oneOf/0')!; + const cardNumber = cafe.get('#/Payment/oneOf/0/properties/cardNumber')!; + + expect(appliesTo(['OneOf', 'Schema'], card)).toBe(true); + expect(appliesTo(['OneOf', 'Schema'], cardNumber)).toBe(false); + }); + + it('should apply a flat path to every node of its type and the empty path to any node', () => { + const cafe = diffTreeOf(` + #/ Root + #/Order Schema + #/Order/items Schema + `); + const root = cafe.get('#/')!; + const orderLine = cafe.get('#/Order/items')!; + + expect(appliesTo(['Schema'], orderLine)).toBe(true); + expect(appliesTo([], root)).toBe(true); + }); +}); diff --git a/packages/core/src/diff/__tests__/pair-children.test.ts b/packages/core/src/diff/__tests__/pair-children.test.ts new file mode 100644 index 0000000000..a1c0cdbb8f --- /dev/null +++ b/packages/core/src/diff/__tests__/pair-children.test.ts @@ -0,0 +1,120 @@ +import type { NodeEntry, NodeValue } from '../../node-tree/types.js'; +import { Location } from '../../ref-utils.js'; +import { Source } from '../../resolve.js'; +import { pairChildren } from '../pair-children.js'; +import type { Pair } from '../types.js'; + +const source = new Source('cafe.yaml', ''); + +const child = (key: string | number, type: string, value: object = {}): NodeEntry => ({ + type: { name: type, properties: {} }, + key, + // The pairing reads the key, the type name and the value of a child, and the type of its parent + // to tell a field from a map entry. + value: value as NodeValue, + location: new Location(source, `#/${key}`), + parent: null, + children: [], +}); +const parameter = (index: number, value: object) => child(index, 'Parameter', value); +const property = (name: string) => child(name, 'Schema', { type: 'string' }); +const field = (name: string) => ({ + ...property(name), + parent: { + ...child('owner', 'Schema'), + type: { name: 'Schema', properties: { [name]: { name: 'Schema', properties: {} } } }, + }, +}); + +const keysOf = (pairs: Pair[]) => pairs.map((pair) => [pair.base?.key, pair.revision?.key]); + +describe('pairChildren', () => { + it('should pair children that are the same, wherever they are', () => { + const pairs = pairChildren( + [parameter(0, { name: 'limit', in: 'query' }), parameter(1, { name: 'offset', in: 'query' })], + [parameter(0, { name: 'offset', in: 'query' }), parameter(1, { name: 'limit', in: 'query' })] + ); + + expect(keysOf(pairs)).toEqual([ + [0, 1], + [1, 0], + ]); + }); + + it('should pair the most alike of the rest and leave the others alone', () => { + const pairs = pairChildren( + [property('price'), property('name')], + [property('prices'), property('color')] + ); + + expect(keysOf(pairs)).toEqual([ + ['price', 'prices'], + ['name', undefined], + [undefined, 'color'], + ]); + }); + + it('should pair the same before the alike, so a renamed child never takes the place of an unchanged one', () => { + const pairs = pairChildren([property('limit'), property('limits')], [property('limits')]); + + expect(keysOf(pairs)).toEqual([ + ['limits', 'limits'], + ['limit', undefined], + ]); + }); + + it('should pair duplicates in turn and leave the extra one alone', () => { + const search = { name: 'search', in: 'query' }; + + const pairs = pairChildren( + [parameter(0, search), parameter(1, search)], + [parameter(0, search)] + ); + + expect(keysOf(pairs)).toEqual([ + [0, 0], + [1, undefined], + ]); + }); + + it('should only pair children of one type', () => { + const pairs = pairChildren([child('/menu', 'PathItem')], [child('x-menu', 'SpecExtension')]); + + expect(keysOf(pairs)).toEqual([ + ['/menu', undefined], + [undefined, 'x-menu'], + ]); + }); + + it('should pair a field only with the same field', () => { + const pairs = pairChildren( + [field('items'), field('unevaluatedProperties')], + [field('unevaluatedItems'), field('items')] + ); + + expect(keysOf(pairs)).toEqual([ + ['items', 'items'], + ['unevaluatedProperties', undefined], + [undefined, 'unevaluatedItems'], + ]); + }); + + it('should pair an inline item with a $ref that stands for the same content', () => { + const limit = { name: 'limit', in: 'query' }; + const limitRef = parameter(0, { $ref: '#/components/parameters/Limit' }); + limitRef.resolved = child('Limit', 'Parameter', limit); + + const pairs = pairChildren([parameter(0, limit)], [limitRef]); + + expect(keysOf(pairs)).toEqual([[0, 0]]); + }); + + it('should pair items whose keys are written in another order', () => { + const pairs = pairChildren( + [parameter(0, { name: 'limit', in: 'query', required: true, style: 'form', example: 20 })], + [parameter(0, { example: 20, style: 'form', required: true, in: 'query', name: 'limit' })] + ); + + expect(keysOf(pairs)).toEqual([[0, 0]]); + }); +}); diff --git a/packages/core/src/diff/__tests__/utils.ts b/packages/core/src/diff/__tests__/utils.ts new file mode 100644 index 0000000000..1d52abdcb3 --- /dev/null +++ b/packages/core/src/diff/__tests__/utils.ts @@ -0,0 +1,88 @@ +import { createConfig } from '../../config/index.js'; +import { detectSpec } from '../../detect-spec.js'; +import { buildNodeTree } from '../../node-tree/index.js'; +import type { NodeEntry } from '../../node-tree/types.js'; +import { getTypes } from '../../oas-types.js'; +import { Location } from '../../ref-utils.js'; +import { BaseResolver, makeDocumentFromString, resolveDocument, Source } from '../../resolve.js'; +import { normalizeTypes } from '../../types/index.js'; +import type { DiffNode } from '../types.js'; + +const source = new Source('tree.yaml', ''); + +/** The node tree of a YAML document, typed the way the diff types it. */ +export async function nodeTreeOf( + yaml: string, + absoluteRef = 'api.yaml', + externalRefResolver = new BaseResolver() +): Promise { + const document = makeDocumentFromString(yaml, absoluteRef); + const config = await createConfig({}); + const specVersion = detectSpec(document.parsed); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + const resolvedRefMap = await resolveDocument({ + rootDocument: document, + rootType: types.Root, + externalRefResolver, + }); + const ctx = { problems: [], specVersion, visitorsData: {} }; + return buildNodeTree({ document, types, resolvedRefMap, ctx }); +} + +/** + * A node tree spelled out line by line as `pointer Type`, optionally followed by `name=value` + * fields, for tests that need node types rather than a document. The first line is the root; + * each other line is a child of the line whose pointer it extends by one segment. + */ +export function treeOf(lines: string): Map { + const entries = new Map(); + + for (const line of lines.trim().split('\n')) { + const [pointer, type, ...fields] = line.trim().split(/\s+/); + const parentPointer = pointer.slice(0, pointer.lastIndexOf('/')); + const parent = entries.get(parentPointer === '#' ? '#/' : parentPointer) ?? null; + const entry: NodeEntry = { + type: { name: type, properties: {} }, + key: pointer.slice(pointer.lastIndexOf('/') + 1), + location: new Location(source, pointer), + value: Object.fromEntries( + fields.map((field) => { + const [name, value] = field.split('='); + return [name, value === 'true' ? true : value === 'false' ? false : value]; + }) + ), + parent, + children: [], + }; + parent?.children.push(entry); + entries.set(pointer, entry); + } + + return entries; +} + +/** A spelled-out tree paired with itself, its diff nodes keyed by pointer. */ +export function diffTreeOf(lines: string): Map { + const nodes = new Map(); + + for (const [pointer, entry] of treeOf(lines)) { + const parent = entry.parent ? nodes.get(entry.parent.location.pointer)! : null; + nodes.set(pointer, { base: entry, revision: entry, parent, referencedBy: [] }); + } + + return nodes; +} + +/** + * The spelled-out diff tree with each `[from, to]` pair linked as a `$ref`: the pair at `to` + * is reached again from the place at `from`, the way `compareTrees` notes it. + */ +export function withReferences( + nodes: Map, + references: Array<[from: string, to: string]> +): Map { + for (const [from, to] of references) { + nodes.get(to)!.referencedBy.push(nodes.get(from)!); + } + return nodes; +} diff --git a/packages/core/src/diff/changes.ts b/packages/core/src/diff/changes.ts new file mode 100644 index 0000000000..90ecd8e951 --- /dev/null +++ b/packages/core/src/diff/changes.ts @@ -0,0 +1,68 @@ +import { valueOf } from '../node-tree/access.js'; +import type { NodeEntry } from '../node-tree/types.js'; +import { dequal } from '../utils/dequal.js'; +import { isPlainObject } from '../utils/is-plain-object.js'; +import { isScalar, isScalarArray } from '../utils/is-scalar.js'; +import { comparedSidesOf, latestOf, pointerOf } from './diff-node.js'; +import type { Change, DiffNode, LocatedNode } from './types.js'; + +export function displaySide(change: Change): LocatedNode { + return change.kind === 'removed' ? change.base : change.revision; +} + +export function changesOf(node: DiffNode): Change[] { + const { base, revision } = comparedSidesOf(node); + + if (!base) { + return [{ key: pointerOf(node), node, kind: 'added', revision: located(latestOf(node)) }]; + } + if (!revision) { + return [{ key: pointerOf(node), node, kind: 'removed', base: located(base) }]; + } + + const changes: Change[] = []; + let key: string | undefined; + + const before = comparableValues(base); + const after = comparableValues(revision); + + for (const property of Object.keys({ ...before, ...after })) { + if (dequal(before[property], after[property])) continue; + + key ??= pointerOf(node); + changes.push({ + key, + node, + kind: 'modified', + property, + base: locatedProperty(base, property, before[property]), + revision: locatedProperty(revision, property, after[property]), + }); + } + + return changes; +} + +function comparableValues(side: NodeEntry): Record { + const values: Record = {}; + const childKeys = new Set(side.children.map((child) => child.key)); + + if (isPlainObject(side.value)) { + for (const [name, item] of Object.entries(side.value)) { + // Children and the targets of `$ref`s are compared as pairs of their own. + if (childKeys.has(name) || (name === '$ref' && side.resolved)) continue; + if (isScalar(item) || isScalarArray(item)) values[name] = item; + } + } + + return values; +} + +function located(side: NodeEntry): LocatedNode { + return { location: side.location, value: valueOf(side) }; +} + +function locatedProperty(side: NodeEntry, property: string, value: unknown): LocatedNode { + const isOwnProperty = isPlainObject(side.value) && property in side.value; + return { location: isOwnProperty ? side.location.child([property]) : side.location, value }; +} diff --git a/packages/core/src/diff/diff-node.ts b/packages/core/src/diff/diff-node.ts new file mode 100644 index 0000000000..2a7a0b0755 --- /dev/null +++ b/packages/core/src/diff/diff-node.ts @@ -0,0 +1,48 @@ +import { keyKindOf } from '../node-tree/access.js'; +import type { NodeEntry } from '../node-tree/types.js'; +import { escapePointerFragment, isRefWithSiblings, joinPointer } from '../ref-utils.js'; +import type { DiffNode, Pair } from './types.js'; + +// A pair has at least one side. +export function latestOf(node: Pair): NodeEntry { + return (node.revision ?? node.base)!; +} + +export function typeOf(node: DiffNode): string { + return latestOf(node).type.name; +} + +// A field or a list item written as a `$ref` is named after its target: `schema` or `0` says less +// than `Order`. A map entry keeps its own key. +export function nameOf(node: DiffNode): string { + const written = latestOf(node); + const name = + keyKindOf(written) === 'entry' ? written.key : (written.resolved?.key ?? written.key); + return String(name); +} + +export function hasKeysNextToRefs(sides: Pair): boolean { + if (!sides.base?.resolved || !sides.revision?.resolved) return false; + return isRefWithSiblings(sides.base.value) || isRefWithSiblings(sides.revision.value); +} + +// A `$ref` stands for its target, unless both sides are `$ref`s and either has keys next to its +// `$ref` (OpenAPI 3.1): then the written keys are compared here, and the targets one level down. +export function comparedSidesOf(node: DiffNode): Pair { + if (!node.base || !node.revision || hasKeysNextToRefs(node)) return node; + + return { + base: node.base.resolved ?? node.base, + revision: node.revision.resolved ?? node.revision, + }; +} + +export function pointerOf(node: DiffNode): string { + if (!node.parent) return '#/'; + + const written = latestOf(node); + const isRefTarget = written === latestOf(comparedSidesOf(node.parent)).resolved; + const segment = isRefTarget ? '$ref' : escapePointerFragment(String(written.key)); + + return joinPointer(pointerOf(node.parent), segment); +} diff --git a/packages/core/src/diff/diff-tree.ts b/packages/core/src/diff/diff-tree.ts new file mode 100644 index 0000000000..05af33b1e0 --- /dev/null +++ b/packages/core/src/diff/diff-tree.ts @@ -0,0 +1,68 @@ +import type { NodeEntry } from '../node-tree/types.js'; +import { changesOf } from './changes.js'; +import { comparedSidesOf, hasKeysNextToRefs, pointerOf } from './diff-node.js'; +import { pairChildren } from './pair-children.js'; +import type { Change, DiffNode } from './types.js'; + +/** A node that one side does not have is one change: nothing below it is compared. */ +export function compareTrees(base: NodeEntry, revision: NodeEntry): Change[] { + const changes: Change[] = []; + const compared = new Map(); + + function compare(node: DiffNode): void { + const sides = comparedSidesOf(node); + + if (sides.base && sides.revision) { + // A pair reached again, or through itself, was compared where it was reached first. + const id = `${sides.base.location.absolutePointer}|${sides.revision.location.absolutePointer}`; + const first = compared.get(id); + + if (first) { + first.referencedBy.push(node); + return; + } + + compared.set(id, node); + } + + changes.push(...changesOf(node)); + + if (!sides.base || !sides.revision) return; + + for (const pair of pairChildren(sides.base.children, sides.revision.children)) { + const child: DiffNode = { ...pair, parent: node, referencedBy: [] }; + + // A renamed map entry, such as a path or a property. + if ( + pair.base && + pair.revision && + typeof pair.base.key === 'string' && + pair.base.key !== pair.revision.key + ) { + changes.push({ + key: pointerOf(child), + node: child, + kind: 'modified', + property: 'key', + base: { location: pair.base.location, value: pair.base.key }, + revision: { location: pair.revision.location, value: pair.revision.key }, + }); + } + + compare(child); + } + + if (hasKeysNextToRefs(sides)) { + compare({ + base: sides.base.resolved, + revision: sides.revision.resolved, + parent: node, + referencedBy: [], + }); + } + } + + compare({ base, revision, parent: null, referencedBy: [] }); + + return changes; +} diff --git a/packages/core/src/diff/direction.ts b/packages/core/src/diff/direction.ts new file mode 100644 index 0000000000..76ffa5a963 --- /dev/null +++ b/packages/core/src/diff/direction.ts @@ -0,0 +1,78 @@ +import { enclosing, fieldOf } from '../node-tree/access.js'; +import type { NodeEntry } from '../node-tree/types.js'; +import { comparedSidesOf } from './diff-node.js'; +import type { DiffNode, Direction } from './types.js'; + +const bothDirections: Direction[] = ['request', 'response']; + +/** Empty for data that travels neither way, such as `info` or a component nothing references. */ +export function directionsOf(node: DiffNode): Direction[] { + const directions = directionsIn(node.revision ? 'revision' : 'base', node, new Set()); + + return bothDirections.filter((direction) => directions.includes(direction)); +} + +function directionsIn( + side: 'base' | 'revision', + node: DiffNode, + visited: Set +): Direction[] { + // Each place is asked once, which also ends the climb through a component that references itself. + if (visited.has(node)) return []; + visited.add(node); + + // Only pairs have children or other places that reach them, so every place climbed has `side`. + const own = ownDirection(comparedSidesOf(node)[side]!) ?? ownDirection(node[side]!); + if (own) return [own]; + + return [node.parent, ...node.referencedBy].flatMap((place) => + place ? directionsIn(side, place, visited) : [] + ); +} + +function ownDirection(node: NodeEntry): Direction | undefined { + switch (node.type.name) { + case 'RequestBody': + case 'ParameterList': + return directionAt(node, 'request'); + case 'Responses': + return directionAt(node, 'response'); + // "readOnly" data only comes from the API and "writeOnly" data only goes to it (OpenAPI, + // Schema Object), wherever the schema is used. + case 'Schema': + if (fieldOf(node, 'readOnly') === true) return 'response'; + if (fieldOf(node, 'writeOnly') === true) return 'request'; + return undefined; + // AsyncAPI 3: `receive` means another application produces the message, so its payload is + // judged the way a request body is; `send` means this application produces it. An OpenAPI + // operation has no `action`, so it says nothing. + case 'Operation': + return actionDirection(fieldOf(node, 'action')); + // A reply answers the operation, so it travels back the other way. + case 'OperationReply': { + const direction = actionDirection(fieldOf(enclosing(node, 'Operation'), 'action')); + return direction && opposite(direction); + } + default: + return undefined; + } +} + +// Outside `components`, every `Parameter` sits under a `ParameterList` and every `Response` +// under `Responses`, so the lists carry the direction. Under a callback or a webhook the API +// sends the request itself, so it is the other way round. +function directionAt(node: NodeEntry, direction: Direction): Direction | undefined { + if (enclosing(node, 'Components')) return undefined; + const sentByApi = enclosing(node, 'CallbacksMap') ?? enclosing(node, 'WebhooksMap'); + return sentByApi ? opposite(direction) : direction; +} + +function actionDirection(action: unknown): Direction | undefined { + if (action === 'receive') return 'request'; + if (action === 'send') return 'response'; + return undefined; +} + +function opposite(direction: Direction): Direction { + return direction === 'request' ? 'response' : 'request'; +} diff --git a/packages/core/src/diff/impact.ts b/packages/core/src/diff/impact.ts new file mode 100644 index 0000000000..6cdb058f2b --- /dev/null +++ b/packages/core/src/diff/impact.ts @@ -0,0 +1,30 @@ +import { typeOf } from './diff-node.js'; +import type { Change, DiffNode, Impact } from './types.js'; + +/** Lowest first. */ +export const impacts = ['patch', 'minor', 'major'] as const; + +export function impactRank(impact: Impact): number { + return impacts.indexOf(impact); +} + +export function highestImpact(candidates: Impact[]): Impact | undefined { + return candidates.reduce( + (highest, impact) => + highest === undefined || impactRank(impact) > impactRank(highest) ? impact : highest, + undefined + ); +} + +// What a change means when no rule spoke: something new is a minor, everything else a patch. +// A new component adds nothing to the API until a place uses it, and that place is a change of +// its own. +export function defaultImpact(change: Change): Impact { + return change.kind === 'added' && !isComponentOrContainer(change.node) ? 'minor' : 'patch'; +} + +// `components` itself, a map in it such as `schemas`, or a component in that map. +function isComponentOrContainer(node: DiffNode): boolean { + const map = node.parent; + return [node, map, map?.parent].some((level) => !!level && typeOf(level) === 'Components'); +} diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts new file mode 100644 index 0000000000..3ec49b0024 --- /dev/null +++ b/packages/core/src/diff/index.ts @@ -0,0 +1,135 @@ +import type { Config } from '../config/index.js'; +import { initDiffRules, initRules } from '../config/rules.js'; +import { detectSpec, getMajorSpecVersion } from '../detect-spec.js'; +import { buildNodeTree } from '../node-tree/index.js'; +import { getTypes, type SpecVersion } from '../oas-types.js'; +import { BaseResolver, resolveDocument, type Document } from '../resolve.js'; +import { normalizeTypes } from '../types/index.js'; +import { HandledError } from '../utils/error.js'; +import { isPlainObject } from '../utils/is-plain-object.js'; +import { normalizeVisitors } from '../visitors.js'; +import { walkDocument, type WalkContext } from '../walk.js'; +import { typeOf } from './diff-node.js'; +import { compareTrees } from './diff-tree.js'; +import { highestImpact } from './impact.js'; +import { judgeChanges } from './judge.js'; +import type { DiffResult, DiffSummary, Impact, JudgedChange } from './types.js'; + +export async function diffDocuments(opts: { + base: Document; + revision: Document; + config: Config; + externalRefResolver?: BaseResolver; +}): Promise { + const { base, revision, config, externalRefResolver = new BaseResolver(config.resolve) } = opts; + + const baseVersion = detectSpec(base.parsed); + const revisionVersion = detectSpec(revision.parsed); + + const family = getMajorSpecVersion(revisionVersion); + + if (getMajorSpecVersion(baseVersion) !== family) { + throw new HandledError(`Cannot compare ${baseVersion} with ${revisionVersion}.`); + } + + const [baseTree, revisionTree] = await Promise.all([ + preparedNodeTree(base, baseVersion, config, externalRefResolver), + preparedNodeTree(revision, revisionVersion, config, externalRefResolver), + ]); + + const rules = initDiffRules(config.getDiffRulesForSpecVersion(family), config, revisionVersion); + + const changes = judgeChanges( + compareTrees(baseTree.root, revisionTree.root), + rules, + revisionVersion + ); + + return { + files: { + base: base.source.absoluteRef, + revision: revision.source.absoluteRef, + }, + specVersions: { + base: baseVersion, + revision: revisionVersion, + }, + infoVersions: { base: infoVersionOf(base), revision: infoVersionOf(revision) }, + summary: countByImpact(changes), + bump: requiredBump(changes), + changes, + problems: [...baseTree.problems, ...revisionTree.problems], + }; +} + +// Preprocessors change the document first, as they do before lint. +async function preparedNodeTree( + document: Document, + specVersion: SpecVersion, + config: Config, + externalRefResolver: BaseResolver +) { + const rules = config.getRulesForSpecVersion(getMajorSpecVersion(specVersion)); + const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config); + + const ctx: WalkContext = { + problems: [], + specVersion, + config, + visitorsData: {}, + }; + + const preprocessors = initRules(rules, config, 'preprocessors', specVersion); + + let resolvedRefMap = await resolveDocument({ + rootDocument: document, + rootType: types.Root, + externalRefResolver, + }); + + if (preprocessors.length > 0) { + // Make additional pass to resolve refs defined in preprocessors. + walkDocument({ + document, + rootType: types.Root, + normalizedVisitors: normalizeVisitors(preprocessors, types), + resolvedRefMap, + ctx, + }); + resolvedRefMap = await resolveDocument({ + rootDocument: document, + rootType: types.Root, + externalRefResolver, + }); + } + + return { + root: buildNodeTree({ document, types, resolvedRefMap, ctx }), + problems: ctx.problems.map((problem) => config.addProblemToIgnore(problem)), + }; +} + +function infoVersionOf({ parsed }: Document): string | undefined { + const version = + isPlainObject(parsed) && isPlainObject(parsed.info) ? parsed.info.version : undefined; + return typeof version === 'string' ? version : undefined; +} + +function countByImpact(changes: JudgedChange[]): DiffSummary { + const summary: DiffSummary = { major: 0, minor: 0, patch: 0 }; + for (const change of changes) summary[change.impact]++; + return summary; +} + +function requiredBump(changes: JudgedChange[]): Impact | undefined { + return highestImpact( + changes.flatMap((change) => { + const isVersionChange = + change.kind === 'modified' && + change.property === 'version' && + typeOf(change.node) === 'Info'; + + return !isVersionChange ? [change.impact] : []; + }) + ); +} diff --git a/packages/core/src/diff/judge.ts b/packages/core/src/diff/judge.ts new file mode 100644 index 0000000000..babf8418d5 --- /dev/null +++ b/packages/core/src/diff/judge.ts @@ -0,0 +1,106 @@ +import type { InitializedDiffRule } from '../config/rules.js'; +import type { SpecVersion } from '../oas-types.js'; +import { displaySide } from './changes.js'; +import { typeOf } from './diff-node.js'; +import { directionsOf } from './direction.js'; +import { defaultImpact, highestImpact } from './impact.js'; +import type { + Change, + DiffNode, + DiffRuleContext, + DiffVisit, + DiffVisitor, + Direction, + Impact, + JudgedChange, + RuleVerdict, +} from './types.js'; + +type RuleHandler = { + ruleId: string; + impact: Impact; + typePath: string[]; + visit: DiffVisit; +}; + +// A diff visitor sees whole changes, not a walk, so lint's hooks have nothing to run on. +const HOOKS = new Set(['enter', 'leave', 'skip']); + +// Each handler with the types it is nested under, so matching a change needs no walk. +function flattenVisitor( + visitor: DiffVisitor, + ruleId: string, + outerTypes: string[] = [] +): Pick[] { + return Object.entries(visitor).flatMap(([type, value]) => { + if (HOOKS.has(type) || (type === 'any' && outerTypes.length > 0)) { + throw new Error(`Diff rule '${ruleId}' uses '${type}' where diff visitors do not have it.`); + } + if (typeof value !== 'function') return flattenVisitor(value, ruleId, [...outerTypes, type]); + return [{ typePath: type === 'any' ? [] : [...outerTypes, type], visit: value }]; + }); +} + +/** + * Lint's nesting: `SchemaProperties › Schema` applies to a schema whose nearest enclosing + * `SchemaProperties` or `Schema` is a `SchemaProperties` — a property, not a schema further down + * inside one. + */ +export function appliesTo(typePath: string[], node: DiffNode): boolean { + let current: DiffNode | undefined = node; + + for (let index = typePath.length - 1; index >= 0; index--) { + if (current === undefined || typeOf(current) !== typePath[index]) return false; + if (index > 0) current = nearest(current.parent, [typePath[index - 1], typePath[index]]); + } + return true; +} + +function nearest(node: DiffNode | null, types: string[]): DiffNode | undefined { + for (let current = node; current; current = current.parent) { + if (types.includes(typeOf(current))) return current; + } + return undefined; +} + +export function judgeChanges( + changes: Change[], + rules: InitializedDiffRule[], + specVersion: SpecVersion +): JudgedChange[] { + const handlers: RuleHandler[] = rules.flatMap(({ ruleId, impact, visitor }) => + flattenVisitor(visitor, ruleId).map((handler) => ({ ruleId, impact, ...handler })) + ); + + return changes.map((change) => judgeChange(change, handlers, specVersion)); +} + +function judgeChange( + change: Change, + handlers: RuleHandler[], + specVersion: SpecVersion +): JudgedChange { + // Computed once per change, and only when a rule asks. + let directions: Direction[] | undefined; + const getDirections = () => (directions ??= directionsOf(change.node)); + const location = displaySide(change).location; + const verdicts: RuleVerdict[] = []; + + for (const { ruleId, impact, typePath, visit } of handlers) { + if (!appliesTo(typePath, change.node)) continue; + + const report: DiffRuleContext['report'] = (verdict) => { + verdicts.push({ + ruleId, + impact, + message: verdict.message, + location: verdict.location ?? location, + }); + }; + + visit(change, { report, location, getDirections, specVersion }); + } + + const impact = highestImpact(verdicts.map((verdict) => verdict.impact)); + return { ...change, verdicts, impact: impact ?? defaultImpact(change) }; +} diff --git a/packages/core/src/diff/pair-children.ts b/packages/core/src/diff/pair-children.ts new file mode 100644 index 0000000000..95095ade6e --- /dev/null +++ b/packages/core/src/diff/pair-children.ts @@ -0,0 +1,117 @@ +import { distance } from 'fastest-levenshtein'; + +import { keyKindOf, valueOf } from '../node-tree/access.js'; +import type { NodeEntry } from '../node-tree/types.js'; +import { isPlainObject } from '../utils/is-plain-object.js'; +import type { Pair } from './types.js'; + +// Git counts a file as renamed when at least half of it is unchanged; the same share pairs a +// renamed key or a changed list item with what it was. +const PAIRING_THRESHOLD = 0.5; + +/** + * Pairs each child with the same one on the other side, or else the most alike of its type. The + * same ones pair first, so a changed child never takes the place of an unchanged one. + */ +export function pairChildren(base: NodeEntry[], revision: NodeEntry[]): Pair[] { + const pairs: Pair[] = []; + const unpaired = new Set(revision); + const changed: NodeEntry[] = []; + + const written = knownAs([...base, ...revision], (child) => child.value); + for (const child of base) { + const same = findSame(child, unpaired, written); + + if (same) { + unpaired.delete(same); + pairs.push({ base: child, revision: same }); + } else { + changed.push(child); + } + } + + // A `$ref` is written as the `$ref`, so two `$ref`s to one place are the same at once; what + // changed is paired by what it stands for, so a `$ref` and inline content can still meet. + const meant = knownAs([...changed, ...unpaired], valueOf); + for (const child of changed) { + const alike = findMostAlike(child, unpaired, meant); + + if (alike) unpaired.delete(alike); + pairs.push({ base: child, revision: alike }); + } + + for (const child of unpaired) pairs.push({ revision: child }); + + return pairs; +} + +function knownAs( + children: NodeEntry[], + contentOf: (child: NodeEntry) => unknown +): Map { + return new Map( + children.map((child) => [ + child, + typeof child.key === 'string' ? child.key : jsonWithSortedKeys(contentOf(child)), + ]) + ); +} + +function findSame( + child: NodeEntry, + candidates: Set, + known: Map +): NodeEntry | undefined { + for (const candidate of candidates) { + if (candidate.type.name === child.type.name && known.get(candidate) === known.get(child)) { + return candidate; + } + } + + return undefined; +} + +// The first of the most alike wins a tie, so the document order decides. +function findMostAlike( + child: NodeEntry, + candidates: Set, + known: Map +): NodeEntry | undefined { + let mostAlike: NodeEntry | undefined; + let highestScore = 0; + + for (const candidate of candidates) { + if (candidate.type.name !== child.type.name) continue; + + const score = similarity(child, candidate, known); + + if (score >= PAIRING_THRESHOLD && score > highestScore) { + mostAlike = candidate; + highestScore = score; + } + } + + return mostAlike; +} + +// The share of the longer of the two that the edit leaves untouched. A field pairs only with the +// same field. +function similarity(child: NodeEntry, other: NodeEntry, known: Map): number { + if (keyKindOf(child) === 'field') return child.key === other.key ? 1 : 0; + + const childName = known.get(child)!; + const otherName = known.get(other)!; + const longest = Math.max(childName.length, otherName.length); + return longest === 0 ? 1 : 1 - distance(childName, otherName) / longest; +} + +function jsonWithSortedKeys(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(jsonWithSortedKeys).join(',')}]`; + if (!isPlainObject(value)) return JSON.stringify(value) ?? 'null'; + + const fields = Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${jsonWithSortedKeys(value[key])}`); + + return `{${fields.join(',')}}`; +} diff --git a/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts b/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts new file mode 100644 index 0000000000..b057c5343a --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/channel-address-changed.test.ts @@ -0,0 +1,159 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (address: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orderPlaced: { address: ${address} } +`; + +describe('channel-address-changed', () => { + it('should report a channel that moved to another address', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('orders.placed'), 'base.yaml'), + revision: makeDocumentFromString(cafe('kitchen.orders.placed'), 'revision.yaml'), + config: await createConfig({ diff: { 'channel-address-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orderPlaced/address", + "value": "orders.placed", + }, + "impact": "major", + "key": "#/channels/orderPlaced", + "kind": "modified", + "property": "address", + "revision": { + "location": "revision.yaml#/channels/orderPlaced/address", + "value": "kitchen.orders.placed", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/channels/orderPlaced/address", + "message": "\`address\` of channel \`orderPlaced\` changed from 'orders.placed' to 'kitchen.orders.placed'.", + "ruleId": "channel-address-changed", + }, + ], + }, + ] + `); + }); + + it('should report a channel that got an address', async () => { + const orders = (channel: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: ${channel} + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(orders('{ title: Orders }'), 'base.yaml'), + revision: makeDocumentFromString( + orders('{ address: orders, title: Orders }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'channel-address-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orders", + "value": undefined, + }, + "impact": "major", + "key": "#/channels/orders", + "kind": "modified", + "property": "address", + "revision": { + "location": "revision.yaml#/channels/orders/address", + "value": "orders", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/channels/orders/address", + "message": "\`address\` of channel \`orders\` was set to 'orders'.", + "ruleId": "channel-address-changed", + }, + ], + }, + ] + `); + }); + + it('should report a channel that lost its address', async () => { + const orders = (channel: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: ${channel} + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(orders('{ address: orders, title: Orders }'), 'base.yaml'), + revision: makeDocumentFromString(orders('{ title: Orders }'), 'revision.yaml'), + config: await createConfig({ diff: { 'channel-address-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orders/address", + "value": "orders", + }, + "impact": "major", + "key": "#/channels/orders", + "kind": "modified", + "property": "address", + "revision": { + "location": "revision.yaml#/channels/orders", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/channels/orders", + "message": "\`address\` of channel \`orders\` was removed.", + "ruleId": "channel-address-changed", + }, + ], + }, + ] + `); + }); + + it('should name the channel an operation points to by the channel, not by the operation field', async () => { + const kitchen = (channel: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: { address: orders } + ordersV2: { address: orders.v2 } + operations: + placeOrder: { action: send, channel: { $ref: '#/channels/${channel}' } } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(kitchen('orders'), 'base.yaml'), + revision: makeDocumentFromString(kitchen('ordersV2'), 'revision.yaml'), + config: await createConfig({ diff: { 'channel-address-changed': 'major' } }), + }); + + expect(result.changes.flatMap((change) => change.verdicts)).toMatchObject([ + { message: "`address` of channel `ordersV2` changed from 'orders' to 'orders.v2'." }, + ]); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/channel-removed.test.ts b/packages/core/src/diff/rules/__tests__/channel-removed.test.ts new file mode 100644 index 0000000000..22c64a005d --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/channel-removed.test.ts @@ -0,0 +1,117 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (channels?: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + ${channels === undefined ? '' : `channels: ${channels}`} +`; + +const placed = 'orderPlaced: { address: orders.placed }'; +const ready = 'orderReady: { address: orders.ready }'; + +// A channel is where messages travel, so both sides of it break together. +describe('channel-removed', () => { + it('should report a channel that is gone', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${placed}, ${ready} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${placed} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'channel-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orderReady", + "value": { + "address": "orders.ready", + }, + }, + "impact": "major", + "key": "#/channels/orderReady", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/channels/orderReady", + "message": "Channel \`orderReady\` was removed.", + "ruleId": "channel-removed", + }, + ], + }, + ] + `); + }); + + it('should report every channel leaving with the whole map', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${placed} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(), 'revision.yaml'), + config: await createConfig({ diff: { 'channel-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels", + "value": { + "orderPlaced": { + "address": "orders.placed", + }, + }, + }, + "impact": "major", + "key": "#/channels", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/channels", + "message": "All channels were removed.", + "ruleId": "channel-removed", + }, + ], + }, + ] + `); + }); + + it('should not report the components channels map', async () => { + const withComponents = (components: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + components: ${components} + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(withComponents(`{ channels: { ${placed} } }`), 'base.yaml'), + revision: makeDocumentFromString(withComponents('{}'), 'revision.yaml'), + config: await createConfig({ diff: { 'channel-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/channels", + "value": { + "orderPlaced": { + "address": "orders.placed", + }, + }, + }, + "impact": "patch", + "key": "#/components/channels", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts b/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts new file mode 100644 index 0000000000..8f1fb0faff --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/enum-values-added.test.ts @@ -0,0 +1,194 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (status: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${status} + responses: + '201': + description: Created + content: + application/json: + schema: ${status} +`; + +describe('enum-values-added', () => { + it('should report the values a response may now send, not the ones a request now accepts', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: string, enum: [placed, ready] }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: string, enum: [placed, preparing, ready] }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'enum-values-added': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum", + "value": [ + "placed", + "ready", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum", + "value": [ + "placed", + "preparing", + "ready", + ], + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum", + "value": [ + "placed", + "ready", + ], + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum", + "value": [ + "placed", + "preparing", + "ready", + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum", + "message": "Enum gained values: 'preparing'.", + "ruleId": "enum-values-added", + }, + ], + }, + ] + `); + }); + + it('should report an enum removed from a response, not an enum set on one', async () => { + const order = (status: string, size: string) => + cafe(`{ type: object, properties: { status: ${status}, size: ${size} } }`); + + const result = await diffDocuments({ + base: makeDocumentFromString( + order('{ type: string, enum: [placed, ready] }', '{ type: string }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + order('{ type: string }', '{ type: string, enum: [small, large] }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'enum-values-added': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status/enum", + "value": [ + "placed", + "ready", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size/enum", + "value": [ + "small", + "large", + ], + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status/enum", + "value": [ + "placed", + "ready", + ], + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status", + "message": "Enum of \`status\` was removed.", + "ruleId": "enum-values-added", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size/enum", + "value": [ + "small", + "large", + ], + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts b/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts new file mode 100644 index 0000000000..6fa31a89a0 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/enum-values-removed.test.ts @@ -0,0 +1,271 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (status: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${status} + responses: + '201': + description: Created + content: + application/json: + schema: ${status} +`; + +describe('enum-values-removed', () => { + it('should report the values a request no longer accepts, not the ones a response stops sending', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: string, enum: [placed, preparing, ready] }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: string, enum: [placed, ready] }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'enum-values-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum", + "value": [ + "placed", + "preparing", + "ready", + ], + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum", + "value": [ + "placed", + "ready", + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/enum", + "message": "Enum lost values: 'preparing'.", + "ruleId": "enum-values-removed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum", + "value": [ + "placed", + "preparing", + "ready", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/enum", + "value": [ + "placed", + "ready", + ], + }, + "verdicts": [], + }, + ] + `); + }); + + it('should not report a schema used only in a response, even where a base-side writeOnly property loses values', async () => { + const cafeWithNote = (note: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders/{orderId}: + get: + parameters: + - { name: orderId, in: path, required: true, schema: { type: string } } + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + components: + schemas: + Order: + type: object + properties: + note: ${note} + `; + const result = await diffDocuments({ + base: makeDocumentFromString( + cafeWithNote('{ type: string, writeOnly: true, enum: [a, b] }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafeWithNote('{ type: string, enum: [a] }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'enum-values-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/schemas/Order/properties/note/writeOnly", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders~1{orderId}/get/responses/200/content/application~1json/schema/properties/note", + "kind": "modified", + "property": "writeOnly", + "revision": { + "location": "revision.yaml#/components/schemas/Order/properties/note", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/components/schemas/Order/properties/note/enum", + "value": [ + "a", + "b", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders~1{orderId}/get/responses/200/content/application~1json/schema/properties/note", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/components/schemas/Order/properties/note/enum", + "value": [ + "a", + ], + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report an enum set on a request, not an enum removed from one', async () => { + const order = (status: string, size: string) => + cafe(`{ type: object, properties: { status: ${status}, size: ${size} } }`); + const result = await diffDocuments({ + base: makeDocumentFromString( + order('{ type: string }', '{ type: string, enum: [small, large] }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + order('{ type: string, enum: [placed, ready] }', '{ type: string }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'enum-values-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status/enum", + "value": [ + "placed", + "ready", + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/status/enum", + "message": "Enum of \`status\` was set to 'placed', 'ready'.", + "ruleId": "enum-values-removed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size/enum", + "value": [ + "small", + "large", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/size", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/status/enum", + "value": [ + "placed", + "ready", + ], + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size/enum", + "value": [ + "small", + "large", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size", + "kind": "modified", + "property": "enum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/size", + "value": undefined, + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts b/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts new file mode 100644 index 0000000000..fb7ad8d6a9 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/media-type-removed.test.ts @@ -0,0 +1,184 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (content?: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + responses: + '200': + description: OK + ${content === undefined ? '' : `content: ${content}`} +`; + +const json = 'application/json: { schema: { type: array } }'; +const csv = 'text/csv: { schema: { type: string } }'; + +describe('media-type-removed', () => { + it('should report a media type that is gone', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${json}, ${csv} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${json} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'media-type-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/responses/200/content/text~1csv", + "value": { + "schema": { + "type": "string", + }, + }, + }, + "impact": "major", + "key": "#/paths/~1menu/get/responses/200/content/text~1csv", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1menu/get/responses/200/content/text~1csv", + "message": "Media type \`text/csv\` was removed.", + "ruleId": "media-type-removed", + }, + ], + }, + ] + `); + }); + + it('should report every media type leaving with the whole content map', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${json} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(), 'revision.yaml'), + config: await createConfig({ diff: { 'media-type-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/responses/200/content", + "value": { + "application/json": { + "schema": { + "type": "array", + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1menu/get/responses/200/content", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1menu/get/responses/200/content", + "message": "All media types were removed.", + "ruleId": "media-type-removed", + }, + ], + }, + ] + `); + }); + + it('should report a media type replaced by another', async () => { + const xml = 'application/xml: { schema: { type: array } }'; + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${json} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${xml} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'media-type-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/responses/200/content/application~1json", + "value": "application/json", + }, + "impact": "major", + "key": "#/paths/~1menu/get/responses/200/content/application~1xml", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/responses/200/content/application~1xml", + "value": "application/xml", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/responses/200/content/application~1xml", + "message": "Media type \`application/json\` became \`application/xml\`.", + "ruleId": "media-type-removed", + }, + ], + }, + ] + `); + }); + + it('should not report a media type of a component response nothing references', async () => { + const withComponent = (content: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: {} + components: + responses: + Unused: + description: OK + content: ${content} + `; + const plain = 'text/plain: { schema: { type: string } }'; + const xml = 'application/xml: { schema: { type: array } }'; + + const result = await diffDocuments({ + base: makeDocumentFromString(withComponent(`{ ${json}, ${plain} }`), 'base.yaml'), + revision: makeDocumentFromString(withComponent(`{ ${xml} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'media-type-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/responses/Unused/content/application~1json", + "value": "application/json", + }, + "impact": "patch", + "key": "#/components/responses/Unused/content/application~1xml", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/components/responses/Unused/content/application~1xml", + "value": "application/xml", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/components/responses/Unused/content/text~1plain", + "value": { + "schema": { + "type": "string", + }, + }, + }, + "impact": "patch", + "key": "#/components/responses/Unused/content/text~1plain", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts b/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts new file mode 100644 index 0000000000..ced0be0211 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/message-content-type-changed.test.ts @@ -0,0 +1,119 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (contentType?: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: + address: orders + messages: + orderPlaced: { ${contentType === undefined ? '' : `contentType: ${contentType}, `}payload: { type: object } } +`; + +describe('message-content-type-changed', () => { + it('should report a message encoded another way', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('application/json'), 'base.yaml'), + revision: makeDocumentFromString(cafe('application/avro'), 'revision.yaml'), + config: await createConfig({ diff: { 'message-content-type-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orders/messages/orderPlaced/contentType", + "value": "application/json", + }, + "impact": "major", + "key": "#/channels/orders/messages/orderPlaced", + "kind": "modified", + "property": "contentType", + "revision": { + "location": "revision.yaml#/channels/orders/messages/orderPlaced/contentType", + "value": "application/avro", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/channels/orders/messages/orderPlaced/contentType", + "message": "\`contentType\` of message \`orderPlaced\` changed from 'application/json' to 'application/avro'.", + "ruleId": "message-content-type-changed", + }, + ], + }, + ] + `); + }); + + it('should report a message that gained or lost its content type', async () => { + const gained = await diffDocuments({ + base: makeDocumentFromString(cafe(), 'base.yaml'), + revision: makeDocumentFromString(cafe('application/json'), 'revision.yaml'), + config: await createConfig({ diff: { 'message-content-type-changed': 'major' } }), + }); + const lost = await diffDocuments({ + base: makeDocumentFromString(cafe('application/json'), 'base.yaml'), + revision: makeDocumentFromString(cafe(), 'revision.yaml'), + config: await createConfig({ diff: { 'message-content-type-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(gained.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orders/messages/orderPlaced", + "value": undefined, + }, + "impact": "major", + "key": "#/channels/orders/messages/orderPlaced", + "kind": "modified", + "property": "contentType", + "revision": { + "location": "revision.yaml#/channels/orders/messages/orderPlaced/contentType", + "value": "application/json", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/channels/orders/messages/orderPlaced/contentType", + "message": "\`contentType\` of message \`orderPlaced\` was set to 'application/json'.", + "ruleId": "message-content-type-changed", + }, + ], + }, + ] + `); + expect(replaceSourceWithRefInChanges(lost.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orders/messages/orderPlaced/contentType", + "value": "application/json", + }, + "impact": "major", + "key": "#/channels/orders/messages/orderPlaced", + "kind": "modified", + "property": "contentType", + "revision": { + "location": "revision.yaml#/channels/orders/messages/orderPlaced", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/channels/orders/messages/orderPlaced", + "message": "\`contentType\` of message \`orderPlaced\` was removed.", + "ruleId": "message-content-type-changed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/message-removed.test.ts b/packages/core/src/diff/rules/__tests__/message-removed.test.ts new file mode 100644 index 0000000000..909b3ec369 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/message-removed.test.ts @@ -0,0 +1,136 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (messages?: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: + address: orders + ${messages === undefined ? '' : `messages: ${messages}`} +`; + +const placed = 'orderPlaced: { payload: { type: object } }'; +const cancelled = 'orderCancelled: { payload: { type: object } }'; + +describe('message-removed', () => { + it('should report a message that is gone from its channel', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${placed}, ${cancelled} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${placed} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'message-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orders/messages/orderCancelled", + "value": { + "payload": { + "type": "object", + }, + }, + }, + "impact": "major", + "key": "#/channels/orders/messages/orderCancelled", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/channels/orders/messages/orderCancelled", + "message": "Message \`orderCancelled\` was removed.", + "ruleId": "message-removed", + }, + ], + }, + ] + `); + }); + + it('should report every message of a channel leaving with the whole map', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${placed} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(), 'revision.yaml'), + config: await createConfig({ diff: { 'message-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/channels/orders/messages", + "value": { + "orderPlaced": { + "payload": { + "type": "object", + }, + }, + }, + }, + "impact": "major", + "key": "#/channels/orders/messages", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/channels/orders/messages", + "message": "All messages of channel \`orders\` were removed.", + "ruleId": "message-removed", + }, + ], + }, + ] + `); + }); + + it('should not report a components messages map, which no channel is', async () => { + const withComponents = (components: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: + address: orders + messages: + ${placed} + components: ${components} + `; + + const result = await diffDocuments({ + base: makeDocumentFromString( + withComponents(`{ messages: { ${cancelled} }, schemas: { Order: { type: object } } }`), + 'base.yaml' + ), + revision: makeDocumentFromString( + withComponents('{ schemas: { Order: { type: object } } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'message-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/messages", + "value": { + "orderCancelled": { + "payload": { + "type": "object", + }, + }, + }, + }, + "impact": "patch", + "key": "#/components/messages", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts b/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts new file mode 100644 index 0000000000..506dcb9466 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/operation-action-changed.test.ts @@ -0,0 +1,54 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (action: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: { address: orders } + operations: + onOrderPlaced: + action: ${action} + channel: { $ref: '#/channels/orders' } +`; + +describe('operation-action-changed', () => { + it('should report an operation that now sends what it received', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('receive'), 'base.yaml'), + revision: makeDocumentFromString(cafe('send'), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-action-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/operations/onOrderPlaced/action", + "value": "receive", + }, + "impact": "major", + "key": "#/operations/onOrderPlaced", + "kind": "modified", + "property": "action", + "revision": { + "location": "revision.yaml#/operations/onOrderPlaced/action", + "value": "send", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/operations/onOrderPlaced/action", + "message": "\`action\` of operation \`onOrderPlaced\` changed from 'receive' to 'send'.", + "ruleId": "operation-action-changed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/operation-id-changed.test.ts b/packages/core/src/diff/rules/__tests__/operation-id-changed.test.ts new file mode 100644 index 0000000000..91b097af91 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/operation-id-changed.test.ts @@ -0,0 +1,184 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (operationId?: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + ${operationId === undefined ? '' : `operationId: ${operationId}`} + responses: { 200: { description: OK } } +`; + +const diffOperationIds = async (base?: string, revision?: string) => + diffDocuments({ + base: makeDocumentFromString(cafe(base), 'base.yaml'), + revision: makeDocumentFromString(cafe(revision), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-id-changed': 'major' } }), + }); + +describe('operation-id-changed', () => { + it('should report an operationId that changed or was removed, not one that was added', async () => { + const changed = await diffOperationIds('listMenu', 'getMenu'); + + expect(replaceSourceWithRefInChanges(changed.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/operationId", + "value": "listMenu", + }, + "impact": "major", + "key": "#/paths/~1menu/get", + "kind": "modified", + "property": "operationId", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/operationId", + "value": "getMenu", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/operationId", + "message": "\`operationId\` of operation \`GET /menu\` changed from 'listMenu' to 'getMenu'.", + "ruleId": "operation-id-changed", + }, + ], + }, + ] + `); + + const removed = await diffOperationIds('listMenu'); + + expect(replaceSourceWithRefInChanges(removed.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/operationId", + "value": "listMenu", + }, + "impact": "major", + "key": "#/paths/~1menu/get", + "kind": "modified", + "property": "operationId", + "revision": { + "location": "revision.yaml#/paths/~1menu/get", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get", + "message": "\`operationId\` of operation \`GET /menu\` was removed.", + "ruleId": "operation-id-changed", + }, + ], + }, + ] + `); + + const added = await diffOperationIds(undefined, 'listMenu'); + + expect(replaceSourceWithRefInChanges(added.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1menu/get", + "kind": "modified", + "property": "operationId", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/operationId", + "value": "listMenu", + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report an operationId changed in a webhook or a callback', async () => { + const withOperationIds = (webhookId: string, callbackId: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + callbacks: + ready: + "{$request.body#/url}": + post: + operationId: ${callbackId} + responses: { 200: { description: OK } } + responses: { 200: { description: OK } } + webhooks: + orderReady: + post: + operationId: ${webhookId} + responses: { 200: { description: OK } } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(withOperationIds('onReady', 'notifyReady'), 'base.yaml'), + revision: makeDocumentFromString(withOperationIds('readyHook', 'readyCall'), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-id-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post/operationId", + "value": "notifyReady", + }, + "impact": "major", + "key": "#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post", + "kind": "modified", + "property": "operationId", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post/operationId", + "value": "readyCall", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post/operationId", + "message": "\`operationId\` of operation \`POST {$request.body#/url}\` changed from 'notifyReady' to 'readyCall'.", + "ruleId": "operation-id-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/webhooks/orderReady/post/operationId", + "value": "onReady", + }, + "impact": "major", + "key": "#/webhooks/orderReady/post", + "kind": "modified", + "property": "operationId", + "revision": { + "location": "revision.yaml#/webhooks/orderReady/post/operationId", + "value": "readyHook", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/webhooks/orderReady/post/operationId", + "message": "\`operationId\` of operation \`POST orderReady\` changed from 'onReady' to 'readyHook'.", + "ruleId": "operation-id-changed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/operation-removed.test.ts b/packages/core/src/diff/rules/__tests__/operation-removed.test.ts new file mode 100644 index 0000000000..efb60dd8f7 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/operation-removed.test.ts @@ -0,0 +1,270 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (operations: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders/{orderId}: ${operations} +`; + +const get = 'get: { responses: { 200: { description: OK } } }'; +const cancel = 'delete: { responses: { 204: { description: Cancelled } } }'; + +describe('operation-removed', () => { + it('should report an operation that is gone', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${get}, ${cancel} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${get} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders~1{orderId}/delete", + "value": { + "responses": { + "204": { + "description": "Cancelled", + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders~1{orderId}/delete", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders~1{orderId}/delete", + "message": "Operation \`DELETE /orders/{orderId}\` was removed.", + "ruleId": "operation-removed", + }, + ], + }, + ] + `); + }); + + it('should name an AsyncAPI operation by its key', async () => { + const kitchen = (operations: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: { address: orders } + operations: ${operations} + `; + const receive = `onOrderPlaced: { action: receive, channel: { $ref: '#/channels/orders' } }`; + const send = `sendOrderReady: { action: send, channel: { $ref: '#/channels/orders' } }`; + + const result = await diffDocuments({ + base: makeDocumentFromString(kitchen(`{ ${receive}, ${send} }`), 'base.yaml'), + revision: makeDocumentFromString(kitchen(`{ ${receive} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/operations/sendOrderReady", + "value": { + "action": "send", + "channel": { + "$ref": "#/channels/orders", + }, + }, + }, + "impact": "major", + "key": "#/operations/sendOrderReady", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/operations/sendOrderReady", + "message": "Operation \`sendOrderReady\` was removed.", + "ruleId": "operation-removed", + }, + ], + }, + ] + `); + }); + + it('should report an operation removed from a callback', async () => { + const withCallback = (operations: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + callbacks: { ready: { "{$request.body#/url}": { ${operations} } } } + responses: { 200: { description: OK } } + `; + const post = 'post: { responses: { 200: { description: OK } } }'; + const put = 'put: { responses: { 200: { description: OK } } }'; + + const result = await diffDocuments({ + base: makeDocumentFromString(withCallback(`${post}, ${put}`), 'base.yaml'), + revision: makeDocumentFromString(withCallback(put), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post", + "value": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1url}/post", + "message": "Operation \`POST {$request.body#/url}\` was removed.", + "ruleId": "operation-removed", + }, + ], + }, + ] + `); + }); + + it('should report an operation removed from a webhook', async () => { + const withWebhook = (operations: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: {} + webhooks: + orderReady: { ${operations} } + `; + const post = 'post: { responses: { 200: { description: OK } } }'; + const put = 'put: { responses: { 200: { description: OK } } }'; + + const result = await diffDocuments({ + base: makeDocumentFromString(withWebhook(`${post}, ${put}`), 'base.yaml'), + revision: makeDocumentFromString(withWebhook(put), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/webhooks/orderReady/post", + "value": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + "impact": "major", + "key": "#/webhooks/orderReady/post", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/webhooks/orderReady/post", + "message": "Operation \`POST orderReady\` was removed.", + "ruleId": "operation-removed", + }, + ], + }, + ] + `); + }); + + it('should report an AsyncAPI document losing all its operations, not its components operations', async () => { + const kitchen = (operations: string, components: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: + orders: { address: orders } + ${operations} + ${components} + `; + const receive = `onOrderPlaced: { action: receive, channel: { $ref: '#/channels/orders' } }`; + const operations = `operations: { ${receive} }`; + const components = `components: { operations: { ${receive} } }`; + + const document = await diffDocuments({ + base: makeDocumentFromString(kitchen(operations, ''), 'base.yaml'), + revision: makeDocumentFromString(kitchen('', ''), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(document.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/operations", + "value": { + "onOrderPlaced": { + "action": "receive", + "channel": { + "$ref": "#/channels/orders", + }, + }, + }, + }, + "impact": "major", + "key": "#/operations", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/operations", + "message": "All operations were removed.", + "ruleId": "operation-removed", + }, + ], + }, + ] + `); + + const unused = await diffDocuments({ + base: makeDocumentFromString(kitchen('', components), 'base.yaml'), + revision: makeDocumentFromString(kitchen('', 'components: {}'), 'revision.yaml'), + config: await createConfig({ diff: { 'operation-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(unused.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/operations", + "value": { + "onOrderPlaced": { + "action": "receive", + "channel": { + "$ref": "#/channels/orders", + }, + }, + }, + }, + "impact": "patch", + "key": "#/components/operations", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts b/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts new file mode 100644 index 0000000000..117b983895 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/parameter-became-required.test.ts @@ -0,0 +1,292 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (parameters: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + parameters: ${parameters} + responses: + '200': { description: OK } + components: + parameters: + Sort: { name: sort, in: query, schema: { type: string } } + Category: { name: category, in: query, required: true, schema: { type: string } } +`; + +const search = '{ name: search, in: query, schema: { type: string } }'; + +describe('parameter-became-required', () => { + it('should report an optional parameter that became required, not one that became optional', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe( + '[{ name: limit, in: query, schema: { type: integer } }, { name: search, in: query, required: true }]' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe( + '[{ name: limit, in: query, required: true, schema: { type: integer } }, { name: search, in: query, required: false }]' + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-became-required': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/0", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/0/required", + "value": true, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/0/required", + "message": "\`limit\` query parameter became required.", + "ruleId": "parameter-became-required", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/1/required", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1menu/get/parameters/1", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/1/required", + "value": false, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a new required parameter, not a new optional one', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`[${search}]`), 'base.yaml'), + revision: makeDocumentFromString( + cafe( + `[${search}, { name: limit, in: query, schema: { type: integer } }, { name: locale, in: header, required: true, schema: { type: string } }]` + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-became-required': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "minor", + "key": "#/paths/~1menu/get/parameters/1", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/1", + "value": { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + }, + }, + }, + "verdicts": [], + }, + { + "impact": "major", + "key": "#/paths/~1menu/get/parameters/2", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/2", + "value": { + "in": "header", + "name": "locale", + "required": true, + "schema": { + "type": "string", + }, + }, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/2/required", + "message": "Required \`locale\` header parameter was added.", + "ruleId": "parameter-became-required", + }, + ], + }, + ] + `); + }); + + it('should report each required parameter of a new parameters list at its own required field', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + responses: + '200': { description: OK } + components: + parameters: + C: { name: c, in: query, required: true, schema: { type: string } } + `, + 'base.yaml' + ), + revision: makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + parameters: + - { name: a, in: query, required: true, schema: { type: string } } + - { name: b, in: query, schema: { type: string } } + - { $ref: '#/components/parameters/C' } + responses: + '200': { description: OK } + components: + parameters: + C: { name: c, in: query, required: true, schema: { type: string } } + `, + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-became-required': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1menu/get/parameters", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters", + "value": [ + { + "in": "query", + "name": "a", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "in": "query", + "name": "b", + "schema": { + "type": "string", + }, + }, + { + "$ref": "#/components/parameters/C", + }, + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/0/required", + "message": "Required \`a\` query parameter was added.", + "ruleId": "parameter-became-required", + }, + { + "impact": "major", + "location": "revision.yaml#/components/parameters/C/required", + "message": "Required \`c\` query parameter was added.", + "ruleId": "parameter-became-required", + }, + ], + }, + ] + `); + }); + + it('should read whether a referenced parameter is required from what it points at', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe("[{ $ref: '#/components/parameters/Sort' }]"), 'base.yaml'), + revision: makeDocumentFromString( + cafe( + "[{ $ref: '#/components/parameters/Sort' }, { $ref: '#/components/parameters/Category' }]" + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-became-required': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1menu/get/parameters/1", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/1", + "value": { + "in": "query", + "name": "category", + "required": true, + "schema": { + "type": "string", + }, + }, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/components/parameters/Category/required", + "message": "Required \`category\` query parameter was added.", + "ruleId": "parameter-became-required", + }, + ], + }, + ] + `); + }); + + it('should not report referenced parameters listed in another order', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe( + "[{ $ref: '#/components/parameters/Sort' }, { $ref: '#/components/parameters/Category' }]" + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe( + "[{ $ref: '#/components/parameters/Category' }, { $ref: '#/components/parameters/Sort' }]" + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-became-required': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(`[]`); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts b/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts new file mode 100644 index 0000000000..aaadbabdca --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/parameter-removed.test.ts @@ -0,0 +1,343 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (parameters?: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + ${parameters === undefined ? '' : `parameters: ${parameters}`} + responses: + '200': { description: OK } + components: + parameters: + Sort: { name: sort, in: query, schema: { type: string } } + Category: { name: category, in: query, required: true, schema: { type: string } } +`; + +const limit = '{ name: limit, in: query, schema: { type: integer } }'; +const search = '{ name: search, in: query, schema: { type: string } }'; + +describe('parameter-removed', () => { + it('should report a parameter that is gone', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`[${limit}, ${search}]`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`[${search}]`), 'revision.yaml'), + config: await createConfig({ diff: { 'parameter-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0", + "value": { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + }, + }, + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/0", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1menu/get/parameters/0", + "message": "\`limit\` query parameter was removed.", + "ruleId": "parameter-removed", + }, + ], + }, + ] + `); + }); + + it('should report the last parameter leaving with the whole list', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`[${limit}]`), 'base.yaml'), + revision: makeDocumentFromString(cafe(), 'revision.yaml'), + config: await createConfig({ diff: { 'parameter-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters", + "value": [ + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + }, + }, + ], + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1menu/get/parameters", + "message": "All parameters were removed.", + "ruleId": "parameter-removed", + }, + ], + }, + ] + `); + }); + + it('should report a referenced parameter swapped for another', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe("[{ $ref: '#/components/parameters/Sort' }]"), 'base.yaml'), + revision: makeDocumentFromString( + cafe("[{ $ref: '#/components/parameters/Category' }]"), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/parameters/Sort/name", + "value": "sort", + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/0", + "kind": "modified", + "property": "name", + "revision": { + "location": "revision.yaml#/components/parameters/Category/name", + "value": "category", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/components/parameters/Category/name", + "message": "\`sort\` query parameter was renamed to \`category\`.", + "ruleId": "parameter-removed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/components/parameters/Sort", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1menu/get/parameters/0", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/components/parameters/Category/required", + "value": true, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a parameter moved to another place in the request', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('[{ name: token, in: query, schema: { type: string } }]'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('[{ name: token, in: header, schema: { type: string } }]'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0/in", + "value": "query", + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/0", + "kind": "modified", + "property": "in", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/0/in", + "value": "header", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/0/in", + "message": "\`token\` query parameter is now a header parameter.", + "ruleId": "parameter-removed", + }, + ], + }, + ] + `); + }); + + it('should name a parameter by its old name and place when both changed', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('[{ name: id, in: query, schema: { type: string } }]'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('[{ name: itemId, in: header, schema: { type: string } }]'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0/name", + "value": "id", + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/0", + "kind": "modified", + "property": "name", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/0/name", + "value": "itemId", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/0/name", + "message": "\`id\` query parameter was renamed to \`itemId\`.", + "ruleId": "parameter-removed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0/in", + "value": "query", + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/0", + "kind": "modified", + "property": "in", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/0/in", + "value": "header", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/0/in", + "message": "\`id\` query parameter is now a header parameter.", + "ruleId": "parameter-removed", + }, + ], + }, + ] + `); + }); + + it('should not report a path parameter renamed with its path, or a header renamed only in case', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu/{id}: + get: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - { name: X-Token, in: header, schema: { type: string } } + responses: + '200': { description: OK } + `, + 'base.yaml' + ), + revision: makeDocumentFromString( + outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu/{itemId}: + get: + parameters: + - { name: itemId, in: path, required: true, schema: { type: string } } + - { name: x-token, in: header, schema: { type: string } } + responses: + '200': { description: OK } + `, + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu~1{id}", + "value": "/menu/{id}", + }, + "impact": "patch", + "key": "#/paths/~1menu~1{itemId}", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1menu~1{itemId}", + "value": "/menu/{itemId}", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1menu~1{id}/get/parameters/0/name", + "value": "id", + }, + "impact": "patch", + "key": "#/paths/~1menu~1{itemId}/get/parameters/0", + "kind": "modified", + "property": "name", + "revision": { + "location": "revision.yaml#/paths/~1menu~1{itemId}/get/parameters/0/name", + "value": "itemId", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1menu~1{id}/get/parameters/1/name", + "value": "X-Token", + }, + "impact": "patch", + "key": "#/paths/~1menu~1{itemId}/get/parameters/1", + "kind": "modified", + "property": "name", + "revision": { + "location": "revision.yaml#/paths/~1menu~1{itemId}/get/parameters/1/name", + "value": "x-token", + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts b/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts new file mode 100644 index 0000000000..e90eeeca98 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/parameter-serialization-changed.test.ts @@ -0,0 +1,135 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (parameters: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /menu: + get: + parameters: ${parameters} + responses: + '200': { description: OK } + components: + parameters: + Sort: { name: sort, in: query, schema: { type: string } } + Category: { name: category, in: query, required: true, schema: { type: string } } +`; + +describe('parameter-serialization-changed', () => { + it('should report a parameter put on the wire another way', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe( + '[{ name: category, in: query, style: form, explode: true, schema: { type: array } }]' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe( + '[{ name: category, in: query, style: form, explode: false, schema: { type: array } }]' + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-serialization-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0/explode", + "value": true, + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/0", + "kind": "modified", + "property": "explode", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/0/explode", + "value": false, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/0/explode", + "message": "\`explode\` of \`category\` query parameter changed from 'true' to 'false'.", + "ruleId": "parameter-serialization-changed", + }, + ], + }, + ] + `); + }); + + it('should report a serialization keyword that was set or removed', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe( + '[{ name: category, in: query, style: form, schema: { type: array } }, { name: sort, in: query, schema: { type: string } }]' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe( + '[{ name: category, in: query, schema: { type: array } }, { name: sort, in: query, explode: false, schema: { type: string } }]' + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'parameter-serialization-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/0/style", + "value": "form", + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/0", + "kind": "modified", + "property": "style", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/0", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/0", + "message": "\`style\` of \`category\` query parameter was removed.", + "ruleId": "parameter-serialization-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1menu/get/parameters/1", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1menu/get/parameters/1", + "kind": "modified", + "property": "explode", + "revision": { + "location": "revision.yaml#/paths/~1menu/get/parameters/1/explode", + "value": false, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menu/get/parameters/1/explode", + "message": "\`explode\` of \`sort\` query parameter was set to 'false'.", + "ruleId": "parameter-serialization-changed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/path-removed.test.ts b/packages/core/src/diff/rules/__tests__/path-removed.test.ts new file mode 100644 index 0000000000..ad6003c914 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/path-removed.test.ts @@ -0,0 +1,496 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (paths: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: ${paths} +`; + +const menu = '/menu: { get: { responses: { 200: { description: OK } } } }'; +const revenue = '/revenue: { get: { responses: { 200: { description: OK } } } }'; + +describe('path-removed', () => { + it('should report a path that is gone', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${menu}, ${revenue} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${menu} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1revenue", + "value": { + "get": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1revenue", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1revenue", + "message": "Path \`/revenue\` was removed.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + }); + + it('should report a renamed path, not one whose template names changed', async () => { + const renamed = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${menu} }`), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ /menus: { get: { responses: { 200: { description: OK } } } } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(renamed.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu", + "value": "/menu", + }, + "impact": "major", + "key": "#/paths/~1menus", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1menus", + "value": "/menus", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1menus", + "message": "Path \`/menu\` was renamed to \`/menus\`.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + + const menuItem = (name: string) => + `{ "/menu/{${name}}": { get: { responses: { 200: { description: OK } } } } }`; + const renamedTemplate = await diffDocuments({ + base: makeDocumentFromString(cafe(menuItem('id')), 'base.yaml'), + revision: makeDocumentFromString(cafe(menuItem('itemId')), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(renamedTemplate.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu~1{id}", + "value": "/menu/{id}", + }, + "impact": "patch", + "key": "#/paths/~1menu~1{itemId}", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1menu~1{itemId}", + "value": "/menu/{itemId}", + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a removed webhook', async () => { + const withWebhooks = (webhooks: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: { ${menu} } + webhooks: ${webhooks} + `; + const orderPlaced = 'orderPlaced: { post: { responses: { 200: { description: OK } } } }'; + const orderReady = 'orderReady: { post: { responses: { 200: { description: OK } } } }'; + const webhook = await diffDocuments({ + base: makeDocumentFromString(withWebhooks(`{ ${orderPlaced}, ${orderReady} }`), 'base.yaml'), + revision: makeDocumentFromString(withWebhooks(`{ ${orderPlaced} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(webhook.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/webhooks/orderReady", + "value": { + "post": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/webhooks/orderReady", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/webhooks/orderReady", + "message": "Webhook \`orderReady\` was removed.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + }); + + it('should report a removed callback and a callback URL it no longer calls', async () => { + const withCallbacks = (callbacks: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + callbacks: ${callbacks} + responses: { 200: { description: OK } } + `; + const url = '"{$request.body#/url}": { post: { responses: { 200: { description: OK } } } }'; + const other = '"{$request.body#/other}": { post: { responses: { 200: { description: OK } } } }'; + + const callback = await diffDocuments({ + base: makeDocumentFromString(withCallbacks(`{ ready: { ${url} } }`), 'base.yaml'), + revision: makeDocumentFromString(withCallbacks('{}'), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(callback.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/callbacks/ready", + "value": { + "{$request.body#/url}": { + "post": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/callbacks/ready", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/callbacks/ready", + "message": "Callback \`ready\` was removed.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + + const callbackUrl = await diffDocuments({ + base: makeDocumentFromString(withCallbacks(`{ ready: { ${url}, ${other} } }`), 'base.yaml'), + revision: makeDocumentFromString(withCallbacks(`{ ready: { ${url} } }`), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(callbackUrl.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1other}", + "value": { + "post": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/callbacks/ready/{$request.body#~1other}", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/callbacks/ready/{$request.body#~1other}", + "message": "Callback \`ready\` no longer calls \`{$request.body#/other}\`.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + }); + + it('should report an operation losing all its callbacks', async () => { + const withCallbacks = (callbacks: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + ${callbacks} + responses: { 200: { description: OK } } + `; + const url = '"{$request.body#/url}": { post: { responses: { 200: { description: OK } } } }'; + + const result = await diffDocuments({ + base: makeDocumentFromString(withCallbacks(`callbacks: { ready: { ${url} } }`), 'base.yaml'), + revision: makeDocumentFromString(withCallbacks(''), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/callbacks", + "value": { + "ready": { + "{$request.body#/url}": { + "post": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/callbacks", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/callbacks", + "message": "All callbacks were removed.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + }); + + it('should report the document losing all its paths', async () => { + const withWebhooks = (paths: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + ${paths} + webhooks: + orderReady: { post: { responses: { 200: { description: OK } } } } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(withWebhooks(`paths: { ${menu} }`), 'base.yaml'), + revision: makeDocumentFromString(withWebhooks(''), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths", + "value": { + "/menu": { + "get": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/paths", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths", + "message": "All paths were removed.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + }); + + it('should report the document losing all its webhooks', async () => { + const withWebhooks = (webhooks: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: { ${menu} } + ${webhooks} + `; + + const result = await diffDocuments({ + base: makeDocumentFromString( + withWebhooks( + 'webhooks: { orderReady: { post: { responses: { 200: { description: OK } } } } }' + ), + 'base.yaml' + ), + revision: makeDocumentFromString(withWebhooks(''), 'revision.yaml'), + config: await createConfig({ diff: { 'path-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/webhooks", + "value": { + "orderReady": { + "post": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/webhooks", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/webhooks", + "message": "All webhooks were removed.", + "ruleId": "path-removed", + }, + ], + }, + ] + `); + }); + + it('should not report a callback of the components that nothing references', async () => { + const withComponentCallback = (callback: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: {} + components: + callbacks: + Ready: ${callback} + `; + const url = (operations: string) => `"{$request.body#/url}": { ${operations} }`; + const post = (operationId: string) => + `post: { operationId: ${operationId}, responses: { 200: { description: OK } } }`; + const put = 'put: { responses: { 200: { description: OK } } }'; + const other = '"{$request.body#/other}": { post: { responses: { 200: { description: OK } } } }'; + + const result = await diffDocuments({ + base: makeDocumentFromString( + withComponentCallback(`{ ${url(`${post('notifyReady')}, ${put}`)}, ${other} }`), + 'base.yaml' + ), + revision: makeDocumentFromString( + withComponentCallback(`{ ${url(post('readyCall'))} }`), + 'revision.yaml' + ), + config: await createConfig({ + diff: { + 'path-removed': 'major', + 'operation-removed': 'major', + 'operation-id-changed': 'major', + }, + }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/callbacks/Ready/{$request.body#~1url}/post/operationId", + "value": "notifyReady", + }, + "impact": "patch", + "key": "#/components/callbacks/Ready/{$request.body#~1url}/post", + "kind": "modified", + "property": "operationId", + "revision": { + "location": "revision.yaml#/components/callbacks/Ready/{$request.body#~1url}/post/operationId", + "value": "readyCall", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/components/callbacks/Ready/{$request.body#~1url}/put", + "value": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + "impact": "patch", + "key": "#/components/callbacks/Ready/{$request.body#~1url}/put", + "kind": "removed", + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/components/callbacks/Ready/{$request.body#~1other}", + "value": { + "post": { + "responses": { + "200": { + "description": "OK", + }, + }, + }, + }, + }, + "impact": "patch", + "key": "#/components/callbacks/Ready/{$request.body#~1other}", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/property-removed.test.ts b/packages/core/src/diff/rules/__tests__/property-removed.test.ts new file mode 100644 index 0000000000..0b20c5fbfe --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/property-removed.test.ts @@ -0,0 +1,396 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (order: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${order} + responses: + '201': + description: Created + content: + application/json: + schema: ${order} +`; + +describe('property-removed', () => { + it('should report a property a response no longer sends, not one a request no longer takes', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe( + '{ type: object, properties: { menuItemId: { type: string }, note: { type: string } } }' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, properties: { menuItemId: { type: string } } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'property-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "value": { + "type": "string", + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "kind": "removed", + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note", + "value": { + "type": "string", + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note", + "message": "Property \`note\` was removed.", + "ruleId": "property-removed", + }, + ], + }, + ] + `); + }); + + it('should not report a oneOf alternative, which is not a property', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ oneOf: [{ type: string }, { type: integer }] }'), + 'base.yaml' + ), + revision: makeDocumentFromString(cafe('{ oneOf: [{ type: string }] }'), 'revision.yaml'), + config: await createConfig({ diff: { 'property-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1", + "value": { + "type": "integer", + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1", + "kind": "removed", + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf/1", + "value": { + "type": "integer", + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf/1", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); + + it('should not report a writeOnly property, which a response never sends', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ properties: { pin: { type: string, writeOnly: true } } }'), + 'base.yaml' + ), + revision: makeDocumentFromString(cafe('{ properties: {} }'), 'revision.yaml'), + config: await createConfig({ diff: { 'property-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/pin", + "value": { + "type": "string", + "writeOnly": true, + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/pin", + "kind": "removed", + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/pin", + "value": { + "type": "string", + "writeOnly": true, + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/pin", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); + + it('should report every property leaving with the whole properties map', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: object, properties: { note: { type: string } } }'), + 'base.yaml' + ), + revision: makeDocumentFromString(cafe('{ type: object }'), 'revision.yaml'), + config: await createConfig({ diff: { 'property-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties", + "value": { + "note": { + "type": "string", + }, + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties", + "kind": "removed", + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties", + "value": { + "note": { + "type": "string", + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties", + "message": "All properties were removed.", + "ruleId": "property-removed", + }, + ], + }, + ] + `); + }); + + it('should report a property renamed in a response, not in a request', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: object, properties: { name: { type: string } } }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, properties: { names: { type: string } } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'property-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/name", + "value": "name", + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/names", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/names", + "value": "names", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/name", + "value": "name", + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/names", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/names", + "value": "names", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/names", + "message": "Property \`name\` was renamed to \`names\`.", + "ruleId": "property-removed", + }, + ], + }, + ] + `); + }); + + it('should name the owner of a removed property by the place of its $ref, not by its target', async () => { + const order = (item: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + get: + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + components: + schemas: + Order: { type: object, properties: { item: { $ref: '#/components/schemas/${item}' } } } + Item: { type: object, properties: { name: { type: string }, price: { type: number } } } + MenuItem: { type: object, properties: { name: { type: string } } } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(order('Item'), 'base.yaml'), + revision: makeDocumentFromString(order('MenuItem'), 'revision.yaml'), + config: await createConfig({ diff: { 'property-removed': 'major' } }), + }); + + expect(result.changes.flatMap((change) => change.verdicts)).toMatchObject([ + { message: 'Property `price` of `item` was removed.' }, + ]); + }); + + it('should not name the owner of a property written next to a $ref', async () => { + const order = (properties: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + get: + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/Order', properties: ${properties} } + components: + schemas: + Order: { type: object } + `; + + const result = await diffDocuments({ + base: makeDocumentFromString( + order('{ id: { type: string }, extra: { type: string } }'), + 'base.yaml' + ), + revision: makeDocumentFromString(order('{ id: { type: string } }'), 'revision.yaml'), + config: await createConfig({ diff: { 'property-removed': 'major' } }), + }); + + expect(result.changes.flatMap((change) => change.verdicts)).toMatchObject([ + { message: 'Property `extra` was removed.' }, + ]); + }); + + it('should report a property a request no longer takes only where its schema rejects unlisted properties', async () => { + const order = (properties: string, extras: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + menuItemId: { type: string } + ${properties} + extras: { type: object, properties: ${extras} } + responses: + '201': { description: Created } + `; + const result = await diffDocuments({ + base: makeDocumentFromString( + order('note: { type: string }', '{ sugar: { type: boolean }, milk: { type: boolean } }'), + 'base.yaml' + ), + revision: makeDocumentFromString(order('', '{ sugar: { type: boolean } }'), 'revision.yaml'), + config: await createConfig({ diff: { 'property-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/extras/properties/milk", + "value": { + "type": "boolean", + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/extras/properties/milk", + "kind": "removed", + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "value": { + "type": "string", + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "message": "Property \`note\` was removed.", + "ruleId": "property-removed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts b/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts new file mode 100644 index 0000000000..656971a384 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/request-body-became-required.test.ts @@ -0,0 +1,84 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (required: boolean) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + required: ${required} + content: { application/json: { schema: { type: object } } } + responses: + '201': { description: Created } +`; + +describe('request-body-became-required', () => { + it('should report an optional request body that became required', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(false), 'base.yaml'), + revision: makeDocumentFromString(cafe(true), 'revision.yaml'), + config: await createConfig({ diff: { 'request-body-became-required': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/required", + "value": false, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/required", + "value": true, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/required", + "message": "Request body became required.", + "ruleId": "request-body-became-required", + }, + ], + }, + ] + `); + }); + + it('should not report a required request body that became optional', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(true), 'base.yaml'), + revision: makeDocumentFromString(cafe(false), 'revision.yaml'), + config: await createConfig({ diff: { 'request-body-became-required': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/required", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/required", + "value": false, + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts b/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts new file mode 100644 index 0000000000..6d58b087e9 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/request-body-removed.test.ts @@ -0,0 +1,57 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (operation: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: ${operation} +`; + +const created = "responses: { '201': { description: Created } }"; +const order = 'requestBody: { content: { application/json: { schema: { type: object } } } }'; + +describe('request-body-removed', () => { + it('should report a request body that is gone', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${order}, ${created} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${created} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'request-body-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody", + "value": { + "content": { + "application/json": { + "schema": { + "type": "object", + }, + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/requestBody", + "message": "Request body was removed.", + "ruleId": "request-body-removed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts b/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts new file mode 100644 index 0000000000..4a77331bd4 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/required-properties-added.test.ts @@ -0,0 +1,286 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (order: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${order} + responses: + '201': + description: Created + content: + application/json: + schema: ${order} +`; + +describe('required-properties-added', () => { + it('should report the properties a request must now send, not the ones a response now promises', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: object, required: [menuItemId] }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: object, required: [menuItemId, quantity] }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'required-properties-added': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "value": [ + "menuItemId", + ], + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "value": [ + "menuItemId", + "quantity", + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "message": "Properties became required: \`quantity\`.", + "ruleId": "required-properties-added", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required", + "value": [ + "menuItemId", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required", + "value": [ + "menuItemId", + "quantity", + ], + }, + "verdicts": [], + }, + ] + `); + }); + + it('should not report a readOnly property, which a request never sends', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ properties: { orderId: { type: string, readOnly: true } } }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ required: [orderId], properties: { orderId: { type: string, readOnly: true } } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'required-properties-added': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "value": [ + "orderId", + ], + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required", + "value": [ + "orderId", + ], + }, + "verdicts": [], + }, + ] + `); + }); + + it('should not report a schema that became readOnly along with its own new required property', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe( + '{ type: object, properties: { note: { type: object, properties: { text: { type: string } } } } }' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe( + '{ type: object, properties: { note: { type: object, readOnly: true, required: [text], properties: { text: { type: string } } } } }' + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'required-properties-added': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "kind": "modified", + "property": "readOnly", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/readOnly", + "value": true, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/note/required", + "value": [ + "text", + ], + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note", + "kind": "modified", + "property": "readOnly", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note/readOnly", + "value": true, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/note/required", + "value": [ + "text", + ], + }, + "verdicts": [], + }, + ] + `); + }); + + it('should not report a $ref property named by the component key whose target is readOnly', async () => { + const cafeWithUser = (order: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${order} + responses: + '201': + description: Created + components: + schemas: + User: { type: object, readOnly: true } + `; + const result = await diffDocuments({ + base: makeDocumentFromString( + cafeWithUser( + '{ type: object, properties: { owner: { $ref: "#/components/schemas/User" } } }' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafeWithUser( + '{ type: object, required: [owner], properties: { owner: { $ref: "#/components/schemas/User" } } }' + ), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'required-properties-added': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "value": [ + "owner", + ], + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts b/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts new file mode 100644 index 0000000000..1b3cb4432d --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/required-properties-removed.test.ts @@ -0,0 +1,226 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (order: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${order} + responses: + '201': + description: Created + content: + application/json: + schema: ${order} +`; + +describe('required-properties-removed', () => { + it('should report the properties a response no longer promises, not the ones a request may now omit', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: object, required: [menuItemId, quantity] }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, required: [menuItemId] }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'required-properties-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "value": [ + "menuItemId", + "quantity", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "value": [ + "menuItemId", + ], + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required", + "value": [ + "menuItemId", + "quantity", + ], + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required", + "value": [ + "menuItemId", + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required", + "message": "Properties are no longer required: \`quantity\`.", + "ruleId": "required-properties-removed", + }, + ], + }, + ] + `); + }); + + it('should not report a writeOnly property, which a response never sends', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ required: [pin], properties: { pin: { type: string, writeOnly: true } } }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ properties: { pin: { type: string, writeOnly: true } } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'required-properties-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "value": [ + "pin", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required", + "value": [ + "pin", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should not report a writeOnly property removed together with its required entry', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe( + '{ required: [secret], properties: { id: { type: string }, secret: { type: string, writeOnly: true } } }' + ), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ properties: { id: { type: string } } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'required-properties-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/required", + "value": [ + "secret", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/secret", + "value": { + "type": "string", + "writeOnly": true, + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/secret", + "kind": "removed", + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/required", + "value": [ + "secret", + ], + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "required", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/secret", + "value": { + "type": "string", + "writeOnly": true, + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/properties/secret", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts b/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts new file mode 100644 index 0000000000..2373446db6 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/response-header-removed.test.ts @@ -0,0 +1,204 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (response: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + responses: + '201': ${response} +`; + +const location = 'Location: { schema: { type: string } }'; +const rateLimit = 'X-Rate-Limit: { schema: { type: integer } }'; + +describe('response-header-removed', () => { + it('should report a header that is gone', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe(`{ description: Created, headers: { ${location}, ${rateLimit} } }`), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe(`{ description: Created, headers: { ${location} } }`), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'response-header-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/headers/X-Rate-Limit", + "value": { + "schema": { + "type": "integer", + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/headers/X-Rate-Limit", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/responses/201/headers/X-Rate-Limit", + "message": "Response header \`X-Rate-Limit\` was removed.", + "ruleId": "response-header-removed", + }, + ], + }, + ] + `); + }); + + it('should report every header leaving with the whole map', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe(`{ description: Created, headers: { ${location} } }`), + 'base.yaml' + ), + revision: makeDocumentFromString(cafe('{ description: Created }'), 'revision.yaml'), + config: await createConfig({ diff: { 'response-header-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/headers", + "value": { + "Location": { + "schema": { + "type": "string", + }, + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/headers", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/responses/201/headers", + "message": "All headers of response \`201\` were removed.", + "ruleId": "response-header-removed", + }, + ], + }, + ] + `); + }); + + it('should report a renamed header, not one renamed only in case', async () => { + const withHeader = (name: string) => + cafe(`{ description: Created, headers: { ${name}: { schema: { type: integer } } } }`); + const config = await createConfig({ diff: { 'response-header-removed': 'major' } }); + + const renamed = await diffDocuments({ + base: makeDocumentFromString(withHeader('X-Rate'), 'base.yaml'), + revision: makeDocumentFromString(withHeader('X-RateLimit'), 'revision.yaml'), + config, + }); + const recased = await diffDocuments({ + base: makeDocumentFromString(withHeader('X-Rate'), 'base.yaml'), + revision: makeDocumentFromString(withHeader('x-rate'), 'revision.yaml'), + config, + }); + + expect(replaceSourceWithRefInChanges(renamed.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/headers/X-Rate", + "value": "X-Rate", + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/headers/X-RateLimit", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/headers/X-RateLimit", + "value": "X-RateLimit", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/headers/X-RateLimit", + "message": "Response header \`X-Rate\` was renamed to \`X-RateLimit\`.", + "ruleId": "response-header-removed", + }, + ], + }, + ] + `); + expect(replaceSourceWithRefInChanges(recased.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/headers/X-Rate", + "value": "X-Rate", + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/headers/x-rate", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/headers/x-rate", + "value": "x-rate", + }, + "verdicts": [], + }, + ] + `); + }); + + it('should not report a removed component header', async () => { + const withComponents = (headers: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + responses: + '201': { description: Created } + components: + headers: ${headers} + `; + const result = await diffDocuments({ + base: makeDocumentFromString( + withComponents('{ X-Rate: { schema: { type: integer } } }'), + 'base.yaml' + ), + revision: makeDocumentFromString(withComponents('{}'), 'revision.yaml'), + config: await createConfig({ diff: { 'response-header-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/headers/X-Rate", + "value": { + "schema": { + "type": "integer", + }, + }, + }, + "impact": "patch", + "key": "#/components/headers/X-Rate", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/response-removed.test.ts b/packages/core/src/diff/rules/__tests__/response-removed.test.ts new file mode 100644 index 0000000000..1f49001167 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/response-removed.test.ts @@ -0,0 +1,162 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges, resolverWithFiles } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString, type Document } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (responses: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders/{orderId}: + get: + parameters: + - { name: orderId, in: path, required: true, schema: { type: string } } + responses: ${responses} +`; + +describe('response-removed', () => { + it('should report a response that is gone', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe("{ '200': { description: OK }, '404': { description: Not found } }"), + 'base.yaml' + ), + revision: makeDocumentFromString(cafe("{ '200': { description: OK } }"), 'revision.yaml'), + config: await createConfig({ diff: { 'response-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders~1{orderId}/get/responses/404", + "value": { + "description": "Not found", + }, + }, + "impact": "major", + "key": "#/paths/~1orders~1{orderId}/get/responses/404", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders~1{orderId}/get/responses/404", + "message": "Response \`404\` was removed.", + "ruleId": "response-removed", + }, + ], + }, + ] + `); + }); + + it('should report a response that became another status code', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe("{ '200': { description: OK } }"), 'base.yaml'), + revision: makeDocumentFromString(cafe("{ '204': { description: OK } }"), 'revision.yaml'), + config: await createConfig({ diff: { 'response-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders~1{orderId}/get/responses/200", + "value": "200", + }, + "impact": "major", + "key": "#/paths/~1orders~1{orderId}/get/responses/204", + "kind": "modified", + "property": "key", + "revision": { + "location": "revision.yaml#/paths/~1orders~1{orderId}/get/responses/204", + "value": "204", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders~1{orderId}/get/responses/204", + "message": "Response \`200\` became \`204\`.", + "ruleId": "response-removed", + }, + ], + }, + ] + `); + }); + + it('should not report a removed component response, which no operation returns', async () => { + const withComponents = (components: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + get: + responses: + '200': { description: OK } + components: + responses: ${components} + `; + const result = await diffDocuments({ + base: makeDocumentFromString( + withComponents('{ NotFound: { description: Not found } }'), + 'base.yaml' + ), + revision: makeDocumentFromString(withComponents('{}'), 'revision.yaml'), + config: await createConfig({ diff: { 'response-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/responses/NotFound", + "value": { + "description": "Not found", + }, + }, + "impact": "patch", + "key": "#/components/responses/NotFound", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); + + it('should name a removed response by its own code when it points at a response in another file', async () => { + const failed = "{ $ref: '#/components/responses/Failed' }"; + const orders = (responses: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + responses: ${responses} + components: + responses: + Failed: { $ref: failed.yaml } + `; + const resolver = resolverWithFiles({ + 'failed.yaml': 'description: Failed', + 'base.yaml': orders(`{ '400': ${failed}, '500': ${failed} }`), + 'revision.yaml': orders(`{ '400': ${failed} }`), + }); + const [base, revision] = (await Promise.all( + ['base.yaml', 'revision.yaml'].map((file) => resolver.resolveDocument(null, file, true)) + )) as Document[]; + + const result = await diffDocuments({ + base, + revision, + config: await createConfig({ diff: { 'response-removed': 'major' } }), + externalRefResolver: resolver, + }); + + expect( + result.changes.flatMap(({ verdicts }) => verdicts.map(({ message }) => message)) + ).toEqual(['Response `500` was removed.']); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts new file mode 100644 index 0000000000..995a8ec814 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/schema-combinator-changed.test.ts @@ -0,0 +1,190 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (payment: string, responsePayment = payment) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${payment} + responses: + '201': + description: Created + content: + application/json: + schema: ${responsePayment} +`; + +describe('schema-combinator-changed', () => { + it('should report a oneOf alternative a request no longer accepts, not one a response stops sending', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ oneOf: [{ const: card }, { const: cash }] }'), + 'base.yaml' + ), + revision: makeDocumentFromString(cafe('{ oneOf: [{ const: card }] }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-combinator-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1", + "value": { + "const": "cash", + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/oneOf/1", + "message": "\`oneOf\` subschema was removed.", + "ruleId": "schema-combinator-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf/1", + "value": { + "const": "cash", + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf/1", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); + + it('should report an allOf member a request must now satisfy, not one a response now satisfies', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ allOf: [{ required: [amount] }] }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ allOf: [{ required: [amount] }, { required: [currency] }] }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-combinator-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf/1", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf/1", + "value": { + "required": [ + "currency", + ], + }, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf/1", + "message": "\`allOf\` subschema was added.", + "ruleId": "schema-combinator-changed", + }, + ], + }, + { + "impact": "minor", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/allOf/1", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/allOf/1", + "value": { + "required": [ + "currency", + ], + }, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a whole oneOf removed from a response and a whole allOf added to a request', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: object }', '{ type: string, oneOf: [{ const: card }, { const: cash }] }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, allOf: [{ required: [card] }] }', '{ type: string }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-combinator-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf", + "value": [ + { + "required": [ + "card", + ], + }, + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/allOf", + "message": "\`allOf\` was added.", + "ruleId": "schema-combinator-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf", + "value": [ + { + "const": "card", + }, + { + "const": "cash", + }, + ], + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/oneOf", + "message": "\`oneOf\` was removed.", + "ruleId": "schema-combinator-changed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/schema-constraint-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-constraint-changed.test.ts new file mode 100644 index 0000000000..1d8df967b0 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/schema-constraint-changed.test.ts @@ -0,0 +1,1272 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (requestSchema: string, responseSchema = requestSchema) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${requestSchema} + responses: + '201': + description: Created + content: + application/json: + schema: ${responseSchema} +`; + +describe('schema-constraint-changed', () => { + it('should report a bound that makes a request accept less, not one that lets a response send less', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: integer, maximum: 20 }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ type: integer, maximum: 10 }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum", + "value": 20, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "maximum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum", + "value": 10, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maximum", + "message": "Upper bound changed from '<= 20' to '<= 10'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maximum", + "value": 20, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "maximum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maximum", + "value": 10, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a bound that lets a response send more, not one that makes a request accept more', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: integer, minimum: 1 }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ type: integer, minimum: 0 }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", + "value": 1, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", + "value": 0, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", + "value": 1, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", + "value": 0, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", + "message": "Lower bound changed from '>= 1' to '>= 0'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should not report an OpenAPI 3.0 bound written the OpenAPI 3.1 way', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: number, minimum: 0, exclusiveMinimum: true }').replace('3.1.0', '3.0.3'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: number, exclusiveMinimum: 0 }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/openapi", + "value": "3.0.3", + }, + "impact": "patch", + "key": "#/", + "kind": "modified", + "property": "openapi", + "revision": { + "location": "revision.yaml#/openapi", + "value": "3.1.0", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", + "value": 0, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/exclusiveMinimum", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "exclusiveMinimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/exclusiveMinimum", + "value": 0, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", + "value": 0, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/exclusiveMinimum", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "exclusiveMinimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/exclusiveMinimum", + "value": 0, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a bound that became exclusive in a request', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: number, minimum: 0 }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: number, exclusiveMinimum: 0 }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", + "value": 0, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "message": "Lower bound changed from '>= 0' to '> 0'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "exclusiveMinimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/exclusiveMinimum", + "value": 0, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/minimum", + "value": 0, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "exclusiveMinimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/exclusiveMinimum", + "value": 0, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a length limit that makes a request accept less', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: string, maxLength: 200 }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ type: string, maxLength: 100 }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength", + "value": 200, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "maxLength", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength", + "value": 100, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxLength", + "message": "\`maxLength\` changed from '200' to '100'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxLength", + "value": 200, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "maxLength", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxLength", + "value": 100, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a pattern that changed in a response, which may now send values a client rejects', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe("{ type: string, pattern: '^[a-z]+$' }"), 'base.yaml'), + revision: makeDocumentFromString( + cafe("{ type: string, pattern: '^[a-z ]+$' }"), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern", + "value": "^[a-z]+$", + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "pattern", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern", + "value": "^[a-z ]+$", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/pattern", + "message": "\`pattern\` changed from '^[a-z]+$' to '^[a-z ]+$'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/pattern", + "value": "^[a-z]+$", + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "pattern", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/pattern", + "value": "^[a-z ]+$", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/pattern", + "message": "\`pattern\` changed from '^[a-z]+$' to '^[a-z ]+$'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should report a format added to a request, not to a response', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: string }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: string, format: date-time }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "format", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/format", + "value": "date-time", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/format", + "message": "\`format\` was set to 'date-time'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "format", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/format", + "value": "date-time", + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report closing an open request object, not an open response object', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: object, additionalProperties: true }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, additionalProperties: false }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "value": true, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "additionalProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "value": false, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "message": "\`additionalProperties\` changed from 'true' to 'false'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "additionalProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", + "value": false, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report opening a closed response object, not a closed request object', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: object, additionalProperties: false }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, additionalProperties: true }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "value": false, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "additionalProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "value": true, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", + "value": false, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "additionalProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", + "value": true, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/additionalProperties", + "message": "\`additionalProperties\` changed from 'false' to 'true'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should report an array limit that makes a request accept less, not one that lets a response send less', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: array, maxItems: 10 }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ type: array, maxItems: 5 }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxItems", + "value": 10, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "maxItems", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxItems", + "value": 5, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/maxItems", + "message": "\`maxItems\` changed from '10' to '5'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxItems", + "value": 10, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "maxItems", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maxItems", + "value": 5, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report items that must now be unique in a request', async () => { + const config = await createConfig({ diff: { 'schema-constraint-changed': 'major' } }); + + const uniqueItemsRequired = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: array }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ type: array, uniqueItems: true }'), 'revision.yaml'), + config, + }); + const uniqueItemsDeclined = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: array }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: array, uniqueItems: false }'), + 'revision.yaml' + ), + config, + }); + + expect(replaceSourceWithRefInChanges(uniqueItemsRequired.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "uniqueItems", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/uniqueItems", + "value": true, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/uniqueItems", + "message": "\`uniqueItems\` was set to 'true'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "uniqueItems", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/uniqueItems", + "value": true, + }, + "verdicts": [], + }, + ] + `); + expect(replaceSourceWithRefInChanges(uniqueItemsDeclined.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "uniqueItems", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/uniqueItems", + "value": false, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "uniqueItems", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/uniqueItems", + "value": false, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a const that changed on both sides, since neither accepts the other value', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ const: small }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ const: large }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/const", + "value": "small", + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "const", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/const", + "value": "large", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/const", + "message": "\`const\` changed from 'small' to 'large'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/const", + "value": "small", + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "const", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/const", + "value": "large", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/const", + "message": "\`const\` changed from 'small' to 'large'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should report a subschema that constrains a request, and its removal from a response', async () => { + const constrained = '{ type: object, not: { required: [secret] } }'; + const config = await createConfig({ diff: { 'schema-constraint-changed': 'major' } }); + + const addedToRequest = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: object }', '{ type: object }'), 'base.yaml'), + revision: makeDocumentFromString(cafe(constrained, '{ type: object }'), 'revision.yaml'), + config, + }); + const removedFromResponse = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: object }', constrained), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: object }', '{ type: object }'), + 'revision.yaml' + ), + config, + }); + + expect(replaceSourceWithRefInChanges(addedToRequest.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/not", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/not", + "value": { + "required": [ + "secret", + ], + }, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/not", + "message": "\`not\` was added.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + expect(replaceSourceWithRefInChanges(removedFromResponse.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/not", + "value": { + "required": [ + "secret", + ], + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/not", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/not", + "message": "\`not\` was removed.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should report a tuple added to a request and dependent schemas removed from a response', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: array }', '{ type: object, dependentSchemas: { a: { required: [b] } } }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: array, prefixItems: [{ type: string }] }', '{ type: object }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/prefixItems", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/prefixItems", + "value": [ + { + "type": "string", + }, + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/prefixItems", + "message": "\`prefixItems\` was added.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/dependentSchemas", + "value": { + "a": { + "required": [ + "b", + ], + }, + }, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/dependentSchemas", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/dependentSchemas", + "message": "\`dependentSchemas\` was removed.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should not report a property named like a keyword', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: object, properties: {} }', '{ type: object }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: object, properties: { not: { type: string } } }', '{ type: object }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "minor", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/not", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/properties/not", + "value": { + "type": "string", + }, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a closed object opened with a schema once, as accepting more', async () => { + const config = await createConfig({ diff: { 'schema-constraint-changed': 'major' } }); + const closed = '{ type: object, unevaluatedProperties: false }'; + const opened = '{ type: object, unevaluatedProperties: { type: string } }'; + + const openedInResponse = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: object }', closed), 'base.yaml'), + revision: makeDocumentFromString(cafe('{ type: object }', opened), 'revision.yaml'), + config, + }); + const openedInRequest = await diffDocuments({ + base: makeDocumentFromString(cafe(closed, '{ type: object }'), 'base.yaml'), + revision: makeDocumentFromString(cafe(opened, '{ type: object }'), 'revision.yaml'), + config, + }); + + expect(replaceSourceWithRefInChanges(openedInResponse.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/unevaluatedProperties", + "value": false, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "unevaluatedProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/unevaluatedProperties", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/unevaluatedProperties", + "message": "\`unevaluatedProperties\` was removed.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "impact": "minor", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/unevaluatedProperties", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/unevaluatedProperties", + "value": { + "type": "string", + }, + }, + "verdicts": [], + }, + ] + `); + expect(replaceSourceWithRefInChanges(openedInRequest.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/unevaluatedProperties", + "value": false, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "unevaluatedProperties", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/unevaluatedProperties", + "value": undefined, + }, + "verdicts": [], + }, + { + "impact": "minor", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/unevaluatedProperties", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/unevaluatedProperties", + "value": { + "type": "string", + }, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a lower bound set in a request and an upper bound removed in a response', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ type: integer }', '{ type: integer, maximum: 10 }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ type: integer, minimum: 1 }', '{ type: integer }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "minimum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", + "value": 1, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/minimum", + "message": "Lower bound was set to '>= 1'.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/maximum", + "value": 10, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "maximum", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "message": "Upper bound was removed.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should report dependent required properties added to a request', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: object }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: object, dependentRequired: { a: [b] } }', '{ type: object }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/dependentRequired", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/dependentRequired", + "value": { + "a": [ + "b", + ], + }, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/dependentRequired", + "message": "\`dependentRequired\` was added.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should report a schema that constrains the extra properties of an open request object', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: object }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: object, additionalProperties: { type: string } }', '{ type: object }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "value": { + "type": "string", + }, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/additionalProperties", + "message": "\`additionalProperties\` was added.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + ] + `); + }); + + it('should judge a closed tuple opened with an items schema as accepting more', async () => { + const closed = '{ type: array, prefixItems: [{ type: string }], items: false }'; + const opened = '{ type: array, prefixItems: [{ type: string }], items: { type: string } }'; + + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(closed), 'base.yaml'), + revision: makeDocumentFromString(cafe(opened), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/items", + "value": false, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "items", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/items", + "value": undefined, + }, + "verdicts": [], + }, + { + "impact": "minor", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/items", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/items", + "value": { + "type": "string", + }, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/items", + "value": false, + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "items", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/items", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/items", + "message": "\`items\` was removed.", + "ruleId": "schema-constraint-changed", + }, + ], + }, + { + "impact": "minor", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema/items", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/items", + "value": { + "type": "string", + }, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should not report a lone if, which constrains nothing', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('{ type: object }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ type: object, if: { required: [a] } }', '{ type: object }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-constraint-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "minor", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema/if", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/if", + "value": { + "required": [ + "a", + ], + }, + }, + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts b/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts new file mode 100644 index 0000000000..ad794e6034 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/schema-type-changed.test.ts @@ -0,0 +1,379 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (openapi: string, price: string) => outdent` + openapi: ${openapi} + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + post: + requestBody: + content: + application/json: + schema: ${price} + responses: + '201': + description: Created + content: + application/json: + schema: ${price} +`; + +describe('schema-type-changed', () => { + it('should report a request that accepts fewer types, not a response that sends fewer', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('3.1.0', '{ type: number }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('3.1.0', '{ type: integer }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-type-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "value": "number", + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "value": "integer", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "message": "Type narrowed from 'number' to 'integer'.", + "ruleId": "schema-type-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "value": "number", + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "value": "integer", + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a response that sends more types, not a request that accepts more', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('3.1.0', '{ type: integer }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('3.1.0', '{ type: number }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-type-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "value": "integer", + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "value": "number", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "value": "integer", + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "value": "number", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "message": "Type widened from 'integer' to 'number'.", + "ruleId": "schema-type-changed", + }, + ], + }, + ] + `); + }); + + it('should report a request that no longer accepts null in OpenAPI 3.0', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('3.0.3', '{ type: number, nullable: true }'), 'base.yaml'), + revision: makeDocumentFromString(cafe('3.0.3', '{ type: number }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-type-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/nullable", + "value": true, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "nullable", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "message": "Type narrowed from 'number | null' to 'number'.", + "ruleId": "schema-type-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/nullable", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "nullable", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + ] + `); + }); + + it("should read OpenAPI 3.0 `nullable: true` as the 3.1 `type: [..., 'null']`", async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('3.0.3', '{ type: number, nullable: true }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('3.1.0', '{ type: [number, "null"] }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-type-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/openapi", + "value": "3.0.3", + }, + "impact": "patch", + "key": "#/", + "kind": "modified", + "property": "openapi", + "revision": { + "location": "revision.yaml#/openapi", + "value": "3.1.0", + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "value": "number", + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "value": [ + "number", + "null", + ], + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/nullable", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "nullable", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "value": "number", + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "value": [ + "number", + "null", + ], + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/nullable", + "value": true, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "nullable", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a type that a schema without one now requires in a request', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('3.1.0', '{ description: Price }'), 'base.yaml'), + revision: makeDocumentFromString( + cafe('3.1.0', '{ description: Price, type: number }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'schema-type-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "value": "number", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "message": "Type narrowed from 'any' to 'number'.", + "ruleId": "schema-type-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "impact": "patch", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "value": "number", + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a type that a response no longer keeps to', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('3.1.0', '{ description: Price, type: number }'), + 'base.yaml' + ), + revision: makeDocumentFromString(cafe('3.1.0', '{ description: Price }'), 'revision.yaml'), + config: await createConfig({ diff: { 'schema-type-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema/type", + "value": "number", + }, + "impact": "patch", + "key": "#/paths/~1orders/post/requestBody/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/requestBody/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema/type", + "value": "number", + }, + "impact": "major", + "key": "#/paths/~1orders/post/responses/201/content/application~1json/schema", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/post/responses/201/content/application~1json/schema", + "message": "Type widened from 'number' to 'any'.", + "ruleId": "schema-type-changed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/security-requirement-changed.test.ts b/packages/core/src/diff/rules/__tests__/security-requirement-changed.test.ts new file mode 100644 index 0000000000..131b6b550a --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/security-requirement-changed.test.ts @@ -0,0 +1,359 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (operation: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: + /orders: + get: ${operation} + components: + securitySchemes: + ApiKey: { type: apiKey, in: header, name: X-Api-Key } + OAuth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://api.cafe.example/token + scopes: { orders:read: Read orders, orders:write: Write orders } +`; + +const ok = 'responses: { 200: { description: OK } }'; + +describe('security-requirement-changed', () => { + it('should report a security list that appears where there was none', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${ok} }`), 'base.yaml'), + revision: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/get/security", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/get/security", + "value": [ + { + "ApiKey": [], + }, + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/get/security", + "message": "Authentication became required.", + "ruleId": "security-requirement-changed", + }, + ], + }, + ] + `); + }); + + it('should report the first requirement filling a list that asked for none', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${ok}, security: [] }`), 'base.yaml'), + revision: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "major", + "key": "#/paths/~1orders/get/security/0", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/get/security/0", + "value": { + "ApiKey": [], + }, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/get/security/0", + "message": "Authentication became required.", + "ruleId": "security-requirement-changed", + }, + ], + }, + ] + `); + }); + + it('should not report another way to authenticate', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'), + revision: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ ApiKey: [] }, { OAuth: [orders:read] }] }`), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "impact": "minor", + "key": "#/paths/~1orders/get/security/1", + "kind": "added", + "revision": { + "location": "revision.yaml#/paths/~1orders/get/security/1", + "value": { + "OAuth": [ + "orders:read", + ], + }, + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report the scopes a requirement now asks for', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ OAuth: [orders:read] }] }`), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ OAuth: [orders:read, orders:write] }] }`), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/get/security/0/OAuth", + "value": [ + "orders:read", + ], + }, + "impact": "major", + "key": "#/paths/~1orders/get/security/0", + "kind": "modified", + "property": "OAuth", + "revision": { + "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth", + "value": [ + "orders:read", + "orders:write", + ], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth", + "message": "Security scheme \`OAuth\` requires new scopes: 'orders:write'.", + "ruleId": "security-requirement-changed", + }, + ], + }, + ] + `); + }); + + it('should report a requirement that now asks for one more scheme', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'), + revision: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ ApiKey: [], OAuth: [] }] }`), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/get/security/0", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1orders/get/security/0", + "kind": "modified", + "property": "OAuth", + "revision": { + "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth", + "value": [], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth", + "message": "Security requirement \`ApiKey\` now requires \`OAuth\`.", + "ruleId": "security-requirement-changed", + }, + ], + }, + ] + `); + }); + + it('should report a requirement that swapped its scheme', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'), + revision: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ OAuth: [] }] }`), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/get/security/0/ApiKey", + "value": [], + }, + "impact": "patch", + "key": "#/paths/~1orders/get/security/0", + "kind": "modified", + "property": "ApiKey", + "revision": { + "location": "revision.yaml#/paths/~1orders/get/security/0", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/paths/~1orders/get/security/0", + "value": undefined, + }, + "impact": "major", + "key": "#/paths/~1orders/get/security/0", + "kind": "modified", + "property": "OAuth", + "revision": { + "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth", + "value": [], + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/paths/~1orders/get/security/0/OAuth", + "message": "Security requirement \`ApiKey\` now requires \`OAuth\`.", + "ruleId": "security-requirement-changed", + }, + ], + }, + ] + `); + }); + + it('should report an alternative that clients can no longer use', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ ApiKey: [] }, { OAuth: [] }] }`), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ OAuth: [] }] }`), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/get/security/0", + "value": { + "ApiKey": [], + }, + }, + "impact": "major", + "key": "#/paths/~1orders/get/security/0", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/paths/~1orders/get/security/0", + "message": "Security requirement \`ApiKey\` is no longer accepted.", + "ruleId": "security-requirement-changed", + }, + ], + }, + ] + `); + }); + + it('should not report a requirement that another one still covers, or a list that asks for none', async () => { + const covered = await diffDocuments({ + base: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ ApiKey: [], OAuth: [] }] }`), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }), + }); + const optional = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${ok}, security: [{ ApiKey: [] }] }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${ok}, security: [] }`), 'revision.yaml'), + config: await createConfig({ diff: { 'security-requirement-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(covered.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/get/security/0/OAuth", + "value": [], + }, + "impact": "patch", + "key": "#/paths/~1orders/get/security/0", + "kind": "modified", + "property": "OAuth", + "revision": { + "location": "revision.yaml#/paths/~1orders/get/security/0", + "value": undefined, + }, + "verdicts": [], + }, + ] + `); + expect(replaceSourceWithRefInChanges(optional.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1orders/get/security/0", + "value": { + "ApiKey": [], + }, + }, + "impact": "patch", + "key": "#/paths/~1orders/get/security/0", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts b/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts new file mode 100644 index 0000000000..2428f7bc18 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/security-scheme-changed.test.ts @@ -0,0 +1,240 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (schemes: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + paths: {} + components: + securitySchemes: ${schemes} +`; + +const apiKey = 'ApiKey: { type: apiKey, in: header, name: X-Api-Key }'; +const bearer = 'Bearer: { type: http, scheme: bearer }'; + +describe('security-scheme-changed', () => { + it('should report a field clients authenticate with', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${apiKey} }`), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ ApiKey: { type: apiKey, in: header, name: X-Cafe-Key } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/securitySchemes/ApiKey/name", + "value": "X-Api-Key", + }, + "impact": "major", + "key": "#/components/securitySchemes/ApiKey", + "kind": "modified", + "property": "name", + "revision": { + "location": "revision.yaml#/components/securitySchemes/ApiKey/name", + "value": "X-Cafe-Key", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/components/securitySchemes/ApiKey/name", + "message": "\`name\` of security scheme \`ApiKey\` changed from 'X-Api-Key' to 'X-Cafe-Key'.", + "ruleId": "security-scheme-changed", + }, + ], + }, + ] + `); + }); + + it('should report a scheme of another type once, not for every field it drags along', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${apiKey} }`), 'base.yaml'), + revision: makeDocumentFromString( + cafe('{ ApiKey: { type: http, scheme: bearer } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/securitySchemes/ApiKey/type", + "value": "apiKey", + }, + "impact": "major", + "key": "#/components/securitySchemes/ApiKey", + "kind": "modified", + "property": "type", + "revision": { + "location": "revision.yaml#/components/securitySchemes/ApiKey/type", + "value": "http", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/components/securitySchemes/ApiKey/type", + "message": "\`type\` of security scheme \`ApiKey\` changed from 'apiKey' to 'http'.", + "ruleId": "security-scheme-changed", + }, + ], + }, + { + "base": { + "location": "base.yaml#/components/securitySchemes/ApiKey/in", + "value": "header", + }, + "impact": "patch", + "key": "#/components/securitySchemes/ApiKey", + "kind": "modified", + "property": "in", + "revision": { + "location": "revision.yaml#/components/securitySchemes/ApiKey", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/components/securitySchemes/ApiKey/name", + "value": "X-Api-Key", + }, + "impact": "patch", + "key": "#/components/securitySchemes/ApiKey", + "kind": "modified", + "property": "name", + "revision": { + "location": "revision.yaml#/components/securitySchemes/ApiKey", + "value": undefined, + }, + "verdicts": [], + }, + { + "base": { + "location": "base.yaml#/components/securitySchemes/ApiKey", + "value": undefined, + }, + "impact": "patch", + "key": "#/components/securitySchemes/ApiKey", + "kind": "modified", + "property": "scheme", + "revision": { + "location": "revision.yaml#/components/securitySchemes/ApiKey/scheme", + "value": "bearer", + }, + "verdicts": [], + }, + ] + `); + }); + + it('should report a field that a scheme gains or loses', async () => { + const withFormat = 'Bearer: { type: http, scheme: bearer, bearerFormat: JWT }'; + const gained = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${bearer} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${withFormat} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }), + }); + const lost = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${withFormat} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${bearer} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(gained.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/securitySchemes/Bearer", + "value": undefined, + }, + "impact": "major", + "key": "#/components/securitySchemes/Bearer", + "kind": "modified", + "property": "bearerFormat", + "revision": { + "location": "revision.yaml#/components/securitySchemes/Bearer/bearerFormat", + "value": "JWT", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/components/securitySchemes/Bearer/bearerFormat", + "message": "\`bearerFormat\` of security scheme \`Bearer\` was set to 'JWT'.", + "ruleId": "security-scheme-changed", + }, + ], + }, + ] + `); + expect(replaceSourceWithRefInChanges(lost.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/securitySchemes/Bearer/bearerFormat", + "value": "JWT", + }, + "impact": "major", + "key": "#/components/securitySchemes/Bearer", + "kind": "modified", + "property": "bearerFormat", + "revision": { + "location": "revision.yaml#/components/securitySchemes/Bearer", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/components/securitySchemes/Bearer", + "message": "\`bearerFormat\` of security scheme \`Bearer\` was removed.", + "ruleId": "security-scheme-changed", + }, + ], + }, + ] + `); + }); + + it('should report a security scheme that is gone', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${apiKey}, ${bearer} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${apiKey} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'security-scheme-changed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/securitySchemes/Bearer", + "value": { + "scheme": "bearer", + "type": "http", + }, + }, + "impact": "major", + "key": "#/components/securitySchemes/Bearer", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/components/securitySchemes/Bearer", + "message": "Security scheme \`Bearer\` was removed.", + "ruleId": "security-scheme-changed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/__tests__/server-removed.test.ts b/packages/core/src/diff/rules/__tests__/server-removed.test.ts new file mode 100644 index 0000000000..b9862afa93 --- /dev/null +++ b/packages/core/src/diff/rules/__tests__/server-removed.test.ts @@ -0,0 +1,376 @@ +import { outdent } from 'outdent'; + +import { replaceSourceWithRefInChanges } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { makeDocumentFromString } from '../../../resolve.js'; +import { diffDocuments } from '../../index.js'; + +const cafe = (servers?: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + ${servers === undefined ? '' : `servers: ${servers}`} + channels: {} +`; + +const production = 'production: { host: broker.cafe.example, protocol: amqp }'; +const sandbox = 'sandbox: { host: sandbox.cafe.example, protocol: amqp }'; + +describe('server-removed', () => { + it('should report a server that is gone', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${production}, ${sandbox} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`{ ${production} }`), 'revision.yaml'), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers/sandbox", + "value": { + "host": "sandbox.cafe.example", + "protocol": "amqp", + }, + }, + "impact": "major", + "key": "#/servers/sandbox", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/servers/sandbox", + "message": "Server \`sandbox\` was removed.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + }); + + it('should report every server leaving with the whole map', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`{ ${production} }`), 'base.yaml'), + revision: makeDocumentFromString(cafe(), 'revision.yaml'), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers", + "value": { + "production": { + "host": "broker.cafe.example", + "protocol": "amqp", + }, + }, + }, + "impact": "major", + "key": "#/servers", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/servers", + "message": "All servers were removed.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + }); + + it('should report an AsyncAPI server that moved to another host', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString( + cafe('{ production: { host: kitchen.cafe.test, protocol: amqp } }'), + 'base.yaml' + ), + revision: makeDocumentFromString( + cafe('{ production: { host: kitchen.cafe.dev, protocol: amqp } }'), + 'revision.yaml' + ), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers/production/host", + "value": "kitchen.cafe.test", + }, + "impact": "major", + "key": "#/servers/production", + "kind": "modified", + "property": "host", + "revision": { + "location": "revision.yaml#/servers/production/host", + "value": "kitchen.cafe.dev", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/servers/production/host", + "message": "\`host\` of server \`production\` changed from 'kitchen.cafe.test' to 'kitchen.cafe.dev'.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + }); + + it('should report an AsyncAPI server that gained or lost its pathname', async () => { + const withoutPathname = '{ production: { host: broker.cafe.example, protocol: amqp } }'; + const withPathname = + '{ production: { host: broker.cafe.example, pathname: /v1, protocol: amqp } }'; + const config = await createConfig({ diff: { 'server-removed': 'major' } }); + + const gained = await diffDocuments({ + base: makeDocumentFromString(cafe(withoutPathname), 'base.yaml'), + revision: makeDocumentFromString(cafe(withPathname), 'revision.yaml'), + config, + }); + const lost = await diffDocuments({ + base: makeDocumentFromString(cafe(withPathname), 'base.yaml'), + revision: makeDocumentFromString(cafe(withoutPathname), 'revision.yaml'), + config, + }); + + expect(replaceSourceWithRefInChanges(gained.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers/production", + "value": undefined, + }, + "impact": "major", + "key": "#/servers/production", + "kind": "modified", + "property": "pathname", + "revision": { + "location": "revision.yaml#/servers/production/pathname", + "value": "/v1", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/servers/production/pathname", + "message": "\`pathname\` of server \`production\` was set to '/v1'.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + expect(replaceSourceWithRefInChanges(lost.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers/production/pathname", + "value": "/v1", + }, + "impact": "major", + "key": "#/servers/production", + "kind": "modified", + "property": "pathname", + "revision": { + "location": "revision.yaml#/servers/production", + "value": undefined, + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/servers/production", + "message": "\`pathname\` of server \`production\` was removed.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + }); + + it('should not report the components servers map', async () => { + const withComponents = (components: string) => outdent` + asyncapi: 3.0.0 + info: { title: Cafe kitchen, version: 1.0.0 } + channels: {} + components: ${components} + `; + + const result = await diffDocuments({ + base: makeDocumentFromString(withComponents(`{ servers: { ${production} } }`), 'base.yaml'), + revision: makeDocumentFromString(withComponents('{}'), 'revision.yaml'), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/components/servers", + "value": { + "production": { + "host": "broker.cafe.example", + "protocol": "amqp", + }, + }, + }, + "impact": "patch", + "key": "#/components/servers", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); +}); + +describe('server-removed for OpenAPI', () => { + const cafe = (servers?: string, operationServers?: string) => outdent` + openapi: 3.1.0 + info: { title: Cafe, version: 1.0.0 } + ${servers === undefined ? '' : `servers: ${servers}`} + paths: + /menu: + get: + ${operationServers === undefined ? '' : `servers: ${operationServers}`} + responses: { 200: { description: OK } } + `; + const production = '{ url: https://api.cafe.example }'; + const sandbox = '{ url: https://sandbox.cafe.example }'; + + it('should report a server that is gone, whatever its position in the list', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`[${sandbox}, ${production}]`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`[${production}]`), 'revision.yaml'), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers/0", + "value": { + "url": "https://sandbox.cafe.example", + }, + }, + "impact": "major", + "key": "#/servers/0", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/servers/0", + "message": "Server \`https://sandbox.cafe.example\` was removed.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + }); + + it('should report the document losing its servers list', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`[${production}]`), 'base.yaml'), + revision: makeDocumentFromString(cafe(), 'revision.yaml'), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers", + "value": [ + { + "url": "https://api.cafe.example", + }, + ], + }, + "impact": "major", + "key": "#/servers", + "kind": "removed", + "verdicts": [ + { + "impact": "major", + "location": "base.yaml#/servers", + "message": "All servers were removed.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + }); + + it('should not report an operation losing its own servers, which falls back to the document', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe(`[${production}]`, `[${sandbox}]`), 'base.yaml'), + revision: makeDocumentFromString(cafe(`[${production}]`), 'revision.yaml'), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/paths/~1menu/get/servers", + "value": [ + { + "url": "https://sandbox.cafe.example", + }, + ], + }, + "impact": "patch", + "key": "#/paths/~1menu/get/servers", + "kind": "removed", + "verdicts": [], + }, + ] + `); + }); + + it('should report a server that moved to another URL', async () => { + const result = await diffDocuments({ + base: makeDocumentFromString(cafe('[{ url: https://api.cafe.test }]'), 'base.yaml'), + revision: makeDocumentFromString(cafe('[{ url: https://api.cafe.dev }]'), 'revision.yaml'), + config: await createConfig({ diff: { 'server-removed': 'major' } }), + }); + + expect(replaceSourceWithRefInChanges(result.changes)).toMatchInlineSnapshot(` + [ + { + "base": { + "location": "base.yaml#/servers/0/url", + "value": "https://api.cafe.test", + }, + "impact": "major", + "key": "#/servers/0", + "kind": "modified", + "property": "url", + "revision": { + "location": "revision.yaml#/servers/0/url", + "value": "https://api.cafe.dev", + }, + "verdicts": [ + { + "impact": "major", + "location": "revision.yaml#/servers/0/url", + "message": "Server \`https://api.cafe.test\` became \`https://api.cafe.dev\`.", + "ruleId": "server-removed", + }, + ], + }, + ] + `); + }); +}); diff --git a/packages/core/src/diff/rules/channel-address-changed.ts b/packages/core/src/diff/rules/channel-address-changed.ts new file mode 100644 index 0000000000..d135619ac8 --- /dev/null +++ b/packages/core/src/diff/rules/channel-address-changed.ts @@ -0,0 +1,16 @@ +import { nameOf } from '../diff-node.js'; +import type { DiffRule } from '../types.js'; + +export const ChannelAddressChanged: DiffRule = () => ({ + Channel(change, { report }) { + if (change.kind !== 'modified' || change.property !== 'address') return; + + const subject = `\`address\` of channel \`${nameOf(change.node)}\``; + const { value: before } = change.base; + const { value: after } = change.revision; + + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); + }, +}); diff --git a/packages/core/src/diff/rules/channel-removed.ts b/packages/core/src/diff/rules/channel-removed.ts new file mode 100644 index 0000000000..bc072d441b --- /dev/null +++ b/packages/core/src/diff/rules/channel-removed.ts @@ -0,0 +1,16 @@ +import { nameOf } from '../diff-node.js'; +import type { DiffRule } from '../types.js'; +import { isRootField } from './utils.js'; + +export const ChannelRemoved: DiffRule = () => ({ + Channel(change, { report }) { + if (change.kind === 'removed') { + report({ message: `Channel \`${nameOf(change.node)}\` was removed.` }); + } + }, + NamedChannels(change, { report }) { + if (change.kind === 'removed' && isRootField(change.node)) { + report({ message: 'All channels were removed.' }); + } + }, +}); diff --git a/packages/core/src/diff/rules/enum-values-added.ts b/packages/core/src/diff/rules/enum-values-added.ts new file mode 100644 index 0000000000..8b7c245bbc --- /dev/null +++ b/packages/core/src/diff/rules/enum-values-added.ts @@ -0,0 +1,26 @@ +import { itemsNotIn } from '../../utils/items-not-in.js'; +import type { DiffRule } from '../types.js'; +import { ofNamedSchema } from './utils.js'; + +export const EnumValuesAdded: DiffRule = () => ({ + Schema(change, { report, getDirections }) { + if (change.kind !== 'modified' || change.property !== 'enum') return; + if (!getDirections().includes('response')) return; + + const { value: before } = change.base; + const { value: after } = change.revision; + + if (before === undefined) return; + + if (after === undefined) { + report({ message: `Enum${ofNamedSchema(change.node)} was removed.` }); + return; + } + + const added = itemsNotIn(after, before); + if (!added.length) return; + + const values = added.map((value) => `'${value}'`).join(', '); + report({ message: `Enum${ofNamedSchema(change.node)} gained values: ${values}.` }); + }, +}); diff --git a/packages/core/src/diff/rules/enum-values-removed.ts b/packages/core/src/diff/rules/enum-values-removed.ts new file mode 100644 index 0000000000..bf86b12083 --- /dev/null +++ b/packages/core/src/diff/rules/enum-values-removed.ts @@ -0,0 +1,29 @@ +import { itemsNotIn } from '../../utils/items-not-in.js'; +import type { DiffRule } from '../types.js'; +import { ofNamedSchema } from './utils.js'; + +export const EnumValuesRemoved: DiffRule = () => ({ + Schema(change, { report, getDirections }) { + if (change.kind !== 'modified' || change.property !== 'enum') return; + if (!getDirections().includes('request')) return; + + const { value: before } = change.base; + const { value: after } = change.revision; + + if (after === undefined) return; + + if (before === undefined) { + const listed = Array.isArray(after) ? after : []; + const values = listed.map((value) => `'${value}'`).join(', '); + + report({ message: `Enum${ofNamedSchema(change.node)} was set to ${values}.` }); + return; + } + + const removed = itemsNotIn(before, after); + if (!removed.length) return; + + const values = removed.map((value) => `'${value}'`).join(', '); + report({ message: `Enum${ofNamedSchema(change.node)} lost values: ${values}.` }); + }, +}); diff --git a/packages/core/src/diff/rules/index.ts b/packages/core/src/diff/rules/index.ts new file mode 100644 index 0000000000..4bd1f62b66 --- /dev/null +++ b/packages/core/src/diff/rules/index.ts @@ -0,0 +1,90 @@ +import type { DiffRule } from '../types.js'; +import { ChannelAddressChanged } from './channel-address-changed.js'; +import { ChannelRemoved } from './channel-removed.js'; +import { EnumValuesAdded } from './enum-values-added.js'; +import { EnumValuesRemoved } from './enum-values-removed.js'; +import { MediaTypeRemoved } from './media-type-removed.js'; +import { MessageContentTypeChanged } from './message-content-type-changed.js'; +import { MessageRemoved } from './message-removed.js'; +import { OperationActionChanged } from './operation-action-changed.js'; +import { OperationIdChanged } from './operation-id-changed.js'; +import { OperationRemoved } from './operation-removed.js'; +import { ParameterBecameRequired } from './parameter-became-required.js'; +import { ParameterRemoved } from './parameter-removed.js'; +import { ParameterSerializationChanged } from './parameter-serialization-changed.js'; +import { PathRemoved } from './path-removed.js'; +import { PropertyRemoved } from './property-removed.js'; +import { RequestBodyBecameRequired } from './request-body-became-required.js'; +import { RequestBodyRemoved } from './request-body-removed.js'; +import { RequiredPropertiesAdded } from './required-properties-added.js'; +import { RequiredPropertiesRemoved } from './required-properties-removed.js'; +import { ResponseHeaderRemoved } from './response-header-removed.js'; +import { ResponseRemoved } from './response-removed.js'; +import { SchemaCombinatorChanged } from './schema-combinator-changed.js'; +import { SchemaConstraintChanged } from './schema-constraint-changed.js'; +import { SchemaTypeChanged } from './schema-type-changed.js'; +import { SecurityRequirementChanged } from './security-requirement-changed.js'; +import { SecuritySchemeChanged } from './security-scheme-changed.js'; +import { ServerRemoved } from './server-removed.js'; + +// An AsyncAPI 3 payload is a `Schema` node too. +const schemaRules = { + 'schema-type-changed': SchemaTypeChanged, + 'enum-values-removed': EnumValuesRemoved, + 'enum-values-added': EnumValuesAdded, + 'required-properties-added': RequiredPropertiesAdded, + 'required-properties-removed': RequiredPropertiesRemoved, + 'property-removed': PropertyRemoved, + 'schema-constraint-changed': SchemaConstraintChanged, +} satisfies Record; + +export const oas3Rules = { + 'operation-removed': OperationRemoved, + 'operation-id-changed': OperationIdChanged, + 'path-removed': PathRemoved, + 'parameter-removed': ParameterRemoved, + 'parameter-became-required': ParameterBecameRequired, + 'parameter-serialization-changed': ParameterSerializationChanged, + 'request-body-removed': RequestBodyRemoved, + 'request-body-became-required': RequestBodyBecameRequired, + 'response-removed': ResponseRemoved, + 'response-header-removed': ResponseHeaderRemoved, + 'media-type-removed': MediaTypeRemoved, + 'security-requirement-changed': SecurityRequirementChanged, + 'security-scheme-changed': SecuritySchemeChanged, + 'server-removed': ServerRemoved, + ...schemaRules, + 'schema-combinator-changed': SchemaCombinatorChanged, +} satisfies Record; + +// No `schema-combinator-changed`: the JSON Schema type tree of AsyncAPI calls every combinator +// list `SchemaList`, so the rule could not tell the keywords apart. +export const async3Rules = { + 'channel-removed': ChannelRemoved, + 'channel-address-changed': ChannelAddressChanged, + 'message-removed': MessageRemoved, + 'message-content-type-changed': MessageContentTypeChanged, + 'operation-removed': OperationRemoved, + 'operation-action-changed': OperationActionChanged, + 'server-removed': ServerRemoved, + ...schemaRules, +} satisfies Record; + +export const diffRuleSets = { + oas3: oas3Rules, + async3: async3Rules, +}; + +export type DiffRuleSets = typeof diffRuleSets; +export type DiffFamily = keyof DiffRuleSets; +export type DiffRuleId = { + [Family in DiffFamily]: keyof DiffRuleSets[Family] & string; +}[DiffFamily]; + +export function isDiffFamily(family: string): family is DiffFamily { + return Object.hasOwn(diffRuleSets, family); +} + +export const diffRuleIds: string[] = [ + ...new Set(Object.values(diffRuleSets).flatMap((rules) => Object.keys(rules))), +]; diff --git a/packages/core/src/diff/rules/media-type-removed.ts b/packages/core/src/diff/rules/media-type-removed.ts new file mode 100644 index 0000000000..c7a0c5d580 --- /dev/null +++ b/packages/core/src/diff/rules/media-type-removed.ts @@ -0,0 +1,23 @@ +import { nameOf } from '../diff-node.js'; +import type { DiffRule } from '../types.js'; + +// A media type of a component nothing references travels neither way, so it breaks no client. +export const MediaTypeRemoved: DiffRule = () => ({ + MediaType(change, { report, getDirections }) { + if (getDirections().length === 0) return; + + if (change.kind === 'removed') { + report({ message: `Media type \`${nameOf(change.node)}\` was removed.` }); + } + if (change.kind === 'modified' && change.property === 'key') { + report({ + message: `Media type \`${change.base.value}\` became \`${change.revision.value}\`.`, + }); + } + }, + MediaTypesMap(change, { report, getDirections }) { + if (change.kind === 'removed' && getDirections().length > 0) { + report({ message: 'All media types were removed.' }); + } + }, +}); diff --git a/packages/core/src/diff/rules/message-content-type-changed.ts b/packages/core/src/diff/rules/message-content-type-changed.ts new file mode 100644 index 0000000000..3f7142de7a --- /dev/null +++ b/packages/core/src/diff/rules/message-content-type-changed.ts @@ -0,0 +1,16 @@ +import { nameOf } from '../diff-node.js'; +import type { DiffRule } from '../types.js'; + +export const MessageContentTypeChanged: DiffRule = () => ({ + Message(change, { report }) { + if (change.kind !== 'modified' || change.property !== 'contentType') return; + + const subject = `\`contentType\` of message \`${nameOf(change.node)}\``; + const { value: before } = change.base; + const { value: after } = change.revision; + + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); + }, +}); diff --git a/packages/core/src/diff/rules/message-removed.ts b/packages/core/src/diff/rules/message-removed.ts new file mode 100644 index 0000000000..b9714f901f --- /dev/null +++ b/packages/core/src/diff/rules/message-removed.ts @@ -0,0 +1,19 @@ +import { nameOf } from '../diff-node.js'; +import type { DiffRule } from '../types.js'; + +export const MessageRemoved: DiffRule = () => ({ + Message(change, { report }) { + if (change.kind === 'removed') { + report({ message: `Message \`${nameOf(change.node)}\` was removed.` }); + } + }, + Channel: { + NamedMessages(change, { report }) { + if (change.kind === 'removed') { + report({ + message: `All messages of channel \`${nameOf(change.node.parent!)}\` were removed.`, + }); + } + }, + }, +}); diff --git a/packages/core/src/diff/rules/operation-action-changed.ts b/packages/core/src/diff/rules/operation-action-changed.ts new file mode 100644 index 0000000000..7fc20b3e48 --- /dev/null +++ b/packages/core/src/diff/rules/operation-action-changed.ts @@ -0,0 +1,18 @@ +import { nameOf } from '../diff-node.js'; +import type { DiffRule } from '../types.js'; + +// AsyncAPI only: an operation states its own direction, and swapping it turns every +// message of the channel around. +export const OperationActionChanged: DiffRule = () => ({ + Operation(change, { report }) { + if (change.kind !== 'modified' || change.property !== 'action') return; + + const subject = `\`action\` of operation \`${nameOf(change.node)}\``; + const { value: before } = change.base; + const { value: after } = change.revision; + + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); + }, +}); diff --git a/packages/core/src/diff/rules/operation-id-changed.ts b/packages/core/src/diff/rules/operation-id-changed.ts new file mode 100644 index 0000000000..ce575b77a7 --- /dev/null +++ b/packages/core/src/diff/rules/operation-id-changed.ts @@ -0,0 +1,24 @@ +import { nameOf } from '../diff-node.js'; +import type { DiffRule, DiffVisit } from '../types.js'; + +const operationIdChanged: DiffVisit = (change, { report }) => { + if (change.kind !== 'modified' || change.property !== 'operationId') return; + + const { value: before } = change.base; + const { value: after } = change.revision; + if (before === undefined) return; + + const method = nameOf(change.node).toUpperCase(); + const subject = `\`operationId\` of operation \`${method} ${nameOf(change.node.parent!)}\``; + + if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); +}; + +// Generated clients name their methods after `operationId`, so a changed or removed one breaks the +// code that calls them. A first `operationId` breaks nothing. +export const OperationIdChanged: DiffRule = () => ({ + Paths: { PathItem: { Operation: operationIdChanged } }, + WebhooksMap: { PathItem: { Operation: operationIdChanged } }, + CallbacksMap: { Callback: { PathItem: { Operation: operationIdChanged } } }, +}); diff --git a/packages/core/src/diff/rules/operation-removed.ts b/packages/core/src/diff/rules/operation-removed.ts new file mode 100644 index 0000000000..96181446d5 --- /dev/null +++ b/packages/core/src/diff/rules/operation-removed.ts @@ -0,0 +1,31 @@ +import { nameOf } from '../diff-node.js'; +import type { DiffRule, DiffVisit } from '../types.js'; +import { isRootField } from './utils.js'; + +// An OpenAPI operation is named by its method and its path, or the URL of its callback. +const pathOperationRemoved: DiffVisit = (change, { report }) => { + if (change.kind !== 'removed') return; + + const method = nameOf(change.node).toUpperCase(); + report({ message: `Operation \`${method} ${nameOf(change.node.parent!)}\` was removed.` }); +}; + +export const OperationRemoved: DiffRule = () => ({ + Paths: { PathItem: { Operation: pathOperationRemoved } }, + WebhooksMap: { PathItem: { Operation: pathOperationRemoved } }, + CallbacksMap: { Callback: { PathItem: { Operation: pathOperationRemoved } } }, + NamedOperations: { + Operation(change, { report }) { + if (change.kind === 'removed') { + report({ message: `Operation \`${nameOf(change.node)}\` was removed.` }); + } + }, + }, + Root: { + NamedOperations(change, { report }) { + if (change.kind === 'removed' && isRootField(change.node)) { + report({ message: 'All operations were removed.' }); + } + }, + }, +}); diff --git a/packages/core/src/diff/rules/parameter-became-required.ts b/packages/core/src/diff/rules/parameter-became-required.ts new file mode 100644 index 0000000000..a1b9f63aaf --- /dev/null +++ b/packages/core/src/diff/rules/parameter-became-required.ts @@ -0,0 +1,42 @@ +import { fieldOf, locationOf } from '../../node-tree/access.js'; +import type { NodeEntry } from '../../node-tree/types.js'; +import type { Location } from '../../ref-utils.js'; +import { latestOf } from '../diff-node.js'; +import type { DiffRule } from '../types.js'; + +// The first parameters of an operation arrive with the whole `parameters` list, so each required +// one is reported on the list, at its own `required` field. +export const ParameterBecameRequired: DiffRule = () => ({ + Parameter(change, { report, getDirections }) { + if (!getDirections().includes('request')) return; + + const parameter = latestOf(change.node); + const name = `\`${fieldOf(parameter, 'name')}\` ${fieldOf(parameter, 'in')} parameter`; + + if (change.kind === 'added' && fieldOf(parameter, 'required') === true) { + report({ message: `Required ${name} was added.`, location: requiredFieldOf(parameter) }); + } + if ( + change.kind === 'modified' && + change.property === 'required' && + change.revision.value === true + ) { + report({ message: `${name} became required.` }); + } + }, + ParameterList(change, { report, getDirections }) { + if (change.kind !== 'added' || !getDirections().includes('request')) return; + + for (const parameter of latestOf(change.node).children) { + if (fieldOf(parameter, 'required') !== true) continue; + + const name = `\`${fieldOf(parameter, 'name')}\` ${fieldOf(parameter, 'in')} parameter`; + report({ message: `Required ${name} was added.`, location: requiredFieldOf(parameter) }); + } + }, +}); + +// A `$ref` parameter is required where it points. +function requiredFieldOf(parameter: NodeEntry): Location { + return locationOf(parameter).child(['required']); +} diff --git a/packages/core/src/diff/rules/parameter-removed.ts b/packages/core/src/diff/rules/parameter-removed.ts new file mode 100644 index 0000000000..70224559a0 --- /dev/null +++ b/packages/core/src/diff/rules/parameter-removed.ts @@ -0,0 +1,39 @@ +import { fieldOf } from '../../node-tree/access.js'; +import { latestOf } from '../diff-node.js'; +import type { DiffRule } from '../types.js'; + +export const ParameterRemoved: DiffRule = () => ({ + Parameter(change, { report, getDirections }) { + if (!getDirections().includes('request')) return; + + if (change.kind === 'removed') { + const parameter = latestOf(change.node); + const name = `\`${fieldOf(parameter, 'name')}\` ${fieldOf(parameter, 'in')} parameter`; + report({ message: `${name} was removed.` }); + } + if (change.kind !== 'modified') return; + + // The client still sends the parameter as the base had it, so both reports name that one. + const oldName = fieldOf(change.node.base, 'name'); + const oldIn = fieldOf(change.node.base, 'in'); + const { value: before } = change.base; + const { value: after } = change.revision; + + if (change.property === 'in') { + report({ message: `\`${oldName}\` ${before} parameter is now a ${after} parameter.` }); + } + + // A path parameter is named for the docs only, and a header name is not case-sensitive, so + // renaming those asks nothing of the client. + const caseOnly = + oldIn === 'header' && String(before).toLowerCase() === String(after).toLowerCase(); + if (change.property === 'name' && oldIn !== 'path' && !caseOnly) { + report({ message: `\`${before}\` ${oldIn} parameter was renamed to \`${after}\`.` }); + } + }, + ParameterList(change, { report, getDirections }) { + if (change.kind === 'removed' && getDirections().includes('request')) { + report({ message: 'All parameters were removed.' }); + } + }, +}); diff --git a/packages/core/src/diff/rules/parameter-serialization-changed.ts b/packages/core/src/diff/rules/parameter-serialization-changed.ts new file mode 100644 index 0000000000..23599bc79e --- /dev/null +++ b/packages/core/src/diff/rules/parameter-serialization-changed.ts @@ -0,0 +1,24 @@ +import { fieldOf } from '../../node-tree/access.js'; +import { latestOf } from '../diff-node.js'; +import type { DiffRule } from '../types.js'; + +// How a value is put on the wire is part of the contract: a client that encoded +// the old way is not understood after the change. +const SERIALIZATION = new Set(['style', 'explode', 'allowReserved', 'allowEmptyValue']); + +export const ParameterSerializationChanged: DiffRule = () => ({ + Parameter(change, { report, getDirections }) { + if (change.kind !== 'modified' || !SERIALIZATION.has(change.property)) return; + if (!getDirections().includes('request')) return; + + const parameter = latestOf(change.node); + const name = `\`${fieldOf(parameter, 'name')}\` ${fieldOf(parameter, 'in')} parameter`; + const subject = `\`${change.property}\` of ${name}`; + const { value: before } = change.base; + const { value: after } = change.revision; + + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); + }, +}); diff --git a/packages/core/src/diff/rules/path-removed.ts b/packages/core/src/diff/rules/path-removed.ts new file mode 100644 index 0000000000..9d9683f285 --- /dev/null +++ b/packages/core/src/diff/rules/path-removed.ts @@ -0,0 +1,60 @@ +import { nameOf } from '../diff-node.js'; +import type { DiffRule } from '../types.js'; + +// `/menu/{id}` and `/menu/{itemId}` are one URL: a template names its variables for the docs only. +const templateShape = (path: unknown) => String(path).replace(/\{[^}]+\}/g, '{}'); + +export const PathRemoved: DiffRule = () => ({ + Paths: { + PathItem(change, { report }) { + if (change.kind === 'removed') { + report({ message: `Path \`${nameOf(change.node)}\` was removed.` }); + } + if (change.kind !== 'modified' || change.property !== 'key') return; + + const { value: before } = change.base; + const { value: after } = change.revision; + if (templateShape(before) !== templateShape(after)) { + report({ message: `Path \`${before}\` was renamed to \`${after}\`.` }); + } + }, + }, + Root: { + Paths(change, { report }) { + if (change.kind === 'removed') report({ message: 'All paths were removed.' }); + }, + WebhooksMap(change, { report }) { + if (change.kind === 'removed') report({ message: 'All webhooks were removed.' }); + }, + }, + // A webhook's key names it for the docs, so only its removal is reported. + WebhooksMap: { + PathItem(change, { report }) { + if (change.kind === 'removed') { + report({ message: `Webhook \`${nameOf(change.node)}\` was removed.` }); + } + }, + }, + // A callback is the API calling the client back, judged like a webhook. The callbacks of + // `components` are reported where an operation uses them. + Operation: { + CallbacksMap(change, { report }) { + if (change.kind === 'removed') report({ message: 'All callbacks were removed.' }); + }, + Callback(change, { report }) { + if (change.kind === 'removed') { + report({ message: `Callback \`${nameOf(change.node)}\` was removed.` }); + } + }, + }, + CallbacksMap: { + Callback: { + PathItem(change, { report }) { + if (change.kind !== 'removed') return; + + const callback = nameOf(change.node.parent!); + report({ message: `Callback \`${callback}\` no longer calls \`${nameOf(change.node)}\`.` }); + }, + }, + }, +}); diff --git a/packages/core/src/diff/rules/property-removed.ts b/packages/core/src/diff/rules/property-removed.ts new file mode 100644 index 0000000000..4785a3ac0b --- /dev/null +++ b/packages/core/src/diff/rules/property-removed.ts @@ -0,0 +1,49 @@ +import { fieldOf } from '../../node-tree/access.js'; +import { nameOf } from '../diff-node.js'; +import type { DiffNode, DiffRule, Direction } from '../types.js'; +import { ofNamedSchema } from './utils.js'; + +export const PropertyRemoved: DiffRule = () => ({ + SchemaProperties: { + Schema(change, { report, getDirections }) { + const objectSchema = change.node.parent?.parent; + const directions = getDirections(); + + if (!removalBreaksClients(objectSchema, directions)) return; + + const owner = ofNamedSchema(objectSchema); + + if (change.kind === 'removed') { + report({ message: `Property \`${nameOf(change.node)}\`${owner} was removed.` }); + } + if (change.kind === 'modified' && change.property === 'key') { + const { value: before } = change.base; + const { value: after } = change.revision; + report({ message: `Property \`${before}\`${owner} was renamed to \`${after}\`.` }); + } + }, + }, + Schema: { + SchemaProperties(change, { report, getDirections }) { + const objectSchema = change.node.parent; + const directions = getDirections(); + + if (change.kind === 'removed' && removalBreaksClients(objectSchema, directions)) { + report({ message: `All properties${ofNamedSchema(objectSchema)} were removed.` }); + } + }, + }, +}); + +function removalBreaksClients( + objectSchema: DiffNode | null | undefined, + directions: Direction[] +): boolean { + if (directions.includes('response')) return true; + if (!directions.includes('request')) return false; + + return ( + fieldOf(objectSchema?.revision, 'additionalProperties') === false || + fieldOf(objectSchema?.revision, 'unevaluatedProperties') === false + ); +} diff --git a/packages/core/src/diff/rules/request-body-became-required.ts b/packages/core/src/diff/rules/request-body-became-required.ts new file mode 100644 index 0000000000..0bf7628d8e --- /dev/null +++ b/packages/core/src/diff/rules/request-body-became-required.ts @@ -0,0 +1,10 @@ +import type { DiffRule } from '../types.js'; + +export const RequestBodyBecameRequired: DiffRule = () => ({ + RequestBody(change, { report, getDirections }) { + if (change.kind !== 'modified' || change.property !== 'required') return; + if (getDirections().includes('request') && change.revision.value === true) { + report({ message: 'Request body became required.' }); + } + }, +}); diff --git a/packages/core/src/diff/rules/request-body-removed.ts b/packages/core/src/diff/rules/request-body-removed.ts new file mode 100644 index 0000000000..03293ddee2 --- /dev/null +++ b/packages/core/src/diff/rules/request-body-removed.ts @@ -0,0 +1,9 @@ +import type { DiffRule } from '../types.js'; + +export const RequestBodyRemoved: DiffRule = () => ({ + RequestBody(change, { report, getDirections }) { + if (change.kind === 'removed' && getDirections().includes('request')) { + report({ message: 'Request body was removed.' }); + } + }, +}); diff --git a/packages/core/src/diff/rules/required-properties-added.ts b/packages/core/src/diff/rules/required-properties-added.ts new file mode 100644 index 0000000000..a80bf3e5f7 --- /dev/null +++ b/packages/core/src/diff/rules/required-properties-added.ts @@ -0,0 +1,20 @@ +import { itemsNotIn } from '../../utils/items-not-in.js'; +import type { DiffRule } from '../types.js'; +import { ofNamedSchema, propertiesMarkedAs } from './utils.js'; + +export const RequiredPropertiesAdded: DiffRule = () => ({ + Schema(change, { report, getDirections }) { + if (change.kind !== 'modified' || change.property !== 'required') return; + if (!getDirections().includes('request')) return; + + // A request does not send a `readOnly` property, so requiring it asks nothing of the client. + const readOnly = propertiesMarkedAs(change.node, 'readOnly'); + const added = itemsNotIn(change.revision.value, change.base.value).filter( + (name) => !readOnly.includes(String(name)) + ); + if (!added.length) return; + + const names = added.map((name) => `\`${name}\``).join(', '); + report({ message: `Properties${ofNamedSchema(change.node)} became required: ${names}.` }); + }, +}); diff --git a/packages/core/src/diff/rules/required-properties-removed.ts b/packages/core/src/diff/rules/required-properties-removed.ts new file mode 100644 index 0000000000..b102f719ff --- /dev/null +++ b/packages/core/src/diff/rules/required-properties-removed.ts @@ -0,0 +1,22 @@ +import { itemsNotIn } from '../../utils/items-not-in.js'; +import type { DiffRule } from '../types.js'; +import { ofNamedSchema, propertiesMarkedAs } from './utils.js'; + +export const RequiredPropertiesRemoved: DiffRule = () => ({ + Schema(change, { report, getDirections }) { + if (change.kind !== 'modified' || change.property !== 'required') return; + if (!getDirections().includes('response')) return; + + // A response does not send a `writeOnly` property, so no client reads it. + const writeOnly = propertiesMarkedAs(change.node, 'writeOnly'); + const removed = itemsNotIn(change.base.value, change.revision.value).filter( + (name) => !writeOnly.includes(String(name)) + ); + if (!removed.length) return; + + const names = removed.map((name) => `\`${name}\``).join(', '); + report({ + message: `Properties${ofNamedSchema(change.node)} are no longer required: ${names}.`, + }); + }, +}); diff --git a/packages/core/src/diff/rules/response-header-removed.ts b/packages/core/src/diff/rules/response-header-removed.ts new file mode 100644 index 0000000000..3882af2284 --- /dev/null +++ b/packages/core/src/diff/rules/response-header-removed.ts @@ -0,0 +1,29 @@ +import { nameOf } from '../diff-node.js'; +import type { DiffRule } from '../types.js'; + +export const ResponseHeaderRemoved: DiffRule = () => ({ + Response: { + Header(change, { report, getDirections }) { + if (!getDirections().includes('response')) return; + + if (change.kind === 'removed') { + report({ message: `Response header \`${nameOf(change.node)}\` was removed.` }); + } + if (change.kind !== 'modified' || change.property !== 'key') return; + + // A header name is not case-sensitive. + const { value: before } = change.base; + const { value: after } = change.revision; + if (String(before).toLowerCase() !== String(after).toLowerCase()) { + report({ message: `Response header \`${before}\` was renamed to \`${after}\`.` }); + } + }, + HeadersMap(change, { report, getDirections }) { + if (change.kind === 'removed' && getDirections().includes('response')) { + report({ + message: `All headers of response \`${nameOf(change.node.parent!)}\` were removed.`, + }); + } + }, + }, +}); diff --git a/packages/core/src/diff/rules/response-removed.ts b/packages/core/src/diff/rules/response-removed.ts new file mode 100644 index 0000000000..761b020713 --- /dev/null +++ b/packages/core/src/diff/rules/response-removed.ts @@ -0,0 +1,18 @@ +import { nameOf } from '../diff-node.js'; +import type { DiffRule } from '../types.js'; + +// A component response is returned by no operation until one references it. +export const ResponseRemoved: DiffRule = () => ({ + Responses: { + Response(change, { report }) { + if (change.kind === 'removed') { + report({ message: `Response \`${nameOf(change.node)}\` was removed.` }); + } + if (change.kind === 'modified' && change.property === 'key') { + report({ + message: `Response \`${change.base.value}\` became \`${change.revision.value}\`.`, + }); + } + }, + }, +}); diff --git a/packages/core/src/diff/rules/schema-combinator-changed.ts b/packages/core/src/diff/rules/schema-combinator-changed.ts new file mode 100644 index 0000000000..6ea4750de1 --- /dev/null +++ b/packages/core/src/diff/rules/schema-combinator-changed.ts @@ -0,0 +1,38 @@ +import { nameOf } from '../diff-node.js'; +import type { DiffRule, DiffVisit } from '../types.js'; +import { ofNamedSchema } from './utils.js'; + +// `oneOf`/`anyOf` list alternatives, so dropping one accepts less; `allOf` combines constraints, +// so adding one accepts less. +function combinatorChanged(combinator: 'allOf' | 'anyOf' | 'oneOf'): DiffVisit { + return (change, { report, getDirections }) => { + if (change.kind === 'modified') return; + + const removed = change.kind === 'removed'; + const acceptsLess = combinator === 'allOf' ? !removed : removed; + if (!getDirections().includes(acceptsLess ? 'request' : 'response')) return; + + const owner = ofNamedSchema(change.node.parent?.parent); + report({ + message: `\`${combinator}\` subschema${owner} was ${removed ? 'removed' : 'added'}.`, + }); + }; +} + +// A whole list added constrains the schema, whichever keyword it is; removed, it frees it. +const listChanged: DiffVisit = (change, { report, getDirections }) => { + if (change.kind === 'modified') return; + + const added = change.kind === 'added'; + if (!getDirections().includes(added ? 'request' : 'response')) return; + + const owner = ofNamedSchema(change.node.parent); + report({ message: `\`${nameOf(change.node)}\`${owner} was ${added ? 'added' : 'removed'}.` }); +}; + +export const SchemaCombinatorChanged: DiffRule = () => ({ + Schema: { AllOf: listChanged, AnyOf: listChanged, OneOf: listChanged }, + AllOf: { Schema: combinatorChanged('allOf') }, + AnyOf: { Schema: combinatorChanged('anyOf') }, + OneOf: { Schema: combinatorChanged('oneOf') }, +}); diff --git a/packages/core/src/diff/rules/schema-constraint-changed.ts b/packages/core/src/diff/rules/schema-constraint-changed.ts new file mode 100644 index 0000000000..1ebd241fd4 --- /dev/null +++ b/packages/core/src/diff/rules/schema-constraint-changed.ts @@ -0,0 +1,202 @@ +import { fieldOf, keyKindOf } from '../../node-tree/access.js'; +import type { NodeEntry } from '../../node-tree/types.js'; +import { dequal } from '../../utils/dequal.js'; +import { latestOf } from '../diff-node.js'; +import type { Change, DiffRule, DiffRuleContext, DiffVisit, Direction } from '../types.js'; +import { ofNamedSchema } from './utils.js'; + +type ModifiedChange = Extract; + +/** How a change moves the values a schema accepts; `unknown` where the two cannot be compared. */ +type Shift = 'fewer' | 'more' | 'unknown'; + +const LOWER_LIMITS = new Set(['minLength', 'minItems', 'minProperties']); +const UPPER_LIMITS = new Set(['maxLength', 'maxItems', 'maxProperties']); +// Two values of these cannot be compared, so any change is taken as both fewer and more values. +const OPAQUE = new Set([ + 'multipleOf', + 'pattern', + 'format', + 'const', + 'contentEncoding', + 'contentMediaType', +]); +// Flags that constrain the values only when set to this value. +const CONSTRAINING_FLAGS: Record = { + uniqueItems: true, + additionalProperties: false, + unevaluatedItems: false, + unevaluatedProperties: false, + items: false, +}; +// `if` is not here: it constrains nothing on its own. +const SUBSCHEMAS = new Set([ + 'not', + 'then', + 'else', + 'contains', + 'propertyNames', + 'items', + 'additionalProperties', + 'unevaluatedItems', + 'unevaluatedProperties', + 'prefixItems', + 'dependentSchemas', + 'dependentRequired', +]); + +type Bound = { value: number; exclusive: boolean }; + +// `sign` makes a greater difference mean a tighter bound on both sides: a higher minimum and a +// lower maximum each accept fewer values. +const LOWER = { + name: 'Lower bound', + inclusive: 'minimum', + exclusive: 'exclusiveMinimum', + operator: '>', + sign: 1, +}; +const UPPER = { + name: 'Upper bound', + inclusive: 'maximum', + exclusive: 'exclusiveMaximum', + operator: '<', + sign: -1, +}; +type Side = typeof LOWER; + +// Accepting fewer values breaks the clients that send them; accepting more, the ones that read +// them. +function breaks(shift: Shift, directions: Direction[]): boolean { + return ( + (shift !== 'more' && directions.includes('request')) || + (shift !== 'fewer' && directions.includes('response')) + ); +} + +function shiftOf(keyword: string, before: unknown, after: unknown): Shift | undefined { + if (Object.hasOwn(CONSTRAINING_FLAGS, keyword)) { + const wasConstrained = before === CONSTRAINING_FLAGS[keyword]; + const isConstrained = after === CONSTRAINING_FLAGS[keyword]; + if (wasConstrained === isConstrained) return undefined; + return isConstrained ? 'fewer' : 'more'; + } + if (!LOWER_LIMITS.has(keyword) && !UPPER_LIMITS.has(keyword) && !OPAQUE.has(keyword)) { + return undefined; + } + if (before === undefined) return 'fewer'; + if (after === undefined) return 'more'; + if (typeof before !== 'number' || typeof after !== 'number' || OPAQUE.has(keyword)) { + return 'unknown'; + } + return LOWER_LIMITS.has(keyword) === after > before ? 'fewer' : 'more'; +} + +/** Above zero when `bound` accepts fewer values than `other`, below zero when it accepts more. */ +function compareTightness(bound: Bound, other: Bound, side: Side): number { + if (bound.value !== other.value) return (bound.value - other.value) * side.sign; + return Number(bound.exclusive) - Number(other.exclusive); +} + +// OpenAPI 3.0 makes `minimum` exclusive with `exclusiveMinimum: true`; OpenAPI 3.1 gives +// `exclusiveMinimum` a value of its own, and the tighter of the two keywords is the bound. +function boundOf(schema: NodeEntry | undefined, side: Side): Bound | undefined { + const inclusive = fieldOf(schema, side.inclusive); + const exclusive = fieldOf(schema, side.exclusive); + if (typeof exclusive === 'number') { + const exclusiveBound = { value: exclusive, exclusive: true }; + if (typeof inclusive !== 'number') return exclusiveBound; + const inclusiveBound = { value: inclusive, exclusive: false }; + return compareTightness(inclusiveBound, exclusiveBound, side) > 0 + ? inclusiveBound + : exclusiveBound; + } + if (typeof inclusive === 'number') return { value: inclusive, exclusive: exclusive === true }; + return undefined; +} + +/** Above zero when the side accepts fewer values after the change, below zero when more. */ +function tighteningOf(before: Bound | undefined, after: Bound | undefined, side: Side): number { + if (!before) return 1; + if (!after) return -1; + return compareTightness(after, before, side); +} + +function describeBound(bound: Bound | undefined, side: Side): string | undefined { + return bound && `${side.operator}${bound.exclusive ? '' : '='} ${bound.value}`; +} + +// A subschema constrains the values: added, the schema accepts fewer; removed, it accepts more. +const judgeSubschema: DiffVisit = (change, { report, getDirections }) => { + const keyword = latestOf(change.node); + const key = String(keyword.key); + if (change.kind === 'modified' || keyKindOf(keyword) !== 'field' || !SUBSCHEMAS.has(key)) return; + + const added = change.kind === 'added'; + const parent = change.node.parent; + const other = added ? parent?.base : parent?.revision; + // A schema in place of `additionalProperties: false` accepts more; the flag's change reports it. + if (Object.hasOwn(CONSTRAINING_FLAGS, key) && fieldOf(other, key) === CONSTRAINING_FLAGS[key]) { + return; + } + if (!breaks(added ? 'fewer' : 'more', getDirections())) return; + + report({ message: `\`${key}\`${ofNamedSchema(parent)} was ${added ? 'added' : 'removed'}.` }); +}; + +function judgeKeyword(change: ModifiedChange, { report, getDirections }: DiffRuleContext): void { + const { value: before } = change.base; + const { value: after } = change.revision; + const shift = shiftOf(change.property, before, after); + if (!shift || !breaks(shift, getDirections())) return; + + const subject = `\`${change.property}\`${ofNamedSchema(change.node)}`; + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); +} + +// Both keywords of a side can change together, so the side is judged once: by its inclusive +// keyword when that one changed. +function judgeBound( + change: ModifiedChange, + side: Side, + { report, getDirections }: DiffRuleContext +): void { + const { base, revision } = change.node; + const inclusiveChanged = !dequal( + fieldOf(base, side.inclusive), + fieldOf(revision, side.inclusive) + ); + if (change.property === side.exclusive && inclusiveChanged) return; + + const beforeBound = boundOf(base, side); + const afterBound = boundOf(revision, side); + if (!beforeBound && !afterBound) return; + + const tightening = tighteningOf(beforeBound, afterBound, side); + if (tightening === 0 || !breaks(tightening > 0 ? 'fewer' : 'more', getDirections())) return; + + const subject = `${side.name}${ofNamedSchema(change.node)}`; + const before = describeBound(beforeBound, side); + const after = describeBound(afterBound, side); + + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); +} + +export const SchemaConstraintChanged: DiffRule = () => ({ + Schema(change, context) { + if (change.kind !== 'modified') return judgeSubschema(change, context); + + const side = [LOWER, UPPER].find( + ({ inclusive, exclusive }) => change.property === inclusive || change.property === exclusive + ); + if (side) judgeBound(change, side, context); + else judgeKeyword(change, context); + }, + SchemaList: judgeSubschema, + SchemaMap: judgeSubschema, + DependentRequired: judgeSubschema, +}); diff --git a/packages/core/src/diff/rules/schema-type-changed.ts b/packages/core/src/diff/rules/schema-type-changed.ts new file mode 100644 index 0000000000..cf0d6b9524 --- /dev/null +++ b/packages/core/src/diff/rules/schema-type-changed.ts @@ -0,0 +1,53 @@ +import { fieldOf } from '../../node-tree/access.js'; +import { dequal } from '../../utils/dequal.js'; +import type { DiffRule } from '../types.js'; +import { ofNamedSchema } from './utils.js'; + +// `integer` accepts a subset of what `number` does, so it is the one implicit widening among +// the JSON Schema primitive types. +const WIDER_TYPE: Partial> = { integer: 'number' }; + +/** + * The types a schema accepts, folding OpenAPI 3.0's `nullable: true` into the 3.1 spelling + * (`type: [..., 'null']`) so the two compare as equal. + */ +function acceptedTypes(type: unknown, nullable?: unknown): string[] { + const declared = Array.isArray(type) ? type : type === undefined ? [] : [type]; + const types = declared.filter((value): value is string => typeof value === 'string'); + // Without a `type`, a schema already accepts `null` with every other type. + const addsNull = nullable === true && types.length > 0 && !types.includes('null'); + return addsNull ? [...types, 'null'] : types; +} + +function typesNotIn(types: string[], accepted: string[]): string[] { + return types.filter((type) => { + const wider = WIDER_TYPE[type]; + return !accepted.includes(type) && (wider === undefined || !accepted.includes(wider)); + }); +} + +export const SchemaTypeChanged: DiffRule = () => ({ + Schema(change, { report, getDirections }) { + if (change.kind !== 'modified') return; + + const { base, revision } = change.node; + // A change to `type` or `nullable` is judged on what the schema accepts as a whole, and once: + // by `type` when both changed. + const typeChanged = !dequal(fieldOf(base, 'type'), fieldOf(revision, 'type')); + if (change.property !== 'type' && (change.property !== 'nullable' || typeChanged)) return; + + const before = acceptedTypes(fieldOf(base, 'type'), fieldOf(base, 'nullable')); + const after = acceptedTypes(fieldOf(revision, 'type'), fieldOf(revision, 'nullable')); + // An absent `type` accepts every type: adding one narrows the schema, and removing one widens it. + const narrowed = after.length > 0 && (!before.length || typesNotIn(before, after).length > 0); + const widened = before.length > 0 && (!after.length || typesNotIn(after, before).length > 0); + const described = `from '${before.join(' | ') || 'any'}' to '${after.join(' | ') || 'any'}'`; + + if (getDirections().includes('request') && narrowed) { + report({ message: `Type${ofNamedSchema(change.node)} narrowed ${described}.` }); + } + if (getDirections().includes('response') && widened) { + report({ message: `Type${ofNamedSchema(change.node)} widened ${described}.` }); + } + }, +}); diff --git a/packages/core/src/diff/rules/security-requirement-changed.ts b/packages/core/src/diff/rules/security-requirement-changed.ts new file mode 100644 index 0000000000..2b999380a5 --- /dev/null +++ b/packages/core/src/diff/rules/security-requirement-changed.ts @@ -0,0 +1,67 @@ +import { valueOf } from '../../node-tree/access.js'; +import { isPlainObject } from '../../utils/is-plain-object.js'; +import { itemsNotIn } from '../../utils/items-not-in.js'; +import type { DiffRule } from '../types.js'; + +/** The schemes a client authenticates with, each with the scopes that its credentials hold. */ +type Credentials = Record; + +// A client passes a requirement when it has every scheme of it, with every scope that the scheme +// asks for. An empty list, or a missing one, asks for no authentication. +function passesOne(credentials: Credentials, requirements: unknown): boolean { + if (!Array.isArray(requirements) || requirements.length === 0) return true; + return requirements.some( + (requirement) => + isPlainObject(requirement) && + Object.entries(requirement).every( + ([scheme, scopes]) => + scheme in credentials && itemsNotIn(scopes, credentials[scheme]).length === 0 + ) + ); +} + +// Security sits outside the request/response split, so the rule does not read the direction. +// Each change asks one question: do the clients that authenticated before still pass one of the +// requirements of the revision? +export const SecurityRequirementChanged: DiffRule = () => ({ + SecurityRequirementList(change, { report }) { + if (change.kind === 'added' && !passesOne({}, change.revision.value)) { + report({ message: 'Authentication became required.' }); + } + }, + SecurityRequirement(change, { report }) { + const list = change.node.parent; + const requirements = list?.revision && valueOf(list.revision); + + if (change.kind === 'added') { + // Before, the list was empty and asked for no authentication; otherwise the new + // requirement only offers one more way to pass. + const before = list?.base && valueOf(list.base); + if (Array.isArray(before) && before.length === 0 && !passesOne({}, requirements)) { + report({ message: 'Authentication became required.' }); + } + return; + } + + const credentials = change.node.base && valueOf(change.node.base); + if (!isPlainObject(credentials) || passesOne(credentials, requirements)) return; + + const schemes = Object.keys(credentials).join(' + '); + if (change.kind === 'removed') { + report({ message: `Security requirement \`${schemes}\` is no longer accepted.` }); + } else if (change.base.value === undefined) { + // A scheme the requirement did not have: a client must now hold it too. + report({ + message: `Security requirement \`${schemes}\` now requires \`${change.property}\`.`, + }); + } else { + // The requirement keeps this scheme: its value is the list of scopes. + const added = itemsNotIn(change.revision.value, change.base.value); + if (!added.length) return; + const scopes = added.map((scope) => `'${scope}'`).join(', '); + report({ + message: `Security scheme \`${change.property}\` requires new scopes: ${scopes}.`, + }); + } + }, +}); diff --git a/packages/core/src/diff/rules/security-scheme-changed.ts b/packages/core/src/diff/rules/security-scheme-changed.ts new file mode 100644 index 0000000000..22efbd240f --- /dev/null +++ b/packages/core/src/diff/rules/security-scheme-changed.ts @@ -0,0 +1,34 @@ +import { fieldOf } from '../../node-tree/access.js'; +import { nameOf } from '../diff-node.js'; +import type { DiffRule } from '../types.js'; + +const SCHEME_IDENTITY = new Set([ + 'type', + 'scheme', + 'in', + 'name', + 'bearerFormat', + 'openIdConnectUrl', +]); + +export const SecuritySchemeChanged: DiffRule = () => ({ + SecurityScheme(change, { report }) { + if (change.kind === 'removed') { + report({ message: `Security scheme \`${nameOf(change.node)}\` was removed.` }); + } + if (change.kind !== 'modified' || !SCHEME_IDENTITY.has(change.property)) return; + + // Switching the scheme's `type` drags its other fields along (an apiKey has + // `in`/`name`, a bearer has `scheme`), so the type change speaks for them all. + const typeChanged = fieldOf(change.node.base, 'type') !== fieldOf(change.node.revision, 'type'); + if (typeChanged && change.property !== 'type') return; + + const subject = `\`${change.property}\` of security scheme \`${nameOf(change.node)}\``; + const { value: before } = change.base; + const { value: after } = change.revision; + + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); + }, +}); diff --git a/packages/core/src/diff/rules/server-removed.ts b/packages/core/src/diff/rules/server-removed.ts new file mode 100644 index 0000000000..f61491ae90 --- /dev/null +++ b/packages/core/src/diff/rules/server-removed.ts @@ -0,0 +1,43 @@ +import { fieldOf } from '../../node-tree/access.js'; +import { latestOf, nameOf } from '../diff-node.js'; +import type { DiffRule, DiffVisit } from '../types.js'; +import { isRootField } from './utils.js'; + +const ADDRESS = new Set(['host', 'pathname', 'protocol']); + +// An OpenAPI path or operation that loses its own `servers` falls back to the document's, so only +// the document's list counts. +const allServersRemoved: DiffVisit = (change, { report }) => { + if (change.kind === 'removed' && isRootField(change.node)) { + report({ message: 'All servers were removed.' }); + } +}; + +export const ServerRemoved: DiffRule = () => ({ + Server(change, { report }) { + if (change.kind === 'removed') { + const url = fieldOf(latestOf(change.node), 'url'); + const name = typeof url === 'string' ? url : nameOf(change.node); + report({ message: `Server \`${name}\` was removed.` }); + } + if (change.kind !== 'modified') return; + + // An OpenAPI server is known by its URL; an AsyncAPI one by its key, and it moves with its + // `host`, `pathname`, or `protocol`. + const { value: before } = change.base; + const { value: after } = change.revision; + + if (change.property === 'url') { + report({ message: `Server \`${before}\` became \`${after}\`.` }); + } + if (ADDRESS.has(change.property)) { + const subject = `\`${change.property}\` of server \`${nameOf(change.node)}\``; + + if (before === undefined) report({ message: `${subject} was set to '${after}'.` }); + else if (after === undefined) report({ message: `${subject} was removed.` }); + else report({ message: `${subject} changed from '${before}' to '${after}'.` }); + } + }, + ServerMap: allServersRemoved, + ServerList: allServersRemoved, +}); diff --git a/packages/core/src/diff/rules/utils.ts b/packages/core/src/diff/rules/utils.ts new file mode 100644 index 0000000000..d32e1ca328 --- /dev/null +++ b/packages/core/src/diff/rules/utils.ts @@ -0,0 +1,34 @@ +import { fieldOf } from '../../node-tree/access.js'; +import type { NodeEntry } from '../../node-tree/types.js'; +import { comparedSidesOf, latestOf, nameOf, typeOf } from '../diff-node.js'; +import type { DiffNode } from '../types.js'; + +/** `` of `Order` `` for a property or a component schema; nothing for an inline one. */ +export function ofNamedSchema(schema: DiffNode | null | undefined): string { + if (!schema) return ''; + + const container = latestOf(comparedSidesOf(schema)).parent?.type.name; + const isNamed = container === 'SchemaProperties' || container === 'NamedSchemas'; + return isNamed ? ` of \`${nameOf(schema)}\`` : ''; +} + +export function propertiesMarkedAs(schema: DiffNode, keyword: 'readOnly' | 'writeOnly'): string[] { + const latestByName = new Map(); + const sides = comparedSidesOf(schema); + + for (const side of [sides.base, sides.revision]) { + const properties = side?.children.find((child) => child.type.name === 'SchemaProperties'); + for (const property of properties?.children ?? []) { + latestByName.set(String(property.key), property); + } + } + + return [...latestByName.keys()].filter( + (name) => fieldOf(latestByName.get(name), keyword) === true + ); +} + +// `components` has maps of the same types as the document; only the document's own count. +export function isRootField(node: DiffNode): boolean { + return node.parent !== null && typeOf(node.parent) === 'Root'; +} diff --git a/packages/core/src/diff/types.ts b/packages/core/src/diff/types.ts new file mode 100644 index 0000000000..d0d51c3f83 --- /dev/null +++ b/packages/core/src/diff/types.ts @@ -0,0 +1,68 @@ +import type { NodeEntry } from '../node-tree/types.js'; +import type { SpecVersion } from '../oas-types.js'; +import type { Location } from '../ref-utils.js'; +import type { NormalizedProblem } from '../walk.js'; + +export type Impact = 'patch' | 'minor' | 'major'; + +export type Direction = 'request' | 'response'; + +export type Pair = { base?: NodeEntry; revision?: NodeEntry }; + +/** One place in both documents; `base` or `revision` is missing where it was added or removed. */ +export type DiffNode = { + base?: NodeEntry; + revision?: NodeEntry; + parent: DiffNode | null; + /** The places that reach this pair after `parent` did; it is compared under `parent` only. */ + referencedBy: DiffNode[]; +}; + +export type LocatedNode = { location: Location; value: unknown }; + +export type Change = { key: string; node: DiffNode } & ( + | { kind: 'added'; revision: LocatedNode } + | { kind: 'removed'; base: LocatedNode } + | { + kind: 'modified'; + property: string; + base: LocatedNode; + revision: LocatedNode; + } +); + +export type DiffRuleContext = { + report: (report: { message: string; location?: Location }) => void; + location: Location; + getDirections: () => Direction[]; + specVersion: SpecVersion; +}; + +export type DiffVisit = (change: Change, context: DiffRuleContext) => void; +export type DiffVisitor = { [type: string]: DiffVisit | DiffVisitor }; +export type DiffRule = () => DiffVisitor; + +export type RuleVerdict = { + ruleId: string; + impact: Impact; + message: string; + location: Location; +}; +export type JudgedChange = Change & { impact: Impact; verdicts: RuleVerdict[] }; +export type DiffSummary = Record; + +export type DiffResult = { + files: { base: string; revision: string }; + specVersions: { + base: SpecVersion; + revision: SpecVersion; + }; + infoVersions: { + base?: string; + revision?: string; + }; + summary: DiffSummary; + bump?: Impact; + changes: JudgedChange[]; + problems: NormalizedProblem[]; +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 361ee28200..04263a8678 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,13 +61,14 @@ export { YamlParseError } from './errors/yaml-parse-error.js'; export { parseYaml, stringifyYaml } from './js-yaml/index.js'; export { unescapePointerFragment, + parsePointer, isRef, isMappingRef, isAbsoluteUrl, getDir, resolvePath, escapePointerFragment, - type Location, + Location, } from './ref-utils.js'; export { detectSpec, getMajorSpecVersion } from './detect-spec.js'; export { getTypes, type SpecVersion, type SpecMajorVersion } from './oas-types.js'; @@ -129,6 +130,19 @@ export { bundleDocument, type ComponentNamesStrategy } from './bundle/bundle-doc export { mapTypeToComponent } from './bundle/bundle-visitor.js'; export { type Assertions, type Assertion } from './rules/common/assertions/index.js'; export { logger, type LoggerInterface } from './logger.js'; +export { diffDocuments } from './diff/index.js'; +export { + type Change, + type DiffNode, + type DiffResult, + type DiffSummary, + type Impact, + type JudgedChange, + type LocatedNode, +} from './diff/types.js'; +export { impactRank, impacts } from './diff/impact.js'; +export { displaySide } from './diff/changes.js'; +export { nameOf, typeOf } from './diff/diff-node.js'; export { HandledError, AbortFlowError } from './utils/error.js'; export { isSupportedExtension } from './utils/is-supported-extension.js'; export { isBrowser } from './env.js'; diff --git a/packages/core/src/node-tree/__tests__/access.test.ts b/packages/core/src/node-tree/__tests__/access.test.ts new file mode 100644 index 0000000000..e2c99a9536 --- /dev/null +++ b/packages/core/src/node-tree/__tests__/access.test.ts @@ -0,0 +1,82 @@ +import { outdent } from 'outdent'; + +import { resolverWithFiles } from '../../../__tests__/utils.js'; +import { enclosing, keyKindOf, locationOf, valueOf } from '../access.js'; +import { buildTree, cafe, nodeAt } from './utils.js'; + +const menuItemDocument = () => + cafe(outdent` + paths: + /menu: + get: + parameters: + - { name: item, in: query, schema: { $ref: '#/components/schemas/MenuItem' } } + components: + schemas: + MenuItem: + type: object + properties: + name: { type: string } + `); + +describe('node access', () => { + it('should give what a $ref points at, or else the node itself, for its value and location', async () => { + const { root } = await buildTree(menuItemDocument()); + const menuItemRef = nodeAt(root, '#/paths/~1menu/get/parameters/0/schema')!; + const menuItem = nodeAt(root, '#/components/schemas/MenuItem')!; + + expect(valueOf(menuItemRef)).toBe(menuItem.value); + expect(locationOf(menuItemRef)).toBe(menuItem.location); + expect(valueOf(menuItem)).toBe(menuItem.value); + expect(locationOf(menuItem)).toBe(menuItem.location); + }); + + it('should tell a field of the type from a map entry and a list item', async () => { + const { root } = await buildTree(menuItemDocument()); + + expect(keyKindOf(nodeAt(root, '#/paths')!)).toBe('field'); + expect(keyKindOf(nodeAt(root, '#/paths/~1menu')!)).toBe('entry'); + expect(keyKindOf(nodeAt(root, '#/paths/~1menu/get/parameters/0')!)).toBe('item'); + }); + + it('should climb from a node in another file to the $ref that reaches it', async () => { + const externalRefResolver = resolverWithFiles({ 'menu.yaml': 'get: {}' }); + const { root } = await buildTree( + cafe(outdent` + paths: + /menu: { $ref: menu.yaml } + `), + externalRefResolver + ); + const getOperation = nodeAt(root, '#/paths/~1menu')!.resolved!.children[0]; + + expect(enclosing(getOperation, 'Paths')).toBe(nodeAt(root, '#/paths')); + expect(enclosing(getOperation, 'Components')).toBeUndefined(); + }); + + it('should end the climb where a $ref leads back into the node', async () => { + const externalRefResolver = resolverWithFiles({ + 'menu.yaml': '{ $ref: ./item.yaml, summary: Menu }', + 'item.yaml': outdent` + get: + responses: + '200': { description: OK } + callbacks: + onEvent: + '{$request.body#/url}': { $ref: '#' } + `, + }); + const { root } = await buildTree( + cafe(outdent` + paths: + /menu: { $ref: ./menu.yaml } + `), + externalRefResolver + ); + const item = nodeAt(root, '#/paths/~1menu')!.resolved!.resolved!; + const responses = nodeAt(item, '#/get/responses')!; + + expect(enclosing(responses, 'Paths')).toBe(nodeAt(root, '#/paths')); + expect(enclosing(responses, 'Components')).toBeUndefined(); + }); +}); diff --git a/packages/core/src/node-tree/__tests__/index.test.ts b/packages/core/src/node-tree/__tests__/index.test.ts new file mode 100644 index 0000000000..7b5dc614b8 --- /dev/null +++ b/packages/core/src/node-tree/__tests__/index.test.ts @@ -0,0 +1,221 @@ +import { outdent } from 'outdent'; + +import { resolverWithFiles } from '../../../__tests__/utils.js'; +import { isRef } from '../../ref-utils.js'; +import type { NodeEntry } from '../types.js'; +import { buildTree, cafe, nodeAt } from './utils.js'; + +function refsIn(node: NodeEntry, seen = new Set()): NodeEntry[] { + if (seen.has(node)) return []; + seen.add(node); + const reached = node.resolved ? [...node.children, node.resolved] : node.children; + return [...(isRef(node.value) ? [node] : []), ...reached.flatMap((child) => refsIn(child, seen))]; +} + +describe('buildNodeTree', () => { + it('should record a node with its type, key and value under the node it is written in', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: + /menu: + get: + parameters: + - { name: limit, in: query } + `) + ); + + expect(nodeAt(root, '#/paths/~1menu/get/parameters/0')).toMatchObject({ + type: { name: 'Parameter' }, + key: 0, + value: { name: 'limit', in: 'query' }, + parent: nodeAt(root, '#/paths/~1menu/get/parameters'), + }); + }); + + it('should make a $ref a node of the type its place expects, pointing at its target', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: + /menu: + get: + parameters: + - { name: item, in: query, schema: { $ref: '#/components/schemas/MenuItem' } } + components: + schemas: + MenuItem: { type: string } + `) + ); + + expect(nodeAt(root, '#/paths/~1menu/get/parameters/0/schema')).toMatchObject({ + type: { name: 'Schema' }, + resolved: { location: { pointer: '#/components/schemas/MenuItem' } }, + }); + }); + + it('should place a target where it is written, not under the $ref that reaches it first', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: + /menu: + get: + parameters: + - { name: item, in: query, schema: { $ref: '#/components/schemas/MenuItem' } } + components: + schemas: + MenuItem: { type: string } + `) + ); + + expect(nodeAt(root, '#/components/schemas/MenuItem')).toMatchObject({ + key: 'MenuItem', + parent: nodeAt(root, '#/components/schemas'), + }); + }); + + it('should place a target where it is written when a $ref before it in the same map reaches it first', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: {} + components: + schemas: + Receipt: { $ref: '#/components/schemas/Order' } + Order: { type: object } + `) + ); + + expect(nodeAt(root, '#/components/schemas/Order')).toMatchObject({ + key: 'Order', + parent: nodeAt(root, '#/components/schemas'), + }); + }); + + it('should place a node inside a component where it is written when a $ref reaches it first', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: + /orders: + get: + parameters: + - { name: total, in: query, schema: { $ref: '#/components/schemas/Order/properties/total' } } + components: + schemas: + Order: + properties: + total: { type: number } + `) + ); + + expect(nodeAt(root, '#/components/schemas/Order/properties/total')).toMatchObject({ + key: 'total', + parent: nodeAt(root, '#/components/schemas/Order/properties'), + }); + }); + + it('should leave a $ref that does not resolve without a target and report it', async () => { + const { root, problems } = await buildTree( + cafe(outdent` + paths: + /menu: + get: + parameters: + - { name: item, in: query, schema: { $ref: '#/components/schemas/Dessert' } } + `) + ); + const dessertRef = '#/paths/~1menu/get/parameters/0/schema'; + + expect(nodeAt(root, dessertRef)!.resolved).toBeUndefined(); + expect(problems).toMatchObject([ + { ruleId: 'no-unresolved-refs', location: [{ pointer: dessertRef }] }, + ]); + }); + + it('should keep a target in another file without a parent, named by the $ref that reaches it', async () => { + const externalRefResolver = resolverWithFiles({ 'menu.yaml': 'get: {}' }); + + const { root } = await buildTree( + cafe(outdent` + paths: + /menu: { $ref: menu.yaml } + `), + externalRefResolver + ); + + const menuRef = nodeAt(root, '#/paths/~1menu')!; + expect(menuRef.resolved).toMatchObject({ key: '/menu', parent: null }); + expect(menuRef.resolved!.referencedFrom).toBe(menuRef); + }); + + it('should give the root no way up when a $ref inside it points at it', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: {} + components: + schemas: + Everything: { $ref: '#/' } + `) + ); + + expect(root.referencedFrom).toBeUndefined(); + }); + + it('should link every $ref that meets its target before the target is built', async () => { + const externalRefResolver = resolverWithFiles({ + 'menu.yaml': '{ $ref: ./item.yaml, summary: Menu }', + 'item.yaml': outdent` + get: + callbacks: + onEvent: + '{$request.body#/url}': { $ref: ./menu.yaml } + `, + }); + + const { root } = await buildTree( + cafe(outdent` + paths: + /menu: { $ref: ./menu.yaml } + /other: { $ref: ./item.yaml } + `), + externalRefResolver + ); + + const refs = refsIn(root); + expect(refs).toHaveLength(4); + expect(refs.filter((ref) => !ref.resolved).map((ref) => ref.location.pointer)).toEqual([]); + }); + + it('should record the keys written next to a $ref under that $ref', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: {} + components: + schemas: + Drink: { type: object } + Coffee: + $ref: '#/components/schemas/Drink' + properties: + roast: { type: string } + `) + ); + + expect(nodeAt(root, '#/components/schemas/Coffee')!.children).toMatchObject([ + { key: 'properties' }, + ]); + }); + + it('should point a $ref at a $ref with keys written next to it, not at the end of the chain', async () => { + const { root } = await buildTree( + cafe(outdent` + paths: {} + components: + schemas: + DailySpecial: { $ref: '#/components/schemas/Latte' } + Latte: { $ref: '#/components/schemas/Coffee', description: Coffee with milk } + Coffee: { type: object } + `) + ); + + expect(nodeAt(root, '#/components/schemas/DailySpecial')!.resolved).toBe( + nodeAt(root, '#/components/schemas/Latte') + ); + }); +}); diff --git a/packages/core/src/node-tree/__tests__/utils.ts b/packages/core/src/node-tree/__tests__/utils.ts new file mode 100644 index 0000000000..960feb2611 --- /dev/null +++ b/packages/core/src/node-tree/__tests__/utils.ts @@ -0,0 +1,40 @@ +import { detectSpec } from '../../detect-spec.js'; +import { getTypes } from '../../oas-types.js'; +import { + BaseResolver, + makeDocumentFromString, + resolveDocument, + type Document, +} from '../../resolve.js'; +import { normalizeTypes } from '../../types/index.js'; +import type { WalkContext } from '../../walk.js'; +import { buildNodeTree } from '../index.js'; +import type { NodeEntry } from '../types.js'; + +export const cafe = (description: string) => + makeDocumentFromString( + `openapi: 3.1.0\ninfo: { title: Cafe, version: 1.0.0 }\n${description}`, + 'cafe.yaml' + ); + +export async function buildTree(document: Document, externalRefResolver = new BaseResolver()) { + const specVersion = detectSpec(document.parsed); + const types = normalizeTypes(getTypes(specVersion)); + const resolvedRefMap = await resolveDocument({ + rootDocument: document, + rootType: types.Root, + externalRefResolver, + }); + const ctx: WalkContext = { problems: [], specVersion, visitorsData: {} }; + const root = buildNodeTree({ document, types, resolvedRefMap, ctx }); + return { root, problems: ctx.problems }; +} + +export function nodeAt(node: NodeEntry, pointer: string): NodeEntry | undefined { + if (node.location.pointer === pointer) return node; + for (const child of node.children) { + const found = nodeAt(child, pointer); + if (found) return found; + } + return undefined; +} diff --git a/packages/core/src/node-tree/access.ts b/packages/core/src/node-tree/access.ts new file mode 100644 index 0000000000..09a8789aa0 --- /dev/null +++ b/packages/core/src/node-tree/access.ts @@ -0,0 +1,39 @@ +import type { Location } from '../ref-utils.js'; +import { isPlainObject } from '../utils/is-plain-object.js'; +import type { NodeEntry, NodeValue } from './types.js'; + +/** The nearest node of that type, the node itself included. */ +export function enclosing(node: NodeEntry, typeName: string): NodeEntry | undefined { + // A `$ref` inside a file can lead back to the file's root, so each node is climbed once. + const climbed = new Set(); + let current: NodeEntry | undefined = node; + + while (current && !climbed.has(current)) { + if (current.type.name === typeName) return current; + climbed.add(current); + current = current.parent ?? current.referencedFrom; + } + + return undefined; +} + +// A `$ref` stands for the node it points at. +export function valueOf(node: NodeEntry): NodeValue { + return (node.resolved ?? node).value; +} + +export function locationOf(node: NodeEntry): Location { + return (node.resolved ?? node).location; +} + +export function fieldOf(node: NodeEntry | undefined, name: string): unknown { + const value = node && valueOf(node); + return isPlainObject(value) ? value[name] : undefined; +} + +export function keyKindOf(node: NodeEntry): 'field' | 'entry' | 'item' { + if (typeof node.key === 'number') return 'item'; + + const isField = !!node.parent && Object.hasOwn(node.parent.type.properties, node.key); + return isField ? 'field' : 'entry'; +} diff --git a/packages/core/src/node-tree/index.ts b/packages/core/src/node-tree/index.ts new file mode 100644 index 0000000000..0aaa8d4102 --- /dev/null +++ b/packages/core/src/node-tree/index.ts @@ -0,0 +1,106 @@ +import type { Document, ResolvedRefMap } from '../resolve.js'; +import { NoUnresolvedRefs } from '../rules/common/no-unresolved-refs.js'; +import type { NormalizedNodeType } from '../types/index.js'; +import { isPlainObject } from '../utils/is-plain-object.js'; +import { + normalizeVisitors, + type BaseVisitor, + type NestedVisitObject, + type RuleInstanceConfig, +} from '../visitors.js'; +import { walkDocument, type UserContext, type WalkContext } from '../walk.js'; +import type { NodeEntry, NodeValue } from './types.js'; + +export function buildNodeTree(opts: { + document: Document; + types: Record; + resolvedRefMap: ResolvedRefMap; + ctx: WalkContext; +}): NodeEntry { + const { document, types, resolvedRefMap, ctx } = opts; + + const nodes = new Map(); + const waitingRefs = new Map(); + + const add = (value: NodeValue, { type, key, location }: UserContext, parent?: NodeEntry) => { + const node: NodeEntry = { + type, + key, + location, + value, + parent: parent ?? null, + children: [], + }; + + parent?.children.push(node); + nodes.set(value, node); + + for (const waiting of waitingRefs.get(value) ?? []) waiting.resolved = node; + + return node; + }; + + const visitor: BaseVisitor = { + any: { + enter(value: unknown, context) { + const isContainer = isPlainObject(value) || Array.isArray(value); + + if (!isContainer || nodes.has(value)) return; + + const reachedFrom = nodes.get(context.rawNode); + + if (reachedFrom) { + add(value, context).referencedFrom = reachedFrom; + } else { + add(value, context, nodes.get(context.parent)); + } + }, + // A target the walker reached through a `$ref` before its own place joins it here + leave(value: unknown) { + const container = nodes.get(value); + + if (!container) return; + + for (const [key, childValue] of Object.entries(container.value)) { + const child = nodes.get(childValue); + + if (!child || child.parent) continue; + + child.parent = container; + child.key = Array.isArray(container.value) ? Number(key) : key; + container.children.push(child); + } + }, + }, + ref(value: { $ref: string }, context, resolved) { + if (nodes.has(value)) return; + + const ref = add(value, context, nodes.get(context.parent)); + const target = resolved.chain?.[0]?.node ?? resolved.node; + const resolvedNode = nodes.get(target); + + if (resolvedNode) { + ref.resolved = resolvedNode; + } else { + waitingRefs.set(target, [...(waitingRefs.get(target) ?? []), ref]); + } + }, + }; + + const visitors: Array< + RuleInstanceConfig & { visitor: NestedVisitObject } + > = [ + { severity: 'warn', ruleId: 'node-tree', visitor }, + { severity: 'error', ruleId: 'no-unresolved-refs', visitor: NoUnresolvedRefs({}) }, + ]; + + walkDocument({ + document, + rootType: types.Root, + normalizedVisitors: normalizeVisitors(visitors, types), + resolvedRefMap, + ctx, + }); + + return nodes.get(document.parsed)!; +} diff --git a/packages/core/src/node-tree/types.ts b/packages/core/src/node-tree/types.ts new file mode 100644 index 0000000000..6c31f8429a --- /dev/null +++ b/packages/core/src/node-tree/types.ts @@ -0,0 +1,18 @@ +import type { Location } from '../ref-utils.js'; +import type { NormalizedNodeType } from '../types/index.js'; + +export type NodeValue = Record | unknown[]; + +export type NodeEntry = { + type: NormalizedNodeType; + /** The root of another file takes the key of the `$ref` that reaches it. */ + key: string | number; + location: Location; + value: NodeValue; + parent: NodeEntry | null; + children: NodeEntry[]; + /** The target of a `$ref`. */ + resolved?: NodeEntry; + /** For a node with no parent, such as the root of another file: the `$ref` it was reached by. */ + referencedFrom?: NodeEntry; +}; diff --git a/packages/core/src/rules/utils.ts b/packages/core/src/rules/utils.ts index a79f983724..ec0e2314de 100644 --- a/packages/core/src/rules/utils.ts +++ b/packages/core/src/rules/utils.ts @@ -1,5 +1,5 @@ import type { Context as AjvContext } from '@redocly/ajv/dist/2020.js'; -import { default as levenshtein } from 'js-levenshtein'; +import { distance as levenshtein } from 'fastest-levenshtein'; import { isRef, Location } from '../ref-utils.js'; import type { ResolvedRefChainHop } from '../resolve.js'; diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index ecab4839f2..f9c2b5a07f 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -3,6 +3,7 @@ import type { JSONSchema } from 'json-schema-to-ts'; import path from 'node:path'; import type { Config, RawGovernanceConfig } from '../config/index.js'; +import { diffRuleIds } from '../diff/rules/index.js'; import { graphqlNodeKinds } from '../graphql/node-kinds.js'; import { specVersions, getTypes } from '../oas-types.js'; import { isAbsoluteUrl } from '../ref-utils.js'; @@ -319,6 +320,12 @@ const configGovernanceProperties: Record< arazzo1_1Decorators: 'Decorators', overlay1Decorators: 'Decorators', openrpc1Decorators: 'Decorators', + + diff: 'DiffRules', + oas3_0Diff: 'DiffRules', + oas3_1Diff: 'DiffRules', + oas3_2Diff: 'DiffRules', + async3Diff: 'DiffRules', }; const ConfigGovernance: NodeType = { @@ -355,7 +362,15 @@ const createConfigApisProperties = (nodeTypes: Record): NodeTy ...nodeTypes['rootRedoclyConfigSchema.apis_additionalProperties'], properties: { ...nodeTypes['rootRedoclyConfigSchema.apis_additionalProperties']?.properties, - ...omit(ConfigGovernance.properties, ['plugins']), // plugins are not allowed in apis + // plugins are not allowed in apis, and diff compares two apis, so it is configured once. + ...omit(ConfigGovernance.properties, [ + 'plugins', + 'diff', + 'oas3_0Diff', + 'oas3_1Diff', + 'oas3_2Diff', + 'async3Diff', + ]), // TODO: move `client` and `clientOutput` into the Redocly config schema (@redocly/config). client: 'Client', clientOutput: { type: 'string' }, @@ -455,6 +470,16 @@ const Rules: NodeType = { }, }; +const DiffRules: NodeType = { + properties: {}, + description: + 'The `diff` block sets the semver impact of each diff rule: `off`, `patch`, `minor`, or `major`.', + additionalProperties: (_value: unknown, key: string) => + diffRuleIds.includes(key) || isCustomRuleId(key) + ? { enum: ['off', 'patch', 'minor', 'major'] } + : undefined, +}; + const BuiltinRule: NodeType = { properties: { severity: { enum: ['error', 'warn', 'off'] }, @@ -836,6 +861,7 @@ const CoreConfigTypes: Record = { Decorators, Preprocessors, Assertions, + DiffRules, }; // FIXME: remove this once we remove `theme` from the schema diff --git a/packages/core/src/utils/__tests__/is-scalar.test.ts b/packages/core/src/utils/__tests__/is-scalar.test.ts new file mode 100644 index 0000000000..b48c34752a --- /dev/null +++ b/packages/core/src/utils/__tests__/is-scalar.test.ts @@ -0,0 +1,12 @@ +import { isScalarArray } from '../is-scalar.js'; + +describe('isScalarArray', () => { + it.each([ + [[], true], + [['coffee', 1, true, null], true], + [[{ name: 'coffee' }], false], + ['coffee', false], + ])('tells whether %j is an array of scalars', (value, isArrayOfScalars) => { + expect(isScalarArray(value)).toBe(isArrayOfScalars); + }); +}); diff --git a/packages/core/src/utils/is-scalar.ts b/packages/core/src/utils/is-scalar.ts new file mode 100644 index 0000000000..89b990c03c --- /dev/null +++ b/packages/core/src/utils/is-scalar.ts @@ -0,0 +1,12 @@ +export function isScalar(value: unknown): boolean { + return ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ); +} + +export function isScalarArray(value: unknown): boolean { + return Array.isArray(value) && value.every(isScalar); +} diff --git a/packages/core/src/utils/items-not-in.ts b/packages/core/src/utils/items-not-in.ts new file mode 100644 index 0000000000..fdc301d56c --- /dev/null +++ b/packages/core/src/utils/items-not-in.ts @@ -0,0 +1,6 @@ +/** The items of `list` that `other` does not have. */ +export function itemsNotIn(list: unknown, other: unknown): unknown[] { + if (!Array.isArray(list)) return []; + const otherItems = Array.isArray(other) ? other : []; + return list.filter((item) => !otherItems.includes(item)); +} diff --git a/tests/e2e/diff/asyncapi3/base.yaml b/tests/e2e/diff/asyncapi3/base.yaml new file mode 100644 index 0000000000..75d1150692 --- /dev/null +++ b/tests/e2e/diff/asyncapi3/base.yaml @@ -0,0 +1,36 @@ +asyncapi: 3.0.0 +info: { title: Cafe kitchen, version: 1.0.0 } +servers: + production: { host: kitchen.cafe.test, protocol: mqtt } + staging: { host: staging.cafe.test, protocol: mqtt } +channels: + orderPlaced: + address: orders/placed + messages: + orderPlaced: + contentType: application/json + payload: + type: object + properties: + orderId: { type: string } + items: { type: array } + orderReady: + address: orders/ready + messages: + orderReady: + payload: { type: object, properties: { orderId: { type: string } } } + menuChanged: + address: menu/changed + messages: + menuChanged: + payload: { type: object } +operations: + onOrderPlaced: + action: receive + channel: { $ref: '#/channels/orderPlaced' } + sendOrderReady: + action: send + channel: { $ref: '#/channels/orderReady' } + sendMenuChanged: + action: send + channel: { $ref: '#/channels/menuChanged' } diff --git a/tests/e2e/diff/asyncapi3/revision.yaml b/tests/e2e/diff/asyncapi3/revision.yaml new file mode 100644 index 0000000000..23afe4bdf4 --- /dev/null +++ b/tests/e2e/diff/asyncapi3/revision.yaml @@ -0,0 +1,28 @@ +asyncapi: 3.0.0 +info: { title: Cafe kitchen, version: 1.0.0 } +servers: + production: { host: kitchen.cafe.test, protocol: mqtt } +channels: + orderPlaced: + address: orders/new + messages: + orderPlaced: + contentType: application/avro + payload: + type: object + required: [items] + properties: + orderId: { type: string } + items: { type: array } + orderReady: + address: orders/ready + messages: + orderReady: + payload: { type: object, properties: {} } +operations: + onOrderPlaced: + action: receive + channel: { $ref: '#/channels/orderPlaced' } + sendOrderReady: + action: receive + channel: { $ref: '#/channels/orderReady' } diff --git a/tests/e2e/diff/asyncapi3/snapshot.txt b/tests/e2e/diff/asyncapi3/snapshot.txt new file mode 100644 index 0000000000..b1aaee1d31 --- /dev/null +++ b/tests/e2e/diff/asyncapi3/snapshot.txt @@ -0,0 +1,42 @@ +channel menuChanged + ✖ major removed + Channel `menuChanged` was removed. (channel-removed) + at base.yaml:23:5 + +channel orderPlaced + ✖ major modified address + `address` of channel `orderPlaced` changed from 'orders/placed' to 'orders/new'. (channel-address-changed) + at revision.yaml:7:14 + ✖ major modified messages/orderPlaced · contentType + `contentType` of message `orderPlaced` changed from 'application/json' to 'application/avro'. (message-content-type-changed) + at revision.yaml:10:22 + ✖ major modified messages/orderPlaced/payload · required + Properties became required: `items`. (required-properties-added) + at revision.yaml:13:21 + +channel orderReady + ✖ major removed messages/orderReady/payload/properties/orderId + Property `orderId` was removed. (property-removed) + at base.yaml:21:57 + +operation sendMenuChanged + ✖ major removed + Operation `sendMenuChanged` was removed. (operation-removed) + at base.yaml:35:5 + +operation sendOrderReady + ✖ major modified action + `action` of operation `sendOrderReady` changed from 'send' to 'receive'. (operation-action-changed) + at revision.yaml:27:13 + +server staging + ✖ major removed + Server `staging` was removed. (server-removed) + at base.yaml:5:12 + +8 major, 0 minor, 0 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 8 major changes. diff --git a/tests/e2e/diff/config-file/base.yaml b/tests/e2e/diff/config-file/base.yaml new file mode 100644 index 0000000000..d9fc441b18 --- /dev/null +++ b/tests/e2e/diff/config-file/base.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.2.0 } +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + responses: + '200': { description: OK } + /orders/{orderId}: + delete: + responses: + '204': { description: Cancelled } diff --git a/tests/e2e/diff/config-file/redocly.yaml b/tests/e2e/diff/config-file/redocly.yaml new file mode 100644 index 0000000000..94ad796aa0 --- /dev/null +++ b/tests/e2e/diff/config-file/redocly.yaml @@ -0,0 +1,2 @@ +extends: + - diff-recommended diff --git a/tests/e2e/diff/config-file/relaxed.yaml b/tests/e2e/diff/config-file/relaxed.yaml new file mode 100644 index 0000000000..1f4efc2ea8 --- /dev/null +++ b/tests/e2e/diff/config-file/relaxed.yaml @@ -0,0 +1,7 @@ +# Picked with --config in place of redocly.yaml: here a removed path and a parameter that became +# required only count as minor changes. +extends: + - diff-recommended +diff: + path-removed: minor + parameter-became-required: minor diff --git a/tests/e2e/diff/config-file/revision.yaml b/tests/e2e/diff/config-file/revision.yaml new file mode 100644 index 0000000000..68e0ea18b1 --- /dev/null +++ b/tests/e2e/diff/config-file/revision.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.3.0 } +paths: + /menu: + get: + summary: List the whole menu + parameters: + - { name: limit, in: query, required: true, schema: { type: integer } } + responses: + '200': { description: OK } + /specials: + get: + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/config-file/snapshot.txt b/tests/e2e/diff/config-file/snapshot.txt new file mode 100644 index 0000000000..672bbb19f4 --- /dev/null +++ b/tests/e2e/diff/config-file/snapshot.txt @@ -0,0 +1,26 @@ +info + · patch modified version + at revision.yaml:2:31 + +GET /menu + ✔ minor modified parameters/0 · required + `limit` query parameter became required. (parameter-became-required) + at revision.yaml:8:47 + · patch modified summary + at revision.yaml:6:16 + +/orders/{orderId} + ✔ minor removed + Path `/orders/{orderId}` was removed. (path-removed) + at base.yaml:12:5 + +/specials + ✔ minor added + at revision.yaml:12:5 + +0 major, 3 minor, 2 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +✅ Diff passed. diff --git a/tests/e2e/diff/configuration/base.yaml b/tests/e2e/diff/configuration/base.yaml new file mode 100644 index 0000000000..4f1c8df49b --- /dev/null +++ b/tests/e2e/diff/configuration/base.yaml @@ -0,0 +1,65 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.2.0 } +servers: + - url: https://api.cafe.test +security: + - apiKey: [] +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + - { name: search, in: query, schema: { type: string } } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } } + /menu/{id}: + get: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': { description: OK } + delete: + responses: + '204': { description: Deleted } + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + headers: + Location: { schema: { type: string } } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: + { type: object, properties: { orderId: { type: string }, eta: { type: string } } } + responses: + '200': { description: OK } +components: + securitySchemes: + apiKey: { type: apiKey, in: header, name: X-Key } + schemas: + MenuItem: + type: object + properties: + name: { type: string } + price: { type: number } + status: { type: string, enum: [available, sold-out] } + Order: + type: object + required: [items] + properties: + items: { type: array, items: { type: string } } + note: { type: string, maxLength: 200 } diff --git a/tests/e2e/diff/configuration/redocly.yaml b/tests/e2e/diff/configuration/redocly.yaml new file mode 100644 index 0000000000..30a442fec8 --- /dev/null +++ b/tests/e2e/diff/configuration/redocly.yaml @@ -0,0 +1,12 @@ +extends: + - diff-recommended +apis: + cafe@v1: + root: base.yaml + cafe@v2: + root: revision.yaml +# The cafe accepts new enum values in its responses, and treats a dropped header as a patch. +diff: + enum-values-added: off +oas3_1Diff: + response-header-removed: patch diff --git a/tests/e2e/diff/configuration/revision.yaml b/tests/e2e/diff/configuration/revision.yaml new file mode 100644 index 0000000000..24ebf27912 --- /dev/null +++ b/tests/e2e/diff/configuration/revision.yaml @@ -0,0 +1,62 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.3.0 } +servers: + - url: https://api.cafe.test +security: + - apiKey: [] +paths: + /menu: + get: + summary: List the whole menu + parameters: + - { name: limit, in: query, required: true, schema: { type: integer } } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } } + /menu/{menuItemId}: + get: + parameters: + - { name: menuItemId, in: path, required: true, schema: { type: string } } + responses: + '200': { description: OK } + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + /specials: + get: + responses: + '200': { description: OK } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: { type: object, properties: { orderId: { type: string } } } + responses: + '200': { description: OK } +components: + securitySchemes: + apiKey: { type: apiKey, in: header, name: X-Key } + schemas: + MenuItem: + type: object + properties: + name: { type: string } + status: { type: string, enum: [available, sold-out, seasonal] } + Order: + type: object + required: [items, pickupAt] + properties: + items: { type: array, items: { type: string } } + note: { type: string, maxLength: 100 } + pickupAt: { type: string, format: date-time } diff --git a/tests/e2e/diff/configuration/snapshot-skip-rule.txt b/tests/e2e/diff/configuration/snapshot-skip-rule.txt new file mode 100644 index 0000000000..723dcc321a --- /dev/null +++ b/tests/e2e/diff/configuration/snapshot-skip-rule.txt @@ -0,0 +1,57 @@ +info + · patch modified version + at revision.yaml:2:31 + +GET /menu + ✖ major modified parameters/0 · required + `limit` query parameter became required. (parameter-became-required) + at revision.yaml:12:47 + ✖ major removed parameters/1 + `search` query parameter was removed. (parameter-removed) + at base.yaml:13:11 + · patch modified summary + at revision.yaml:10:16 + · patch removed responses/200/content/application/json/schema/items/properties/price + at base.yaml:58:16 + · patch modified responses/200/content/application/json/schema/items/properties/status · enum + at revision.yaml:55:39 + +/menu/{menuItemId} + · patch modified key + at revision.yaml:20:5 + +DELETE /menu/{menuItemId} + · patch removed + at base.yaml:27:7 + +GET /menu/{menuItemId} + · patch modified parameters/0 · name + at revision.yaml:22:19 + +POST /orders + ✖ major modified requestBody/content/application/json/schema · required + Properties of `Order` became required: `pickupAt`. (required-properties-added) + at revision.yaml:58:17 + ✖ major modified requestBody/content/application/json/schema/properties/note · maxLength + `maxLength` of `note` changed from '200' to '100'. (schema-constraint-changed) + at revision.yaml:61:42 + ✔ minor added requestBody/content/application/json/schema/properties/pickupAt + at revision.yaml:62:19 + · patch removed responses/201/headers + All headers of response `201` were removed. (response-header-removed) + at base.yaml:39:13 + +/specials + ✔ minor added + at revision.yaml:35:5 + +POST orderReady (webhook) + · patch removed requestBody/content/application/json/schema/properties/eta + at base.yaml:47:77 + +4 major, 2 minor, 9 patch. + + +./tests/e2e/diff/configuration/base.yaml vs ./tests/e2e/diff/configuration/revision.yaml: diff processed in ms + +❌ Diff failed with 4 major changes. diff --git a/tests/e2e/diff/configuration/snapshot.txt b/tests/e2e/diff/configuration/snapshot.txt new file mode 100644 index 0000000000..deed73bd93 --- /dev/null +++ b/tests/e2e/diff/configuration/snapshot.txt @@ -0,0 +1,60 @@ +info + · patch modified version + at revision.yaml:2:31 + +GET /menu + ✖ major modified parameters/0 · required + `limit` query parameter became required. (parameter-became-required) + at revision.yaml:12:47 + ✖ major removed parameters/1 + `search` query parameter was removed. (parameter-removed) + at base.yaml:13:11 + ✖ major removed responses/200/content/application/json/schema/items/properties/price + Property `price` of `MenuItem` was removed. (property-removed) + at base.yaml:58:16 + · patch modified summary + at revision.yaml:10:16 + · patch modified responses/200/content/application/json/schema/items/properties/status · enum + at revision.yaml:55:39 + +/menu/{menuItemId} + · patch modified key + at revision.yaml:20:5 + +DELETE /menu/{menuItemId} + ✖ major removed + Operation `DELETE /menu/{menuItemId}` was removed. (operation-removed) + at base.yaml:27:7 + +GET /menu/{menuItemId} + · patch modified parameters/0 · name + at revision.yaml:22:19 + +POST /orders + ✖ major modified requestBody/content/application/json/schema · required + Properties of `Order` became required: `pickupAt`. (required-properties-added) + at revision.yaml:58:17 + ✖ major modified requestBody/content/application/json/schema/properties/note · maxLength + `maxLength` of `note` changed from '200' to '100'. (schema-constraint-changed) + at revision.yaml:61:42 + ✔ minor added requestBody/content/application/json/schema/properties/pickupAt + at revision.yaml:62:19 + · patch removed responses/201/headers + All headers of response `201` were removed. (response-header-removed) + at base.yaml:39:13 + +/specials + ✔ minor added + at revision.yaml:35:5 + +POST orderReady (webhook) + ✖ major removed requestBody/content/application/json/schema/properties/eta + Property `eta` was removed. (property-removed) + at base.yaml:47:77 + +7 major, 2 minor, 6 patch. + + +./tests/e2e/diff/configuration/base.yaml vs ./tests/e2e/diff/configuration/revision.yaml: diff processed in ms + +❌ Diff failed with 7 major changes. diff --git a/tests/e2e/diff/diff.test.ts b/tests/e2e/diff/diff.test.ts new file mode 100644 index 0000000000..b79b9b55ac --- /dev/null +++ b/tests/e2e/diff/diff.test.ts @@ -0,0 +1,195 @@ +import { readFileSync, rmSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { getCommandOutput, getParams, cleanupOutput } from '../helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const indexEntryPoint = join(process.cwd(), 'packages/cli/lib/index.js'); + +describe('diff', () => { + test('should list what breaks per endpoint and fail on a major change', async () => { + const testPath = join(__dirname, 'openapi-breaking-changes'); + const args = getParams(indexEntryPoint, ['diff', 'base.yaml', 'revision.yaml']); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('should fail --check-version when info.version is bumped less than the changes require', async () => { + const testPath = join(__dirname, 'openapi-breaking-changes'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + '--check-version', + '--fail-on=none', + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, 'snapshot-check-version.txt') + ); + }); + + test('should pass a revision that only adds to the API and bumps info.version', async () => { + const testPath = join(__dirname, 'openapi-compatible-changes'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + '--check-version', + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('should fail on the impact --fail-on names', async () => { + const testPath = join(__dirname, 'openapi-compatible-changes'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + '--fail-on=minor', + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, 'snapshot-fail-on-minor.txt') + ); + }); + + test.each(['markdown', 'html'])( + 'should write a %s report to the file --output names', + async (format) => { + const testPath = join(__dirname, 'markdown-and-html'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + `--format=${format}`, + `--output=report.${format}`, + ]); + + getCommandOutput(args, { testPath }); + const report = readFileSync(join(testPath, `report.${format}`), 'utf-8'); + rmSync(join(testPath, `report.${format}`)); + await expect(report).toMatchFileSnapshot(join(testPath, `snapshot-${format}.txt`)); + } + ); + + test.each(['json', 'next-version'])( + 'should print the %s format for a script to read', + async (format) => { + const testPath = join(__dirname, 'json-and-next-version'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + `--format=${format}`, + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, `snapshot-${format}.txt`) + ); + } + ); + + test('should annotate each breaking change on GitHub', async () => { + const testPath = join(__dirname, 'github-actions'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + '--format=github-actions', + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('should refuse to write github-actions annotations to a file', async () => { + const testPath = join(__dirname, 'github-actions'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + '--format=github-actions', + '--output=annotations.txt', + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot-output.txt')); + }); + + test('should judge an AsyncAPI 3 change per channel, operation and server', async () => { + const testPath = join(__dirname, 'asyncapi3'); + const args = getParams(indexEntryPoint, ['diff', 'base.yaml', 'revision.yaml']); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('should not report a move from OpenAPI 3.0 to 3.1 that describes the same API', async () => { + const testPath = join(__dirname, 'openapi-3-0-to-3-1'); + const args = getParams(indexEntryPoint, ['diff', 'base.yaml', 'revision.yaml']); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('should point each change at the file and line that has it', async () => { + const testPath = join(__dirname, 'multi-file'); + const args = getParams(indexEntryPoint, ['diff', 'base/openapi.yaml', 'revision/openapi.yaml']); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('should compare two APIs by their aliases and judge them with the rules of redocly.yaml', async () => { + const testPath = join(__dirname, 'configuration'); + const args = getParams(indexEntryPoint, ['diff', 'cafe@v1', 'cafe@v2']); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('should leave out the rules --skip-rule names', async () => { + const testPath = join(__dirname, 'configuration'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'cafe@v1', + 'cafe@v2', + '--skip-rule=operation-removed', + '--skip-rule=property-removed', + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot( + join(testPath, 'snapshot-skip-rule.txt') + ); + }); + + test('should use the config file --config names', async () => { + const testPath = join(__dirname, 'config-file'); + const args = getParams(indexEntryPoint, [ + 'diff', + 'base.yaml', + 'revision.yaml', + '--config=relaxed.yaml', + ]); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); + + test('should judge a change with a diff rule from a plugin', async () => { + const testPath = join(__dirname, 'plugin'); + const args = getParams(indexEntryPoint, ['diff', 'base.yaml', 'revision.yaml']); + + const result = getCommandOutput(args, { testPath }); + await expect(cleanupOutput(result)).toMatchFileSnapshot(join(testPath, 'snapshot.txt')); + }); +}); diff --git a/tests/e2e/diff/github-actions/base.yaml b/tests/e2e/diff/github-actions/base.yaml new file mode 100644 index 0000000000..d9fc441b18 --- /dev/null +++ b/tests/e2e/diff/github-actions/base.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.2.0 } +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + responses: + '200': { description: OK } + /orders/{orderId}: + delete: + responses: + '204': { description: Cancelled } diff --git a/tests/e2e/diff/github-actions/revision.yaml b/tests/e2e/diff/github-actions/revision.yaml new file mode 100644 index 0000000000..68e0ea18b1 --- /dev/null +++ b/tests/e2e/diff/github-actions/revision.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.3.0 } +paths: + /menu: + get: + summary: List the whole menu + parameters: + - { name: limit, in: query, required: true, schema: { type: integer } } + responses: + '200': { description: OK } + /specials: + get: + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/github-actions/snapshot-output.txt b/tests/e2e/diff/github-actions/snapshot-output.txt new file mode 100644 index 0000000000..0fabf18317 --- /dev/null +++ b/tests/e2e/diff/github-actions/snapshot-output.txt @@ -0,0 +1,3 @@ + +The github-actions format prints to stdout only. To write a report to a file, use one of these formats: stylish, json, markdown, html, next-version. + diff --git a/tests/e2e/diff/github-actions/snapshot.txt b/tests/e2e/diff/github-actions/snapshot.txt new file mode 100644 index 0000000000..dc3db812fe --- /dev/null +++ b/tests/e2e/diff/github-actions/snapshot.txt @@ -0,0 +1,7 @@ +::error title=parameter-became-required,file=revision.yaml,line=8,col=47,endLine=8,endColumn=51::`limit` query parameter became required. +::error title=path-removed,file=base.yaml,line=12,col=5,endLine=14,endColumn=42::Path `/orders/{orderId}` was removed. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 2 major changes. diff --git a/tests/e2e/diff/json-and-next-version/base.yaml b/tests/e2e/diff/json-and-next-version/base.yaml new file mode 100644 index 0000000000..d9fc441b18 --- /dev/null +++ b/tests/e2e/diff/json-and-next-version/base.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.2.0 } +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + responses: + '200': { description: OK } + /orders/{orderId}: + delete: + responses: + '204': { description: Cancelled } diff --git a/tests/e2e/diff/json-and-next-version/revision.yaml b/tests/e2e/diff/json-and-next-version/revision.yaml new file mode 100644 index 0000000000..68e0ea18b1 --- /dev/null +++ b/tests/e2e/diff/json-and-next-version/revision.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.3.0 } +paths: + /menu: + get: + summary: List the whole menu + parameters: + - { name: limit, in: query, required: true, schema: { type: integer } } + responses: + '200': { description: OK } + /specials: + get: + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/json-and-next-version/snapshot-json.txt b/tests/e2e/diff/json-and-next-version/snapshot-json.txt new file mode 100644 index 0000000000..28fc2ccab6 --- /dev/null +++ b/tests/e2e/diff/json-and-next-version/snapshot-json.txt @@ -0,0 +1,161 @@ +{ + "reportVersion": "1", + "files": { + "base": "base.yaml", + "revision": "revision.yaml" + }, + "specVersions": { + "base": "oas3_1", + "revision": "oas3_1" + }, + "infoVersions": { + "base": "1.2.0", + "revision": "1.3.0" + }, + "summary": { + "major": 2, + "minor": 1, + "patch": 2 + }, + "bump": "major", + "changes": [ + { + "key": "#/info", + "typeName": "Info", + "kind": "modified", + "impact": "patch", + "verdicts": [], + "property": "version", + "base": { + "file": "base.yaml", + "pointer": "#/info/version", + "line": 2, + "col": 31, + "value": "1.2.0" + }, + "revision": { + "file": "revision.yaml", + "pointer": "#/info/version", + "line": 2, + "col": 31, + "value": "1.3.0" + } + }, + { + "key": "#/paths/~1menu/get", + "typeName": "Operation", + "kind": "modified", + "impact": "patch", + "verdicts": [], + "property": "summary", + "base": { + "file": "base.yaml", + "pointer": "#/paths/~1menu/get/summary", + "line": 6, + "col": 16, + "value": "List the menu" + }, + "revision": { + "file": "revision.yaml", + "pointer": "#/paths/~1menu/get/summary", + "line": 6, + "col": 16, + "value": "List the whole menu" + } + }, + { + "key": "#/paths/~1menu/get/parameters/0", + "typeName": "Parameter", + "kind": "modified", + "impact": "major", + "verdicts": [ + { + "ruleId": "parameter-became-required", + "impact": "major", + "message": "`limit` query parameter became required.", + "location": { + "file": "revision.yaml", + "pointer": "#/paths/~1menu/get/parameters/0/required", + "line": 8, + "col": 47 + } + } + ], + "property": "required", + "base": { + "file": "base.yaml", + "pointer": "#/paths/~1menu/get/parameters/0", + "line": 8, + "col": 11 + }, + "revision": { + "file": "revision.yaml", + "pointer": "#/paths/~1menu/get/parameters/0/required", + "line": 8, + "col": 47, + "value": true + } + }, + { + "key": "#/paths/~1orders~1{orderId}", + "typeName": "PathItem", + "kind": "removed", + "impact": "major", + "verdicts": [ + { + "ruleId": "path-removed", + "impact": "major", + "message": "Path `/orders/{orderId}` was removed.", + "location": { + "file": "base.yaml", + "pointer": "#/paths/~1orders~1{orderId}", + "line": 12, + "col": 5 + } + } + ], + "base": { + "file": "base.yaml", + "pointer": "#/paths/~1orders~1{orderId}", + "line": 12, + "col": 5, + "value": { + "delete": { + "responses": { + "204": { + "description": "Cancelled" + } + } + } + } + } + }, + { + "key": "#/paths/~1specials", + "typeName": "PathItem", + "kind": "added", + "impact": "minor", + "verdicts": [], + "revision": { + "file": "revision.yaml", + "pointer": "#/paths/~1specials", + "line": 12, + "col": 5, + "value": { + "get": { + "responses": { + "200": { + "description": "OK" + } + } + } + } + } + } + ] +} + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 2 major changes. diff --git a/tests/e2e/diff/json-and-next-version/snapshot-next-version.txt b/tests/e2e/diff/json-and-next-version/snapshot-next-version.txt new file mode 100644 index 0000000000..d48c5b1845 --- /dev/null +++ b/tests/e2e/diff/json-and-next-version/snapshot-next-version.txt @@ -0,0 +1,6 @@ +2.0.0 + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 2 major changes. diff --git a/tests/e2e/diff/markdown-and-html/base.yaml b/tests/e2e/diff/markdown-and-html/base.yaml new file mode 100644 index 0000000000..d9fc441b18 --- /dev/null +++ b/tests/e2e/diff/markdown-and-html/base.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.2.0 } +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + responses: + '200': { description: OK } + /orders/{orderId}: + delete: + responses: + '204': { description: Cancelled } diff --git a/tests/e2e/diff/markdown-and-html/revision.yaml b/tests/e2e/diff/markdown-and-html/revision.yaml new file mode 100644 index 0000000000..68e0ea18b1 --- /dev/null +++ b/tests/e2e/diff/markdown-and-html/revision.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.3.0 } +paths: + /menu: + get: + summary: List the whole menu + parameters: + - { name: limit, in: query, required: true, schema: { type: integer } } + responses: + '200': { description: OK } + /specials: + get: + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/markdown-and-html/snapshot-html.txt b/tests/e2e/diff/markdown-and-html/snapshot-html.txt new file mode 100644 index 0000000000..7ff0ff15c2 --- /dev/null +++ b/tests/e2e/diff/markdown-and-html/snapshot-html.txt @@ -0,0 +1,158 @@ + + + + +API diff + + + +

API diff: base.yaml → revision.yaml

+

+ 2 major + 1 minor + 2 patch + requires a major bump + OpenAPI 3.1 +

+ +
+ + patch + modified + #/info · version + + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/info/version",
+    "line": 2,
+    "col": 31,
+    "value": "1.2.0"
+  },
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/info/version",
+    "line": 2,
+    "col": 31,
+    "value": "1.3.0"
+  }
+}
+
+ +
+ + patch + modified + #/paths/~1menu/get · summary + + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1menu/get/summary",
+    "line": 6,
+    "col": 16,
+    "value": "List the menu"
+  },
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/paths/~1menu/get/summary",
+    "line": 6,
+    "col": 16,
+    "value": "List the whole menu"
+  }
+}
+
+ +
+ + major + modified + #/paths/~1menu/get/parameters/0 · required + `limit` query parameter became required. parameter-became-required + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1menu/get/parameters/0",
+    "line": 8,
+    "col": 11
+  },
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/paths/~1menu/get/parameters/0/required",
+    "line": 8,
+    "col": 47,
+    "value": true
+  }
+}
+
+ +
+ + major + removed + #/paths/~1orders~1{orderId} + Path `/orders/{orderId}` was removed. path-removed + +
{
+  "base": {
+    "file": "base.yaml",
+    "pointer": "#/paths/~1orders~1{orderId}",
+    "line": 12,
+    "col": 5,
+    "value": {
+      "delete": {
+        "responses": {
+          "204": {
+            "description": "Cancelled"
+          }
+        }
+      }
+    }
+  }
+}
+
+ +
+ + minor + added + #/paths/~1specials + + +
{
+  "revision": {
+    "file": "revision.yaml",
+    "pointer": "#/paths/~1specials",
+    "line": 12,
+    "col": 5,
+    "value": {
+      "get": {
+        "responses": {
+          "200": {
+            "description": "OK"
+          }
+        }
+      }
+    }
+  }
+}
+
+ + \ No newline at end of file diff --git a/tests/e2e/diff/markdown-and-html/snapshot-markdown.txt b/tests/e2e/diff/markdown-and-html/snapshot-markdown.txt new file mode 100644 index 0000000000..7d1a39ced3 --- /dev/null +++ b/tests/e2e/diff/markdown-and-html/snapshot-markdown.txt @@ -0,0 +1,11 @@ +## API diff: base.yaml → revision.yaml + +**2** major · **1** minor · **2** patch · requires a **major** bump + +| Impact | Change | Pointer | Location | Details | +| --- | --- | --- | --- | --- | +| ⚪ patch | modified | `#/info · version` | revision.yaml:2:31 | | +| ⚪ patch | modified | `#/paths/~1menu/get · summary` | revision.yaml:6:16 | | +| 🔴 major | modified | `#/paths/~1menu/get/parameters/0 · required` | revision.yaml:8:47 | \`limit\` query parameter became required. `parameter-became-required` | +| 🔴 major | removed | `#/paths/~1orders~1{orderId}` | base.yaml:12:5 | Path \`/orders/{orderId}\` was removed. `path-removed` | +| 🟢 minor | added | `#/paths/~1specials` | revision.yaml:12:5 | | \ No newline at end of file diff --git a/tests/e2e/diff/multi-file/base/openapi.yaml b/tests/e2e/diff/multi-file/base/openapi.yaml new file mode 100644 index 0000000000..93260314ac --- /dev/null +++ b/tests/e2e/diff/multi-file/base/openapi.yaml @@ -0,0 +1,11 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.0.0 } +paths: + /menu: + get: + responses: + '200': + description: The menu + content: + application/json: + schema: { $ref: ./schemas/item.yaml } diff --git a/tests/e2e/diff/multi-file/base/schemas/item.yaml b/tests/e2e/diff/multi-file/base/schemas/item.yaml new file mode 100644 index 0000000000..a360c5d1e8 --- /dev/null +++ b/tests/e2e/diff/multi-file/base/schemas/item.yaml @@ -0,0 +1,4 @@ +type: object +properties: + name: { type: string } + price: { type: number } diff --git a/tests/e2e/diff/multi-file/revision/openapi.yaml b/tests/e2e/diff/multi-file/revision/openapi.yaml new file mode 100644 index 0000000000..93260314ac --- /dev/null +++ b/tests/e2e/diff/multi-file/revision/openapi.yaml @@ -0,0 +1,11 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.0.0 } +paths: + /menu: + get: + responses: + '200': + description: The menu + content: + application/json: + schema: { $ref: ./schemas/item.yaml } diff --git a/tests/e2e/diff/multi-file/revision/schemas/item.yaml b/tests/e2e/diff/multi-file/revision/schemas/item.yaml new file mode 100644 index 0000000000..98029bf67d --- /dev/null +++ b/tests/e2e/diff/multi-file/revision/schemas/item.yaml @@ -0,0 +1,3 @@ +type: object +properties: + name: { type: integer } diff --git a/tests/e2e/diff/multi-file/snapshot.txt b/tests/e2e/diff/multi-file/snapshot.txt new file mode 100644 index 0000000000..5decd4d0af --- /dev/null +++ b/tests/e2e/diff/multi-file/snapshot.txt @@ -0,0 +1,14 @@ +GET /menu + ✖ major modified responses/200/content/application/json/schema/properties/name · type + Type of `name` widened from 'string' to 'integer'. (schema-type-changed) + at revision/schemas/item.yaml:3:17 + ✖ major removed responses/200/content/application/json/schema/properties/price + Property `price` was removed. (property-removed) + at base/schemas/item.yaml:4:10 + +2 major, 0 minor, 0 patch. + + +base/openapi.yaml vs revision/openapi.yaml: diff processed in ms + +❌ Diff failed with 2 major changes. diff --git a/tests/e2e/diff/openapi-3-0-to-3-1/base.yaml b/tests/e2e/diff/openapi-3-0-to-3-1/base.yaml new file mode 100644 index 0000000000..232ecc3755 --- /dev/null +++ b/tests/e2e/diff/openapi-3-0-to-3-1/base.yaml @@ -0,0 +1,21 @@ +openapi: 3.0.3 +info: { title: Cafe, version: 1.0.0 } +paths: + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } +components: + schemas: + Order: + type: object + properties: + note: { type: string, nullable: true } diff --git a/tests/e2e/diff/openapi-3-0-to-3-1/revision.yaml b/tests/e2e/diff/openapi-3-0-to-3-1/revision.yaml new file mode 100644 index 0000000000..500571b66e --- /dev/null +++ b/tests/e2e/diff/openapi-3-0-to-3-1/revision.yaml @@ -0,0 +1,21 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.0.0 } +paths: + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } +components: + schemas: + Order: + type: object + properties: + note: { type: [string, 'null'] } diff --git a/tests/e2e/diff/openapi-3-0-to-3-1/snapshot.txt b/tests/e2e/diff/openapi-3-0-to-3-1/snapshot.txt new file mode 100644 index 0000000000..e34cbdda4d --- /dev/null +++ b/tests/e2e/diff/openapi-3-0-to-3-1/snapshot.txt @@ -0,0 +1,16 @@ +document + · patch modified openapi + at revision.yaml:1:10 + +POST /orders + · patch modified requestBody/content/application/json/schema/properties/note · nullable + at revision.yaml:21:15 + · patch modified requestBody/content/application/json/schema/properties/note · type + at revision.yaml:21:23 + +0 major, 0 minor, 3 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +✅ Diff passed. diff --git a/tests/e2e/diff/openapi-breaking-changes/base.yaml b/tests/e2e/diff/openapi-breaking-changes/base.yaml new file mode 100644 index 0000000000..4f1c8df49b --- /dev/null +++ b/tests/e2e/diff/openapi-breaking-changes/base.yaml @@ -0,0 +1,65 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.2.0 } +servers: + - url: https://api.cafe.test +security: + - apiKey: [] +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + - { name: search, in: query, schema: { type: string } } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } } + /menu/{id}: + get: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': { description: OK } + delete: + responses: + '204': { description: Deleted } + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + headers: + Location: { schema: { type: string } } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: + { type: object, properties: { orderId: { type: string }, eta: { type: string } } } + responses: + '200': { description: OK } +components: + securitySchemes: + apiKey: { type: apiKey, in: header, name: X-Key } + schemas: + MenuItem: + type: object + properties: + name: { type: string } + price: { type: number } + status: { type: string, enum: [available, sold-out] } + Order: + type: object + required: [items] + properties: + items: { type: array, items: { type: string } } + note: { type: string, maxLength: 200 } diff --git a/tests/e2e/diff/openapi-breaking-changes/revision.yaml b/tests/e2e/diff/openapi-breaking-changes/revision.yaml new file mode 100644 index 0000000000..24ebf27912 --- /dev/null +++ b/tests/e2e/diff/openapi-breaking-changes/revision.yaml @@ -0,0 +1,62 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.3.0 } +servers: + - url: https://api.cafe.test +security: + - apiKey: [] +paths: + /menu: + get: + summary: List the whole menu + parameters: + - { name: limit, in: query, required: true, schema: { type: integer } } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } } + /menu/{menuItemId}: + get: + parameters: + - { name: menuItemId, in: path, required: true, schema: { type: string } } + responses: + '200': { description: OK } + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + /specials: + get: + responses: + '200': { description: OK } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: { type: object, properties: { orderId: { type: string } } } + responses: + '200': { description: OK } +components: + securitySchemes: + apiKey: { type: apiKey, in: header, name: X-Key } + schemas: + MenuItem: + type: object + properties: + name: { type: string } + status: { type: string, enum: [available, sold-out, seasonal] } + Order: + type: object + required: [items, pickupAt] + properties: + items: { type: array, items: { type: string } } + note: { type: string, maxLength: 100 } + pickupAt: { type: string, format: date-time } diff --git a/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt b/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt new file mode 100644 index 0000000000..60fa49eab4 --- /dev/null +++ b/tests/e2e/diff/openapi-breaking-changes/snapshot-check-version.txt @@ -0,0 +1,61 @@ +info + · patch modified version + at revision.yaml:2:31 + +GET /menu + ✖ major modified parameters/0 · required + `limit` query parameter became required. (parameter-became-required) + at revision.yaml:12:47 + ✖ major removed parameters/1 + `search` query parameter was removed. (parameter-removed) + at base.yaml:13:11 + ✖ major removed responses/200/content/application/json/schema/items/properties/price + Property `price` of `MenuItem` was removed. (property-removed) + at base.yaml:58:16 + ✖ major modified responses/200/content/application/json/schema/items/properties/status · enum + Enum of `status` gained values: 'seasonal'. (enum-values-added) + at revision.yaml:55:39 + · patch modified summary + at revision.yaml:10:16 + +/menu/{menuItemId} + · patch modified key + at revision.yaml:20:5 + +DELETE /menu/{menuItemId} + ✖ major removed + Operation `DELETE /menu/{menuItemId}` was removed. (operation-removed) + at base.yaml:27:7 + +GET /menu/{menuItemId} + · patch modified parameters/0 · name + at revision.yaml:22:19 + +POST /orders + ✖ major modified requestBody/content/application/json/schema · required + Properties of `Order` became required: `pickupAt`. (required-properties-added) + at revision.yaml:58:17 + ✖ major modified requestBody/content/application/json/schema/properties/note · maxLength + `maxLength` of `note` changed from '200' to '100'. (schema-constraint-changed) + at revision.yaml:61:42 + ✖ major removed responses/201/headers + All headers of response `201` were removed. (response-header-removed) + at base.yaml:39:13 + ✔ minor added requestBody/content/application/json/schema/properties/pickupAt + at revision.yaml:62:19 + +/specials + ✔ minor added + at revision.yaml:35:5 + +POST orderReady (webhook) + ✖ major removed requestBody/content/application/json/schema/properties/eta + Property `eta` was removed. (property-removed) + at base.yaml:47:77 + +9 major, 2 minor, 4 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ info.version went 1.2.0 → 1.3.0, but these changes require a major bump (2.0.0). diff --git a/tests/e2e/diff/openapi-breaking-changes/snapshot.txt b/tests/e2e/diff/openapi-breaking-changes/snapshot.txt new file mode 100644 index 0000000000..eb5410a943 --- /dev/null +++ b/tests/e2e/diff/openapi-breaking-changes/snapshot.txt @@ -0,0 +1,61 @@ +info + · patch modified version + at revision.yaml:2:31 + +GET /menu + ✖ major modified parameters/0 · required + `limit` query parameter became required. (parameter-became-required) + at revision.yaml:12:47 + ✖ major removed parameters/1 + `search` query parameter was removed. (parameter-removed) + at base.yaml:13:11 + ✖ major removed responses/200/content/application/json/schema/items/properties/price + Property `price` of `MenuItem` was removed. (property-removed) + at base.yaml:58:16 + ✖ major modified responses/200/content/application/json/schema/items/properties/status · enum + Enum of `status` gained values: 'seasonal'. (enum-values-added) + at revision.yaml:55:39 + · patch modified summary + at revision.yaml:10:16 + +/menu/{menuItemId} + · patch modified key + at revision.yaml:20:5 + +DELETE /menu/{menuItemId} + ✖ major removed + Operation `DELETE /menu/{menuItemId}` was removed. (operation-removed) + at base.yaml:27:7 + +GET /menu/{menuItemId} + · patch modified parameters/0 · name + at revision.yaml:22:19 + +POST /orders + ✖ major modified requestBody/content/application/json/schema · required + Properties of `Order` became required: `pickupAt`. (required-properties-added) + at revision.yaml:58:17 + ✖ major modified requestBody/content/application/json/schema/properties/note · maxLength + `maxLength` of `note` changed from '200' to '100'. (schema-constraint-changed) + at revision.yaml:61:42 + ✖ major removed responses/201/headers + All headers of response `201` were removed. (response-header-removed) + at base.yaml:39:13 + ✔ minor added requestBody/content/application/json/schema/properties/pickupAt + at revision.yaml:62:19 + +/specials + ✔ minor added + at revision.yaml:35:5 + +POST orderReady (webhook) + ✖ major removed requestBody/content/application/json/schema/properties/eta + Property `eta` was removed. (property-removed) + at base.yaml:47:77 + +9 major, 2 minor, 4 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 9 major changes. diff --git a/tests/e2e/diff/openapi-compatible-changes/base.yaml b/tests/e2e/diff/openapi-compatible-changes/base.yaml new file mode 100644 index 0000000000..4f1c8df49b --- /dev/null +++ b/tests/e2e/diff/openapi-compatible-changes/base.yaml @@ -0,0 +1,65 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.2.0 } +servers: + - url: https://api.cafe.test +security: + - apiKey: [] +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + - { name: search, in: query, schema: { type: string } } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } } + /menu/{id}: + get: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': { description: OK } + delete: + responses: + '204': { description: Deleted } + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + headers: + Location: { schema: { type: string } } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: + { type: object, properties: { orderId: { type: string }, eta: { type: string } } } + responses: + '200': { description: OK } +components: + securitySchemes: + apiKey: { type: apiKey, in: header, name: X-Key } + schemas: + MenuItem: + type: object + properties: + name: { type: string } + price: { type: number } + status: { type: string, enum: [available, sold-out] } + Order: + type: object + required: [items] + properties: + items: { type: array, items: { type: string } } + note: { type: string, maxLength: 200 } diff --git a/tests/e2e/diff/openapi-compatible-changes/revision.yaml b/tests/e2e/diff/openapi-compatible-changes/revision.yaml new file mode 100644 index 0000000000..48d1c84f03 --- /dev/null +++ b/tests/e2e/diff/openapi-compatible-changes/revision.yaml @@ -0,0 +1,70 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.3.0 } +servers: + - url: https://api.cafe.test +security: + - apiKey: [] +paths: + /menu: + get: + summary: List the menu + parameters: + - { name: limit, in: query, schema: { type: integer } } + - { name: search, in: query, schema: { type: string } } + - { name: category, in: query, schema: { type: string } } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/MenuItem' } } + /menu/{id}: + get: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': { description: OK } + delete: + responses: + '204': { description: Deleted } + /specials: + get: + responses: + '200': { description: OK } + /orders: + post: + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: Created + headers: + Location: { schema: { type: string } } +webhooks: + orderReady: + post: + requestBody: + content: + application/json: + schema: + { type: object, properties: { orderId: { type: string }, eta: { type: string } } } + responses: + '200': { description: OK } +components: + securitySchemes: + apiKey: { type: apiKey, in: header, name: X-Key } + schemas: + MenuItem: + type: object + properties: + name: { type: string } + price: { type: number } + status: { type: string, enum: [available, sold-out] } + Order: + type: object + required: [items] + properties: + items: { type: array, items: { type: string } } + note: { type: string, maxLength: 200 } diff --git a/tests/e2e/diff/openapi-compatible-changes/snapshot-fail-on-minor.txt b/tests/e2e/diff/openapi-compatible-changes/snapshot-fail-on-minor.txt new file mode 100644 index 0000000000..2542b0714f --- /dev/null +++ b/tests/e2e/diff/openapi-compatible-changes/snapshot-fail-on-minor.txt @@ -0,0 +1,18 @@ +info + · patch modified version + at revision.yaml:2:31 + +GET /menu + ✔ minor added parameters/2 + at revision.yaml:14:11 + +/specials + ✔ minor added + at revision.yaml:31:5 + +0 major, 2 minor, 1 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 2 minor changes. diff --git a/tests/e2e/diff/openapi-compatible-changes/snapshot.txt b/tests/e2e/diff/openapi-compatible-changes/snapshot.txt new file mode 100644 index 0000000000..9704fdfc3b --- /dev/null +++ b/tests/e2e/diff/openapi-compatible-changes/snapshot.txt @@ -0,0 +1,18 @@ +info + · patch modified version + at revision.yaml:2:31 + +GET /menu + ✔ minor added parameters/2 + at revision.yaml:14:11 + +/specials + ✔ minor added + at revision.yaml:31:5 + +0 major, 2 minor, 1 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +✅ Diff passed. diff --git a/tests/e2e/diff/plugin/base.yaml b/tests/e2e/diff/plugin/base.yaml new file mode 100644 index 0000000000..db01368d95 --- /dev/null +++ b/tests/e2e/diff/plugin/base.yaml @@ -0,0 +1,8 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.0.0 } +paths: + /menu: + get: + tags: [menu, public] + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/plugin/plugins/cafe.cjs b/tests/e2e/diff/plugin/plugins/cafe.cjs new file mode 100644 index 0000000000..880895fc54 --- /dev/null +++ b/tests/e2e/diff/plugin/plugins/cafe.cjs @@ -0,0 +1,18 @@ +// Clients of the cafe route operations by their tags, so removing one is a breaking change. +function TagRemoved() { + return { + Operation(change, { report }) { + if (change.kind !== 'modified' || change.property !== 'tags') return; + const after = change.revision.value ?? []; + const removed = (change.base.value ?? []).filter((tag) => !after.includes(tag)); + if (removed.length) report({ message: `Tags removed: ${removed.join(', ')}.` }); + }, + }; +} + +module.exports = function cafePlugin() { + return { + id: 'cafe', + diff: { oas3: { 'tag-removed': TagRemoved } }, + }; +}; diff --git a/tests/e2e/diff/plugin/redocly.yaml b/tests/e2e/diff/plugin/redocly.yaml new file mode 100644 index 0000000000..ff7b3ea68c --- /dev/null +++ b/tests/e2e/diff/plugin/redocly.yaml @@ -0,0 +1,6 @@ +extends: + - diff-recommended +plugins: + - plugins/cafe.cjs +diff: + cafe/tag-removed: major diff --git a/tests/e2e/diff/plugin/revision.yaml b/tests/e2e/diff/plugin/revision.yaml new file mode 100644 index 0000000000..91b4b4bd5d --- /dev/null +++ b/tests/e2e/diff/plugin/revision.yaml @@ -0,0 +1,8 @@ +openapi: 3.1.0 +info: { title: Cafe, version: 1.0.0 } +paths: + /menu: + get: + tags: [menu] + responses: + '200': { description: OK } diff --git a/tests/e2e/diff/plugin/snapshot.txt b/tests/e2e/diff/plugin/snapshot.txt new file mode 100644 index 0000000000..13e52842f0 --- /dev/null +++ b/tests/e2e/diff/plugin/snapshot.txt @@ -0,0 +1,11 @@ +GET /menu + ✖ major modified tags + Tags removed: public. (cafe/tag-removed) + at revision.yaml:6:13 + +1 major, 0 minor, 0 patch. + + +base.yaml vs revision.yaml: diff processed in ms + +❌ Diff failed with 1 major change.