diff --git a/package.json b/package.json index 6559da12ca..b85b7850f1 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "scripts": { "build.binary": "pkg . --output ./binaries/spectral", "build.clean": "rimraf ./coverage && rimraf ./dist && rimraf ./rulesets && rimraf ./__karma__/__fixtures__", - "build.oas-functions": "rollup -c", + "build.functions": "rollup -c", "build": "tsc -p ./tsconfig.build.json", "cli": "node -r ts-node/register -r tsconfig-paths/register src/cli/index.ts", "cli:debug": "node -r ts-node/register -r tsconfig-paths/register --inspect-brk src/cli/index.ts", @@ -44,9 +44,9 @@ "lint.fix": "yarn lint --fix", "lint": "tsc --noEmit && tslint 'src/**/*.ts'", "copy.html-templates": "copyfiles -u 1 \"./src/formatters/html/*.html\" \"./dist/\"", - "postbuild.oas-functions": "copyfiles -u 1 \"dist/rulesets/oas*/functions/*.js\" ./", - "postbuild": "yarn build.oas-functions && yarn generate-assets", - "prebuild": "yarn build.clean && copyfiles -u 1 \"src/rulesets/oas*/**/*.json\" dist && copyfiles -u 1 \"src/rulesets/oas*/**/*.json\" ./ && yarn copy.html-templates", + "postbuild.functions": "copyfiles -u 1 \"dist/rulesets/{oas,asyncapi}/functions/*.js\" ./", + "postbuild": "yarn build.functions && yarn generate-assets", + "prebuild": "yarn build.clean && copyfiles -u 1 \"src/rulesets/{oas,asyncapi}/**/*.json\" dist && copyfiles -u 1 \"src/rulesets/{oas,asyncapi}/**/*.json\" ./ && yarn copy.html-templates", "prebuild.binary": "yarn build", "pretest.karma": "node ./scripts/generate-karma-fixtures.js && yarn pretest", "pretest": "yarn generate-assets", diff --git a/rollup.config.js b/rollup.config.js index 40fbf0bc56..7c4e713448 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -6,18 +6,27 @@ import commonjs from 'rollup-plugin-commonjs'; import { terser } from 'rollup-plugin-terser'; const BASE_PATH = process.cwd(); -const directory = 'dist/rulesets/oas/functions'; -const targetDir = path.join(BASE_PATH, directory); const functions = []; -for (const file of fs.readdirSync(targetDir)) { - const targetFile = path.join(targetDir, file); - const stat = fs.statSync(targetFile); - if (!stat.isFile()) continue; - const ext = path.extname(targetFile); - if (ext !== '.js') continue; - - functions.push(targetFile); + +const builtIns = ['oas', 'asyncapi'] + +for (const rulesetName of builtIns) { + const targetDir = path.join(BASE_PATH, `dist/rulesets/${rulesetName}/functions/`); + + if (!fs.existsSync(targetDir)) { + continue; + } + + for (const file of fs.readdirSync(targetDir)) { + const targetFile = path.join(targetDir, file); + const stat = fs.statSync(targetFile); + if (!stat.isFile()) continue; + const ext = path.extname(targetFile); + if (ext !== '.js') continue; + + functions.push(targetFile); + } } module.exports = functions.map(fn => ({ diff --git a/scripts/generate-assets.ts b/scripts/generate-assets.ts index b68995a761..702f074094 100644 --- a/scripts/generate-assets.ts +++ b/scripts/generate-assets.ts @@ -1,10 +1,10 @@ /** - * This script generates a list of assets that are needed to load spectral:oas ruleset. - * It contains all OAS custom functions and *resolved* rulesets. - * The assets are stores in a single filed call assets.json in the following format: + * This script generates a list of assets that are needed to load spectral:oas and spectral:asyncapi rulesets. + * It contains all custom functions and *resolved* rulesets. + * The assets are stored in a single file named `assets.json` in the following format: * `: ` * where the `require-call-path` is the path you'd normally pass to require(), i.e. `@stoplight/spectral/rulesets/oas/index.js` and `content` is the text data. - * Assets can be loaded using Spectral#registerStaticAssets statc method, i.e. `Spectral.registerStaticAssets(require('@stoplight/spectral/rulesets/assets/assets.json'))`; + * Assets can be loaded using Spectral#registerStaticAssets static method, i.e. `Spectral.registerStaticAssets(require('@stoplight/spectral/rulesets/assets/assets.json'))`; * If you execute the code above, ruleset will be loaded fully offline, without a need to make any request. */ @@ -30,14 +30,16 @@ const assetsPath = path.join(baseDir, `assets.json`); const generatedAssets = {}; (async () => { - await processDirectory(generatedAssets, path.join(__dirname, '../rulesets/oas')); - await writeFileAsync(assetsPath, JSON.stringify(generatedAssets, null, 2)); + for (const kind of ['oas', 'asyncapi']) { + await processDirectory(generatedAssets, path.join(__dirname, `../rulesets/${kind}`)); + await writeFileAsync(assetsPath, JSON.stringify(generatedAssets, null, 2)); + } })(); async function processDirectory(assets: Record, dir: string) { await Promise.all( (await readdirAsync(dir)).map(async (name: string) => { - if (name === 'schemas') return; + if (['schemas', '__tests__'].includes(name)) return; const target = path.join(dir, name); const stats = await statAsync(target); if (stats.isDirectory()) { @@ -53,7 +55,11 @@ async function processDirectory(assets: Record, dir: string) { baseUri: target, parseResolveResult(opts) { return new Promise(resolve => { - resolve({ result: parse(opts.result) }); + try { + resolve({ result: parse(opts.result) }); + } catch (e) { + resolve({ error: e }); + } }); }, }) diff --git a/scripts/generate-karma-fixtures.js b/scripts/generate-karma-fixtures.js index 9503fc2a1c..29bca96883 100755 --- a/scripts/generate-karma-fixtures.js +++ b/scripts/generate-karma-fixtures.js @@ -8,7 +8,7 @@ if (!fs.existsSync(baseDir)) { fs.mkdirSync(baseDir); } -for (const rulesetName of ['oas']) { +for (const rulesetName of ['oas', 'asyncapi']) { const target = path.join(baseDir, `${rulesetName}-functions.json`); const fnsPath = path.join(__dirname, `../rulesets/${rulesetName}/functions`); const bundledFns = {}; diff --git a/setupKarma.ts b/setupKarma.ts index 232218af46..22ac18d82e 100644 --- a/setupKarma.ts +++ b/setupKarma.ts @@ -5,6 +5,9 @@ const oasRuleset = JSON.parse(JSON.stringify(require('./rulesets/oas/index.json' const oasFunctions = JSON.parse(JSON.stringify(require('./__karma__/__fixtures__/oas-functions.json'))); const oas2Schema = JSON.parse(JSON.stringify(require('./rulesets/oas/schemas/schema.oas2.json'))); const oas3Schema = JSON.parse(JSON.stringify(require('./rulesets/oas/schemas/schema.oas3.json'))); +const asyncApiRuleset = JSON.parse(JSON.stringify(require('./rulesets/asyncapi/index.json'))); +const asyncApiFunctions = JSON.parse(JSON.stringify(require('./__karma__/__fixtures__/asyncapi-functions.json'))); +const asyncApi2Schema = JSON.parse(JSON.stringify(require('./rulesets/asyncapi/schemas/schema.asyncapi2.json'))); const { fetch } = window; let fetchMock: FetchMockSandbox; @@ -33,12 +36,27 @@ beforeEach(() => { body: JSON.parse(JSON.stringify(oas3Schema)), }); - for (const [name, fn] of Object.entries(oasFunctions)) { - fetchMock.get(`https://unpkg.com/@stoplight/spectral/rulesets/oas/functions/${name}`, { - status: 200, - body: fn, - }); - } + fetchMock.get('https://unpkg.com/@stoplight/spectral/rulesets/asyncapi/index.json', { + status: 200, + body: JSON.parse(JSON.stringify(asyncApiRuleset)), + }); + + fetchMock.get('https://unpkg.com/@stoplight/spectral/rulesets/asyncapi/schemas/schema.asyncapi2.json', { + status: 200, + body: JSON.parse(JSON.stringify(asyncApi2Schema)), + }); + + [ + ['oas', oasFunctions], + ['asyncapi', asyncApiFunctions], + ].forEach(([rulesetName, funcs]) => { + for (const [name, fn] of Object.entries(funcs)) { + fetchMock.get(`https://unpkg.com/@stoplight/spectral/rulesets/${rulesetName}/functions/${name}`, { + status: 200, + body: fn, + }); + } + }); fetchMock.get('http://json-schema.org/draft-04/schema', { status: 200, diff --git a/setupTests.ts b/setupTests.ts index c7c271db0e..d3890b325f 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -1,5 +1,10 @@ import { RulesetExceptionCollection } from './src/types/ruleset'; +import { Dictionary } from '@stoplight/types'; +import { IRunRule, isAsyncApiv2, Rule, Spectral } from './src'; +import { readRuleset } from './src/rulesets'; +import { getDiagnosticSeverity } from './src/rulesets/severity'; + export const buildRulesetExceptionCollectionFrom = ( loc: string, rules: string[] = ['a'], @@ -8,3 +13,30 @@ export const buildRulesetExceptionCollectionFrom = ( source[loc] = rules; return source; }; + +export const buildTestSpectralWithAsyncApiRule = async (ruleName: string): Promise<[Spectral, IRunRule]> => { + const ruleset = await readRuleset('spectral:asyncapi'); + + expect(Object.keys(ruleset.rules)).toContain(ruleName); + + const s = new Spectral(); + s.registerFormat('asyncapi2', isAsyncApiv2); + + const dic: Dictionary = {}; + const rule = ruleset.rules[ruleName]; + dic[ruleName] = rule; + + if (rule.severity === void 0) { + throw new Error('Unexpected undefined severity'); + } + + const expectedSeverity = getDiagnosticSeverity(rule.severity); + + expect(expectedSeverity).not.toEqual(-1); + + s.setRules(dic); + + expect(Object.keys(s.rules)).toContain(ruleName); + + return [s, s.rules[ruleName]]; +}; diff --git a/src/__tests__/__fixtures__/streetlights.asyncapi2.json b/src/__tests__/__fixtures__/streetlights.asyncapi2.json new file mode 100644 index 0000000000..f792f3c4ff --- /dev/null +++ b/src/__tests__/__fixtures__/streetlights.asyncapi2.json @@ -0,0 +1,297 @@ +{ + "asyncapi": "2.0.0", + "info": { + "title": "Streetlights API", + "version": "1.0.0", + "description": "The Smartylighting Streetlights API allows you to remotely manage the city lights.\n\n### Check out its awesome features:\n\n* Turn a specific streetlight on/off 🌃\n* Dim a specific streetlight 😎\n* Receive real-time information about environmental lighting conditions 📈\n", + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0" + } + }, + "servers": { + "production": { + "url": "test.mosquitto.org:{port}", + "protocol": "mqtt", + "description": "Test broker", + "variables": { + "port": { + "description": "Secure connection (TLS) is available through port 8883.", + "default": "1883", + "enum": [ + "1883", + "8883" + ] + } + }, + "security": [ + { + "apiKey": [] + }, + { + "supportedOauthFlows": [ + "streetlights:on", + "streetlights:off", + "streetlights:dim" + ] + }, + { + "openIdConnectWellKnown": [] + } + ] + } + }, + "defaultContentType": "application/json", + "channels": { + "smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured": { + "description": "The topic on which measured values may be produced and consumed.", + "parameters": { + "streetlightId": { + "$ref": "#/components/parameters/streetlightId" + } + }, + "publish": { + "summary": "Inform about environmental lighting conditions of a particular streetlight.", + "operationId": "receiveLightMeasurement", + "traits": [ + { + "$ref": "#/components/operationTraits/kafka" + } + ], + "message": { + "$ref": "#/components/messages/lightMeasured" + } + } + }, + "smartylighting/streetlights/1/0/action/{streetlightId}/turn/on": { + "parameters": { + "streetlightId": { + "$ref": "#/components/parameters/streetlightId" + } + }, + "subscribe": { + "operationId": "turnOn", + "traits": [ + { + "$ref": "#/components/operationTraits/kafka" + } + ], + "message": { + "$ref": "#/components/messages/turnOnOff" + } + } + }, + "smartylighting/streetlights/1/0/action/{streetlightId}/turn/off": { + "parameters": { + "streetlightId": { + "$ref": "#/components/parameters/streetlightId" + } + }, + "subscribe": { + "operationId": "turnOff", + "traits": [ + { + "$ref": "#/components/operationTraits/kafka" + } + ], + "message": { + "$ref": "#/components/messages/turnOnOff" + } + } + }, + "smartylighting/streetlights/1/0/action/{streetlightId}/dim": { + "parameters": { + "streetlightId": { + "$ref": "#/components/parameters/streetlightId" + } + }, + "subscribe": { + "operationId": "dimLight", + "traits": [ + { + "$ref": "#/components/operationTraits/kafka" + } + ], + "message": { + "$ref": "#/components/messages/dimLight" + } + } + } + }, + "components": { + "messages": { + "lightMeasured": { + "name": "lightMeasured", + "title": "Light measured", + "summary": "Inform about environmental lighting conditions of a particular streetlight.", + "contentType": "application/json", + "traits": [ + { + "$ref": "#/components/messageTraits/commonHeaders" + } + ], + "payload": { + "$ref": "#/components/schemas/lightMeasuredPayload" + } + }, + "turnOnOff": { + "name": "turnOnOff", + "title": "Turn on/off", + "summary": "Command a particular streetlight to turn the lights on or off.", + "traits": [ + { + "$ref": "#/components/messageTraits/commonHeaders" + } + ], + "payload": { + "$ref": "#/components/schemas/turnOnOffPayload" + } + }, + "dimLight": { + "name": "dimLight", + "title": "Dim light", + "summary": "Command a particular streetlight to dim the lights.", + "traits": [ + { + "$ref": "#/components/messageTraits/commonHeaders" + } + ], + "payload": { + "$ref": "#/components/schemas/dimLightPayload" + } + } + }, + "schemas": { + "lightMeasuredPayload": { + "type": "object", + "properties": { + "lumens": { + "type": "integer", + "minimum": 0, + "description": "Light intensity measured in lumens." + }, + "sentAt": { + "$ref": "#/components/schemas/sentAt" + } + } + }, + "turnOnOffPayload": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": [ + "on", + "off" + ], + "description": "Whether to turn on or off the light." + }, + "sentAt": { + "$ref": "#/components/schemas/sentAt" + } + } + }, + "dimLightPayload": { + "type": "object", + "properties": { + "percentage": { + "type": "integer", + "description": "Percentage to which the light should be dimmed to.", + "minimum": 0, + "maximum": 100 + }, + "sentAt": { + "$ref": "#/components/schemas/sentAt" + } + } + }, + "sentAt": { + "type": "string", + "format": "date-time", + "description": "Date and time when the message was sent." + } + }, + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "user", + "description": "Provide your API key as the user and leave the password empty." + }, + "supportedOauthFlows": { + "type": "oauth2", + "description": "Flows to support OAuth 2.0", + "flows": { + "implicit": { + "authorizationUrl": "https://authserver.example/auth", + "scopes": { + "streetlights:on": "Ability to switch lights on", + "streetlights:off": "Ability to switch lights off", + "streetlights:dim": "Ability to dim the lights" + } + }, + "password": { + "tokenUrl": "https://authserver.example/token", + "scopes": { + "streetlights:on": "Ability to switch lights on", + "streetlights:off": "Ability to switch lights off", + "streetlights:dim": "Ability to dim the lights" + } + }, + "clientCredentials": { + "tokenUrl": "https://authserver.example/token", + "scopes": { + "streetlights:on": "Ability to switch lights on", + "streetlights:off": "Ability to switch lights off", + "streetlights:dim": "Ability to dim the lights" + } + }, + "authorizationCode": { + "authorizationUrl": "https://authserver.example/auth", + "tokenUrl": "https://authserver.example/token", + "refreshUrl": "https://authserver.example/refresh", + "scopes": { + "streetlights:on": "Ability to switch lights on", + "streetlights:off": "Ability to switch lights off", + "streetlights:dim": "Ability to dim the lights" + } + } + } + }, + "openIdConnectWellKnown": { + "type": "openIdConnect", + "openIdConnectUrl": "https://authserver.example/.well-known" + } + }, + "parameters": { + "streetlightId": { + "description": "The ID of the streetlight.", + "schema": { + "type": "string" + } + } + }, + "messageTraits": { + "commonHeaders": { + "headers": { + "type": "object", + "properties": { + "my-app-header": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + } + } + } + }, + "operationTraits": { + "kafka": { + "bindings": { + "kafka": { + "clientId": "my-app-id" + } + } + } + } + } +} \ No newline at end of file diff --git a/src/__tests__/generate-assets.jest.test.ts b/src/__tests__/generate-assets.jest.test.ts index f9609b16ec..aafd644490 100644 --- a/src/__tests__/generate-assets.jest.test.ts +++ b/src/__tests__/generate-assets.jest.test.ts @@ -19,6 +19,7 @@ describe('generate-assets', () => { const testCases = [ ['oas', 'oas2-schema', 'title', 'A JSON Schema for Swagger 2.0 API.'], ['oas', 'oas3-schema', 'description', 'Validation schema for OpenAPI Specification 3.0.X.'], + ['asyncapi', 'asyncapi-schema', 'title', 'AsyncAPI 2.0.0 schema.'], ]; it.each(testCases)( @@ -35,4 +36,10 @@ describe('generate-assets', () => { }, ); }); + + it('Does not contain test files', () => { + Object.keys(assets).forEach(key => { + expect(key).not.toMatch('__tests__'); + }); + }); }); diff --git a/src/__tests__/linter.test.ts b/src/__tests__/linter.test.ts index 5f1ee0212f..11f3cc97bc 100644 --- a/src/__tests__/linter.test.ts +++ b/src/__tests__/linter.test.ts @@ -475,6 +475,7 @@ describe('linter', () => { ]); }); + // TODO: Find a way to cover formats more extensively test('given a string input, should warn about unmatched formats', async () => { const result = await spectral.run('test'); diff --git a/src/__tests__/spectral.test.ts b/src/__tests__/spectral.test.ts index c8c851c1b0..e5aa3c903c 100644 --- a/src/__tests__/spectral.test.ts +++ b/src/__tests__/spectral.test.ts @@ -12,17 +12,22 @@ import { RulesetExceptionCollection } from '../types/ruleset'; import { buildRulesetExceptionCollectionFrom } from '../../setupTests'; const oasRuleset = JSON.parse(JSON.stringify(require('../rulesets/oas/index.json'))); +const asyncApiRuleset = JSON.parse(JSON.stringify(require('../rulesets/asyncapi/index.json'))); const oasRulesetRules: Dictionary = oasRuleset.rules; +const asyncApiRulesetRules: Dictionary = asyncApiRuleset.rules; describe('spectral', () => { describe('loadRuleset', () => { - test('should support loading built-in rulesets', async () => { + test.each([ + ['spectral:oas', oasRulesetRules], + ['spectral:asyncapi', asyncApiRulesetRules], + ])('should support loading "%s" built-in ruleset', async (rulesetName, rules) => { const s = new Spectral(); - await s.loadRuleset('spectral:oas'); + await s.loadRuleset(rulesetName); expect(s.rules).toEqual( expect.objectContaining( - Object.entries(oasRulesetRules).reduce>((oasRules, [name, rule]) => { + Object.entries(rules).reduce>((oasRules, [name, rule]) => { oasRules[name] = { name, ...rule, @@ -37,12 +42,15 @@ describe('spectral', () => { ); }); - test('should support loading multiple times the built-in ruleset', async () => { + test.each([ + ['spectral:oas', oasRulesetRules], + ['spectral:asyncapi', asyncApiRulesetRules], + ])('should support loading multiple times the built-in ruleset "%s"', async (rulesetName, expectedRules) => { const s = new Spectral(); - await s.loadRuleset(['spectral:oas', 'spectral:oas']); + await s.loadRuleset([rulesetName, rulesetName]); expect(s.rules).toEqual( - Object.entries(oasRulesetRules).reduce>((oasRules, [name, rule]) => { + Object.entries(expectedRules).reduce>((oasRules, [name, rule]) => { oasRules[name] = { name, ...rule, diff --git a/src/assets.ts b/src/assets.ts index 7e9d8543f3..f8841fd3f0 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -6,6 +6,7 @@ function resolveSpectralRuleset(ruleset: string) { export const RESOLVE_ALIASES: Dictionary = { 'spectral:oas': resolveSpectralRuleset('oas'), + 'spectral:asyncapi': resolveSpectralRuleset('asyncapi'), }; export const STATIC_ASSETS: Dictionary = {}; diff --git a/src/cli/services/linter/linter.ts b/src/cli/services/linter/linter.ts index 949d079b61..2fe75be19f 100644 --- a/src/cli/services/linter/linter.ts +++ b/src/cli/services/linter/linter.ts @@ -1,5 +1,6 @@ import { Document, STDIN } from '../../../document'; import { + isAsyncApiv2, isJSONSchema, isJSONSchemaDraft2019_09, isJSONSchemaDraft4, @@ -21,6 +22,7 @@ import { getResolver } from './utils/getResolver'; const KNOWN_FORMATS: Array<[string, FormatLookup, string]> = [ ['oas2', isOpenApiv2, 'OpenAPI 2.0 (Swagger) detected'], ['oas3', isOpenApiv3, 'OpenAPI 3.x detected'], + ['asyncapi2', isAsyncApiv2, 'AsyncAPI 2.x detected'], ['json-schema', isJSONSchema, 'JSON Schema detected'], ['json-schema-loose', isJSONSchemaLoose, 'JSON Schema (loose) detected'], ['json-schema-draft4', isJSONSchemaDraft4, 'JSON Schema Draft 4 detected'], diff --git a/src/cli/services/linter/utils/getRuleset.ts b/src/cli/services/linter/utils/getRuleset.ts index cdee8b3220..4b8a998d3e 100644 --- a/src/cli/services/linter/utils/getRuleset.ts +++ b/src/cli/services/linter/utils/getRuleset.ts @@ -21,5 +21,5 @@ export async function getRuleset(rulesetFile: Optional) { return await (rulesetFiles ? loadRulesets(process.cwd(), Array.isArray(rulesetFiles) ? rulesetFiles : [rulesetFiles]) - : readRuleset('spectral:oas')); + : readRuleset(['spectral:oas', 'spectral:asyncapi'])); } diff --git a/src/formats/__tests__/asyncapi.test.ts b/src/formats/__tests__/asyncapi.test.ts new file mode 100644 index 0000000000..e83c209538 --- /dev/null +++ b/src/formats/__tests__/asyncapi.test.ts @@ -0,0 +1,28 @@ +import { isAsyncApiv2 } from '../asyncapi'; + +describe('AsyncApi format', () => { + describe('AsyncApi 2.0', () => { + it.each(['2.0.0'])('recognizes %s version correctly', version => { + expect(isAsyncApiv2({ asyncapi: version })).toBe(true); + }); + + const testCases = [ + { asyncapi: '3.0' }, + { asyncapi: '2' }, + { asyncapi: '2.0' }, + { asyncapi: '1.0' }, + { asyncapi: 2 }, + { openapi: '4.0' }, + { openapi: '2.0' }, + { openapi: null }, + { swagger: null }, + { swagger: '3.0' }, + {}, + null, + ]; + + it.each(testCases)('does not recognize invalid document %o', document => { + expect(isAsyncApiv2(document)).toBe(false); + }); + }); +}); diff --git a/src/formats/asyncapi.ts b/src/formats/asyncapi.ts new file mode 100644 index 0000000000..8190f4b9db --- /dev/null +++ b/src/formats/asyncapi.ts @@ -0,0 +1,10 @@ +import { isObject } from 'lodash'; + +type MaybeAsyncApi2 = Partial<{ asyncapi: unknown }>; + +const bearsAStringPropertyNamed = (document: unknown, propertyName: string) => { + return isObject(document) && propertyName in document && typeof document[propertyName] === 'string'; +}; + +export const isAsyncApiv2 = (document: unknown) => + bearsAStringPropertyNamed(document, 'asyncapi') && String((document as MaybeAsyncApi2).asyncapi) === '2.0.0'; diff --git a/src/formats/index.ts b/src/formats/index.ts index 54f1929ebb..78a2a4f1c0 100644 --- a/src/formats/index.ts +++ b/src/formats/index.ts @@ -1,2 +1,3 @@ export * from './openapi'; +export * from './asyncapi'; export * from './json-schema'; diff --git a/src/rulesets/__tests__/offline.schemas.jest.test.ts b/src/rulesets/__tests__/offline.schemas.jest.test.ts index 227c086fe1..1a23979556 100644 --- a/src/rulesets/__tests__/offline.schemas.jest.test.ts +++ b/src/rulesets/__tests__/offline.schemas.jest.test.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as nock from 'nock'; import { Document } from '../../document'; -import { isOpenApiv2, isOpenApiv3 } from '../../formats'; +import { isAsyncApiv2, isOpenApiv2, isOpenApiv3 } from '../../formats'; import { readParsable } from '../../fs/reader'; import { Spectral } from '../../index'; import * as Parsers from '../../parsers'; @@ -32,6 +32,14 @@ const knownRulesets: ITestCases = { }, ], }, + 'spectral:asyncapi': { + fixtures: [ + { + fixture: '../../__tests__/__fixtures__/streetlights.asyncapi2.json', + format: { name: 'asyncapi2', lookupFn: isAsyncApiv2 }, + }, + ], + }, }; type FlattenedTestCases = [string, string, string, FormatLookup]; @@ -82,6 +90,8 @@ describe('Online vs Offline context', () => { }, ); + const ordinalSort = (arr: string[]) => arr.sort((a, b) => a.localeCompare(b)); + test('all rulesets are accounted for', async () => { const dir = path.join(__dirname, '../../../rulesets/'); @@ -105,6 +115,6 @@ describe('Online vs Offline context', () => { }); // Will fail when a ruleset has not been added to the `knownRulesets` variable - expect(discoveredRulesets).toEqual(Object.keys(knownRulesets)); + expect(ordinalSort(discoveredRulesets)).toEqual(ordinalSort(Object.keys(knownRulesets))); }); }); diff --git a/src/rulesets/asyncapi/__tests__/asyncapi-contact-properties.ts b/src/rulesets/asyncapi/__tests__/asyncapi-contact-properties.ts new file mode 100644 index 0000000000..065b56f5f1 --- /dev/null +++ b/src/rulesets/asyncapi/__tests__/asyncapi-contact-properties.ts @@ -0,0 +1,49 @@ +import { cloneDeep } from 'lodash'; + +import { buildTestSpectralWithAsyncApiRule } from '../../../../setupTests'; +import { Spectral } from '../../../spectral'; +import { IRunRule } from '../../../types'; + +const ruleName = 'asyncapi-contact-properties'; +let s: Spectral; +let rule: IRunRule; + +describe(`Rule '${ruleName}'`, () => { + beforeEach(async () => { + [s, rule] = await buildTestSpectralWithAsyncApiRule(ruleName); + }); + + const doc = { + asyncapi: '2.0.0', + info: { + contact: { + name: 'stoplight', + url: 'stoplight.io', + email: 'support@stoplight.io', + }, + }, + }; + + test('validates a correct object', async () => { + const results = await s.run(doc, { ignoreUnknownFormat: false }); + + expect(results).toEqual([]); + }); + + test.each(['name', 'url', 'email'])('return result if contact.%s property is missing', async (property: string) => { + const clone = cloneDeep(doc); + + delete clone.info.contact[property]; + + const results = await s.run(clone, { ignoreUnknownFormat: false }); + + expect(results).toEqual([ + expect.objectContaining({ + code: ruleName, + message: 'Contact object should have `name`, `url` and `email`.', + path: ['info', 'contact'], + severity: rule.severity, + }), + ]); + }); +}); diff --git a/src/rulesets/asyncapi/index.json b/src/rulesets/asyncapi/index.json new file mode 100644 index 0000000000..640647bd51 --- /dev/null +++ b/src/rulesets/asyncapi/index.json @@ -0,0 +1,337 @@ +{ + "rules": { + "asyncapi-contact-properties": { + "description": "Contact object should have `name`, `url` and `email`.", + "recommended": true, + "formats": [ + "asyncapi2" + ], + "severity": 2, + "type": "style", + "given": "$.info.contact", + "then": [ + { + "field": "name", + "function": "truthy" + }, + { + "field": "url", + "function": "truthy" + }, + { + "field": "email", + "function": "truthy" + } + ] + }, + "asyncapi-example-value-or-externalValue": { + "description": "Example should have either a `value` or `externalValue` field.", + "recommended": true, + "formats": [ + "asyncapi2" + ], + "type": "style", + "given": "$..example", + "then": { + "function": "xor", + "functionOptions": { + "properties": [ + "externalValue", + "value" + ] + } + } + }, + "asyncapi-info-contact": { + "description": "Info object should contain `contact` object.", + "recommended": true, + "formats": [ + "asyncapi2" + ], + "type": "style", + "given": "$", + "then": { + "field": "info.contact", + "function": "truthy" + } + }, + "asyncapi-info-description": { + "description": "AsyncAPI object info `description` must be present and non-empty string.", + "recommended": true, + "formats": [ + "asyncapi2" + ], + "type": "style", + "given": "$", + "then": { + "field": "info.description", + "function": "truthy" + }, + "tags": [ + "api" + ] + }, + "asyncapi-info-license": { + "description": "AsyncAPI object info `license` must be present and non-empty string.", + "recommended": true, + "formats": [ + "asyncapi2" + ], + "type": "style", + "given": "$", + "then": { + "field": "info.license", + "function": "truthy" + }, + "tags": [ + "api" + ] + }, + "asyncapi-license-url": { + "description": "License object should include `url`.", + "recommended": false, + "formats": [ + "asyncapi2" + ], + "type": "style", + "given": "$", + "then": { + "field": "info.license.url", + "function": "truthy" + } + }, + "asyncapi-tags-alphabetical": { + "description": "AsyncAPI object should have alphabetical `tags`.", + "recommended": false, + "formats": [ + "asyncapi2" + ], + "type": "style", + "given": "$", + "then": { + "field": "tags", + "function": "alphabetical", + "functionOptions": { + "keyedBy": "name" + } + } + }, + "asyncapi-operation-description": { + "description": "Operation `description` must be present and non-empty string.", + "recommended": true, + "formats": [ + "asyncapi2" + ], + "type": "style", + "given": "$.paths.*[?( @property === 'get' || @property === 'put' || @property === 'post' || @property === 'delete' || @property === 'options' || @property === 'head' || @property === 'patch' || @property === 'trace' )]", + "then": { + "field": "description", + "function": "truthy" + }, + "tags": [ + "operation" + ] + }, + "asyncapi-operation-operationId": { + "description": "Operation should have an `operationId`.", + "recommended": true, + "formats": [ + "asyncapi2" + ], + "type": "style", + "given": "$.paths.*[?( @property === 'get' || @property === 'put' || @property === 'post' || @property === 'delete' || @property === 'options' || @property === 'head' || @property === 'patch' || @property === 'trace' )]", + "then": { + "field": "operationId", + "function": "truthy" + }, + "tags": [ + "operation" + ] + }, + "asyncapi-path-declarations-must-exist": { + "description": "Path parameter declarations cannot be empty, ex.`/companies/{}` is invalid.", + "recommended": true, + "formats": [ + "asyncapi2" + ], + "type": "style", + "given": "$.paths", + "then": { + "field": "@key", + "function": "pattern", + "functionOptions": { + "notMatch": "{}" + } + }, + "tags": [ + "given" + ] + }, + "asyncapi-path-keys-no-trailing-slash": { + "description": "paths should not end with a slash.", + "recommended": true, + "formats": [ + "asyncapi2" + ], + "type": "style", + "given": "$.paths", + "then": { + "field": "@key", + "function": "pattern", + "functionOptions": { + "notMatch": ".+\\/$" + } + }, + "tags": [ + "given" + ] + }, + "asyncapi-path-not-include-query": { + "description": "given keys should not include a query string.", + "recommended": true, + "formats": [ + "asyncapi2" + ], + "type": "style", + "given": "$.paths", + "then": { + "field": "@key", + "function": "pattern", + "functionOptions": { + "notMatch": "\\?" + } + }, + "tags": [ + "given" + ] + }, + "asyncapi-tag-description": { + "description": "Tag object should have a `description`.", + "recommended": false, + "formats": [ + "asyncapi2" + ], + "type": "style", + "given": "$.tags[*]", + "then": { + "field": "description", + "function": "truthy" + } + }, + "asyncapi-servers": { + "description": "Define at least one server.", + "recommended": true, + "formats": [ + "asyncapi2" + ], + "given": "$", + "then": { + "field": "servers", + "function": "schema", + "functionOptions": { + "schema": { + "type": "object", + "minProperties": 1 + } + } + } + }, + "asyncapi-parameter-description": { + "description": "Parameter objects should have a `description`.", + "recommended": false, + "formats": [ + "asyncapi2" + ], + "given": "$..['parameters']", + "then": { + "field": "description", + "function": "truthy" + }, + "tags": [ + "parameters" + ] + }, + "asyncapi-server-not-example.com": { + "description": "Server URL should not point at example.com.", + "recommended": false, + "formats": [ + "asyncapi2" + ], + "given": "$.servers[*].url", + "then": { + "function": "pattern", + "functionOptions": { + "notMatch": "example\\.com" + } + } + }, + "asyncapi-server-trailing-slash": { + "description": "Server URL should not have a trailing slash.", + "recommended": true, + "formats": [ + "asyncapi2" + ], + "type": "style", + "given": "$.servers[*].url", + "then": { + "function": "pattern", + "functionOptions": { + "notMatch": "/$" + } + } + }, + "asyncapi-valid-schema-example": { + "description": "Examples must be valid against their defined schema.", + "message": "{{error}}", + "severity": 0, + "recommended": false, + "formats": [ + "asyncapi2" + ], + "type": "validation", + "given": "$.components.schemas..[?(@property !== 'properties' && @.example && (@.type || @.format || @.$ref))]", + "then": { + "function": "schemaPath", + "functionOptions": { + "field": "example", + "schemaPath": "$" + } + } + }, + "asyncapi-schema": { + "description": "Validate structure of AsyncAPI v2.0.0 Specification.", + "message": "{{error}}", + "severity": 0, + "recommended": true, + "formats": [ + "asyncapi2" + ], + "type": "validation", + "given": "$", + "then": { + "function": "schema", + "functionOptions": { + "schema": { + "$ref": "./schemas/schema.asyncapi2.json" + } + } + } + }, + "asyncapi-unused-components-schema": { + "description": "Potentially unused components schema has been detected.", + "recommended": true, + "formats": [ + "asyncapi2" + ], + "type": "style", + "resolved": false, + "given": "$.components.schemas", + "then": { + "function": "unreferencedReusableObject", + "functionOptions": { + "reusableObjectsLocation": "#/components/schemas" + } + } + } + } +} diff --git a/src/rulesets/asyncapi/schemas/schema.asyncapi2.json b/src/rulesets/asyncapi/schemas/schema.asyncapi2.json new file mode 100644 index 0000000000..7020a63a10 --- /dev/null +++ b/src/rulesets/asyncapi/schemas/schema.asyncapi2.json @@ -0,0 +1,1597 @@ +{ + "title": "AsyncAPI 2.0.0 schema.", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": [ + "asyncapi", + "info", + "channels" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "asyncapi": { + "type": "string", + "enum": [ + "2.0.0" + ], + "description": "The AsyncAPI specification version of this document." + }, + "id": { + "type": "string", + "description": "A unique id representing the application.", + "format": "uri" + }, + "info": { + "$ref": "#/definitions/info" + }, + "servers": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/server" + } + }, + "defaultContentType": { + "type": "string" + }, + "channels": { + "$ref": "#/definitions/channels" + }, + "components": { + "$ref": "#/definitions/components" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/tag" + }, + "uniqueItems": true + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + } + }, + "definitions": { + "Reference": { + "type": "object", + "required": [ + "$ref" + ], + "properties": { + "$ref": { + "$ref": "#/definitions/ReferenceObject" + } + } + }, + "ReferenceObject": { + "type": "string", + "format": "uri-reference" + }, + "info": { + "type": "object", + "description": "General information about the API.", + "required": [ + "version", + "title" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "title": { + "type": "string", + "description": "A unique and precise title of the API." + }, + "version": { + "type": "string", + "description": "A semantic version number of the API." + }, + "description": { + "type": "string", + "description": "A longer description of the API. Should be different from the title. CommonMark is allowed." + }, + "termsOfService": { + "type": "string", + "description": "A URL to the Terms of Service for the API. MUST be in the format of a URL.", + "format": "uri" + }, + "contact": { + "$ref": "#/definitions/contact" + }, + "license": { + "$ref": "#/definitions/license" + } + } + }, + "contact": { + "type": "object", + "description": "Contact information for the owners of the API.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The identifying name of the contact person/organization." + }, + "url": { + "type": "string", + "description": "The URL pointing to the contact information.", + "format": "uri" + }, + "email": { + "type": "string", + "description": "The email address of the contact person/organization.", + "format": "email" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "license": { + "type": "object", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the license type. It's encouraged to use an OSI compatible license." + }, + "url": { + "type": "string", + "description": "The URL pointing to the license.", + "format": "uri" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "server": { + "type": "object", + "description": "An object representing a Server.", + "required": [ + "url", + "protocol" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "protocol": { + "type": "string", + "description": "The transfer protocol." + }, + "protocolVersion": { + "type": "string" + }, + "variables": { + "$ref": "#/definitions/serverVariables" + }, + "security": { + "type": "array", + "items": { + "$ref": "#/definitions/SecurityRequirement" + } + }, + "bindings": { + "$ref": "#/definitions/bindingsObject" + } + } + }, + "serverVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/serverVariable" + } + }, + "serverVariable": { + "type": "object", + "description": "An object representing a Server Variable for server URL template substitution.", + "minProperties": 1, + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "examples": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "channels": { + "type": "object", + "propertyNames": { + "type": "string", + "format": "uri-template", + "minLength": 1 + }, + "additionalProperties": { + "$ref": "#/definitions/channelItem" + } + }, + "components": { + "type": "object", + "description": "An object to hold a set of reusable objects for different aspects of the AsyncAPI Specification.", + "additionalProperties": false, + "properties": { + "schemas": { + "$ref": "#/definitions/schemas" + }, + "messages": { + "$ref": "#/definitions/messages" + }, + "securitySchemes": { + "type": "object", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/SecurityScheme" + } + ] + } + } + }, + "parameters": { + "$ref": "#/definitions/parameters" + }, + "correlationIds": { + "type": "object", + "patternProperties": { + "^[\\w\\d\\.\\-_]+$": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/correlationId" + } + ] + } + } + }, + "operationTraits": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/operationTrait" + } + }, + "messageTraits": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/messageTrait" + } + }, + "serverBindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/bindingsObject" + } + }, + "channelBindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/bindingsObject" + } + }, + "operationBindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/bindingsObject" + } + }, + "messageBindings": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/bindingsObject" + } + } + } + }, + "schemas": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema" + }, + "description": "JSON objects describing schemas the API uses." + }, + "messages": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/message" + }, + "description": "JSON objects describing the messages being consumed and produced by the API." + }, + "parameters": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/parameter" + }, + "description": "JSON objects describing re-usable channel parameters." + }, + "schema": { + "allOf": [ + { + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/schema/allOf/0" + } + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "allOf": [ + { + "$ref": "#/definitions/schema/allOf/0/definitions/nonNegativeInteger" + }, + { + "default": 0 + } + ] + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true, + "default": [] + } + }, + "type": [ + "object", + "boolean" + ], + "properties": { + "$id": { + "type": "string", + "format": "uri-reference" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "$comment": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { + "$ref": "#/definitions/schema/allOf/0/definitions/nonNegativeInteger" + }, + "minLength": { + "$ref": "#/definitions/schema/allOf/0/definitions/nonNegativeIntegerDefault0" + }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { + "$ref": "#/definitions/schema/allOf/0" + }, + "items": { + "anyOf": [ + { + "$ref": "#/definitions/schema/allOf/0" + }, + { + "$ref": "#/definitions/schema/allOf/0/definitions/schemaArray" + } + ], + "default": true + }, + "maxItems": { + "$ref": "#/definitions/schema/allOf/0/definitions/nonNegativeInteger" + }, + "minItems": { + "$ref": "#/definitions/schema/allOf/0/definitions/nonNegativeIntegerDefault0" + }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "contains": { + "$ref": "#/definitions/schema/allOf/0" + }, + "maxProperties": { + "$ref": "#/definitions/schema/allOf/0/definitions/nonNegativeInteger" + }, + "minProperties": { + "$ref": "#/definitions/schema/allOf/0/definitions/nonNegativeIntegerDefault0" + }, + "required": { + "$ref": "#/definitions/schema/allOf/0/definitions/stringArray" + }, + "additionalProperties": { + "$ref": "#/definitions/schema/allOf/0" + }, + "definitions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema/allOf/0" + }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema/allOf/0" + }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema/allOf/0" + }, + "propertyNames": { + "format": "regex" + }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/schema/allOf/0" + }, + { + "$ref": "#/definitions/schema/allOf/0/definitions/stringArray" + } + ] + } + }, + "propertyNames": { + "$ref": "#/definitions/schema/allOf/0" + }, + "const": true, + "enum": { + "type": "array", + "items": true, + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + { + "$ref": "#/definitions/schema/allOf/0/definitions/simpleTypes" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/schema/allOf/0/definitions/simpleTypes" + }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "format": { + "type": "string" + }, + "contentMediaType": { + "type": "string" + }, + "contentEncoding": { + "type": "string" + }, + "if": { + "$ref": "#/definitions/schema/allOf/0" + }, + "then": { + "$ref": "#/definitions/schema/allOf/0" + }, + "else": { + "$ref": "#/definitions/schema/allOf/0" + }, + "allOf": { + "$ref": "#/definitions/schema/allOf/0/definitions/schemaArray" + }, + "anyOf": { + "$ref": "#/definitions/schema/allOf/0/definitions/schemaArray" + }, + "oneOf": { + "$ref": "#/definitions/schema/allOf/0/definitions/schemaArray" + }, + "not": { + "$ref": "#/definitions/schema/allOf/0" + } + }, + "default": true + }, + { + "type": "object", + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "type": "boolean" + } + ], + "default": {} + }, + "items": { + "anyOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/schema" + } + } + ], + "default": {} + }, + "allOf": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/schema" + } + }, + "oneOf": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/definitions/schema" + } + }, + "anyOf": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/definitions/schema" + } + }, + "not": { + "$ref": "#/definitions/schema" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema" + }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema" + }, + "default": {} + }, + "propertyNames": { + "$ref": "#/definitions/schema" + }, + "contains": { + "$ref": "#/definitions/schema" + }, + "discriminator": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "deprecated": { + "type": "boolean", + "default": false + } + } + } + ] + }, + "externalDocs": { + "type": "object", + "additionalProperties": false, + "description": "information about external documentation", + "required": [ + "url" + ], + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "channelItem": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "minProperties": 1, + "properties": { + "$ref": { + "$ref": "#/definitions/ReferenceObject" + }, + "parameters": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/parameter" + } + }, + "description": { + "type": "string", + "description": "A description of the channel." + }, + "publish": { + "$ref": "#/definitions/operation" + }, + "subscribe": { + "$ref": "#/definitions/operation" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "bindings": { + "$ref": "#/definitions/bindingsObject" + } + } + }, + "parameter": { + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "description": { + "type": "string", + "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed." + }, + "schema": { + "$ref": "#/definitions/schema" + }, + "location": { + "type": "string", + "description": "A runtime expression that specifies the location of the parameter value", + "pattern": "^\\$message\\.(header|payload)\\#(\\/(([^\\/~])|(~[01]))*)*" + }, + "$ref": { + "$ref": "#/definitions/ReferenceObject" + } + } + }, + "operation": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "traits": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/operationTrait" + }, + { + "type": "array", + "items": [ + { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/operationTrait" + } + ] + }, + { + "type": "object", + "additionalItems": true + } + ] + } + ] + } + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/tag" + }, + "uniqueItems": true + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "operationId": { + "type": "string" + }, + "bindings": { + "$ref": "#/definitions/bindingsObject" + }, + "message": { + "$ref": "#/definitions/message" + } + } + }, + "message": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "oneOf": [ + { + "type": "object", + "required": [ + "oneOf" + ], + "additionalProperties": false, + "properties": { + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/message" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "schemaFormat": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "headers": { + "$ref": "#/definitions/schema" + }, + "payload": {}, + "correlationId": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/correlationId" + } + ] + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/tag" + }, + "uniqueItems": true + }, + "summary": { + "type": "string", + "description": "A brief summary of the message." + }, + "name": { + "type": "string", + "description": "Name of the message." + }, + "title": { + "type": "string", + "description": "A human-friendly title for the message." + }, + "description": { + "type": "string", + "description": "A longer description of the message. CommonMark is allowed." + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": { + "type": "object" + } + }, + "bindings": { + "$ref": "#/definitions/bindingsObject" + }, + "traits": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/messageTrait" + }, + { + "type": "array", + "items": [ + { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/messageTrait" + } + ] + }, + { + "type": "object", + "additionalItems": true + } + ] + } + ] + } + } + } + } + ] + } + ] + }, + "bindingsObject": { + "type": "object", + "additionalProperties": true, + "properties": { + "http": {}, + "ws": {}, + "amqp": {}, + "amqp1": {}, + "mqtt": {}, + "mqtt5": {}, + "kafka": {}, + "nats": {}, + "jms": {}, + "sns": {}, + "sqs": {}, + "stomp": {}, + "redis": {} + } + }, + "correlationId": { + "type": "object", + "required": [ + "location" + ], + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "description": { + "type": "string", + "description": "A optional description of the correlation ID. GitHub Flavored Markdown is allowed." + }, + "location": { + "type": "string", + "description": "A runtime expression that specifies the location of the correlation ID", + "pattern": "^\\$message\\.(header|payload)\\#(\\/(([^\\/~])|(~[01]))*)*" + } + } + }, + "specificationExtension": { + "description": "Any property starting with x- is valid.", + "additionalProperties": true, + "additionalItems": true + }, + "tag": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "operationTrait": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/tag" + }, + "uniqueItems": true + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "operationId": { + "type": "string" + }, + "bindings": { + "$ref": "#/definitions/bindingsObject" + } + } + }, + "messageTrait": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "properties": { + "schemaFormat": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "headers": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/schema" + } + ] + }, + "correlationId": { + "oneOf": [ + { + "$ref": "#/definitions/Reference" + }, + { + "$ref": "#/definitions/correlationId" + } + ] + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/tag" + }, + "uniqueItems": true + }, + "summary": { + "type": "string", + "description": "A brief summary of the message." + }, + "name": { + "type": "string", + "description": "Name of the message." + }, + "title": { + "type": "string", + "description": "A human-friendly title for the message." + }, + "description": { + "type": "string", + "description": "A longer description of the message. CommonMark is allowed." + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": { + "type": "object" + } + }, + "bindings": { + "$ref": "#/definitions/bindingsObject" + } + } + }, + "SecurityScheme": { + "oneOf": [ + { + "$ref": "#/definitions/userPassword" + }, + { + "$ref": "#/definitions/apiKey" + }, + { + "$ref": "#/definitions/X509" + }, + { + "$ref": "#/definitions/symmetricEncryption" + }, + { + "$ref": "#/definitions/asymmetricEncryption" + }, + { + "$ref": "#/definitions/HTTPSecurityScheme" + }, + { + "$ref": "#/definitions/oauth2Flows" + }, + { + "$ref": "#/definitions/openIdConnect" + } + ] + }, + "userPassword": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "userPassword" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "apiKey": { + "type": "object", + "required": [ + "type", + "in" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "apiKey" + ] + }, + "in": { + "type": "string", + "enum": [ + "user", + "password" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "X509": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "X509" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "symmetricEncryption": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "symmetricEncryption" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "asymmetricEncryption": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "asymmetricEncryption" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "HTTPSecurityScheme": { + "oneOf": [ + { + "$ref": "#/definitions/NonBearerHTTPSecurityScheme" + }, + { + "$ref": "#/definitions/BearerHTTPSecurityScheme" + }, + { + "$ref": "#/definitions/APIKeyHTTPSecurityScheme" + } + ] + }, + "NonBearerHTTPSecurityScheme": { + "not": { + "type": "object", + "properties": { + "scheme": { + "type": "string", + "enum": [ + "bearer" + ] + } + } + }, + "type": "object", + "required": [ + "scheme", + "type" + ], + "properties": { + "scheme": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "http" + ] + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "BearerHTTPSecurityScheme": { + "type": "object", + "required": [ + "type", + "scheme" + ], + "properties": { + "scheme": { + "type": "string", + "enum": [ + "bearer" + ] + }, + "bearerFormat": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "http" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "APIKeyHTTPSecurityScheme": { + "type": "object", + "required": [ + "type", + "name", + "in" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "httpApiKey" + ] + }, + "name": { + "type": "string" + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "cookie" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "oauth2Flows": { + "type": "object", + "required": [ + "type", + "flows" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "description": { + "type": "string" + }, + "flows": { + "type": "object", + "properties": { + "implicit": { + "allOf": [ + { + "$ref": "#/definitions/oauth2Flow" + }, + { + "required": [ + "authorizationUrl", + "scopes" + ] + }, + { + "not": { + "required": [ + "tokenUrl" + ] + } + } + ] + }, + "password": { + "allOf": [ + { + "$ref": "#/definitions/oauth2Flow" + }, + { + "required": [ + "tokenUrl", + "scopes" + ] + }, + { + "not": { + "required": [ + "authorizationUrl" + ] + } + } + ] + }, + "clientCredentials": { + "allOf": [ + { + "$ref": "#/definitions/oauth2Flow" + }, + { + "required": [ + "tokenUrl", + "scopes" + ] + }, + { + "not": { + "required": [ + "authorizationUrl" + ] + } + } + ] + }, + "authorizationCode": { + "allOf": [ + { + "$ref": "#/definitions/oauth2Flow" + }, + { + "required": [ + "authorizationUrl", + "tokenUrl", + "scopes" + ] + } + ] + } + }, + "additionalProperties": false, + "minProperties": 1 + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + } + }, + "oauth2Flow": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri" + }, + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "refreshUrl": { + "type": "string", + "format": "uri" + }, + "scopes": { + "$ref": "#/definitions/oauth2Scopes" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "oauth2Scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "openIdConnect": { + "type": "object", + "required": [ + "type", + "openIdConnectUrl" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "openIdConnect" + ] + }, + "description": { + "type": "string" + }, + "openIdConnectUrl": { + "type": "string", + "format": "uri" + } + }, + "patternProperties": { + "^x-[\\w\\d\\.\\-\\_]+$": { + "$ref": "#/definitions/specificationExtension" + } + }, + "additionalProperties": false + }, + "SecurityRequirement": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + } +} diff --git a/src/rulesets/reader.ts b/src/rulesets/reader.ts index 86eef2b1f3..4296e0f9cd 100644 --- a/src/rulesets/reader.ts +++ b/src/rulesets/reader.ts @@ -1,5 +1,5 @@ import { Cache } from '@stoplight/json-ref-resolver'; -import { ICache } from '@stoplight/json-ref-resolver/types'; +import { ICache, IUriParserResult } from '@stoplight/json-ref-resolver/types'; import { join } from '@stoplight/path'; import { Optional } from '@stoplight/types'; import { parse } from '@stoplight/yaml'; @@ -65,15 +65,17 @@ const createRulesetProcessor = ( dereferenceInline: false, uriCache, async parseResolveResult(opts) { - try { - opts.result = parse(opts.result); - } catch { - // happens - } - return opts; + return new Promise(resolve => { + try { + resolve({ result: parse(opts.result) }); + } catch (e) { + resolve({ error: e }); + } + }); }, }, ); + const ruleset = assertValidRuleset(JSON.parse(JSON.stringify(result))); const rules = {}; const functions = {}; diff --git a/test-harness/scenarios/aas2-streetlights.scenario b/test-harness/scenarios/aas2-streetlights.scenario new file mode 100644 index 0000000000..b8c7df402d --- /dev/null +++ b/test-harness/scenarios/aas2-streetlights.scenario @@ -0,0 +1,221 @@ +====test==== +Validate streetlights.yaml AsyncAPI 2.0 sample +====document==== +asyncapi: '2.0.0' +info: + title: Streetlights API + version: '1.0.0' + description: | + The Smartylighting Streetlights API allows you to remotely manage the city lights. + + ### Check out its awesome features: + + * Turn a specific streetlight on/off 🌃 + * Dim a specific streetlight 😎 + * Receive real-time information about environmental lighting conditions 📈 + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0 + +servers: + production: + url: test.mosquitto.org:{port} + protocol: mqtt + description: Test broker + variables: + port: + description: Secure connection (TLS) is available through port 8883. + default: '1883' + enum: + - '1883' + - '8883' + security: + - apiKey: [] + - supportedOauthFlows: + - streetlights:on + - streetlights:off + - streetlights:dim + - openIdConnectWellKnown: [] + +defaultContentType: application/json + +channels: + smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured: + description: The topic on which measured values may be produced and consumed. + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + publish: + summary: Inform about environmental lighting conditions of a particular streetlight. + operationId: receiveLightMeasurement + traits: + - $ref: '#/components/operationTraits/kafka' + message: + $ref: '#/components/messages/lightMeasured' + + smartylighting/streetlights/1/0/action/{streetlightId}/turn/on: + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + subscribe: + operationId: turnOn + traits: + - $ref: '#/components/operationTraits/kafka' + message: + $ref: '#/components/messages/turnOnOff' + + smartylighting/streetlights/1/0/action/{streetlightId}/turn/off: + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + subscribe: + operationId: turnOff + traits: + - $ref: '#/components/operationTraits/kafka' + message: + $ref: '#/components/messages/turnOnOff' + + smartylighting/streetlights/1/0/action/{streetlightId}/dim: + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + subscribe: + operationId: dimLight + traits: + - $ref: '#/components/operationTraits/kafka' + message: + $ref: '#/components/messages/dimLight' + +components: + messages: + lightMeasured: + name: lightMeasured + title: Light measured + summary: Inform about environmental lighting conditions of a particular streetlight. + contentType: application/json + traits: + - $ref: '#/components/messageTraits/commonHeaders' + payload: + $ref: "#/components/schemas/lightMeasuredPayload" + turnOnOff: + name: turnOnOff + title: Turn on/off + summary: Command a particular streetlight to turn the lights on or off. + traits: + - $ref: '#/components/messageTraits/commonHeaders' + payload: + $ref: "#/components/schemas/turnOnOffPayload" + dimLight: + name: dimLight + title: Dim light + summary: Command a particular streetlight to dim the lights. + traits: + - $ref: '#/components/messageTraits/commonHeaders' + payload: + $ref: "#/components/schemas/dimLightPayload" + + schemas: + lightMeasuredPayload: + type: object + properties: + lumens: + type: integer + minimum: 0 + description: Light intensity measured in lumens. + sentAt: + $ref: "#/components/schemas/sentAt" + turnOnOffPayload: + type: object + properties: + command: + type: string + enum: + - on + - off + description: Whether to turn on or off the light. + sentAt: + $ref: "#/components/schemas/sentAt" + dimLightPayload: + type: object + properties: + percentage: + type: integer + description: Percentage to which the light should be dimmed to. + minimum: 0 + maximum: 100 + sentAt: + $ref: "#/components/schemas/sentAt" + sentAt: + type: string + format: date-time + description: Date and time when the message was sent. + + securitySchemes: + apiKey: + type: apiKey + in: user + description: Provide your API key as the user and leave the password empty. + supportedOauthFlows: + type: oauth2 + description: Flows to support OAuth 2.0 + flows: + implicit: + authorizationUrl: 'https://authserver.example/auth' + scopes: + 'streetlights:on': Ability to switch lights on + 'streetlights:off': Ability to switch lights off + 'streetlights:dim': Ability to dim the lights + password: + tokenUrl: 'https://authserver.example/token' + scopes: + 'streetlights:on': Ability to switch lights on + 'streetlights:off': Ability to switch lights off + 'streetlights:dim': Ability to dim the lights + clientCredentials: + tokenUrl: 'https://authserver.example/token' + scopes: + 'streetlights:on': Ability to switch lights on + 'streetlights:off': Ability to switch lights off + 'streetlights:dim': Ability to dim the lights + authorizationCode: + authorizationUrl: 'https://authserver.example/auth' + tokenUrl: 'https://authserver.example/token' + refreshUrl: 'https://authserver.example/refresh' + scopes: + 'streetlights:on': Ability to switch lights on + 'streetlights:off': Ability to switch lights off + 'streetlights:dim': Ability to dim the lights + openIdConnectWellKnown: + type: openIdConnect + openIdConnectUrl: 'https://authserver.example/.well-known' + + parameters: + streetlightId: + description: The ID of the streetlight. + schema: + type: string + + messageTraits: + commonHeaders: + headers: + type: object + properties: + my-app-header: + type: integer + minimum: 0 + maximum: 100 + + operationTraits: + kafka: + bindings: + kafka: + clientId: my-app-id +====command==== +{bin} lint {document} +====stdout==== +AsyncAPI 2.x detected + +{document} + 2:6 warning asyncapi-info-contact Info object should contain `contact` object. + +✖ 1 problem (0 errors, 1 warning, 0 infos, 0 hints) diff --git a/test-harness/scenarios/rules-matching-multiple-places.scenario b/test-harness/scenarios/rules-matching-multiple-places.scenario index 6d26fe3c04..e412e512da 100644 --- a/test-harness/scenarios/rules-matching-multiple-places.scenario +++ b/test-harness/scenarios/rules-matching-multiple-places.scenario @@ -33,7 +33,7 @@ rules: 1 ====stdout==== {document} - 1:1 warning unrecognized-format The provided document does not match any of the registered formats [oas2, oas3, json-schema, json-schema-loose, json-schema-draft4, json-schema-draft6, json-schema-draft7, json-schema-2019-09] + 1:1 warning unrecognized-format The provided document does not match any of the registered formats [oas2, oas3, asyncapi2, json-schema, json-schema-loose, json-schema-draft4, json-schema-draft6, json-schema-draft7, json-schema-2019-09] 6:15 error valid-user-properties must match the pattern '/^string$/' 10:15 error valid-user-properties must match the pattern '/^string$/' 11:11 error require-user-and-address `address` property is not truthy diff --git a/test-harness/scenarios/unrecognized-format.scenario b/test-harness/scenarios/unrecognized-format.scenario index 9c97b0ff76..1ac19f4b4e 100644 --- a/test-harness/scenarios/unrecognized-format.scenario +++ b/test-harness/scenarios/unrecognized-format.scenario @@ -7,6 +7,6 @@ info: {} {bin} lint {document} ====stdout==== {document} - 1:1 warning unrecognized-format The provided document does not match any of the registered formats [oas2, oas3, json-schema, json-schema-loose, json-schema-draft4, json-schema-draft6, json-schema-draft7, json-schema-2019-09] + 1:1 warning unrecognized-format The provided document does not match any of the registered formats [oas2, oas3, asyncapi2, json-schema, json-schema-loose, json-schema-draft4, json-schema-draft6, json-schema-draft7, json-schema-2019-09] ✖ 1 problem (0 errors, 1 warning, 0 infos, 0 hints)