Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@stoplight/spectral-cli": "^6.16.1",
"@stoplight/spectral-core": "^1.23.1",
"@stoplight/spectral-functions": "^1.10.5",
"@stoplight/spectral-parsers": "^1.0.5",
"@stoplight/spectral-ref-resolver": "^1.0.5",
"@stoplight/spectral-ruleset-bundler": "^1.7.0",
"apache-arrow": "^21.1.0",
Expand Down
42 changes: 42 additions & 0 deletions tools/spectral/ipa/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,48 @@ overrides:
x-xgen-IPA-xxx-rule: 'off'
```

#### Consumer-Provided Word Lists

The casing rules `xgen-IPA-117-operation-summary-format` and `xgen-IPA-126-tag-names-should-use-title-case`
use two configurable lists:

- `ignoreList`: words allowed to keep their specific casing (e.g. acronyms such as `API`, `AWS`, `DNS`)
- `grammaticalWords`: common words allowed to stay lowercase in titles (e.g. `and`, `or`, `the`)

Both lists are passed to the rule functions through Spectral's native `functionOptions`. Consumers who
extend the ruleset can provide their own lists by redeclaring the rule **in their own repository's
`.spectral.yaml`**, without editing this package or waiting for a new release. This is done in the
repository that _consumes_ the ruleset, not in this `openapi` repository — note the `extends` on the
published package below:

```yaml
extends:
- '@mongodb/ipa-validation-ruleset'

rules:
xgen-IPA-126-tag-names-should-use-title-case:
given: $.tags[?(@.name && @.name.length > 0)]
then:
function: IPA126TagNamesShouldUseTitleCase
functionOptions:
ignoreList:
- API
- AWS
# ...the words this repository needs
grammaticalWords:
- and
- or
```

Because Spectral requires the whole rule to be redeclared when overriding `functionOptions`, consumers own
the complete lists for their repository. The lists defined in `IPA-117.yaml` and `IPA-126.yaml` are the
package defaults consumers inherit when they don't override them.

Since these word lists are driven by the OpenAPI specs of the consuming repositories, keeping copies of them
in sync just to satisfy this repository's own validation would be a maintenance burden. For that reason both
rules are turned `off` in `ipa-spectral.yaml`, so this repository's second layer of validation does not run
them. The rule definitions and their tests are kept for development and remain available to consumers.

### CI/CD Integration

#### GitHub Actions Example
Expand Down
101 changes: 101 additions & 0 deletions tools/spectral/ipa/__tests__/ConsumerWordLists.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from '@jest/globals';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { Document, Spectral } from '@stoplight/spectral-core';
import * as Parsers from '@stoplight/spectral-parsers';
import { httpAndFileResolver } from '@stoplight/spectral-ref-resolver';
import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader';

// Proof that consumers extending the ruleset can supply their own `ignoreList` and
// `grammaticalWords` by redeclaring the rule with their own `functionOptions` — the
// native Spectral mechanism — without editing this package. The lists in the shipped
// rulesets remain the defaults consumers inherit; this repository disables the two rules
// in ipa-spectral.yaml so it does not have to keep those consumer-owned lists in sync.
async function runRuleWithOptions(ruleName, rulesetFile, functionOptions, document) {
const rulesetPath = path.join(__dirname, '../rulesets', rulesetFile);
const s = new Spectral({ resolver: httpAndFileResolver });
const ruleset = Object(await bundleAndLoadRuleset(rulesetPath, { fs, fetch })).toJSON();
const definition = ruleset.rules[ruleName].definition;
if (functionOptions) {
definition.then.functionOptions = functionOptions;
}
const scopedRuleset = { rules: { [ruleName]: definition } };
if (ruleset.aliases) {
scopedRuleset.aliases = ruleset.aliases;
}
s.setRuleset(scopedRuleset);
return s.run(JSON.stringify(document));
}

async function runFullRuleset(document) {
const rulesetPath = path.join(__dirname, '../ipa-spectral.yaml');
const s = new Spectral({ resolver: httpAndFileResolver });
const ruleset = Object(await bundleAndLoadRuleset(rulesetPath, { fs, fetch }));
s.setRuleset(ruleset);
// ipa-spectral.yaml declares path-based `overrides`, so Spectral requires the
// document to have a source assigned.
return s.run(new Document(JSON.stringify(document), Parsers.Json, 'openapi.json'));
}

describe('Consumer-provided word lists via functionOptions', () => {
describe('xgen-IPA-126-tag-names-should-use-title-case (ignoreList)', () => {
const document = { tags: [{ name: 'ACME Alerts' }] };

it('flags a consumer acronym that is not in the package ignoreList', async () => {
const errors = await runRuleWithOptions(
'xgen-IPA-126-tag-names-should-use-title-case',
'IPA-126.yaml',
null,
document
);
expect(errors).toHaveLength(1);
expect(errors[0].code).toEqual('xgen-IPA-126-tag-names-should-use-title-case');
});

it('accepts the consumer acronym once added to functionOptions', async () => {
const errors = await runRuleWithOptions(
'xgen-IPA-126-tag-names-should-use-title-case',
'IPA-126.yaml',
{ ignoreList: ['ACME'], grammaticalWords: [] },
document
);
expect(errors).toHaveLength(0);
});
});

describe('xgen-IPA-117-operation-summary-format (grammaticalWords)', () => {
const document = { paths: { '/resource': { get: { summary: 'Return One Resource per Project' } } } };

it('flags a consumer grammatical word that is not in the package list', async () => {
const errors = await runRuleWithOptions('xgen-IPA-117-operation-summary-format', 'IPA-117.yaml', null, document);
expect(errors).toHaveLength(1);
expect(errors[0].code).toEqual('xgen-IPA-117-operation-summary-format');
});

it('accepts the consumer grammatical word once added to functionOptions', async () => {
const errors = await runRuleWithOptions(
'xgen-IPA-117-operation-summary-format',
'IPA-117.yaml',
{ ignoreList: [], grammaticalWords: ['per'] },
document
);
expect(errors).toHaveLength(0);
});
});

// These rules are the second layer of validation this repository runs against its own
// OpenAPI files. Because their word lists are owned by the consuming repositories, they
// are turned off in ipa-spectral.yaml to avoid keeping the lists in sync here.
describe('vocabulary-based rules are disabled in ipa-spectral.yaml', () => {
it('does not run the consumer-owned casing rules', async () => {
const document = {
tags: [{ name: 'not title case' }],
paths: { '/resource': { get: { summary: 'return all resources.' } } },
};
const results = await runFullRuleset(document);
const codes = results.map((r) => r.code);
expect(codes).not.toContain('xgen-IPA-117-operation-summary-format');
expect(codes).not.toContain('xgen-IPA-126-tag-names-should-use-title-case');
});
});
});
Loading