├── .eslintrc.js ├── .github └── workflows │ ├── codeql.yml │ └── node.js.yml ├── .gitignore ├── .markdownlint.json ├── .vscode └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── docs ├── azure-ruleset.md └── crossref.md ├── functions ├── consistent-response-body.js ├── error-response.js ├── has-header.js ├── lro-response-schema.js ├── naming-convention.js ├── operation-id.js ├── operation-security.js ├── pagination-parameters.js ├── pagination-response.js ├── param-names-unique.js ├── param-names.js ├── param-order.js ├── patch-content-type.js ├── path-param-names.js ├── path-param-schema.js ├── property-default-not-allowed.js ├── put-request-and-response-body.js ├── readonly-in-response-schema.js ├── schema-type-and-format.js ├── security-definitions.js ├── security-requirements.js ├── unused-definition.js └── version-policy.js ├── openapi-style-guide.md ├── package-lock.json ├── package.json ├── spectral.yaml └── test ├── additional-properties-object.test.js ├── boolean-naming-convention.test.js ├── consistent-response-body.test.js ├── datetime-naming-convention.test.js ├── delete-response-codes.test.js ├── error-code-response-header.test.js ├── error-response.test.js ├── header-disallowed.test.js ├── jsconfig.json ├── lro-put-response-codes.test.js ├── lro-response-codes.test.js ├── lro-response-headers.test.js ├── lro-response-schema.test.js ├── matchers.js ├── operation-id.test.js ├── operation-security.test.js ├── pagination-parameters.test.js ├── pagination-response.test.js ├── parameter-default-not-allowed.test.js ├── parameter-description.test.js ├── parameter-names-convention.test.js ├── parameter-names-unique.test.js ├── parameter-order.test.js ├── patch-content-type.test.js ├── path-case-convention.test.js ├── path-characters.test.js ├── path-param-names.test.js ├── path-param-schema.test.js ├── property-default-not-allowed.test.js ├── property-description.test.js ├── put-path.test.js ├── put-request-and-response-body.test.js ├── readonly-in-response-schema.test.js ├── request-body-optional.test.js ├── schema-type-and-format.test.js ├── security-definition-description.test.js ├── security-definitions.test.js ├── security-requirements.test.js ├── unused-definition.test.js ├── utils.js ├── version-convention.test.js └── version-policy.test.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | commonjs: true, 5 | es2021: true, 6 | jest: true, 7 | }, 8 | extends: [ 9 | 'airbnb-base', 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 12, 13 | }, 14 | rules: { 15 | 'no-restricted-syntax': 'off', 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '16 13 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 27 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 28 | permissions: 29 | actions: read 30 | contents: read 31 | security-events: write 32 | 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | language: [ 'javascript' ] 37 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] 38 | # Use only 'java' to analyze code written in Java, Kotlin or both 39 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 40 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 41 | 42 | steps: 43 | - name: Checkout repository 44 | uses: actions/checkout@v3 45 | 46 | # Initializes the CodeQL tools for scanning. 47 | - name: Initialize CodeQL 48 | uses: github/codeql-action/init@v2 49 | with: 50 | languages: ${{ matrix.language }} 51 | # If you wish to specify custom queries, you can do so here or in a config file. 52 | # By default, queries listed here will override any specified in a config file. 53 | # Prefix the list here with "+" to use these queries and those in the config file. 54 | 55 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 56 | # queries: security-extended,security-and-quality 57 | 58 | 59 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 60 | # If this step fails, then you should remove it and run the build manually (see below) 61 | - name: Autobuild 62 | uses: github/codeql-action/autobuild@v2 63 | 64 | # ℹ️ Command-line programs to run using the OS shell. 65 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 66 | 67 | # If the Autobuild fails above, remove it and uncomment the following three lines. 68 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 69 | 70 | # - run: | 71 | # echo "Run, Build Application using script" 72 | # ./location_of_script_within_repo/buildscript.sh 73 | 74 | - name: Perform CodeQL Analysis 75 | uses: github/codeql-action/analyze@v2 76 | with: 77 | category: "/language:${{matrix.language}}" 78 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | pull_request: 8 | branches: [ main ] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x, 20.x, 22.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | - run: npm ci 29 | - run: npm run build --if-present 30 | - run: npm test 31 | - run: npm run lint 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # NuGet Symbol Packages 188 | *.snupkg 189 | # The packages folder can be ignored because of Package Restore 190 | **/[Pp]ackages/* 191 | # except build/, which is used as an MSBuild target. 192 | !**/[Pp]ackages/build/ 193 | # Uncomment if necessary however generally it will be regenerated when needed 194 | #!**/[Pp]ackages/repositories.config 195 | # NuGet v3's project.json files produces more ignorable files 196 | *.nuget.props 197 | *.nuget.targets 198 | 199 | # Microsoft Azure Build Output 200 | csx/ 201 | *.build.csdef 202 | 203 | # Microsoft Azure Emulator 204 | ecf/ 205 | rcf/ 206 | 207 | # Windows Store app package directories and files 208 | AppPackages/ 209 | BundleArtifacts/ 210 | Package.StoreAssociation.xml 211 | _pkginfo.txt 212 | *.appx 213 | *.appxbundle 214 | *.appxupload 215 | 216 | # Visual Studio cache files 217 | # files ending in .cache can be ignored 218 | *.[Cc]ache 219 | # but keep track of directories ending in .cache 220 | !?*.[Cc]ache/ 221 | 222 | # Others 223 | ClientBin/ 224 | ~$* 225 | *~ 226 | *.dbmdl 227 | *.dbproj.schemaview 228 | *.jfm 229 | *.pfx 230 | *.publishsettings 231 | orleans.codegen.cs 232 | 233 | # Including strong name files can present a security risk 234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 235 | #*.snk 236 | 237 | # Since there are multiple workflows, uncomment next line to ignore bower_components 238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 239 | #bower_components/ 240 | 241 | # RIA/Silverlight projects 242 | Generated_Code/ 243 | 244 | # Backup & report files from converting an old project file 245 | # to a newer Visual Studio version. Backup files are not needed, 246 | # because we have git ;-) 247 | _UpgradeReport_Files/ 248 | Backup*/ 249 | UpgradeLog*.XML 250 | UpgradeLog*.htm 251 | ServiceFabricBackup/ 252 | *.rptproj.bak 253 | 254 | # SQL Server files 255 | *.mdf 256 | *.ldf 257 | *.ndf 258 | 259 | # Business Intelligence projects 260 | *.rdl.data 261 | *.bim.layout 262 | *.bim_*.settings 263 | *.rptproj.rsuser 264 | *- [Bb]ackup.rdl 265 | *- [Bb]ackup ([0-9]).rdl 266 | *- [Bb]ackup ([0-9][0-9]).rdl 267 | 268 | # Microsoft Fakes 269 | FakesAssemblies/ 270 | 271 | # GhostDoc plugin setting file 272 | *.GhostDoc.xml 273 | 274 | # Node.js Tools for Visual Studio 275 | .ntvs_analysis.dat 276 | node_modules/ 277 | coverage/ 278 | .eslintcache 279 | 280 | # Visual Studio 6 build log 281 | *.plg 282 | 283 | # Visual Studio 6 workspace options file 284 | *.opt 285 | 286 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 287 | *.vbw 288 | 289 | # Visual Studio LightSwitch build output 290 | **/*.HTMLClient/GeneratedArtifacts 291 | **/*.DesktopClient/GeneratedArtifacts 292 | **/*.DesktopClient/ModelManifest.xml 293 | **/*.Server/GeneratedArtifacts 294 | **/*.Server/ModelManifest.xml 295 | _Pvt_Extensions 296 | 297 | # Paket dependency manager 298 | .paket/paket.exe 299 | paket-files/ 300 | 301 | # FAKE - F# Make 302 | .fake/ 303 | 304 | # CodeRush personal settings 305 | .cr/personal 306 | 307 | # Python Tools for Visual Studio (PTVS) 308 | __pycache__/ 309 | *.pyc 310 | 311 | # Cake - Uncomment if you are using it 312 | # tools/** 313 | # !tools/packages.config 314 | 315 | # Tabs Studio 316 | *.tss 317 | 318 | # Telerik's JustMock configuration file 319 | *.jmconfig 320 | 321 | # BizTalk build output 322 | *.btp.cs 323 | *.btm.cs 324 | *.odx.cs 325 | *.xsd.cs 326 | 327 | # OpenCover UI analysis results 328 | OpenCover/ 329 | 330 | # Azure Stream Analytics local run output 331 | ASALocalRun/ 332 | 333 | # MSBuild Binary and Structured Log 334 | *.binlog 335 | 336 | # NVidia Nsight GPU debugger configuration file 337 | *.nvuser 338 | 339 | # MFractors (Xamarin productivity tool) working folder 340 | .mfractor/ 341 | 342 | # Local History for Visual Studio 343 | .localhistory/ 344 | 345 | # BeatPulse healthcheck temp database 346 | healthchecksdb 347 | 348 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 349 | MigrationBackup/ 350 | 351 | # Ionide (cross platform F# VS Code tools) working folder 352 | .ionide/ 353 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD004": { 4 | "style": "dash" 5 | }, 6 | "MD012": { 7 | "maximum": 2 8 | }, 9 | "MD013": { 10 | "line_length": 500 11 | }, 12 | "MD022": false, 13 | "MD031": false, 14 | "MD032": false, 15 | "MD033": { 16 | "allowed_elements": [ "sup" ] 17 | }, 18 | "MD036": false 19 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.eol": "\n" 3 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the Azure API Style Guide 2 | 3 | If you would like to become an active contributor to this project please follow the instructions in the 4 | [Microsoft Azure Projects Contribution Guidelines](https://opensource.microsoft.com/collaborate/). 5 | 6 | ## Issues 7 | 8 | - You are welcome to [submit an issue](https://github.com/azure/azure-api-style-guide/issues) with a bug report or a feature request. 9 | - If you are reporting a bug, please indicate which version of the package you are using and provide steps to reproduce the problem. 10 | - If you are submitting a feature request, please indicate if you are willing or able to submit a PR for it. 11 | 12 | ## Coding Style / Conventions 13 | 14 | ### JavaScript 15 | 16 | JavaScript code in this project should follow the [AirBnB JavaScript Style Guide][] as enforced by the [ESLint][] tool 17 | with the configuration file `.eslintrc.js` in the root of the project. 18 | 19 | [AirBnB JavaScript Style Guide]: https://github.com/airbnb/javascript 20 | [ESLint]: https://eslint.org/ 21 | 22 | ### Markdown 23 | 24 | Markdown files in this project should follow the style enforced by the [markdownlint tool][], 25 | as configured by the `.markdownlint.json` file in the root of the project. 26 | 27 | [markdownlint tool]: https://github.com/DavidAnson/markdownlint 28 | 29 | ### Spectral rules file 30 | 31 | Rules in the Spectral rules file `spectral.yaml` should be listed in alphabetical order by rule name. 32 | 33 | ## Building and Testing 34 | 35 | To build and test the project locally, clone the repo and issue the following commands 36 | 37 | ```sh 38 | npm install 39 | npm test 40 | ``` 41 | 42 | ## Adding new rules to the Spectral ruleset 43 | 44 | When you add a new rule there are a number of places you should consider including: 45 | 46 | - `spectral.yaml` should define the new rule, possibly pointing to a new function used by the rule. 47 | - `functions` directory to hold any new function for the rule. 48 | - `test\.test.js` should test at least the error and no-error cases of the rule. 49 | - `openapi-style-guide.md` should be updated with the style guideline that the rule enforces. 50 | - `docs/azure-ruleset.md` should describe the new rule. 51 | - `docs/crossref.md` should be updated if the rule corresponds to an `azure-openapi-validator` rule. 52 | 53 | ## Code of Conduct 54 | 55 | This project's code of conduct can be found in the 56 | [CODE_OF_CONDUCT.md file](https://github.com/Azure/azure-api-style-guide/blob/main/CODE_OF_CONDUCT.md) 57 | (v1.4.0 of the [CoC](https://contributor-covenant.org/)). 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Azure API Style Guide 2 | 3 | This repository contains a [Style Guide for OpenAPI definitions](./openapi-style-guide.md) of Azure services. 4 | The Style Guide is a companion to the [Azure API Guidelines](https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md), the [OpenAPI 2.0 specification](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/2.0.md), and the [OpenAPI 3.1 specification](https://spec.openapis.org/oas/v3.1.0). 5 | 6 | The repository also contains a [Spectral](https://github.com/stoplightio/spectral) ruleset to check 7 | an API definition for conformance to the Azure API Guidelines and this Style Guide. 8 | 9 | > **NOTE:** It is highly recommended that you leverage the Spectral rule set. Azure service teams have found Spectral to be very useful identifying many common mistakes that affect the overall quality of their Open API documentation. It's one of the first things the API Stewardship Board turns to when revieing an API specification. 10 | > 11 | > However, the errors, warnings, and info messages identified by Spectral should be evaluated in the context of *your service*, and using *your judgement*. If you have any questions, concerns, or comments, please don't hesitate to start a discussion in the [API Stewardship Teams Channel](https://teams.microsoft.com/l/channel/19%3a3ebb18fded0e47938f998e196a52952f%40thread.tacv2/General?groupId=1a10b50c-e870-4fe0-8483-bf5542a8d2d8&tenantId=72f988bf-86f1-41af-91ab-2d7cd011db47). 12 | 13 | ## How to use the Spectral Ruleset 14 | 15 | ### Dependencies 16 | 17 | The Spectral Ruleset requires Node version 20 or later. 18 | 19 | ### Install Spectral 20 | 21 | `npm i @stoplight/spectral-cli -g` 22 | 23 | ### Create a Spectral configuration file 24 | 25 | Create a Spectral configuration file (`.spectral.yaml`) that references the Azure ruleset: 26 | 27 | ```yaml 28 | extends: 29 | - https://raw.githubusercontent.com/azure/azure-api-style-guide/main/spectral.yaml 30 | ``` 31 | 32 | You can further customize the ruleset by adding your own rules or overriding existing ones. 33 | For example, to disable the `info-description` rule, the configuration file would look like this: 34 | 35 | ```yaml 36 | extends: 37 | - https://raw.githubusercontent.com/azure/azure-api-style-guide/main/spectral.yaml 38 | rules: 39 | info-description: off 40 | ``` 41 | 42 | ### Usage 43 | 44 | You can run the Spectral linter on an OpenAPI definition file using the following command: 45 | 46 | ```bash 47 | spectral lint 48 | ``` 49 | 50 | ### Example 51 | 52 | ```bash 53 | spectral lint petstore.yaml 54 | ``` 55 | 56 | ### Using the Spectral VSCode extension 57 | 58 | There is a [Spectral VSCode extension](https://marketplace.visualstudio.com/items?itemName=stoplight.spectral) that will run the Spectral linter on an open API definition file and show errors right within VSCode. You can use this ruleset with the Spectral VSCode extension. 59 | 60 | 1. Install the Spectral VSCode extension from the extensions tab in VSCode. 61 | 2. Create a Spectral configuration file (`.spectral.yaml`) in the root directory of your project 62 | as shown above. 63 | 3. Set `spectral.rulesetFile` to the name of this configuration file in your VSCode settings. 64 | 65 | Now when you open an API definition in this project, it should highlight lines with errors. 66 | You can also get a full list of problems in the file by opening the "Problems panel" with "View / Problems". In the Problems panel you can filter to show or hide errors, warnings, or infos. 67 | 68 | ## Contributing 69 | 70 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 71 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 72 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 73 | 74 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 75 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 76 | provided by the bot. You will only need to do this once across all repos using our CLA. 77 | 78 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 79 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 80 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 81 | 82 | ## Trademarks 83 | 84 | This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft 85 | trademarks or logos is subject to and must follow 86 | [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). 87 | Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. 88 | Any use of third-party trademarks or logos are subject to those third-party's policies. 89 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). 40 | 41 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | ## How to file issues and get help 4 | 5 | This project uses GitHub Issues to track bugs and feature requests. Please search the existing 6 | issues before filing new issues to avoid duplicates. For new issues, file your bug or 7 | feature request as a new Issue. 8 | 9 | ## Microsoft Support Policy 10 | 11 | Support for this project is limited to the resources listed above. 12 | -------------------------------------------------------------------------------- /functions/consistent-response-body.js: -------------------------------------------------------------------------------- 1 | // If put or patch is a create (returns 201), then verify that put, get, and patch response body 2 | // schemas are consistent. 3 | 4 | // pathItem should be a [path item object](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/2.0.md#pathItemObject). 5 | // This function assumes it is running on a resolved doc. 6 | module.exports = (pathItem, _opts, paths) => { 7 | if (pathItem === null || typeof pathItem !== 'object') { 8 | return []; 9 | } 10 | const path = paths.path || paths.target || []; 11 | 12 | const errors = []; 13 | 14 | // resource schema is create operation response schema 15 | const createResponseSchema = ((op) => op?.responses?.['201']?.schema); 16 | const resourceSchema = createResponseSchema(pathItem.put) || createResponseSchema(pathItem.patch); 17 | if (resourceSchema) { 18 | ['put', 'get', 'patch'].forEach((method) => { 19 | const responseSchema = pathItem[method]?.responses?.['200']?.schema; 20 | if (responseSchema && responseSchema !== resourceSchema) { 21 | errors.push({ 22 | message: 'Response body schema does not match create response body schema.', 23 | path: [...path, method, 'responses', '200', 'schema'], 24 | }); 25 | } 26 | }); 27 | } 28 | 29 | return errors; 30 | }; 31 | -------------------------------------------------------------------------------- /functions/error-response.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Custom function to verify that error response conforms to Microsoft Azure API Guidelines. 3 | * 4 | * Check that: 5 | * - For all error responses, validate that: 6 | * - the response contains a schema for the response body 7 | * - the response body schema conforms to Azure API guidelines 8 | * - All 4xx or 5xx responses contain x-ms-error-response: true 9 | */ 10 | 11 | function isArraySchema(schema) { 12 | return schema.type === 'array' || !!schema.items; 13 | } 14 | 15 | function isObjectSchema(schema) { 16 | // When schema contains $ref, that means it is recursive 17 | return schema.type === 'object' || !!schema.properties || schema.$ref; 18 | } 19 | 20 | // Validate that the schema conforms to Microsoft API Guidelines 21 | // https://github.com/microsoft/api-guidelines/blob/vNext/Guidelines.md#7102-error-condition-responses 22 | function validateErrorResponseSchema(errorResponseSchema, pathToSchema) { 23 | const errors = []; 24 | // The error response MUST be a single JSON object. 25 | if (!errorResponseSchema.properties) { 26 | errors.push({ 27 | message: 'Error response schema must be an object schema.', 28 | path: pathToSchema, 29 | }); 30 | return errors; 31 | } 32 | // This object MUST have a name/value pair named "error." The value MUST be a JSON object. 33 | if (!errorResponseSchema.properties.error || !errorResponseSchema.properties.error.properties) { 34 | errors.push({ 35 | message: 'Error response schema should contain an object property named `error`.', 36 | path: [...pathToSchema, 'properties', 'error'], 37 | }); 38 | return errors; 39 | } 40 | 41 | // The `error` object should be required (always present) 42 | 43 | if (!errorResponseSchema.required?.includes?.('error')) { 44 | errors.push({ 45 | message: 'The `error` property in the error response schema should be required.', 46 | path: [...pathToSchema, 'required'], 47 | }); 48 | } 49 | 50 | const errorSchema = errorResponseSchema.properties.error; 51 | const pathToErrorSchema = [...pathToSchema, 'properties', 'error']; 52 | 53 | // Spectral message dedup will drop all but first message with the same path, so we need 54 | // combine messages when they would wind up with the same path. 55 | 56 | const hasCode = !!errorSchema.properties.code; 57 | const hasMessage = !!errorSchema.properties.message; 58 | 59 | if (!hasCode && hasMessage) { 60 | errors.push({ 61 | message: 'Error schema should contain `code` property.', 62 | path: [...pathToErrorSchema, 'properties'], 63 | }); 64 | } else if (hasCode && !hasMessage) { 65 | errors.push({ 66 | message: 'Error schema should contain `message` property.', 67 | path: [...pathToErrorSchema, 'properties'], 68 | }); 69 | } else if (!hasCode && !hasMessage) { 70 | errors.push({ 71 | message: 'Error schema should contain `code` and `message` properties.', 72 | path: [...pathToErrorSchema, 'properties'], 73 | }); 74 | } 75 | 76 | if (hasCode && errorSchema.properties.code.type !== 'string') { 77 | errors.push({ 78 | message: 'The `code` property of error schema should be type `string`.', 79 | path: [...pathToErrorSchema, 'properties', 'code', 'type'], 80 | }); 81 | } 82 | 83 | if (hasMessage && errorSchema.properties.message.type !== 'string') { 84 | errors.push({ 85 | message: 'The `message` property of error schema should be type `string`.', 86 | path: [...pathToErrorSchema, 'properties', 'message', 'type'], 87 | }); 88 | } 89 | 90 | // Check if schema defines `code` and `message` as required 91 | 92 | if (['code', 'message'].every((prop) => !errorSchema.required?.includes?.(prop))) { 93 | // Either there is no required or it is missing both properties, so report both missing 94 | errors.push({ 95 | message: 'Error schema should define `code` and `message` properties as required.', 96 | path: [...pathToErrorSchema, 'required'], 97 | }); 98 | } else if (!errorSchema.required.includes('code')) { 99 | errors.push({ 100 | message: 'Error schema should define `code` property as required.', 101 | path: [...pathToErrorSchema, 'required'], 102 | }); 103 | } else if (!errorSchema.required.includes('message')) { 104 | errors.push({ 105 | message: 'Error schema should define `message` property as required.', 106 | path: [...pathToErrorSchema, 'required'], 107 | }); 108 | } 109 | 110 | // The value for the "target" name/value pair is ... the name of the property in error 111 | if (!!errorSchema.properties.target && errorSchema.properties.target.type !== 'string') { 112 | errors.push({ 113 | message: 'The `target` property of the error schema should be type `string`.', 114 | path: [...pathToErrorSchema, 'properties', 'target'], 115 | }); 116 | } 117 | 118 | // The value for the "details" name/value pair MUST be an array of JSON objects 119 | if (!!errorSchema.properties.details && !isArraySchema(errorSchema.properties.details)) { 120 | errors.push({ 121 | message: 'The `details` property of the error schema should be an array.', 122 | path: [...pathToErrorSchema, 'properties', 'details'], 123 | }); 124 | } 125 | 126 | // The value for the "innererror" name/value pair MUST be an object 127 | if (!!errorSchema.properties.innererror && !isObjectSchema(errorSchema.properties.innererror)) { 128 | errors.push({ 129 | message: 'The `innererror` property of the error schema should be an object.', 130 | path: [...pathToErrorSchema, 'properties', 'innererror'], 131 | }); 132 | } 133 | 134 | return errors; 135 | } 136 | 137 | function validateErrorResponse(errorResponse, responsePath) { 138 | const errors = []; 139 | 140 | // The error response schema should conform to Microsoft API Guidelines 141 | if (!errorResponse.schema) { 142 | const method = responsePath[responsePath.length - 3]; 143 | if (method !== 'head') { 144 | errors.push({ 145 | message: 'Error response should have a schema.', 146 | path: responsePath, 147 | }); 148 | } 149 | } else { 150 | errors.push( 151 | ...validateErrorResponseSchema(errorResponse.schema, [...responsePath, 'schema']), 152 | ); 153 | } 154 | 155 | return errors; 156 | } 157 | 158 | module.exports = function errorResponse(responses, _opts, paths) { 159 | const errors = []; 160 | const path = paths.path || paths.target || []; 161 | 162 | // Note: az-default-response rule will flag missing default response 163 | if (responses.default) { 164 | errors.push( 165 | ...validateErrorResponse(responses.default, [...path, 'default']), 166 | ); 167 | } 168 | 169 | Object.keys(responses).filter((code) => code.match(/[45]\d\d/)).forEach((code) => { 170 | errors.push( 171 | ...validateErrorResponse(responses[code], [...path, code]), 172 | ); 173 | 174 | // The error response should contain x-ms-error-response: true 175 | if (!(responses[code]['x-ms-error-response'])) { 176 | errors.push({ 177 | message: 'Error response should contain x-ms-error-response.', 178 | path: [...path, code], 179 | }); 180 | } 181 | }); 182 | 183 | return errors; 184 | }; 185 | -------------------------------------------------------------------------------- /functions/has-header.js: -------------------------------------------------------------------------------- 1 | // Check a response to ensure it has a specific header. 2 | // Comparison must be done case-insensitively since that is the HTTP rule. 3 | 4 | module.exports = (response, opts, paths) => { 5 | if (response === null || typeof response !== 'object') { 6 | return []; 7 | } 8 | 9 | // opts must contain the name of the header to check for 10 | if (opts === null || typeof opts !== 'object' || !opts.name) { 11 | return []; 12 | } 13 | 14 | const path = paths.path || paths.target || []; 15 | 16 | const hasHeader = Object.keys(response.headers || {}) 17 | .some((name) => name.toLowerCase() === opts.name.toLowerCase()); 18 | 19 | if (!hasHeader) { 20 | return [ 21 | { 22 | message: `Response should include an "${opts.name}" response header.`, 23 | path: [...path, 'headers'], 24 | }, 25 | ]; 26 | } 27 | 28 | return []; 29 | }; 30 | -------------------------------------------------------------------------------- /functions/lro-response-schema.js: -------------------------------------------------------------------------------- 1 | // Check conformance to Azure guidelines for 202 responses: 2 | // - A 202 response should have a response body schema 3 | // - The response body schema should contain `id`, `status`, and `error` properties. 4 | // - The `id`, `status`, and `error` properties should be required. 5 | // - The `id` property should be type: string. 6 | // - The `status` property should be type: string and enum with values: 7 | // - "Running", "Succeeded", "Failed", "Cancelled". 8 | // - The `error` property should be type: object and not required. 9 | 10 | // Rule target is a 202 response 11 | module.exports = (lroResponse, _opts, context) => { 12 | // defensive programming - make sure we have an object 13 | if (lroResponse === null || typeof lroResponse !== 'object') { 14 | return []; 15 | } 16 | 17 | const lroResponseSchema = lroResponse.schema; 18 | 19 | // A 202 response should include a schema for the operation status monitor. 20 | if (!lroResponseSchema) { 21 | return [{ 22 | message: 'A 202 response should include a schema for the operation status monitor.', 23 | path: context.path || [], 24 | }]; 25 | } 26 | 27 | const path = [...(context.path || []), 'schema']; 28 | 29 | const errors = []; 30 | 31 | // - The `id`, `status`, and `error` properties should be required. 32 | const requiredProperties = new Set(lroResponseSchema.required || []); 33 | const checkRequiredProperty = (prop) => { 34 | if (!requiredProperties.has(prop)) { 35 | errors.push({ 36 | message: `\`${prop}\` property in LRO response should be required`, 37 | path: [...path, 'required'], 38 | }); 39 | } 40 | }; 41 | 42 | // Check id property 43 | if (lroResponseSchema.properties && 'id' in lroResponseSchema.properties) { 44 | if (lroResponseSchema.properties.id.type !== 'string') { 45 | errors.push({ 46 | message: '\'id\' property in LRO response should be type: string', 47 | path: [...path, 'properties', 'id', 'type'], 48 | }); 49 | } 50 | checkRequiredProperty('id'); 51 | } else { 52 | errors.push({ 53 | message: 'LRO response should contain top-level property `id`', 54 | path: [...path, 'properties'], 55 | }); 56 | } 57 | 58 | // Check status property 59 | if (lroResponseSchema.properties && 'status' in lroResponseSchema.properties) { 60 | if (lroResponseSchema.properties.status.type !== 'string') { 61 | errors.push({ 62 | message: '`status` property in LRO response should be type: string', 63 | path: [...path, 'properties', 'status', 'type'], 64 | }); 65 | } 66 | checkRequiredProperty('status'); 67 | const statusValues = new Set(lroResponseSchema.properties.status.enum || []); 68 | const requiredStatusValues = ['Running', 'Succeeded', 'Failed', 'Canceled']; 69 | if (!requiredStatusValues.every((value) => statusValues.has(value))) { 70 | errors.push({ 71 | message: `'status' property enum in LRO response should contain values: ${requiredStatusValues.join(', ')}`, 72 | path: [...path, 'properties', 'status', 'enum'], 73 | }); 74 | } 75 | } else { 76 | errors.push({ 77 | message: 'LRO response should contain top-level property `status`', 78 | path: [...path, 'properties'], 79 | }); 80 | } 81 | 82 | // Check error property 83 | if (lroResponseSchema.properties && 'error' in lroResponseSchema.properties) { 84 | if (lroResponseSchema.properties.error.type !== 'object') { 85 | errors.push({ 86 | message: '`error` property in LRO response should be type: object', 87 | path: [...path, 'properties', 'error', 'type'], 88 | }); 89 | } 90 | if (requiredProperties.has('error')) { 91 | errors.push({ 92 | message: '`error` property in LRO response should not be required', 93 | path: [...path, 'required'], 94 | }); 95 | } 96 | } else { 97 | errors.push({ 98 | message: 'LRO response should contain top-level property `error`', 99 | path: [...path, 'properties'], 100 | }); 101 | } 102 | 103 | return errors; 104 | }; 105 | -------------------------------------------------------------------------------- /functions/naming-convention.js: -------------------------------------------------------------------------------- 1 | // Check naming convention. 2 | 3 | // options: 4 | // type: 'boolean' | 'date-time' 5 | // match: RegExp 6 | // notMatch: RegExp 7 | 8 | /* eslint no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ 9 | 10 | const { pattern } = require('@stoplight/spectral-functions'); 11 | 12 | function isBooleanSchema(schema) { 13 | return schema.type === 'boolean'; 14 | } 15 | 16 | function isDateTimeSchema(schema) { 17 | return schema.type === 'string' && schema.format === 'date-time'; 18 | } 19 | 20 | function isSchemaType(type) { 21 | switch (type) { 22 | case 'boolean': return isBooleanSchema; 23 | case 'date-time': return isDateTimeSchema; 24 | default: return (_) => false; 25 | } 26 | } 27 | 28 | // Check all property names in the schema comply with the naming convention. 29 | function propertyNamingConvention(schema, options, path) { 30 | const errors = []; 31 | 32 | const { type, ...patternOpts } = options; 33 | const isType = isSchemaType(type); 34 | 35 | // Check property names 36 | for (const name of schema.properties ? Object.keys(schema.properties) : []) { 37 | if (isType(schema.properties[name]) && pattern(name, patternOpts)) { 38 | errors.push({ 39 | message: `property "${name}" does not follow ${options.type} naming convention`, 40 | path: [...path, 'properties', name], 41 | }); 42 | } 43 | } 44 | 45 | if (schema.items) { 46 | errors.push( 47 | ...propertyNamingConvention(schema.items, options, [...path, 'items']), 48 | ); 49 | } 50 | 51 | for (const applicator of ['allOf', 'anyOf', 'oneOf']) { 52 | if (schema[applicator] && Array.isArray(schema[applicator])) { 53 | for (const [index, value] of schema[applicator].entries()) { 54 | errors.push( 55 | ...propertyNamingConvention(value, options, [...path, applicator, index]), 56 | ); 57 | } 58 | } 59 | } 60 | 61 | return errors; 62 | } 63 | 64 | // input is ignored -- we take the whole document as input 65 | // Rule is run on resolved doc. 66 | module.exports = (input, options, _context) => { 67 | const oasDoc = input; 68 | 69 | const oas2 = oasDoc.swagger === '2.0'; 70 | const oas3 = oasDoc.openapi?.startsWith('3.') || false; 71 | 72 | const { type, ...patternOpts } = options; 73 | const isType = isSchemaType(type); 74 | 75 | const errors = []; 76 | 77 | // Check all property names in the schema comply with the naming convention. 78 | for (const pathKey of Object.keys(oasDoc.paths)) { 79 | const pathItem = oasDoc.paths[pathKey]; 80 | for (const opMethod of ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']) { 81 | if (pathItem[opMethod]) { 82 | const op = pathItem[opMethod]; 83 | 84 | // Processing for oas2 documents 85 | if (oas2) { 86 | // Check the oas2 parameters 87 | for (let i = 0; i < op.parameters?.length || 0; i += 1) { 88 | const param = op.parameters[i]; 89 | if (param.in !== 'body' && isType(param) && pattern(param.name, patternOpts)) { 90 | errors.push({ 91 | message: `parameter "${param.name}" does not follow ${options.type} naming convention`, 92 | path: ['paths', pathKey, opMethod, 'parameters', i, 'name'], 93 | }); 94 | } 95 | } 96 | // Check the oas2 body parameter 97 | const bodyParam = op.parameters?.find((p) => p.in === 'body'); 98 | if (bodyParam) { 99 | const bodyIndex = op.parameters.indexOf(bodyParam); 100 | errors.push( 101 | ...propertyNamingConvention(bodyParam.schema, options, ['paths', pathKey, opMethod, 'parameters', bodyIndex, 'schema']), 102 | ); 103 | } 104 | // Check the oas2 responses 105 | for (const [responseKey, response] of Object.entries(op.responses)) { 106 | if (response.schema) { 107 | errors.push( 108 | ...propertyNamingConvention(response.schema, options, ['paths', pathKey, opMethod, 'responses', responseKey, 'schema']), 109 | ); 110 | } 111 | } 112 | } 113 | 114 | // Processing for oas3 documents 115 | if (oas3) { 116 | // Check the oas3 parameters 117 | for (let i = 0; i < op.parameters?.length || 0; i += 1) { 118 | const param = op.parameters[i]; 119 | if (param.schema && isType(param.schema) && pattern(param.name, patternOpts)) { 120 | errors.push({ 121 | message: `parameter "${param.name}" does not follow ${options.type} naming convention`, 122 | path: ['paths', pathKey, opMethod, 'parameters', i, 'name'], 123 | }); 124 | } 125 | } 126 | // Check the oas3 requestBody 127 | if (op.requestBody?.content) { 128 | for (const [contentTypeKey, contentType] of Object.entries(op.requestBody.content)) { 129 | if (contentType.schema) { 130 | errors.push( 131 | ...propertyNamingConvention(contentType.schema, options, ['paths', pathKey, opMethod, 'requestBody', 'content', contentTypeKey, 'schema']), 132 | ); 133 | } 134 | } 135 | } 136 | 137 | // Check the oas3 responses 138 | if (op.responses) { 139 | for (const [responseKey, response] of Object.entries(op.responses)) { 140 | if (response.content) { 141 | for (const [contentTypeKey, contentType] of Object.entries(response.content)) { 142 | if (contentType.schema) { 143 | errors.push( 144 | ...propertyNamingConvention(contentType.schema, options, ['paths', pathKey, opMethod, 'responses', responseKey, 'content', contentTypeKey, 'schema']), 145 | ); 146 | } 147 | } 148 | } 149 | } 150 | } 151 | } 152 | } 153 | } 154 | } 155 | 156 | return errors; 157 | }; 158 | -------------------------------------------------------------------------------- /functions/operation-id.js: -------------------------------------------------------------------------------- 1 | // Check conformance to Azure operationId conventions: 2 | // - operationIds should have the form "noun_verb" with just one underscore separator [R1001, R2055] 3 | // - get operation on a collection should have "list" in the operationId verb 4 | // - get operation on a single instance should have "get" in the operationId verb 5 | // - put operation that returns 201 should have "create" in the operationId verb 6 | // - put operation that returns 200 should have "replace" in the operationId verb 7 | // - put operation that returns 200 should not have "update" in the operationId verb 8 | // - patch operation that returns 201 should have "create" in the operationId verb 9 | // - patch operation that returns 200 should have "update" in the operationId verb 10 | // - patch operation should not have "patch" in the operationId verb 11 | // - post operation should not have "post" in the operationId verb 12 | // - delete operation should have "delete" in the operationId verb 13 | 14 | module.exports = (operation, _opts, paths) => { 15 | // targetVal should be an operation 16 | if (operation === null || typeof operation !== 'object') { 17 | return []; 18 | } 19 | const path = paths.path || paths.target || []; 20 | 21 | const errors = []; 22 | 23 | if (!operation.operationId) { 24 | // Missing operationId is caught elsewhere, so just return 25 | return errors; 26 | } 27 | 28 | const m = operation.operationId.match(/[A-Za-z0-9]+_([A-Za-z0-9]+)/); 29 | if (!m) { 30 | errors.push({ 31 | message: 'OperationId should be of the form "Noun_Verb"', 32 | path: [...path, 'operationId'], 33 | }); 34 | } 35 | 36 | const verb = m ? m[1] : operation.operationId; 37 | const method = path[path.length - 1]; 38 | const statusCodes = operation.responses ? Object.keys(operation.responses) : []; 39 | 40 | if (method === 'get') { 41 | const opPath = path[path.length - 2]; 42 | const pathIsCollection = !opPath.endsWith('}'); 43 | if (pathIsCollection) { 44 | if (!verb.match(/list/i)) { 45 | errors.push({ 46 | message: 'OperationId for get on a collection should contain "list"', 47 | path: [...path, 'operationId'], 48 | }); 49 | } 50 | } else if (!verb.match(/get/i)) { 51 | errors.push({ 52 | message: 'OperationId for get on a single object should contain "get"', 53 | path: [...path, 'operationId'], 54 | }); 55 | } 56 | } else if (method === 'put') { 57 | if (statusCodes.includes('200') && statusCodes.includes('201')) { 58 | if (!verb.match(/create/i) || !verb.match(/replace/i)) { 59 | errors.push({ 60 | message: 'OperationId for put with 200 and 201 responses should contain "create" and "replace"', 61 | path: [...path, 'operationId'], 62 | }); 63 | } 64 | } else if (statusCodes.includes('200') && !statusCodes.includes('201')) { 65 | if (!verb.match(/replace/i)) { 66 | errors.push({ 67 | message: 'OperationId for put with 200 response should contain "replace"', 68 | path: [...path, 'operationId'], 69 | }); 70 | } 71 | if (verb.match(/create/i)) { 72 | errors.push({ 73 | message: 'OperationId for put without 201 response should not contain "create"', 74 | path: [...path, 'operationId'], 75 | }); 76 | } 77 | } else if (statusCodes.includes('201') && !statusCodes.includes('200')) { 78 | if (!verb.match(/create/i)) { 79 | errors.push({ 80 | message: 'OperationId for put with 201 response should contain "create"', 81 | path: [...path, 'operationId'], 82 | }); 83 | } 84 | if (verb.match(/replace/i)) { 85 | errors.push({ 86 | message: 'OperationId for put without 200 response should not contain "replace"', 87 | path: [...path, 'operationId'], 88 | }); 89 | } 90 | } 91 | 92 | // Anti-patterns 93 | 94 | // operationId for put should not contain "update" 95 | const update = verb.match(/update/i)?.[0]; 96 | if (update) { 97 | errors.push({ 98 | message: `OperationId for put should not contain "${update}"`, 99 | path: [...path, 'operationId'], 100 | }); 101 | } 102 | 103 | // operationId for put should not contain "put" 104 | const put = verb.match(/put/i)?.[0]; 105 | if (put) { 106 | errors.push({ 107 | message: `OperationId for put should not contain "${put}"`, 108 | path: [...path, 'operationId'], 109 | }); 110 | } 111 | } else if (method === 'patch') { 112 | if (statusCodes.includes('200') && statusCodes.includes('201')) { 113 | if (!verb.match(/create/i) || !verb.match(/update/i)) { 114 | errors.push({ 115 | message: 'OperationId for patch with 200 and 201 responses should contain "create" and "update"', 116 | path: [...path, 'operationId'], 117 | }); 118 | } 119 | } else if (statusCodes.includes('200') && !statusCodes.includes('201')) { 120 | if (!verb.match(/update/i)) { 121 | errors.push({ 122 | message: 'OperationId for patch with 200 response should contain "update"', 123 | path: [...path, 'operationId'], 124 | }); 125 | } 126 | if (verb.match(/create/i)) { 127 | errors.push({ 128 | message: 'OperationId for patch without 201 response should not contain "create"', 129 | path: [...path, 'operationId'], 130 | }); 131 | } 132 | } else if (statusCodes.includes('201') && !statusCodes.includes('200')) { 133 | if (!verb.match(/create/i)) { 134 | errors.push({ 135 | message: 'OperationId for patch with 201 response should contain "create"', 136 | path: [...path, 'operationId'], 137 | }); 138 | } 139 | if (verb.match(/update/i)) { 140 | errors.push({ 141 | message: 'OperationId for patch without 200 response should not contain "update"', 142 | path: [...path, 'operationId'], 143 | }); 144 | } 145 | } 146 | 147 | // Anti-patterns 148 | 149 | // operationId for patch should not contain "patch" 150 | const patch = verb.match(/patch/i)?.[0]; 151 | if (patch) { 152 | errors.push({ 153 | message: `OperationId for patch should not contain "${patch}"`, 154 | path: [...path, 'operationId'], 155 | }); 156 | } 157 | } else if (method === 'post') { 158 | // operationId for post should not contain "post" 159 | const post = verb.match(/post/i)?.[0]; 160 | if (post) { 161 | errors.push({ 162 | message: `OperationId for post should not contain "${post}"`, 163 | path: [...path, 'operationId'], 164 | }); 165 | } 166 | } else if (method === 'delete') { 167 | if (!verb.match(/delete/i)) { 168 | errors.push({ 169 | message: 'OperationId for delete should contain "delete"', 170 | path: [...path, 'operationId'], 171 | }); 172 | } 173 | } 174 | 175 | return errors; 176 | }; 177 | -------------------------------------------------------------------------------- /functions/operation-security.js: -------------------------------------------------------------------------------- 1 | // Check API definition to ensure conformance to Azure security schemes guidelines. 2 | 3 | // Check: 4 | // - Operation (input) has a `security`, or there is a global `security`. 5 | 6 | // @param input - an operation 7 | module.exports = (input, _, context) => { 8 | if (input === null || typeof input !== 'object') { 9 | return []; 10 | } 11 | 12 | // If there is a global `security`, no need to check the operation. 13 | if (context.document.data.security) { 14 | return []; 15 | } 16 | 17 | const path = context.path || []; 18 | 19 | if (!input.security) { 20 | return [{ 21 | message: 'Operation should have a security requirement.', 22 | path: [...path, 'security'], 23 | }]; 24 | } 25 | 26 | return []; 27 | }; 28 | -------------------------------------------------------------------------------- /functions/pagination-parameters.js: -------------------------------------------------------------------------------- 1 | // Check conformance to Azure guidelines for pagination parameters: 2 | // - if present, `top` must be an integer, optional, with no default value 3 | // - if present, `skip` must be an integer, optional, with a default value of 0 4 | // - if present, `maxpagesize` must be an integer, optional, with no default value 5 | // - if present, `filter` must be a string and optional 6 | // - if present, `orderby` should be be an array of strings and optional 7 | // - if present, `select` should be be an array of strings and optional 8 | // - if present, `expand` should be be an array of strings and optional 9 | 10 | module.exports = (operation, _opts, paths) => { 11 | // operation should be a get or post operation 12 | if (operation === null || typeof operation !== 'object') { 13 | return []; 14 | } 15 | const path = paths.path || paths.target || []; 16 | 17 | // If the operation has no parameters, there is nothing to check 18 | if (!operation.parameters) { 19 | return []; 20 | } 21 | 22 | const errors = []; 23 | 24 | // Check the top parameter 25 | const topIndex = operation.parameters.findIndex((param) => param.name?.toLowerCase() === 'top'); 26 | if (topIndex !== -1) { 27 | const top = operation.parameters[topIndex]; 28 | // Improper casing of top will be flagged by the az-parameter-names-convention rule 29 | // Check that top is an integer 30 | if (top.type !== 'integer') { 31 | errors.push({ 32 | message: 'top parameter must be type: integer', 33 | path: [...path, 'parameters', topIndex, 'type'], 34 | }); 35 | } 36 | // Check that top is optional 37 | if (top.required) { 38 | errors.push({ 39 | message: 'top parameter must be optional', 40 | path: [...path, 'parameters', topIndex, 'required'], 41 | }); 42 | } 43 | // Check that top has no default value 44 | if (top.default !== undefined) { 45 | errors.push({ 46 | message: 'top parameter must have no default value', 47 | path: [...path, 'parameters', topIndex, 'default'], 48 | }); 49 | } 50 | } 51 | 52 | // Check skip parameter 53 | const skipIndex = operation.parameters.findIndex((param) => param.name?.toLowerCase() === 'skip'); 54 | if (skipIndex !== -1) { 55 | const skip = operation.parameters[skipIndex]; 56 | // Improper casing of skip will be flagged by the az-parameter-names-convention rule 57 | // Check that skip is an integer 58 | if (skip.type !== 'integer') { 59 | errors.push({ 60 | message: 'skip parameter must be type: integer', 61 | path: [...path, 'parameters', skipIndex, 'type'], 62 | }); 63 | } 64 | // Check that skip is optional 65 | if (skip.required) { 66 | errors.push({ 67 | message: 'skip parameter must be optional', 68 | path: [...path, 'parameters', skipIndex, 'required'], 69 | }); 70 | } 71 | // Check that skip has a default value of 0 72 | if (skip.default !== 0) { 73 | errors.push({ 74 | message: 'skip parameter must have a default value of 0', 75 | path: [...path, 'parameters', skipIndex, 'default'], 76 | }); 77 | } 78 | } 79 | 80 | // Check maxpagesize parameter 81 | const maxpagesizeIndex = operation.parameters.findIndex((param) => param.name?.toLowerCase() === 'maxpagesize'); 82 | if (maxpagesizeIndex !== -1) { 83 | const maxpagesize = operation.parameters[maxpagesizeIndex]; 84 | // Check case convention for maxpagesize 85 | if (maxpagesize.name !== 'maxpagesize') { 86 | errors.push({ 87 | message: 'maxpagesize parameter must be named "maxpagesize" (all lowercase)', 88 | path: [...path, 'parameters', maxpagesizeIndex, 'name'], 89 | }); 90 | } 91 | // Check that maxpagesize is an integer 92 | if (maxpagesize.type !== 'integer') { 93 | errors.push({ 94 | message: 'maxpagesize parameter must be type: integer', 95 | path: [...path, 'parameters', maxpagesizeIndex, 'type'], 96 | }); 97 | } 98 | // Check that maxpagesize is optional 99 | if (maxpagesize.required) { 100 | errors.push({ 101 | message: 'maxpagesize parameter must be optional', 102 | path: [...path, 'parameters', maxpagesizeIndex, 'required'], 103 | }); 104 | } 105 | // Check that maxpagesize has no default value 106 | if (maxpagesize.default !== undefined) { 107 | errors.push({ 108 | message: 'maxpagesize parameter must have no default value', 109 | path: [...path, 'parameters', maxpagesizeIndex, 'default'], 110 | }); 111 | } 112 | } 113 | 114 | // Check filter parameter 115 | const filterIndex = operation.parameters.findIndex((param) => param.name?.toLowerCase() === 'filter'); 116 | if (filterIndex !== -1) { 117 | const filter = operation.parameters[filterIndex]; 118 | // Improper casing of filter will be flagged by the az-parameter-names-convention rule 119 | // Check that filter is a string 120 | if (filter.type !== 'string') { 121 | errors.push({ 122 | message: 'filter parameter must be type: string', 123 | path: [...path, 'parameters', filterIndex, 'type'], 124 | }); 125 | } 126 | // Check that filter is optional 127 | if (filter.required) { 128 | errors.push({ 129 | message: 'filter parameter must be optional', 130 | path: [...path, 'parameters', filterIndex, 'required'], 131 | }); 132 | } 133 | } 134 | 135 | // Check orderby parameter 136 | const orderbyIndex = operation.parameters.findIndex((param) => param.name?.toLowerCase() === 'orderby'); 137 | if (orderbyIndex !== -1) { 138 | const orderby = operation.parameters[orderbyIndex]; 139 | // Check case convention for orderby 140 | if (orderby.name !== 'orderby') { 141 | errors.push({ 142 | message: 'orderby parameter must be named "orderby" (all lowercase)', 143 | path: [...path, 'parameters', orderbyIndex, 'name'], 144 | }); 145 | } 146 | // Check that orderby is an array of strings 147 | if (orderby.type !== 'array' || orderby.items?.type !== 'string') { 148 | errors.push({ 149 | message: 'orderby parameter must be type: array with items of type: string', 150 | path: [...path, 'parameters', orderbyIndex, 'type'], 151 | }); 152 | } 153 | // Check that orderby is optional 154 | if (orderby.required) { 155 | errors.push({ 156 | message: 'orderby parameter must be optional', 157 | path: [...path, 'parameters', orderbyIndex, 'required'], 158 | }); 159 | } 160 | } 161 | 162 | // Check select parameter 163 | const selectIndex = operation.parameters.findIndex((param) => param.name?.toLowerCase() === 'select'); 164 | if (selectIndex !== -1) { 165 | const select = operation.parameters[selectIndex]; 166 | // Improper casing of select will be flagged by the az-parameter-names-convention rule 167 | // Check that select is an array of strings 168 | if (select.type !== 'array' || select.items?.type !== 'string') { 169 | errors.push({ 170 | message: 'select parameter must be type: array with items of type: string', 171 | path: [...path, 'parameters', selectIndex, 'type'], 172 | }); 173 | } 174 | // Check that select is optional 175 | if (select.required) { 176 | errors.push({ 177 | message: 'select parameter must be optional', 178 | path: [...path, 'parameters', selectIndex, 'required'], 179 | }); 180 | } 181 | } 182 | 183 | // Check expand parameter 184 | const expandIndex = operation.parameters.findIndex((param) => param.name?.toLowerCase() === 'expand'); 185 | if (expandIndex !== -1) { 186 | const expand = operation.parameters[expandIndex]; 187 | // Improper casing of expand will be flagged by the az-parameter-names-convention rule 188 | // Check that expand is an array of strings 189 | if (expand.type !== 'array' || expand.items?.type !== 'string') { 190 | errors.push({ 191 | message: 'expand parameter must be type: array with items of type: string', 192 | path: [...path, 'parameters', expandIndex, 'type'], 193 | }); 194 | } 195 | // Check that expand is optional 196 | if (expand.required) { 197 | errors.push({ 198 | message: 'expand parameter must be optional', 199 | path: [...path, 'parameters', expandIndex, 'required'], 200 | }); 201 | } 202 | } 203 | 204 | return errors; 205 | }; 206 | -------------------------------------------------------------------------------- /functions/pagination-response.js: -------------------------------------------------------------------------------- 1 | // Check conformance to Azure guidelines for paginated responses: 2 | // - The operation should have the `x-ms-pageable` annotation [R2029] 3 | // - The response should contain a top-level `value` property of type array and required 4 | // - The response should contain a top-level `nextLink` property of type string and optional [R4012] 5 | 6 | module.exports = (operation, _opts, paths) => { 7 | // operation should be a get or post operation 8 | if (operation === null || typeof operation !== 'object') { 9 | return []; 10 | } 11 | const path = paths.path || paths.target || []; 12 | 13 | // responses is required property of an operation in OpenAPI 2.0, so if 14 | // isn't present this will be flagged elsewhere -- just return; 15 | if (!operation.responses || typeof operation.responses !== 'object') { 16 | return []; 17 | } 18 | 19 | // Find success response code 20 | const resp = Object.keys(operation.responses) 21 | .find((code) => code.startsWith('2')); 22 | 23 | // No success response will be flagged elsewhere, just return 24 | if (!resp) { 25 | return []; 26 | } 27 | 28 | // Get the schema of the success response 29 | const responseSchema = operation.responses[resp].schema || {}; 30 | 31 | const errors = []; 32 | 33 | if (operation['x-ms-pageable']) { 34 | // Check value property 35 | if (responseSchema.properties && 'value' in responseSchema.properties) { 36 | if (responseSchema.properties.value.type !== 'array') { 37 | errors.push({ 38 | message: '`value` property in pageable response should be type: array', 39 | path: [...path, 'responses', resp, 'schema', 'properties', 'value', 'type'], 40 | }); 41 | } 42 | if (!(responseSchema.required?.includes('value'))) { 43 | errors.push({ 44 | message: '`value` property in pageable response should be required', 45 | path: [...path, 'responses', resp, 'schema', 'required'], 46 | }); 47 | } 48 | } else if (!responseSchema.allOf) { // skip error for missing value -- it might be in allOf 49 | errors.push({ 50 | message: 'Response body schema of pageable response should contain top-level array property `value`', 51 | path: [...path, 'responses', resp, 'schema', 'properties'], 52 | }); 53 | } 54 | // Check nextLink property 55 | const nextLinkName = operation['x-ms-pageable'].nextLinkName || 'nextLink'; 56 | if (responseSchema.properties && nextLinkName in responseSchema.properties) { 57 | const nextLinkProperty = responseSchema.properties[nextLinkName]; 58 | if (nextLinkProperty.type !== 'string') { 59 | errors.push({ 60 | message: `\`${nextLinkName}\` property in pageable response should be type: string`, 61 | path: [...path, 'responses', resp, 'schema', 'properties', nextLinkName, 'type'], 62 | }); 63 | } else if (nextLinkProperty.format !== 'uri' && nextLinkProperty.format !== 'url') { 64 | // Allow "uri" or "url", but prefer "uri" 65 | errors.push({ 66 | message: `\`${nextLinkName}\` property in pageable response should be format: uri`, 67 | path: [...path, 'responses', resp, 'schema', 'properties', nextLinkName, 'format'], 68 | }); 69 | } 70 | if (responseSchema.required?.includes(nextLinkName)) { 71 | errors.push({ 72 | message: `\`${nextLinkName}\` property in pageable response should be optional.`, 73 | path: [...path, 'responses', resp, 'schema', 'required'], 74 | }); 75 | } 76 | } else if (!responseSchema.allOf) { // skip error for missing nextLink -- it might be in allOf 77 | errors.push({ 78 | message: `Response body schema of pageable response should contain top-level property \`${nextLinkName}\``, 79 | path: [...path, 'responses', resp, 'schema', 'properties'], 80 | }); 81 | } 82 | } else { 83 | const responseHasArray = Object.values(responseSchema.properties || {}) 84 | .some((prop) => prop.type === 'array'); 85 | 86 | // Why 3? [value, nextLink, count] 87 | if (responseHasArray && Object.keys(responseSchema.properties).length <= 3) { 88 | errors.push({ 89 | message: 'Operation might be pageable. Consider adding the x-ms-pageable extension.', 90 | path, 91 | }); 92 | } 93 | } 94 | 95 | return errors; 96 | }; 97 | -------------------------------------------------------------------------------- /functions/param-names-unique.js: -------------------------------------------------------------------------------- 1 | // Check that the parameters of an operation -- including those specified on the path -- are 2 | // are case-insensitive unique regardless of "in". 3 | 4 | // Return the "canonical" casing for a string. 5 | // Currently just lowercase but should be extended to convert kebab/camel/snake/Pascal. 6 | function canonical(name) { 7 | return typeof (name) === 'string' ? name.toLowerCase() : name; 8 | } 9 | 10 | // Accept an array and return a list of unique duplicate entries in canonical form. 11 | // This function is intended to work on strings but is resilient to non-strings. 12 | function dupIgnoreCase(arr) { 13 | if (!Array.isArray(arr)) { 14 | return []; 15 | } 16 | 17 | const isDup = (value, index, self) => self.indexOf(value) !== index; 18 | 19 | return [...new Set(arr.map((v) => canonical(v)).filter(isDup))]; 20 | } 21 | 22 | // targetVal should be a [path item object](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/2.0.md#pathItemObject). 23 | // The code assumes it is running on a resolved doc 24 | module.exports = (pathItem, _opts, paths) => { 25 | if (pathItem === null || typeof pathItem !== 'object') { 26 | return []; 27 | } 28 | const path = paths.path || paths.target || []; 29 | 30 | const errors = []; 31 | 32 | const pathParams = pathItem.parameters ? pathItem.parameters.map((p) => p.name) : []; 33 | 34 | // Check path params for dups 35 | const pathDups = dupIgnoreCase(pathParams); 36 | 37 | // Report all dups 38 | pathDups.forEach((dup) => { 39 | // get the index of all names that match dup 40 | const dupKeys = [...pathParams.keys()].filter((k) => canonical(pathParams[k]) === dup); 41 | // Report errors for all the others 42 | dupKeys.slice(1).forEach((key) => { 43 | errors.push({ 44 | message: `Duplicate parameter name (ignoring case): ${dup}.`, 45 | path: [...path, 'parameters', key, 'name'], 46 | }); 47 | }); 48 | }); 49 | 50 | ['get', 'post', 'put', 'patch', 'delete', 'options', 'head'].forEach((method) => { 51 | // If this method exists and it has parameters, check them 52 | if (pathItem[method] && Array.isArray(pathItem[method].parameters)) { 53 | const allParams = [...pathParams, ...pathItem[method].parameters.map((p) => p.name)]; 54 | 55 | // Check method params for dups -- including path params 56 | const dups = dupIgnoreCase(allParams); 57 | 58 | // Report all dups 59 | dups.forEach((dup) => { 60 | // get the index of all names that match dup 61 | const dupKeys = [...allParams.keys()].filter((k) => canonical(allParams[k]) === dup); 62 | // Report errors for any others that are method parameters 63 | dupKeys.slice(1).filter((k) => k >= pathParams.length).forEach((key) => { 64 | errors.push({ 65 | message: `Duplicate parameter name (ignoring case): ${dup}.`, 66 | path: [...path, method, 'parameters', key - pathParams.length, 'name'], 67 | }); 68 | }); 69 | }); 70 | } 71 | }); 72 | 73 | return errors; 74 | }; 75 | -------------------------------------------------------------------------------- /functions/param-names.js: -------------------------------------------------------------------------------- 1 | // Check conformance to Azure parameter naming conventions: 2 | // - path and query parameters must be camel case 3 | // - header parameters must be kebab-case 4 | 5 | module.exports = (targetVal, _opts, paths) => { 6 | if (targetVal === null || typeof targetVal !== 'object') { 7 | return []; 8 | } 9 | 10 | const path = paths.path || paths.target || []; 11 | 12 | // These errors will be caught elsewhere, so silently ignore here 13 | if (!targetVal.in || !targetVal.name) { 14 | return []; 15 | } 16 | 17 | if (targetVal.name.match(/^[$@]/)) { 18 | return [ 19 | { 20 | message: `Parameter name "${targetVal.name}" should not begin with '$' or '@'.`, 21 | path: [...path, 'name'], 22 | }, 23 | ]; 24 | } 25 | if (['path', 'query'].includes(targetVal.in) && targetVal.name !== 'api-version') { 26 | if (!targetVal.name.match(/^[a-z][a-z0-9]*([A-Z][a-z0-9]+)*$/)) { 27 | return [ 28 | { 29 | message: `Parameter name "${targetVal.name}" should be camel case.`, 30 | path: [...path, 'name'], 31 | }, 32 | ]; 33 | } 34 | } else if (targetVal.in === 'header') { 35 | // Tiny fix to allow for ID suffixes on header parameters e.g. Repeatability-Request-ID 36 | if (!targetVal.name.match(/^[A-Za-z][a-z0-9]*(-[A-Za-z][a-z0-9]*)*(-ID)?$/)) { 37 | return [ 38 | { 39 | message: `header parameter name "${targetVal.name}" should be kebab case.`, 40 | path: [...path, 'name'], 41 | }, 42 | ]; 43 | } 44 | } 45 | return []; 46 | }; 47 | -------------------------------------------------------------------------------- /functions/param-order.js: -------------------------------------------------------------------------------- 1 | // Check conformance to Azure parameter order conventions: 2 | // - path parameters must be in the same order as the path 3 | 4 | // NOTE: Missing path parameters will be flagged by the Spectral path-params rule 5 | 6 | // `given` is the paths object 7 | module.exports = (paths) => { 8 | if (paths === null || typeof paths !== 'object') { 9 | return []; 10 | } 11 | 12 | const inPath = (p) => p.in === 'path'; 13 | const paramName = (p) => p.name; 14 | const methods = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head']; 15 | 16 | const errors = []; 17 | 18 | // eslint-disable-next-line no-restricted-syntax 19 | for (const pathKey of Object.keys(paths)) { 20 | // find all the path parameters in pathKey 21 | const paramsInPath = pathKey.match(/[^{}]+(?=})/g) ?? []; 22 | if (paramsInPath.length > 0) { 23 | const pathItem = paths[pathKey]; 24 | const pathItemPathParams = pathItem.parameters?.filter(inPath).map(paramName) ?? []; 25 | 26 | // find the first index where in-consistency observed or offset till no in-consistency 27 | // observed to validate further 28 | const indx = pathItemPathParams.findIndex((v, i) => v !== paramsInPath[i]); 29 | // If path params exists and are not in expected order then raise the error 30 | if (indx >= 0 && indx < paramsInPath.length) { 31 | // NOTE: we do not include `indx` in the path because if the parameter is a ref then 32 | // Spectral will show the path of the ref'ed parameter and not the path/operation with 33 | // improper ordering 34 | errors.push({ 35 | message: `Path parameter "${paramsInPath[indx]}" should appear before "${pathItemPathParams[indx]}".`, 36 | path: ['paths', pathKey, 'parameters'], // no index in path 37 | }); 38 | } else { // this will be a case when few path params are defined in respective methods 39 | const offset = pathItemPathParams.length; 40 | methods.filter((m) => pathItem[m]).forEach((method) => { 41 | const opPathParams = pathItem[method].parameters?.filter(inPath).map(paramName) ?? []; 42 | 43 | const indx2 = opPathParams.findIndex((v, i) => v !== paramsInPath[offset + i]); 44 | if (indx2 >= 0 && (offset + indx2) < paramsInPath.length) { 45 | errors.push({ 46 | message: `Path parameter "${paramsInPath[offset + indx2]}" should appear before "${opPathParams[indx2]}".`, 47 | path: ['paths', pathKey, method, 'parameters'], // no index in path 48 | }); 49 | } 50 | }); 51 | } 52 | } 53 | } 54 | 55 | return errors; 56 | }; 57 | -------------------------------------------------------------------------------- /functions/patch-content-type.js: -------------------------------------------------------------------------------- 1 | const MERGE_PATCH = 'application/merge-patch+json'; 2 | const JSON_PATCH = 'application/json-patch+json'; 3 | 4 | // Verify that all patch operations and only patch operations consume merge-patch. 5 | function checkOperationConsumes(targetVal) { 6 | const { paths } = targetVal; 7 | const errors = []; 8 | if (paths && typeof paths === 'object') { 9 | Object.keys(paths).forEach((path) => { 10 | ['post', 'put'].forEach((method) => { 11 | if (paths[path][method]) { 12 | const { consumes } = paths[path][method]; 13 | const patchTypes = [MERGE_PATCH, JSON_PATCH]; 14 | // eslint-disable-next-line no-restricted-syntax 15 | for (const type of patchTypes) { 16 | if (consumes?.includes(type)) { 17 | errors.push({ 18 | message: `A ${method} operation should not consume '${type}' content type.`, 19 | path: ['paths', path, method, 'consumes'], 20 | }); 21 | } 22 | } 23 | } 24 | }); 25 | if (paths[path].patch) { 26 | const { consumes } = paths[path].patch; 27 | if (!consumes || !consumes.includes(MERGE_PATCH)) { 28 | errors.push({ 29 | message: "A patch operation should consume 'application/merge-patch+json' content type.", 30 | path: ['paths', path, 'patch', ...(consumes ? ['consumes'] : [])], 31 | }); 32 | } else if (consumes.length > 1) { 33 | errors.push({ 34 | message: "A patch operation should only consume 'application/merge-patch+json' content type.", 35 | path: ['paths', path, 'patch', 'consumes'], 36 | }); 37 | } 38 | } 39 | }); 40 | } 41 | 42 | return errors; 43 | } 44 | 45 | // Check API definition to ensure that all patch operations and only patch operations 46 | // are defined with content-type = application/merge-patch+json 47 | // @param targetVal - the entire API document 48 | module.exports = (targetVal) => { 49 | if (targetVal === null || typeof targetVal !== 'object') { 50 | return []; 51 | } 52 | 53 | const errors = []; 54 | 55 | if (targetVal.consumes?.includes(MERGE_PATCH)) { 56 | errors.push({ 57 | message: 'Global consumes should not specify `application/merge-patch+json` content type.', 58 | path: ['consumes'], 59 | }); 60 | } 61 | 62 | errors.push(...checkOperationConsumes(targetVal)); 63 | 64 | return errors; 65 | }; 66 | -------------------------------------------------------------------------------- /functions/path-param-names.js: -------------------------------------------------------------------------------- 1 | // Check that path parameter names are consistent across all paths. 2 | // Specifically: 3 | // - The path parameter that follows a static path segment must be the same across all paths 4 | 5 | // `given` is the paths object 6 | module.exports = (paths) => { 7 | if (paths === null || typeof paths !== 'object') { 8 | return []; 9 | } 10 | 11 | const errors = []; 12 | 13 | // Dict to accumulate the parameter name associated with a path segment 14 | const paramNameForSegment = {}; 15 | 16 | // Identify inconsistent names by iterating over all paths and building up a 17 | // dictionary that maps a static path segment to the path parameter that 18 | // immediately follows that segment. We issue the message when we find 19 | // a static path segment that precedes a path parameter name that is 20 | // different from one previously stored in the dictionary. 21 | 22 | // eslint-disable-next-line no-restricted-syntax 23 | for (const pathKey of Object.keys(paths)) { 24 | const parts = pathKey.split('/').slice(1); 25 | 26 | parts.slice(1).forEach((v, i) => { 27 | if (v.includes('}')) { 28 | const param = v.match(/[^{}]+(?=})/)[0]; 29 | // Get the preceding path segment 30 | const p = parts[i]; 31 | if (paramNameForSegment[p]) { 32 | if (paramNameForSegment[p] !== param) { 33 | errors.push({ 34 | message: `Inconsistent parameter names "${paramNameForSegment[p]}" and "${param}" for path segment "${p}".`, 35 | path: ['paths', pathKey], 36 | }); 37 | } 38 | } else { 39 | paramNameForSegment[p] = param; 40 | } 41 | } 42 | }); 43 | } 44 | 45 | return errors; 46 | }; 47 | -------------------------------------------------------------------------------- /functions/path-param-schema.js: -------------------------------------------------------------------------------- 1 | const URL_MAX_LENGTH = 2083; 2 | 3 | // `given` is a (resolved) parameter entry at the path or operation level 4 | module.exports = (param, _opts, context) => { 5 | if (param === null || typeof param !== 'object') { 6 | return []; 7 | } 8 | 9 | const path = context.path || context.target || []; 10 | 11 | // These errors will be caught elsewhere, so silently ignore here 12 | if (!param.in || !param.name) { 13 | return []; 14 | } 15 | 16 | const errors = []; 17 | 18 | // If the parameter contains a schema, then this must be oas3 19 | const isOas3 = !!param.schema; 20 | 21 | const schema = isOas3 ? param.schema : param; 22 | if (isOas3) { 23 | path.push('schema'); 24 | } 25 | 26 | if (schema.type !== 'string') { 27 | errors.push({ 28 | message: 'Path parameter should be defined as type: string.', 29 | path: [...path, 'type'], 30 | }); 31 | } 32 | 33 | // Only check constraints for the final path parameter on a put or patch that returns a 201 34 | const apiPath = path[1] ?? ''; 35 | if (!apiPath.endsWith(`{${param.name}}`)) { 36 | return errors; 37 | } 38 | if (!['put', 'patch'].includes(path[2] ?? '')) { 39 | return errors; 40 | } 41 | 42 | const oasDoc = context.document.data; 43 | const { responses } = oasDoc.paths[apiPath][path[2]]; 44 | if (!responses || !responses['201']) { 45 | return errors; 46 | } 47 | 48 | if (!schema.maxLength && !schema.pattern) { 49 | errors.push({ 50 | message: 'Path parameter should specify a maximum length (maxLength) and characters allowed (pattern).', 51 | path, 52 | }); 53 | } else if (!schema.maxLength) { 54 | errors.push({ 55 | message: 'Path parameter should specify a maximum length (maxLength).', 56 | path, 57 | }); 58 | } else if (schema.maxLength && schema.maxLength >= URL_MAX_LENGTH) { 59 | errors.push({ 60 | message: `Path parameter maximum length should be less than ${URL_MAX_LENGTH}`, 61 | path: [...path, 'maxLength'], 62 | }); 63 | } else if (!schema.pattern) { 64 | errors.push({ 65 | message: 'Path parameter should specify characters allowed (pattern).', 66 | path, 67 | }); 68 | } 69 | 70 | return errors; 71 | }; 72 | -------------------------------------------------------------------------------- /functions/property-default-not-allowed.js: -------------------------------------------------------------------------------- 1 | // Check that required properties of a schema do not have a default. 2 | 3 | // `input` is the schema of a request or response body 4 | module.exports = function propertyDefaultNotAllowed(schema, options, { path }) { 5 | if (schema === null || typeof schema !== 'object') { 6 | return []; 7 | } 8 | 9 | const errors = []; 10 | 11 | // eslint-disable-next-line no-restricted-syntax 12 | for (const prop of schema.required || []) { 13 | if (schema.properties?.[prop]?.default) { 14 | errors.push({ 15 | message: `Schema property "${prop}" is required and cannot have a default`, 16 | path: [...path, 'properties', prop, 'default'], 17 | }); 18 | } 19 | } 20 | 21 | if (schema.properties && typeof schema.properties === 'object') { 22 | // eslint-disable-next-line no-restricted-syntax 23 | for (const [key, value] of Object.entries(schema.properties)) { 24 | errors.push( 25 | ...propertyDefaultNotAllowed(value, options, { path: [...path, 'properties', key] }), 26 | ); 27 | } 28 | } 29 | 30 | if (schema.items) { 31 | errors.push( 32 | ...propertyDefaultNotAllowed(schema.items, options, { path: [...path, 'items'] }), 33 | ); 34 | } 35 | 36 | if (schema.allOf && Array.isArray(schema.allOf)) { 37 | // eslint-disable-next-line no-restricted-syntax 38 | for (const [index, value] of schema.allOf.entries()) { 39 | errors.push( 40 | ...propertyDefaultNotAllowed(value, options, { path: [...path, 'allOf', index] }), 41 | ); 42 | } 43 | } 44 | 45 | return errors; 46 | }; 47 | -------------------------------------------------------------------------------- /functions/put-request-and-response-body.js: -------------------------------------------------------------------------------- 1 | // The put request and response body should be the same. 2 | 3 | // "given" is a put operation object. 4 | // This function assumes it is running on an unresolved doc. 5 | module.exports = (putOperation, _opts, paths) => { 6 | if (putOperation === null || typeof putOperation !== 'object') { 7 | return []; 8 | } 9 | const path = paths.path || paths.target || []; 10 | 11 | const errors = []; 12 | 13 | // resource schema is create operation response schema 14 | const responseBodyRef = putOperation.responses?.['201']?.schema?.$ref 15 | || putOperation.responses?.['200']?.schema?.$ref; 16 | const requestBodyRef = putOperation.parameters?.find((param) => param.in === 'body')?.schema?.$ref; 17 | 18 | if (responseBodyRef && requestBodyRef && responseBodyRef !== requestBodyRef) { 19 | errors.push({ 20 | message: 'A PUT operation should use the same schema for the request and response body.', 21 | path, 22 | }); 23 | } 24 | 25 | return errors; 26 | }; 27 | -------------------------------------------------------------------------------- /functions/readonly-in-response-schema.js: -------------------------------------------------------------------------------- 1 | // Flag any properties that are readonly in the response schema. 2 | 3 | // Scan an OpenAPI document to determine if a schema is a response-only schema, 4 | // which means it is not referenced by any request schemas. 5 | // Any schema that is referenced by a request is considered a request schema. 6 | // Any schema referenced by a request schema is also considered a request schema. 7 | // Any schema that "allOf"'s a request schema with a discriminator is also a request schema. 8 | // Any schema that is not a request schema is considered a response-only schema. 9 | 10 | // requestSchemas is a set of schema names that we have determined are request schemas 11 | let requestSchemas; 12 | 13 | function getRequestSchemas(oasDoc) { 14 | /* eslint-disable object-curly-newline,object-curly-spacing */ 15 | const getOps = ({put, post, patch}) => [put, post, patch]; 16 | const topLevelRequestSchemas = Object.values(oasDoc.paths || {}) 17 | .flatMap(getOps).filter(Boolean) 18 | .flatMap(({parameters}) => parameters?.filter(({in: location}) => location === 'body') || []) 19 | .flatMap(({schema}) => (schema ? [schema] : [])) 20 | .filter(({$ref}) => $ref && $ref.match(/^#\/definitions\//)) 21 | .map(({$ref}) => $ref.replace(/^#\/definitions\//, '')); 22 | /* eslint-enable object-curly-newline,object-curly-spacing */ 23 | 24 | requestSchemas = new Set(); 25 | 26 | // Now that we have the top-level response schemas, we need to find all the 27 | // schemas that are referenced by those schemas. We do this by iterating 28 | // over the schemas until we find no new schemas to add to the set. 29 | const schemasToProcess = [...topLevelRequestSchemas]; 30 | while (schemasToProcess.length > 0) { 31 | const schemaName = schemasToProcess.pop(); 32 | requestSchemas.add(schemaName); 33 | const schema = oasDoc.definitions[schemaName]; 34 | if (schema) { 35 | if (schema.properties) { 36 | // eslint-disable-next-line no-restricted-syntax 37 | for (const property of Object.values(schema.properties)) { 38 | if (property.$ref && property.$ref.match(/^#\/definitions\//)) { 39 | const ref = property.$ref.replace(/^#\/definitions\//, ''); 40 | if (!requestSchemas.has(ref) && !schemasToProcess.includes(ref)) { 41 | schemasToProcess.push(ref); 42 | } 43 | } 44 | if (property.items && property.items.$ref && property.items.$ref.match(/^#\/definitions\//)) { 45 | const ref = property.items.$ref.replace(/^#\/definitions\//, ''); 46 | if (!requestSchemas.has(ref) && !schemasToProcess.includes(ref)) { 47 | schemasToProcess.push(ref); 48 | } 49 | } 50 | if (property.additionalProperties && property.additionalProperties.$ref && property.additionalProperties.$ref.match(/^#\/definitions\//)) { 51 | const ref = property.additionalProperties.$ref.replace(/^#\/definitions\//, ''); 52 | if (!requestSchemas.has(ref) && !schemasToProcess.includes(ref)) { 53 | schemasToProcess.push(ref); 54 | } 55 | } 56 | } 57 | } 58 | if (schema.allOf) { 59 | // eslint-disable-next-line no-restricted-syntax 60 | for (const element of schema.allOf) { 61 | if (element.$ref && element.$ref.match(/^#\/definitions\//)) { 62 | const ref = element.$ref.replace(/^#\/definitions\//, ''); 63 | if (!requestSchemas.has(ref) && !schemasToProcess.includes(ref)) { 64 | schemasToProcess.push(ref); 65 | } 66 | } 67 | } 68 | } 69 | if (schema.discriminator) { 70 | // Check all the schemas in the document and add any that "allOf" this schema 71 | // into schemasToProcess 72 | const schemaRef = `#/definitions/${schemaName}`; 73 | // eslint-disable-next-line no-restricted-syntax 74 | for (const [key, value] of Object.entries(oasDoc.definitions)) { 75 | if (value.allOf?.some((elem) => elem.$ref === schemaRef)) { 76 | schemasToProcess.push(key); 77 | } 78 | } 79 | } 80 | } 81 | } 82 | } 83 | 84 | // compute a hash for a string 85 | function hashCode(str) { 86 | let hash = 0; 87 | for (let i = 0; i < str.length; i += 1) { 88 | /* eslint-disable no-bitwise */ 89 | hash = ((hash << 5) - hash) + str.charCodeAt(i); 90 | hash |= 0; // Convert to 32bit integer 91 | /* eslint-enable no-bitwise */ 92 | } 93 | return hash; 94 | } 95 | 96 | let docHash; 97 | 98 | function responseOnlySchema(schemaName, oasDoc) { 99 | const thisDocHash = hashCode(JSON.stringify(oasDoc)); 100 | if (!requestSchemas || docHash !== thisDocHash) { 101 | getRequestSchemas(oasDoc); 102 | docHash = thisDocHash; 103 | } 104 | 105 | if (requestSchemas.has(schemaName)) { 106 | return false; 107 | } 108 | 109 | return true; 110 | } 111 | 112 | // `schema` is a (resolved) parameter entry at the path or operation level 113 | module.exports = (schema, _opts, context) => { 114 | const schemaName = context.path[context.path.length - 1]; 115 | const oasDoc = context.document.data; 116 | if (!responseOnlySchema(schemaName, oasDoc)) { 117 | return []; 118 | } 119 | 120 | // Flag any properties that are readonly in the response schema. 121 | 122 | const errors = []; 123 | 124 | // eslint-disable-next-line no-restricted-syntax 125 | for (const [propertyName, property] of Object.entries(schema.properties || {})) { 126 | if (property.readOnly) { 127 | errors.push({ 128 | message: 'Property of response-only schema should not be marked readOnly', 129 | path: [...context.path, 'properties', propertyName, 'readOnly'], 130 | }); 131 | } 132 | } 133 | 134 | return errors; 135 | }; 136 | -------------------------------------------------------------------------------- /functions/schema-type-and-format.js: -------------------------------------------------------------------------------- 1 | // Check that format is valid for a schema type. 2 | // Valid formats are those defined in the OpenAPI spec and extensions in autorest. 3 | // - https://github.com/OAI/OpenAPI-Specification/blob/main/versions/2.0.md#data-types 4 | // - https://github.com/Azure/autorest/blob/main/packages/libs/openapi/src/v3/formats.ts 5 | 6 | // `input` is the schema of a request or response body 7 | module.exports = function checkTypeAndFormat(schema, options, { path }) { 8 | if (schema === null || typeof schema !== 'object') { 9 | return []; 10 | } 11 | 12 | const errors = []; 13 | 14 | const stringFormats = [ 15 | // OAS-defined formats 16 | 'byte', 'binary', 'date', 'date-time', 'password', 17 | // Additional formats recognized by autorest 18 | 'char', 'time', 'date-time-rfc1123', 'duration', 'uuid', 'base64url', 'url', 'uri', 19 | 'odata-query', 'certificate', 20 | ]; 21 | 22 | if (schema.type === 'string') { 23 | if (schema.format) { 24 | if (!stringFormats.includes(schema.format)) { 25 | errors.push({ 26 | message: `Schema with type: string has unrecognized format: ${schema.format}`, 27 | path: [...path, 'format'], 28 | }); 29 | } 30 | } 31 | } else if (schema.type === 'integer') { 32 | if (schema.format) { 33 | if (!['int32', 'int64', 'unixtime'].includes(schema.format)) { 34 | errors.push({ 35 | message: `Schema with type: integer has unrecognized format: ${schema.format}`, 36 | path: [...path, 'format'], 37 | }); 38 | } 39 | } else { 40 | errors.push({ 41 | message: 'Schema with type: integer should specify format', 42 | path, 43 | }); 44 | } 45 | } else if (schema.type === 'number') { 46 | if (schema.format) { 47 | if (!['float', 'double', 'decimal'].includes(schema.format)) { 48 | errors.push({ 49 | message: `Schema with type: number has unrecognized format: ${schema.format}`, 50 | path: [...path, 'format'], 51 | }); 52 | } 53 | } else { 54 | errors.push({ 55 | message: 'Schema with type: number should specify format', 56 | path, 57 | }); 58 | } 59 | } else if (schema.type === 'boolean') { 60 | if (schema.format) { 61 | errors.push({ 62 | message: 'Schema with type: boolean should not specify format', 63 | path: [...path, 'format'], 64 | }); 65 | } 66 | } else if (schema.properties && typeof schema.properties === 'object') { 67 | // eslint-disable-next-line no-restricted-syntax 68 | for (const [key, value] of Object.entries(schema.properties)) { 69 | errors.push( 70 | ...checkTypeAndFormat(value, options, { path: [...path, 'properties', key] }), 71 | ); 72 | } 73 | } 74 | 75 | if (schema.type === 'array') { 76 | errors.push( 77 | ...checkTypeAndFormat(schema.items, options, { path: [...path, 'items'] }), 78 | ); 79 | } 80 | 81 | if (schema.allOf && Array.isArray(schema.allOf)) { 82 | // eslint-disable-next-line no-restricted-syntax 83 | for (const [index, value] of schema.allOf.entries()) { 84 | errors.push( 85 | ...checkTypeAndFormat(value, options, { path: [...path, 'allOf', index] }), 86 | ); 87 | } 88 | } 89 | 90 | return errors; 91 | }; 92 | -------------------------------------------------------------------------------- /functions/security-definitions.js: -------------------------------------------------------------------------------- 1 | // Check API definition to ensure conformance to Azure security schemes guidelines. 2 | 3 | // Check: 4 | // - There is at least one security scheme. 5 | // - All security schemes are either: 6 | // - type: oauth2 or 7 | // - type: apiKey with in: header 8 | // - An oauth2 security scheme defines at least one scope. 9 | // - All scopes defined in an oauth2 security scheme match a pattern: 10 | // - https:\/\/[\w-]+(\.[\w-]+)+/[\w-.]+ 11 | 12 | // @param doc - the entire API document 13 | module.exports = (doc) => { 14 | if (doc === null || typeof doc !== 'object') { 15 | return []; 16 | } 17 | 18 | if (!doc.securityDefinitions || (typeof doc.securityDefinitions === 'object' && Object.keys(doc.securityDefinitions).length === 0)) { 19 | return [{ 20 | message: 'At least one security scheme must be defined.', 21 | path: ['securityDefinitions'], 22 | }]; 23 | } 24 | 25 | const schemes = doc.securityDefinitions; 26 | 27 | const errors = []; 28 | 29 | Object.keys(schemes).forEach((schemeKey) => { 30 | const scheme = schemes[schemeKey]; 31 | // Silently ignore scheme if not an object -- oas2-schema will flag this as an error. 32 | // The check here is just to avoid runtime exceptions. 33 | if (typeof scheme === 'object') { 34 | const path = ['securityDefinitions', schemeKey]; 35 | if (scheme.type === 'oauth2') { 36 | if (!scheme.scopes || (typeof scheme.scopes === 'object' && Object.keys(scheme.scopes).length === 0)) { 37 | errors.push({ 38 | message: 'Security scheme with type: oauth2 should have non-empty "scopes" array.', 39 | path: [...path, 'scopes'], 40 | }); 41 | } else { 42 | // All scopes must match the pattern 43 | Object.keys(scheme.scopes).forEach((scope) => { 44 | if (!scope.match(/^https:\/\/[\w-]+(\.[\w-]+)+\/[\w-.]+$/)) { 45 | errors.push({ 46 | message: 'Oauth2 scope names should have the form: https:///', 47 | path: [...path, 'scopes', scope], 48 | }); 49 | } 50 | }); 51 | } 52 | } else if (scheme.type === 'apiKey') { 53 | if (scheme.in !== 'header') { 54 | errors.push({ 55 | message: 'Security scheme with type "apiKey" should specify "in: header".', 56 | path: [...path, 'in'], 57 | }); 58 | } 59 | } else { 60 | errors.push({ 61 | message: 'Security scheme must be type: oauth2 or type: apiKey.', 62 | path: [...path, 'type'], 63 | }); 64 | } 65 | } 66 | }); 67 | 68 | return errors; 69 | }; 70 | -------------------------------------------------------------------------------- /functions/security-requirements.js: -------------------------------------------------------------------------------- 1 | // Check: 2 | // - each entry of a global or operation `security` property references a defined 3 | // security scheme. 4 | // - all scopes referenced by an "oauth2" entry are defined in the corresponding 5 | // security scheme. 6 | 7 | // @param input - a security property (global or operation) 8 | module.exports = (input, _, context) => { 9 | if (input === null || !Array.isArray(input)) { 10 | return []; 11 | } 12 | 13 | const isObject = (obj) => obj && typeof obj === 'object'; 14 | const oas2Schemes = (doc) => (isObject(doc.securityDefinitions) ? doc.securityDefinitions : {}); 15 | const oas3Schemes = (doc) => (isObject(doc.components) && isObject(doc.components.securitySchemes) 16 | ? doc.components.securitySchemes : {}); 17 | 18 | const oasDoc = context.document.data; 19 | const schemes = oasDoc.swagger ? oas2Schemes(oasDoc) : oas3Schemes(oasDoc); 20 | 21 | const path = context.path || []; 22 | 23 | const errors = []; 24 | input.forEach((securityReq, index) => { 25 | // oas2-schema requires securityReq to be an object. 26 | // Checking here just to avoid runtime errors. 27 | if (isObject(securityReq)) { 28 | // security with no elements will be flagged by az-security-min-length 29 | Object.keys(securityReq).forEach((key) => { 30 | if (!schemes[key]) { 31 | errors.push({ 32 | message: `Security scheme "${key}" is not defined.`, 33 | path: [...path, index, key], 34 | }); 35 | return; 36 | } 37 | 38 | const scheme = schemes[key]; 39 | // oas2-schema requires scheme to be an object. 40 | // Checking here just to avoid runtime errors. 41 | if (!isObject(scheme)) { return; } 42 | 43 | const scopes = securityReq[key]; 44 | // oas2-schema requires scopes to be an array. 45 | // Checking here just to avoid runtime errors. 46 | if (!Array.isArray(scopes)) { return; } 47 | 48 | if (scheme.type === 'oauth2') { 49 | if (scopes.length === 0) { 50 | errors.push({ 51 | message: 'OAuth2 security scheme requires at least one scope.', 52 | path: [...path, index, key], 53 | }); 54 | } 55 | scopes.forEach((scope, scopeIndex) => { 56 | if (!(scope in scheme.scopes)) { 57 | errors.push({ 58 | message: `Scope "${scope}" is not defined for security scheme "${key}".`, 59 | path: [...path, index, key, scopeIndex], 60 | }); 61 | } 62 | }); 63 | } else if (scopes.length > 0) { 64 | errors.push({ 65 | message: `Security scheme "${key}" does not support scopes.`, 66 | path: [...path, index, key], 67 | }); 68 | } 69 | }); 70 | } 71 | }); 72 | 73 | return errors; 74 | }; 75 | -------------------------------------------------------------------------------- /functions/unused-definition.js: -------------------------------------------------------------------------------- 1 | // Check all definitions in the document to see if they are used 2 | // Use the spectral unreferencedReusableObject to find its list of unused definitions, 3 | // and then remove any that `allOf` a used schema. 4 | 5 | const { unreferencedReusableObject } = require('@stoplight/spectral-functions'); 6 | 7 | const isObject = (obj) => obj && typeof obj === 'object'; 8 | 9 | // given should point to the member holding the potential reusable objects. 10 | module.exports = (given, _, context) => { 11 | if (!isObject(given)) { 12 | return []; 13 | } 14 | const opts = { 15 | reusableObjectsLocation: '#/definitions', 16 | }; 17 | const unreferencedDefinitionErrors = unreferencedReusableObject(given, opts, context); 18 | 19 | const unusedDefinitions = unreferencedDefinitionErrors.map((error) => error.path[1]); 20 | 21 | const allOfsUsedSchema = (schemaName) => { 22 | const schema = given[schemaName]; 23 | if (!isObject(schema) || !Array.isArray(schema.allOf)) { 24 | return false; 25 | } 26 | 27 | return schema.allOf.some((subSchema) => { 28 | if (!isObject(subSchema) || !subSchema.$ref) { 29 | return false; 30 | } 31 | 32 | const reffedSchema = subSchema.$ref.split('/').pop(); 33 | if (unusedDefinitions.includes(reffedSchema)) { 34 | return false; 35 | } 36 | 37 | return true; 38 | }); 39 | }; 40 | 41 | return unreferencedDefinitionErrors.filter( 42 | (error) => !allOfsUsedSchema(error.path[1]), 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /functions/version-policy.js: -------------------------------------------------------------------------------- 1 | // Check: 2 | // - DO NOT include a version segment in the base_url or path 3 | 4 | // Return the first segment of a path that matches the pattern 'v\d+' or 'v\d+.\d+ 5 | function getVersion(path) { 6 | const url = new URL(path, 'https://foo.bar'); 7 | const segments = url.pathname.split('/'); 8 | return segments.find((segment) => segment.match(/v[0-9]+(.[0-9]+)?/)); 9 | } 10 | 11 | function checkPaths(targetVal) { 12 | const oas2 = targetVal.swagger; 13 | 14 | if (oas2) { 15 | const basePath = targetVal.basePath || ''; 16 | const version = getVersion(basePath); 17 | if (version) { 18 | return [ 19 | { 20 | message: `Version segment "${version}" in basePath violates Azure versioning policy.`, 21 | path: ['basePath'], 22 | }, 23 | ]; 24 | } 25 | } 26 | 27 | // We did not find a major version in basePath, so now check the paths 28 | 29 | const { paths } = targetVal; 30 | const errors = []; 31 | if (paths && typeof paths === 'object') { 32 | Object.keys(paths).forEach((path) => { 33 | const version = getVersion(path); 34 | if (version) { 35 | errors.push({ 36 | message: `Version segment "${version}" in path violates Azure versioning policy.`, 37 | path: ['paths', path], 38 | }); 39 | } 40 | }); 41 | } 42 | return errors; 43 | } 44 | 45 | function findVersionParam(params) { 46 | const isApiVersion = (elem) => elem.name === 'api-version' && elem.in === 'query'; 47 | if (params && Array.isArray(params)) { 48 | return params.filter(isApiVersion).shift(); 49 | } 50 | return undefined; 51 | } 52 | 53 | // Verify version parameter has certain characteristics: 54 | // - it is required 55 | function validateVersionParam(param, path) { 56 | const errors = []; 57 | if (!param.required) { 58 | errors.push({ 59 | message: '"api-version" should be a required parameter', 60 | path, 61 | }); 62 | } 63 | return errors; 64 | } 65 | 66 | // Verify that every operation defines a query param called `api-version` 67 | function checkVersionParam(targetVal) { 68 | const { paths } = targetVal; 69 | const errors = []; 70 | if (paths && typeof paths === 'object') { 71 | Object.keys(paths).forEach((path) => { 72 | // Parameters can be defined at the path level. 73 | if (paths[path].parameters && Array.isArray(paths[path].parameters)) { 74 | const versionParam = findVersionParam(paths[path].parameters); 75 | if (versionParam) { 76 | const index = paths[path].parameters.indexOf(versionParam); 77 | errors.push(...validateVersionParam(versionParam, ['paths', path, 'parameters', index.toString()])); 78 | return; 79 | } 80 | } 81 | 82 | ['get', 'post', 'put', 'patch', 'delete'].forEach((method) => { 83 | if (paths[path][method]) { 84 | const versionParam = findVersionParam(paths[path][method].parameters); 85 | if (versionParam) { 86 | const index = paths[path][method].parameters.indexOf(versionParam); 87 | errors.push(...validateVersionParam(versionParam, ['paths', path, method, 'parameters', index])); 88 | } else { 89 | errors.push({ 90 | message: 'Operation does not define an "api-version" query parameter.', 91 | path: ['paths', path, method, 'parameters'], 92 | }); 93 | } 94 | } 95 | }); 96 | }); 97 | } 98 | 99 | return errors; 100 | } 101 | 102 | // Check API definition to ensure conformance to Azure versioning guidelines. 103 | // @param targetVal - the entire API document 104 | module.exports = (targetVal) => { 105 | if (targetVal === null || typeof targetVal !== 'object') { 106 | return []; 107 | } 108 | 109 | const errors = checkPaths(targetVal); 110 | errors.push(...checkVersionParam(targetVal)); 111 | 112 | return errors; 113 | }; 114 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "azure-api-style-guide", 3 | "version": "0.0.1", 4 | "description": "Spectral rules for Azure API Guidelines", 5 | "main": "spectral.yaml", 6 | "scripts": { 7 | "lint": "eslint --cache --quiet --ext '.js' functions test", 8 | "lint-fix": "eslint --cache --quiet --ext '.js' --fix functions test", 9 | "test": "jest --coverage", 10 | "update-toc": "markdown-toc -i openapi-style-guide.md" 11 | }, 12 | "author": "Mike Kistler", 13 | "license": "MIT", 14 | "repository": { 15 | "url": "https://github.com/Azure/azure-api-style-guide" 16 | }, 17 | "dependencies": { 18 | "@jest/globals": "^29.7.0", 19 | "@stoplight/spectral-functions": "^1.7.2" 20 | }, 21 | "devDependencies": { 22 | "@stoplight/spectral-core": "^1.19.2", 23 | "@stoplight/spectral-ruleset-migrator": "^1.9.0", 24 | "@stoplight/spectral-rulesets": "^1.14.1", 25 | "ajv": "^8.6.2", 26 | "eslint": "^7.30.0", 27 | "eslint-config-airbnb-base": "^14.2.1", 28 | "eslint-plugin-import": "^2.23.4", 29 | "jest": "^27.0.6", 30 | "markdown-toc": "^1.2.0" 31 | }, 32 | "jest": { 33 | "collectCoverage": true, 34 | "collectCoverageFrom": [ 35 | "functions/*.js" 36 | ], 37 | "coverageThreshold": { 38 | "./functions/*.js": { 39 | "statements": 80 40 | } 41 | }, 42 | "moduleNameMapper": { 43 | "^nimma/legacy$": "/node_modules/nimma/dist/legacy/cjs/index.js", 44 | "^nimma/(.*)": "/node_modules/nimma/dist/cjs/$1", 45 | "^@stoplight/spectral-ruleset-bundler/(.*)$": "/node_modules/@stoplight/spectral-ruleset-bundler/dist/$1" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/additional-properties-object.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-additional-properties-object'); 7 | return linter; 8 | }); 9 | 10 | test('az-additional-properties-object should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | definitions: { 14 | This: { 15 | description: 'This', 16 | type: 'object', 17 | additionalProperties: { 18 | type: 'object', 19 | }, 20 | }, 21 | That: { 22 | description: 'That', 23 | type: 'object', 24 | properties: { 25 | params: { 26 | additionalProperties: { 27 | type: 'object', 28 | }, 29 | }, 30 | }, 31 | }, 32 | }, 33 | }; 34 | return linter.run(oasDoc).then((results) => { 35 | expect(results.length).toBe(2); 36 | expect(results[0].path.join('.')).toBe('definitions.This.additionalProperties'); 37 | expect(results[1].path.join('.')).toBe('definitions.That.properties.params.additionalProperties'); 38 | }); 39 | }); 40 | 41 | test('az-additional-properties-object should find no errors', () => { 42 | const oasDoc = { 43 | swagger: '2.0', 44 | definitions: { 45 | This: { 46 | description: 'This', 47 | type: 'object', 48 | additionalProperties: { 49 | type: 'object', 50 | properties: { 51 | prop1: { 52 | type: 'string', 53 | }, 54 | }, 55 | }, 56 | }, 57 | That: { 58 | description: 'That', 59 | type: 'object', 60 | properties: { 61 | params: { 62 | additionalProperties: { 63 | type: 'string', 64 | }, 65 | }, 66 | }, 67 | }, 68 | ThaOther: { 69 | description: 'ThaOther', 70 | type: 'object', 71 | properties: { 72 | otherMap: { 73 | additionalProperties: { 74 | $ref: '#/definitions/Other', 75 | }, 76 | }, 77 | }, 78 | }, 79 | Other: { 80 | description: 'Other object', 81 | type: 'object', 82 | properties: { 83 | aProp: { 84 | type: 'string', 85 | }, 86 | }, 87 | }, 88 | }, 89 | }; 90 | return linter.run(oasDoc).then((results) => { 91 | expect(results.length).toBe(0); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /test/boolean-naming-convention.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-boolean-naming-convention'); 7 | return linter; 8 | }); 9 | 10 | test('az-boolean-naming-convention should find errors', () => { 11 | const myOpenApiDocument = { 12 | swagger: '2.0', 13 | paths: { 14 | '/path1': { 15 | put: { 16 | parameters: [ 17 | { 18 | name: 'isFoo', 19 | in: 'query', 20 | type: 'boolean', 21 | }, 22 | { 23 | name: 'body', 24 | in: 'body', 25 | schema: { 26 | type: 'object', 27 | properties: { 28 | isBar: { 29 | type: 'boolean', 30 | }, 31 | }, 32 | }, 33 | }, 34 | ], 35 | responses: { 36 | 200: { 37 | description: 'OK', 38 | schema: { 39 | type: 'object', 40 | properties: { 41 | isBaz: { 42 | type: 'boolean', 43 | }, 44 | }, 45 | }, 46 | }, 47 | }, 48 | }, 49 | }, 50 | }, 51 | }; 52 | return linter.run(myOpenApiDocument).then((results) => { 53 | expect(results.length).toBe(3); 54 | expect(results[0].path.join('.')).toBe('paths./path1.put.parameters.0.name'); 55 | expect(results[1].path.join('.')).toBe('paths./path1.put.parameters.1.schema.properties.isBar'); 56 | expect(results[2].path.join('.')).toBe('paths./path1.put.responses.200.schema.properties.isBaz'); 57 | }); 58 | }); 59 | 60 | test('az-boolean-naming-convention should find no errors', () => { 61 | const myOpenApiDocument = { 62 | swagger: '2.0', 63 | paths: { 64 | '/path1': { 65 | put: { 66 | parameters: [ 67 | { 68 | name: 'foo', 69 | in: 'query', 70 | type: 'boolean', 71 | }, 72 | { 73 | name: 'body', 74 | in: 'body', 75 | schema: { 76 | type: 'object', 77 | properties: { 78 | bar: { 79 | type: 'boolean', 80 | }, 81 | }, 82 | }, 83 | }, 84 | ], 85 | responses: { 86 | 200: { 87 | description: 'OK', 88 | schema: { 89 | type: 'object', 90 | properties: { 91 | baz: { 92 | type: 'boolean', 93 | }, 94 | }, 95 | }, 96 | }, 97 | }, 98 | }, 99 | }, 100 | }, 101 | }; 102 | return linter.run(myOpenApiDocument).then((results) => { 103 | expect(results.length).toBe(0); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /test/consistent-response-body.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-consistent-response-body'); 7 | return linter; 8 | }); 9 | 10 | test('az-consistent-response-body should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | paths: { 14 | '/test1/{id}': { 15 | parameters: { 16 | name: 'id', 17 | in: 'path', 18 | type: 'string', 19 | }, 20 | put: { 21 | responses: { 22 | 201: { 23 | description: 'Created', 24 | schema: { 25 | $ref: '#/definitions/This', 26 | }, 27 | }, 28 | }, 29 | }, 30 | patch: { 31 | responses: { 32 | 200: { 33 | description: 'Success', 34 | schema: { 35 | $ref: '#/definitions/That', 36 | }, 37 | }, 38 | }, 39 | }, 40 | get: { 41 | responses: { 42 | 200: { 43 | description: 'Success', 44 | schema: { 45 | $ref: '#/definitions/ThaOther', 46 | }, 47 | }, 48 | }, 49 | }, 50 | }, 51 | }, 52 | definitions: { 53 | This: { 54 | description: 'This', 55 | type: 'object', 56 | }, 57 | That: { 58 | description: 'That', 59 | type: 'object', 60 | }, 61 | ThaOther: { 62 | description: 'ThaOther', 63 | type: 'object', 64 | }, 65 | }, 66 | }; 67 | return linter.run(oasDoc).then((results) => { 68 | expect(results.length).toBe(2); 69 | expect(results[0].path.join('.')).toBe('paths./test1/{id}.patch.responses.200.schema'); 70 | expect(results[0].message).toBe('Response body schema does not match create response body schema.'); 71 | expect(results[1].path.join('.')).toBe('paths./test1/{id}.get.responses.200.schema'); 72 | expect(results[1].message).toBe('Response body schema does not match create response body schema.'); 73 | }); 74 | }); 75 | 76 | test('az-consistent-response-body should find no errors', () => { 77 | const oasDoc = { 78 | swagger: '2.0', 79 | paths: { 80 | '/test1/{id}': { 81 | parameters: { 82 | name: 'id', 83 | in: 'path', 84 | type: 'string', 85 | }, 86 | put: { 87 | responses: { 88 | 200: { 89 | description: 'Success', 90 | schema: { 91 | $ref: '#/definitions/This', 92 | }, 93 | }, 94 | 201: { 95 | description: 'Created', 96 | schema: { 97 | $ref: '#/definitions/This', 98 | }, 99 | }, 100 | }, 101 | }, 102 | post: { 103 | responses: { 104 | 200: { 105 | description: 'Success', 106 | schema: { 107 | $ref: '#/definitions/This', 108 | }, 109 | }, 110 | }, 111 | }, 112 | get: { 113 | responses: { 114 | 200: { 115 | description: 'Success', 116 | schema: { 117 | $ref: '#/definitions/This', 118 | }, 119 | }, 120 | }, 121 | }, 122 | }, 123 | }, 124 | definitions: { 125 | This: { 126 | description: 'This', 127 | type: 'object', 128 | }, 129 | }, 130 | }; 131 | return linter.run(oasDoc).then((results) => { 132 | expect(results.length).toBe(0); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /test/delete-response-codes.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-delete-response-codes'); 7 | return linter; 8 | }); 9 | 10 | test('az-delete-response-codes should find errors', () => { 11 | const myOpenApiDocument = { 12 | swagger: '2.0', 13 | paths: { 14 | '/test1': { 15 | delete: { 16 | responses: { 17 | 200: { 18 | description: 'Success', 19 | }, 20 | }, 21 | }, 22 | }, 23 | '/test2': { 24 | delete: { 25 | responses: { 26 | 200: { 27 | description: 'Success', 28 | }, 29 | 204: { 30 | description: 'No Content', 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | }; 37 | return linter.run(myOpenApiDocument).then((results) => { 38 | expect(results.length).toBe(2); 39 | expect(results[0].path.join('.')).toBe('paths./test1.delete.responses'); 40 | expect(results[1].path.join('.')).toBe('paths./test2.delete.responses'); 41 | }); 42 | }); 43 | 44 | test('az-delete-response-codes should find no errors', () => { 45 | const myOpenApiDocument = { 46 | swagger: '2.0', 47 | paths: { 48 | '/test1': { 49 | delete: { 50 | responses: { 51 | 204: { 52 | description: 'Success', 53 | }, 54 | }, 55 | }, 56 | }, 57 | '/test202': { 58 | delete: { 59 | responses: { 60 | 202: { 61 | description: 'Success', 62 | }, 63 | }, 64 | }, 65 | }, 66 | }, 67 | }; 68 | return linter.run(myOpenApiDocument).then((results) => { 69 | expect(results.length).toBe(0); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /test/error-code-response-header.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-error-code-response-header'); 7 | return linter; 8 | }); 9 | 10 | test('az-error-code-response-header should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | paths: { 14 | '/api/Paths': { 15 | get: { 16 | responses: { 17 | 200: { 18 | description: 'Success', 19 | }, 20 | // Error response should contain a x-ms-error-code header. 21 | 400: { 22 | description: 'Bad request', 23 | schema: { 24 | type: 'string', 25 | }, 26 | }, 27 | default: { 28 | description: 'Precondition Failed', 29 | schema: { 30 | type: 'string', 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | }, 37 | }; 38 | return linter.run(oasDoc).then((results) => { 39 | expect(results.length).toBe(2); 40 | // For some reason the paths have "schema" appended to them here, 41 | // but not when run in VSCode or the CLI. 42 | expect(results[0].path.join('.')).toBe('paths./api/Paths.get.responses.400'); 43 | expect(results[0].message).toBe('Error response should contain a x-ms-error-code header.'); 44 | expect(results[1].path.join('.')).toBe('paths./api/Paths.get.responses.default'); 45 | expect(results[1].message).toBe('Error response should contain a x-ms-error-code header.'); 46 | }); 47 | }); 48 | 49 | test('az-error-response should find no errors', () => { 50 | const oasDoc = { 51 | swagger: '2.0', 52 | paths: { 53 | '/api/Paths': { 54 | get: { 55 | responses: { 56 | 200: { 57 | description: 'Success', 58 | }, 59 | 400: { 60 | description: 'Bad request', 61 | headers: { 62 | 'x-ms-error-code': { 63 | type: 'string', 64 | }, 65 | }, 66 | schema: { 67 | type: 'string', 68 | }, 69 | }, 70 | default: { 71 | description: 'Bad request', 72 | headers: { 73 | 'x-ms-error-code': { 74 | type: 'string', 75 | }, 76 | }, 77 | schema: { 78 | type: 'string', 79 | }, 80 | }, 81 | }, 82 | }, 83 | }, 84 | }, 85 | }; 86 | return linter.run(oasDoc).then((results) => { 87 | expect(results.length).toBe(0); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /test/header-disallowed.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | require('./matchers'); 3 | 4 | let linter; 5 | 6 | beforeAll(async () => { 7 | linter = await linterForRule('az-header-disallowed'); 8 | return linter; 9 | }); 10 | 11 | test('az-header-disallowed should find errors', () => { 12 | // Test parameter names in 3 different places: 13 | // 1. parameter at path level 14 | // 2. inline parameter at operation level 15 | // 3. referenced parameter at operation level 16 | const oasDoc = { 17 | swagger: '2.0', 18 | paths: { 19 | '/test1': { 20 | parameters: [ 21 | { 22 | name: 'Authorization', 23 | in: 'header', 24 | type: 'string', 25 | }, 26 | ], 27 | get: { 28 | parameters: [ 29 | { 30 | name: 'Content-Type', 31 | in: 'header', 32 | type: 'string', 33 | }, 34 | { 35 | $ref: '#/parameters/AcceptParam', 36 | }, 37 | ], 38 | }, 39 | }, 40 | }, 41 | parameters: { 42 | AcceptParam: { 43 | name: 'Accept', 44 | in: 'header', 45 | type: 'string', 46 | }, 47 | }, 48 | }; 49 | return linter.run(oasDoc).then((results) => { 50 | expect(results.length).toBe(3); 51 | expect(results).toContainMatch({ 52 | path: ['paths', '/test1', 'parameters', '0', 'name'], 53 | }); 54 | expect(results).toContainMatch({ 55 | path: ['paths', '/test1', 'get', 'parameters', '0', 'name'], 56 | }); 57 | expect(results).toContainMatch({ 58 | path: ['paths', '/test1', 'get', 'parameters', '1', 'name'], 59 | }); 60 | }); 61 | }); 62 | 63 | test('az-header-disallowed should find no errors', () => { 64 | const oasDoc = { 65 | swagger: '2.0', 66 | paths: { 67 | '/test1': { 68 | parameters: [ 69 | { 70 | name: 'Authorization', 71 | in: 'query', 72 | type: 'string', 73 | }, 74 | { 75 | name: 'Content-Encoding', 76 | in: 'header', 77 | type: 'string', 78 | }, 79 | ], 80 | get: { 81 | parameters: [ 82 | { 83 | name: 'Accept', 84 | in: 'query', 85 | type: 'string', 86 | }, 87 | { 88 | name: 'Accept-Language', 89 | in: 'header', 90 | type: 'string', 91 | }, 92 | { 93 | $ref: '#/parameters/RequestIdParam', 94 | }, 95 | ], 96 | }, 97 | }, 98 | }, 99 | parameters: { 100 | RequestIdParam: { 101 | name: 'x-ms-request-id', 102 | in: 'header', 103 | type: 'string', 104 | }, 105 | }, 106 | }; 107 | return linter.run(oasDoc).then((results) => { 108 | expect(results.length).toBe(0); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /test/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "typeAcquisition": { 3 | "include": [ 4 | "jest" 5 | ] 6 | } 7 | } -------------------------------------------------------------------------------- /test/lro-put-response-codes.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-lro-put-response-codes'); 7 | return linter; 8 | }); 9 | 10 | test('az-lro-put-response-codes should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | paths: { 14 | '/test1': { 15 | put: { 16 | responses: { 17 | 202: { 18 | description: 'Accepted', 19 | }, 20 | }, 21 | }, 22 | }, 23 | '/test2': { 24 | put: { 25 | responses: { 26 | 200: { 27 | description: 'Success', 28 | }, 29 | 201: { 30 | description: 'Created', 31 | }, 32 | 202: { 33 | description: 'Accepted', 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }; 40 | return linter.run(oasDoc).then((results) => { 41 | expect(results).toHaveLength(2); 42 | expect(results[0].path.join('.')).toBe('paths./test1.put.responses.202'); 43 | expect(results[1].path.join('.')).toBe('paths./test2.put.responses.202'); 44 | results.forEach((result) => expect(result.message).toBe( 45 | 'Long-running PUT should not return a 202 response.', 46 | )); 47 | }); 48 | }); 49 | 50 | test('az-lro-put-response-codes should find no errors', () => { 51 | const oasDoc = { 52 | swagger: '2.0', 53 | paths: { 54 | '/test1': { 55 | put: { 56 | responses: { 57 | 200: { 58 | description: 'Success', 59 | }, 60 | 201: { 61 | description: 'Created', 62 | }, 63 | }, 64 | }, 65 | }, 66 | '/test2': { 67 | put: { 68 | responses: { 69 | 200: { 70 | description: 'Success', 71 | }, 72 | }, 73 | }, 74 | }, 75 | '/test3': { 76 | put: { 77 | responses: { 78 | 201: { 79 | description: 'Created', 80 | }, 81 | }, 82 | }, 83 | }, 84 | }, 85 | }; 86 | return linter.run(oasDoc).then((results) => { 87 | expect(results.length).toBe(0); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /test/lro-response-codes.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-lro-response-codes'); 7 | return linter; 8 | }); 9 | 10 | test('az-lro-response-codes should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | paths: { 14 | '/test1': { 15 | post: { 16 | responses: { 17 | 200: { 18 | description: 'Success', 19 | }, 20 | 202: { 21 | description: 'Accepted', 22 | }, 23 | }, 24 | }, 25 | }, 26 | '/test2': { 27 | delete: { 28 | responses: { 29 | 202: { 30 | description: 'Accepted', 31 | }, 32 | 204: { 33 | description: 'No Content', 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }; 40 | return linter.run(oasDoc).then((results) => { 41 | expect(results).toHaveLength(2); 42 | expect(results[0].path.join('.')).toBe('paths./test1.post.responses'); 43 | expect(results[1].path.join('.')).toBe('paths./test2.delete.responses'); 44 | results.forEach((result) => expect(result.message).toBe( 45 | 'An operation that returns 202 should not return other 2XX responses.', 46 | )); 47 | }); 48 | }); 49 | 50 | test('az-lro-response-codes should find no errors', () => { 51 | const oasDoc = { 52 | swagger: '2.0', 53 | paths: { 54 | '/test1': { 55 | post: { 56 | responses: { 57 | 202: { 58 | description: 'Accepted', 59 | }, 60 | }, 61 | }, 62 | }, 63 | '/test2': { 64 | delete: { 65 | responses: { 66 | 202: { 67 | description: 'Accepted', 68 | }, 69 | }, 70 | }, 71 | }, 72 | '/test3': { 73 | post: { 74 | responses: { 75 | 202: { 76 | description: 'Accepted', 77 | }, 78 | default: { 79 | description: 'Error', 80 | }, 81 | }, 82 | }, 83 | }, 84 | '/test4': { 85 | delete: { 86 | responses: { 87 | 202: { 88 | description: 'Accepted', 89 | }, 90 | default: { 91 | description: 'Error', 92 | }, 93 | }, 94 | }, 95 | }, 96 | '/test5': { 97 | post: { 98 | responses: { 99 | 200: { 100 | description: 'Success', 101 | }, 102 | }, 103 | }, 104 | }, 105 | '/test6': { 106 | delete: { 107 | responses: { 108 | 204: { 109 | description: 'No Content', 110 | }, 111 | }, 112 | }, 113 | }, 114 | }, 115 | }; 116 | return linter.run(oasDoc).then((results) => { 117 | expect(results.length).toBe(0); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /test/lro-response-headers.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-lro-response-headers'); 7 | return linter; 8 | }); 9 | 10 | test('az-lro-response-headers should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | paths: { 14 | '/test1': { 15 | post: { 16 | responses: { 17 | 202: { 18 | description: 'Accepted', 19 | }, 20 | }, 21 | }, 22 | }, 23 | '/test2': { 24 | post: { 25 | responses: { 26 | 202: { 27 | description: 'Accepted', 28 | headers: { 29 | location: { 30 | type: 'string', 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | }, 37 | }, 38 | }; 39 | return linter.run(oasDoc).then((results) => { 40 | expect(results).toHaveLength(2); 41 | expect(results[0].path.join('.')).toBe('paths./test1.post.responses.202'); 42 | expect(results[1].path.join('.')).toBe('paths./test2.post.responses.202.headers'); 43 | results.forEach((result) => expect(result.message).toBe( 44 | 'A 202 response should include an Operation-Location response header.', 45 | )); 46 | }); 47 | }); 48 | 49 | test('az-lro-response-headers should find no errors', () => { 50 | const oasDoc = { 51 | swagger: '2.0', 52 | paths: { 53 | '/test1': { 54 | post: { 55 | responses: { 56 | 202: { 57 | description: 'Accepted', 58 | headers: { 59 | 'Operation-location': { 60 | type: 'string', 61 | }, 62 | }, 63 | }, 64 | }, 65 | }, 66 | }, 67 | '/test2': { 68 | post: { 69 | responses: { 70 | 202: { 71 | description: 'Accepted', 72 | headers: { 73 | 'operation-location': { 74 | type: 'string', 75 | }, 76 | }, 77 | }, 78 | }, 79 | }, 80 | }, 81 | '/test3': { 82 | post: { 83 | responses: { 84 | 202: { 85 | description: 'Accepted', 86 | headers: { 87 | 'Operation-Location': { 88 | type: 'string', 89 | }, 90 | }, 91 | }, 92 | }, 93 | }, 94 | }, 95 | '/test4': { 96 | post: { 97 | responses: { 98 | 202: { 99 | description: 'Accepted', 100 | headers: { 101 | 'oPERATION-lOCATION': { 102 | type: 'string', 103 | }, 104 | }, 105 | }, 106 | }, 107 | }, 108 | }, 109 | }, 110 | }; 111 | return linter.run(oasDoc).then((results) => { 112 | expect(results.length).toBe(0); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /test/lro-response-schema.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-lro-response-schema'); 7 | return linter; 8 | }); 9 | 10 | test('az-lro-response-schema should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | paths: { 14 | '/test1': { 15 | post: { 16 | responses: { 17 | 202: { 18 | description: 'Accepted', 19 | }, 20 | }, 21 | }, 22 | }, 23 | '/test2': { 24 | post: { 25 | responses: { 26 | 202: { 27 | description: 'Accepted', 28 | schema: { 29 | type: 'object', 30 | properties: { 31 | id: { 32 | type: 'string', 33 | }, 34 | status: { 35 | type: 'string', 36 | enum: ['Running', 'Succeeded', 'Failed', 'Canceled'], 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | }, 43 | }, 44 | '/test3': { 45 | post: { 46 | responses: { 47 | 202: { 48 | description: 'Accepted', 49 | schema: { 50 | type: 'object', 51 | properties: { 52 | id: { 53 | type: 'uuid', 54 | }, 55 | status: { 56 | type: 'string', 57 | enum: ['InProgress', 'Succeeded', 'Failed', 'Canceled'], 58 | }, 59 | error: { 60 | type: 'string', 61 | }, 62 | }, 63 | required: ['id', 'status', 'error'], 64 | }, 65 | }, 66 | }, 67 | }, 68 | }, 69 | }, 70 | }; 71 | return linter.run(oasDoc).then((results) => { 72 | expect(results).toHaveLength(8); 73 | expect(results[0].path.join('.')).toBe('paths./test1.post.responses.202'); 74 | expect(results[0].message).toBe('A 202 response should include a schema for the operation status monitor.'); 75 | expect(results[1].path.join('.')).toBe('paths./test2.post.responses.202.schema'); 76 | expect(results[1].message).toBe('`id` property in LRO response should be required'); 77 | expect(results[2].path.join('.')).toBe('paths./test2.post.responses.202.schema'); 78 | expect(results[2].message).toBe('`status` property in LRO response should be required'); 79 | expect(results[3].path.join('.')).toBe('paths./test2.post.responses.202.schema.properties'); 80 | expect(results[3].message).toBe('LRO response should contain top-level property `error`'); 81 | expect(results[4].path.join('.')).toBe('paths./test3.post.responses.202.schema.properties.id.type'); 82 | expect(results[4].message).toBe('\'id\' property in LRO response should be type: string'); 83 | expect(results[5].path.join('.')).toBe('paths./test3.post.responses.202.schema.properties.status.enum'); 84 | expect(results[5].message).toBe('\'status\' property enum in LRO response should contain values: Running, Succeeded, Failed, Canceled'); 85 | expect(results[6].path.join('.')).toBe('paths./test3.post.responses.202.schema.properties.error.type'); 86 | expect(results[6].message).toBe('`error` property in LRO response should be type: object'); 87 | expect(results[7].path.join('.')).toBe('paths./test3.post.responses.202.schema.required'); 88 | expect(results[7].message).toBe('`error` property in LRO response should not be required'); 89 | }); 90 | }); 91 | 92 | test('az-lro-response-schema should find no errors', () => { 93 | const oasDoc = { 94 | swagger: '2.0', 95 | paths: { 96 | '/test1': { 97 | post: { 98 | responses: { 99 | 202: { 100 | description: 'Accepted', 101 | schema: { 102 | type: 'object', 103 | properties: { 104 | id: { 105 | type: 'string', 106 | }, 107 | status: { 108 | type: 'string', 109 | enum: ['Running', 'Succeeded', 'Failed', 'Canceled'], 110 | }, 111 | error: { 112 | type: 'object', 113 | properties: { 114 | code: { 115 | type: 'string', 116 | }, 117 | message: { 118 | type: 'string', 119 | }, 120 | }, 121 | required: ['code', 'message'], 122 | }, 123 | }, 124 | required: ['id', 'status'], 125 | }, 126 | }, 127 | }, 128 | }, 129 | }, 130 | }, 131 | }; 132 | return linter.run(oasDoc).then((results) => { 133 | expect(results).toHaveLength(0); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /test/matchers.js: -------------------------------------------------------------------------------- 1 | const { expect } = require('@jest/globals'); 2 | 3 | // Extend jest matchers with a method to check if an array contains an object 4 | // that matches the expected object. Matching means that actual object contains 5 | // all properties of the expected object with the same values. 6 | function toContainMatch(actual, expected) { 7 | if (!Array.isArray(actual)) { 8 | throw new TypeError('Actual value must be an array!'); 9 | } 10 | 11 | const index = actual.findIndex((item) => 12 | // eslint-disable-next-line implicit-arrow-linebreak 13 | Object.keys(expected).every((key) => this.equals(item[key], expected[key]))); 14 | 15 | const pass = index !== -1; 16 | const message = () => `expected ${this.utils.printReceived(actual)} to contain object ${this.utils.printExpected(expected)}`; 17 | 18 | return { message, pass }; 19 | } 20 | 21 | expect.extend({ 22 | toContainMatch, 23 | }); 24 | -------------------------------------------------------------------------------- /test/operation-security.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-operation-security'); 7 | return linter; 8 | }); 9 | 10 | test('az-operation-security should find operations without security', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | paths: { 14 | '/test1': { 15 | get: { 16 | operationId: 'notNounVerb', 17 | }, 18 | post: { 19 | operationId: 'fooBarBaz', 20 | }, 21 | }, 22 | }, 23 | }; 24 | return linter.run(oasDoc).then((results) => { 25 | expect(results).toHaveLength(2); 26 | expect(results[0].path.join('.')).toBe('paths./test1.get'); 27 | expect(results[1].path.join('.')).toBe('paths./test1.post'); 28 | results.forEach((result) => expect(result.message).toContain( 29 | 'Operation should have a security requirement.', 30 | )); 31 | }); 32 | }); 33 | 34 | test('az-operation-security should find operations without security in oas3', () => { 35 | const oasDoc = { 36 | openapi: '3.0', 37 | paths: { 38 | '/test1': { 39 | get: { 40 | operationId: 'notNounVerb', 41 | }, 42 | post: { 43 | operationId: 'fooBarBaz', 44 | }, 45 | }, 46 | }, 47 | }; 48 | return linter.run(oasDoc).then((results) => { 49 | expect(results).toHaveLength(2); 50 | expect(results[0].path.join('.')).toBe('paths./test1.get'); 51 | expect(results[1].path.join('.')).toBe('paths./test1.post'); 52 | results.forEach((result) => expect(result.message).toContain( 53 | 'Operation should have a security requirement.', 54 | )); 55 | }); 56 | }); 57 | 58 | test('az-operation-security should find no errors', () => { 59 | const oasDoc = { 60 | swagger: '2.0', 61 | paths: { 62 | '/test1': { 63 | get: { 64 | operationId: 'Noun_Get', 65 | security: [{ 66 | apiKey: [], 67 | }], 68 | }, 69 | put: { 70 | operationId: 'Noun_Create', 71 | security: [{ 72 | apiKey: [], 73 | }], 74 | }, 75 | }, 76 | }, 77 | }; 78 | return linter.run(oasDoc).then((results) => { 79 | expect(results.length).toBe(0); 80 | }); 81 | }); 82 | 83 | test('az-operation-security should find no errors in valid oas2 doc', () => { 84 | const oasDoc = { 85 | swagger: '2.0', 86 | security: [{ 87 | apiKey: [], 88 | }], 89 | paths: { 90 | '/test1': { 91 | get: { 92 | operationId: 'Noun_Get', 93 | }, 94 | put: { 95 | operationId: 'Noun_Create', 96 | }, 97 | }, 98 | }, 99 | }; 100 | return linter.run(oasDoc).then((results) => { 101 | expect(results.length).toBe(0); 102 | }); 103 | }); 104 | 105 | test('az-operation-security should find no errors in valid oas3 doc', () => { 106 | const oasDoc = { 107 | openapi: '3.0', 108 | security: [{ 109 | apiKey: [], 110 | }], 111 | paths: { 112 | '/test1': { 113 | get: { 114 | operationId: 'Noun_Get', 115 | }, 116 | put: { 117 | operationId: 'Noun_Create', 118 | }, 119 | }, 120 | }, 121 | }; 122 | return linter.run(oasDoc).then((results) => { 123 | expect(results.length).toBe(0); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /test/pagination-parameters.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable object-curly-newline */ 2 | const { linterForRule } = require('./utils'); 3 | 4 | let linter; 5 | 6 | beforeAll(async () => { 7 | linter = await linterForRule('az-pagination-parameters'); 8 | return linter; 9 | }); 10 | 11 | test('az-pagination-parameters should find errors in top parameter', () => { 12 | const oasDoc = { 13 | swagger: '2.0', 14 | paths: { 15 | '/test1': { 16 | get: { 17 | parameters: [{ name: 'top', in: 'query', type: 'string' }], 18 | }, 19 | }, 20 | '/test2': { 21 | get: { 22 | parameters: [{ name: 'top', in: 'query', type: 'integer', required: true }], 23 | }, 24 | }, 25 | '/test3': { 26 | post: { 27 | parameters: [{ name: 'top', in: 'query', type: 'integer', default: 100 }], 28 | }, 29 | }, 30 | }, 31 | }; 32 | return linter.run(oasDoc).then((results) => { 33 | expect(results.length).toBe(3); 34 | expect(results[0].path.join('.')).toBe('paths./test1.get.parameters.0.type'); 35 | expect(results[1].path.join('.')).toBe('paths./test2.get.parameters.0.required'); 36 | expect(results[2].path.join('.')).toBe('paths./test3.post.parameters.0.default'); 37 | }); 38 | }); 39 | 40 | test('az-pagination-parameters should find errors in skip parameter', () => { 41 | const oasDoc = { 42 | swagger: '2.0', 43 | paths: { 44 | '/test1': { 45 | get: { 46 | parameters: [{ name: 'skip', in: 'query', type: 'string', default: 0 }], 47 | }, 48 | }, 49 | '/test2': { 50 | get: { 51 | parameters: [{ name: 'skip', in: 'query', type: 'integer', default: 0, required: true }], 52 | }, 53 | }, 54 | '/test3': { 55 | post: { 56 | parameters: [{ name: 'skip', in: 'query', type: 'integer', default: 100 }], 57 | }, 58 | }, 59 | }, 60 | }; 61 | return linter.run(oasDoc).then((results) => { 62 | expect(results.length).toBe(3); 63 | expect(results[0].path.join('.')).toBe('paths./test1.get.parameters.0.type'); 64 | expect(results[1].path.join('.')).toBe('paths./test2.get.parameters.0.required'); 65 | expect(results[2].path.join('.')).toBe('paths./test3.post.parameters.0.default'); 66 | }); 67 | }); 68 | 69 | test('az-pagination-parameters should find errors in maxpagesize parameter', () => { 70 | const oasDoc = { 71 | swagger: '2.0', 72 | paths: { 73 | '/test0': { 74 | get: { 75 | parameters: [{ name: 'maxPageSize', in: 'query', type: 'integer' }], 76 | }, 77 | }, 78 | '/test1': { 79 | get: { 80 | parameters: [{ name: 'maxpagesize', in: 'query', type: 'string' }], 81 | }, 82 | }, 83 | '/test2': { 84 | get: { 85 | parameters: [{ name: 'maxpagesize', in: 'query', type: 'integer', required: true }], 86 | }, 87 | }, 88 | '/test3': { 89 | post: { 90 | parameters: [{ name: 'maxpagesize', in: 'query', type: 'integer', default: 100 }], 91 | }, 92 | }, 93 | }, 94 | }; 95 | return linter.run(oasDoc).then((results) => { 96 | expect(results.length).toBe(4); 97 | expect(results[0].path.join('.')).toBe('paths./test0.get.parameters.0.name'); 98 | expect(results[1].path.join('.')).toBe('paths./test1.get.parameters.0.type'); 99 | expect(results[2].path.join('.')).toBe('paths./test2.get.parameters.0.required'); 100 | expect(results[3].path.join('.')).toBe('paths./test3.post.parameters.0.default'); 101 | }); 102 | }); 103 | 104 | test('az-pagination-parameters should find errors in filter parameter', () => { 105 | const oasDoc = { 106 | swagger: '2.0', 107 | paths: { 108 | '/test1': { 109 | get: { 110 | parameters: [{ name: 'filter', in: 'query', type: 'integer' }], 111 | }, 112 | }, 113 | '/test2': { 114 | get: { 115 | parameters: [{ name: 'filter', in: 'query', type: 'string', required: true }], 116 | }, 117 | }, 118 | }, 119 | }; 120 | return linter.run(oasDoc).then((results) => { 121 | expect(results.length).toBe(2); 122 | expect(results[0].path.join('.')).toBe('paths./test1.get.parameters.0.type'); 123 | expect(results[1].path.join('.')).toBe('paths./test2.get.parameters.0.required'); 124 | }); 125 | }); 126 | 127 | test('az-pagination-parameters should find errors in orderby parameter', () => { 128 | const oasDoc = { 129 | swagger: '2.0', 130 | paths: { 131 | '/test0': { 132 | get: { 133 | parameters: [{ name: 'orderBy', in: 'query', type: 'array', items: { type: 'string' } }], 134 | }, 135 | }, 136 | '/test1': { 137 | get: { 138 | parameters: [{ name: 'orderby', in: 'query', type: 'string' }], 139 | }, 140 | }, 141 | '/test2': { 142 | get: { 143 | parameters: [{ name: 'orderby', in: 'query', type: 'array', items: { type: 'string' }, required: true }], 144 | }, 145 | }, 146 | }, 147 | }; 148 | return linter.run(oasDoc).then((results) => { 149 | expect(results.length).toBe(3); 150 | expect(results[0].path.join('.')).toBe('paths./test0.get.parameters.0.name'); 151 | expect(results[1].path.join('.')).toBe('paths./test1.get.parameters.0.type'); 152 | expect(results[2].path.join('.')).toBe('paths./test2.get.parameters.0.required'); 153 | }); 154 | }); 155 | 156 | // Test for errors in the select parameter 157 | test('az-pagination-parameters should find errors in select parameter', () => { 158 | const oasDoc = { 159 | swagger: '2.0', 160 | paths: { 161 | '/test1': { 162 | get: { 163 | parameters: [{ name: 'select', in: 'query', type: 'integer' }], 164 | }, 165 | }, 166 | '/test2': { 167 | get: { 168 | parameters: [{ name: 'select', in: 'query', type: 'array', items: { type: 'string' }, required: true }], 169 | }, 170 | }, 171 | }, 172 | }; 173 | return linter.run(oasDoc).then((results) => { 174 | expect(results.length).toBe(2); 175 | expect(results[0].path.join('.')).toBe('paths./test1.get.parameters.0.type'); 176 | expect(results[1].path.join('.')).toBe('paths./test2.get.parameters.0.required'); 177 | }); 178 | }); 179 | 180 | // Test for errors in the expand parameter 181 | test('az-pagination-parameters should find errors in expand parameter', () => { 182 | const oasDoc = { 183 | swagger: '2.0', 184 | paths: { 185 | '/test1': { 186 | get: { 187 | parameters: [{ name: 'expand', in: 'query', type: 'integer' }], 188 | }, 189 | }, 190 | '/test2': { 191 | get: { 192 | parameters: [{ name: 'expand', in: 'query', type: 'array', items: { type: 'string' }, required: true }], 193 | }, 194 | }, 195 | }, 196 | }; 197 | return linter.run(oasDoc).then((results) => { 198 | expect(results.length).toBe(2); 199 | expect(results[0].path.join('.')).toBe('paths./test1.get.parameters.0.type'); 200 | expect(results[1].path.join('.')).toBe('paths./test2.get.parameters.0.required'); 201 | }); 202 | }); 203 | 204 | test('az-pagination-parameters should find no errors', () => { 205 | const oasDoc = { 206 | swagger: '2.0', 207 | paths: { 208 | '/test1': { 209 | get: { 210 | parameters: [ 211 | { name: 'top', in: 'query', type: 'integer' }, 212 | { name: 'skip', in: 'query', type: 'integer', default: 0 }, 213 | { name: 'maxpagesize', in: 'query', type: 'integer' }, 214 | { name: 'filter', in: 'query', type: 'string' }, 215 | { name: 'select', in: 'query', type: 'array', items: { type: 'string' } }, 216 | { name: 'expand', in: 'query', type: 'array', items: { type: 'string' } }, 217 | { name: 'orderby', in: 'query', type: 'array', items: { type: 'string' } }, 218 | ], 219 | }, 220 | }, 221 | '/test2': { 222 | post: { 223 | parameters: [ 224 | { name: 'top', in: 'query', type: 'integer' }, 225 | { name: 'skip', in: 'query', type: 'integer', default: 0 }, 226 | { name: 'maxpagesize', in: 'query', type: 'integer' }, 227 | { name: 'filter', in: 'query', type: 'string' }, 228 | { name: 'select', in: 'query', type: 'array', items: { type: 'string' } }, 229 | { name: 'expand', in: 'query', type: 'array', items: { type: 'string' } }, 230 | { name: 'orderby', in: 'query', type: 'array', items: { type: 'string' } }, 231 | ], 232 | }, 233 | }, 234 | }, 235 | }; 236 | return linter.run(oasDoc).then((results) => { 237 | expect(results.length).toBe(0); 238 | }); 239 | }); 240 | -------------------------------------------------------------------------------- /test/parameter-default-not-allowed.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-parameter-default-not-allowed'); 7 | return linter; 8 | }); 9 | 10 | test('az-parameter-default-not-allowed should find errors', () => { 11 | const myOpenApiDocument = { 12 | swagger: '2.0', 13 | paths: { 14 | '/path1': { 15 | parameters: [ 16 | { 17 | name: 'param1', 18 | in: 'query', 19 | type: 'string', 20 | required: true, 21 | default: 'param1', 22 | }, 23 | ], 24 | get: { 25 | parameters: [ 26 | { 27 | name: 'param2', 28 | in: 'query', 29 | type: 'string', 30 | required: true, 31 | default: 'param2', 32 | }, 33 | ], 34 | }, 35 | }, 36 | }, 37 | }; 38 | return linter.run(myOpenApiDocument).then((results) => { 39 | expect(results.length).toBe(2); 40 | expect(results[0].path.join('.')).toBe('paths./path1.parameters.0.default'); 41 | expect(results[1].path.join('.')).toBe('paths./path1.get.parameters.0.default'); 42 | }); 43 | }); 44 | 45 | test('az-parameter-default-not-allowed should find no errors', () => { 46 | const myOpenApiDocument = { 47 | swagger: '2.0', 48 | paths: { 49 | '/path1': { 50 | parameters: [ 51 | { 52 | name: 'param1', 53 | in: 'query', 54 | type: 'string', 55 | required: false, 56 | default: 'param1', 57 | }, 58 | ], 59 | get: { 60 | parameters: [ 61 | { 62 | name: 'param2', 63 | in: 'query', 64 | type: 'string', 65 | default: 'param2', 66 | }, 67 | ], 68 | }, 69 | }, 70 | }, 71 | }; 72 | return linter.run(myOpenApiDocument).then((results) => { 73 | expect(results.length).toBe(0); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /test/parameter-description.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-parameter-description'); 7 | return linter; 8 | }); 9 | 10 | test('az-parameter-description should find errors', () => { 11 | // Test missing description in 3 different places: 12 | // 1. parameter at path level 13 | // 2. inline parameter at operation level 14 | // 3. referenced parameter at operation level 15 | const oasDoc = { 16 | swagger: '2.0', 17 | paths: { 18 | '/api/Paths': { 19 | parameters: [ 20 | { 21 | name: 'version', 22 | in: 'query', 23 | type: 'string', 24 | }, 25 | ], 26 | get: { 27 | parameters: [ 28 | { 29 | name: 'param1', 30 | in: 'query', 31 | type: 'string', 32 | }, 33 | { 34 | $ref: '#/parameters/Param2', 35 | }, 36 | ], 37 | }, 38 | }, 39 | }, 40 | parameters: { 41 | Param2: { 42 | name: 'param2', 43 | in: 'query', 44 | type: 'string', 45 | }, 46 | }, 47 | }; 48 | return linter.run(oasDoc).then((results) => { 49 | expect(results.length).toBe(3); 50 | expect(results[0].path.join('.')).toBe('paths./api/Paths.parameters.0'); 51 | expect(results[1].path.join('.')).toBe('paths./api/Paths.get.parameters.0'); 52 | expect(results[2].path.join('.')).toBe('paths./api/Paths.get.parameters.1'); 53 | }); 54 | }); 55 | 56 | test('az-parameter-description should find no errors', () => { 57 | const oasDoc = { 58 | swagger: '2.0', 59 | paths: { 60 | '/api/Paths': { 61 | parameters: [ 62 | { 63 | name: 'version', 64 | in: 'query', 65 | type: 'string', 66 | description: 'A descriptive description', 67 | }, 68 | ], 69 | get: { 70 | parameters: [ 71 | { 72 | name: 'param1', 73 | in: 'query', 74 | type: 'string', 75 | description: 'A descriptive description', 76 | }, 77 | { 78 | $ref: '#/parameters/Param2', 79 | }, 80 | ], 81 | }, 82 | }, 83 | }, 84 | parameters: { 85 | Param2: { 86 | name: 'param2', 87 | in: 'query', 88 | type: 'string', 89 | description: 'A descriptive description', 90 | }, 91 | }, 92 | }; 93 | return linter.run(oasDoc).then((results) => { 94 | expect(results.length).toBe(0); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /test/parameter-names-convention.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | require('./matchers'); 3 | 4 | let linter; 5 | 6 | beforeAll(async () => { 7 | linter = await linterForRule('az-parameter-names-convention'); 8 | return linter; 9 | }); 10 | 11 | test('az-parameter-names-convention should find errors', () => { 12 | // Test parameter names in 3 different places: 13 | // 1. parameter at path level 14 | // 2. inline parameter at operation level 15 | // 3. referenced parameter at operation level 16 | const oasDoc = { 17 | swagger: '2.0', 18 | paths: { 19 | '/test1/{test-id}': { 20 | parameters: [ 21 | { 22 | name: 'test-id', 23 | in: 'path', 24 | type: 'string', 25 | }, 26 | { 27 | name: 'foo_bar', 28 | in: 'query', 29 | type: 'string', 30 | }, 31 | { 32 | name: 'fooBar', 33 | in: 'header', 34 | type: 'string', 35 | description: 'Camel case header', 36 | }, 37 | { 38 | name: '$foo-bar', 39 | in: 'header', 40 | type: 'string', 41 | description: '$ should not be first character of header', 42 | }, 43 | { 44 | name: '@foo-bar', 45 | in: 'header', 46 | type: 'string', 47 | description: '@ should not be first character of header', 48 | }, 49 | ], 50 | get: { 51 | parameters: [ 52 | { 53 | name: 'resource-id', 54 | in: 'query', 55 | type: 'string', 56 | }, 57 | { 58 | $ref: '#/parameters/SkipParam', 59 | }, 60 | ], 61 | }, 62 | }, 63 | }, 64 | parameters: { 65 | SkipParam: { 66 | name: '$skip', 67 | in: 'query', 68 | type: 'integer', 69 | }, 70 | }, 71 | }; 72 | return linter.run(oasDoc).then((results) => { 73 | expect(results.length).toBe(7); 74 | expect(results).toContainMatch({ 75 | path: ['paths', '/test1/{test-id}', 'parameters', '0', 'name'], 76 | message: 'Parameter name "test-id" should be camel case.', 77 | }); 78 | expect(results).toContainMatch({ 79 | path: ['paths', '/test1/{test-id}', 'parameters', '1', 'name'], 80 | message: 'Parameter name "foo_bar" should be camel case.', 81 | }); 82 | expect(results).toContainMatch({ 83 | path: ['paths', '/test1/{test-id}', 'parameters', '2', 'name'], 84 | message: 'header parameter name "fooBar" should be kebab case.', 85 | }); 86 | expect(results).toContainMatch({ 87 | path: ['paths', '/test1/{test-id}', 'parameters', '3', 'name'], 88 | message: 'Parameter name "$foo-bar" should not begin with \'$\' or \'@\'.', 89 | }); 90 | expect(results).toContainMatch({ 91 | path: ['paths', '/test1/{test-id}', 'parameters', '4', 'name'], 92 | message: 'Parameter name "@foo-bar" should not begin with \'$\' or \'@\'.', 93 | }); 94 | expect(results).toContainMatch({ 95 | path: ['paths', '/test1/{test-id}', 'get', 'parameters', '0', 'name'], 96 | message: 'Parameter name "resource-id" should be camel case.', 97 | }); 98 | expect(results).toContainMatch({ 99 | path: ['paths', '/test1/{test-id}', 'get', 'parameters', '1', 'name'], 100 | message: 'Parameter name "$skip" should not begin with \'$\' or \'@\'.', 101 | }); 102 | }); 103 | }); 104 | 105 | test('az-parameter-names-convention should find no errors', () => { 106 | const oasDoc = { 107 | swagger: '2.0', 108 | paths: { 109 | '/test1/{id}': { 110 | parameters: [ 111 | { 112 | name: 'id', 113 | in: 'path', 114 | type: 'string', 115 | }, 116 | { 117 | name: 'fooBar', 118 | in: 'query', 119 | type: 'string', 120 | }, 121 | { 122 | name: 'foo-bar', 123 | in: 'header', 124 | type: 'string', 125 | }, 126 | ], 127 | get: { 128 | parameters: [ 129 | { 130 | name: 'resourceId', 131 | in: 'query', 132 | type: 'string', 133 | }, 134 | { 135 | $ref: '#/parameters/SkipParam', 136 | }, 137 | ], 138 | }, 139 | }, 140 | }, 141 | parameters: { 142 | SkipParam: { 143 | name: 'skip', 144 | in: 'query', 145 | type: 'integer', 146 | }, 147 | }, 148 | }; 149 | return linter.run(oasDoc).then((results) => { 150 | expect(results.length).toBe(0); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /test/parameter-names-unique.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | require('./matchers'); 3 | 4 | let linter; 5 | 6 | beforeAll(async () => { 7 | linter = await linterForRule('az-parameter-names-unique'); 8 | return linter; 9 | }); 10 | 11 | test('az-parameter-names-unique should find errors', () => { 12 | // Test parameter names in 3 different places: 13 | // 1. parameter at path level 14 | // 2. inline parameter at operation level 15 | // 3. referenced parameter at operation level 16 | const oasDoc = { 17 | swagger: '2.0', 18 | paths: { 19 | '/test1/{p1}': { 20 | parameters: [ 21 | { 22 | name: 'p1', 23 | in: 'path', 24 | type: 'string', 25 | }, 26 | // Legal in OAS2 for same name w/ different in 27 | { 28 | name: 'p1', 29 | in: 'query', 30 | type: 'string', 31 | }, 32 | { 33 | name: 'p2', 34 | in: 'query', 35 | type: 'string', 36 | }, 37 | ], 38 | get: { 39 | parameters: [ 40 | { 41 | name: 'p1', 42 | in: 'header', 43 | type: 'string', 44 | }, 45 | { 46 | $ref: '#/parameters/Param2', 47 | }, 48 | { 49 | name: 'p3', 50 | in: 'query', 51 | type: 'string', 52 | }, 53 | { 54 | name: 'p3', 55 | in: 'header', 56 | type: 'string', 57 | }, 58 | ], 59 | }, 60 | }, 61 | }, 62 | parameters: { 63 | Param2: { 64 | name: 'p2', 65 | in: 'header', 66 | type: 'integer', 67 | }, 68 | }, 69 | }; 70 | return linter.run(oasDoc).then((results) => { 71 | expect(results.length).toBe(4); 72 | expect(results).toContainMatch({ 73 | path: ['paths', '/test1/{p1}', 'parameters', '1', 'name'], 74 | message: 'Duplicate parameter name (ignoring case): p1.', 75 | }); 76 | expect(results).toContainMatch({ 77 | path: ['paths', '/test1/{p1}', 'get', 'parameters', '0', 'name'], 78 | message: 'Duplicate parameter name (ignoring case): p1.', 79 | }); 80 | expect(results).toContainMatch({ 81 | path: ['paths', '/test1/{p1}', 'get', 'parameters', '1', 'name'], 82 | message: 'Duplicate parameter name (ignoring case): p2.', 83 | }); 84 | expect(results).toContainMatch({ 85 | path: ['paths', '/test1/{p1}', 'get', 'parameters', '3', 'name'], 86 | message: 'Duplicate parameter name (ignoring case): p3.', 87 | }); 88 | }); 89 | }); 90 | 91 | test('az-parameter-names-unique should find no errors', () => { 92 | const oasDoc = { 93 | swagger: '2.0', 94 | paths: { 95 | '/test1/{id}': { 96 | parameters: [ 97 | { 98 | name: 'id', 99 | in: 'path', 100 | type: 'string', 101 | }, 102 | { 103 | name: 'fooBar', 104 | in: 'query', 105 | type: 'string', 106 | }, 107 | { 108 | name: 'foo-bar', 109 | in: 'header', 110 | type: 'string', 111 | }, 112 | ], 113 | get: { 114 | parameters: [ 115 | { 116 | name: 'resourceId', 117 | in: 'query', 118 | type: 'string', 119 | }, 120 | { 121 | $ref: '#/parameters/SkipParam', 122 | }, 123 | ], 124 | }, 125 | }, 126 | }, 127 | parameters: { 128 | SkipParam: { 129 | name: 'skip', 130 | in: 'query', 131 | type: 'integer', 132 | }, 133 | }, 134 | }; 135 | return linter.run(oasDoc).then((results) => { 136 | expect(results.length).toBe(0); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /test/parameter-order.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-parameter-order'); 7 | return linter; 8 | }); 9 | 10 | test('az-parameter-order should find errors', () => { 11 | // Test parameter ordering at path item level and operation level. 12 | const oasDoc = { 13 | swagger: '2.0', 14 | paths: { 15 | '/test1/{p1}/foo/{p2}': { 16 | parameters: [ 17 | { 18 | name: 'p2', 19 | in: 'path', 20 | type: 'string', 21 | }, 22 | { 23 | name: 'p1', 24 | in: 'path', 25 | type: 'string', 26 | }, 27 | ], 28 | get: { 29 | parameters: [ 30 | { 31 | name: 'p3', 32 | in: 'query', 33 | type: 'string', 34 | }, 35 | ], 36 | }, 37 | }, 38 | '/test2/{p1}/foo/{p2}/bar/{p3}': { 39 | parameters: [ 40 | { 41 | name: 'p1', 42 | in: 'path', 43 | type: 'string', 44 | }, 45 | ], 46 | get: { 47 | parameters: [ 48 | { 49 | name: 'p3', 50 | in: 'path', 51 | type: 'string', 52 | }, 53 | { 54 | name: 'p2', 55 | in: 'path', 56 | type: 'string', 57 | }, 58 | ], 59 | }, 60 | }, 61 | }, 62 | }; 63 | return linter.run(oasDoc).then((results) => { 64 | expect(results.length).toBe(2); 65 | expect(results[0].path.join('.')).toBe('paths./test1/{p1}/foo/{p2}.parameters'); 66 | expect(results[1].path.join('.')).toBe('paths./test2/{p1}/foo/{p2}/bar/{p3}.get.parameters'); 67 | }); 68 | }); 69 | 70 | test('az-parameter-order should find no errors', () => { 71 | const oasDoc = { 72 | swagger: '2.0', 73 | paths: { 74 | '/test1/{p1}/foo/{p2}': { 75 | parameters: [ 76 | { 77 | name: 'p1', 78 | in: 'path', 79 | type: 'string', 80 | }, 81 | { 82 | name: 'p2', 83 | in: 'path', 84 | type: 'string', 85 | }, 86 | ], 87 | get: { 88 | parameters: [ 89 | { 90 | name: 'p3', 91 | in: 'query', 92 | type: 'string', 93 | }, 94 | ], 95 | }, 96 | }, 97 | '/test2/{p1}/foo/{p2}/bar/{p3}': { 98 | parameters: [ 99 | { 100 | name: 'p1', 101 | in: 'path', 102 | type: 'string', 103 | }, 104 | ], 105 | get: { 106 | parameters: [ 107 | { 108 | name: 'p2', 109 | in: 'path', 110 | type: 'string', 111 | }, 112 | { 113 | name: 'p3', 114 | in: 'path', 115 | type: 'string', 116 | }, 117 | ], 118 | }, 119 | }, 120 | }, 121 | }; 122 | return linter.run(oasDoc).then((results) => { 123 | expect(results.length).toBe(0); 124 | }); 125 | }); 126 | 127 | test('az-parameter-order should find oas3 errors', () => { 128 | // Test parameter ordering at path item level and operation level. 129 | const oasDoc = { 130 | openapi: '3.0.3', 131 | paths: { 132 | '/test1/{p1}/foo/{p2}': { 133 | parameters: [ 134 | { 135 | name: 'p2', 136 | in: 'path', 137 | schema: { 138 | type: 'string', 139 | }, 140 | }, 141 | { 142 | name: 'p1', 143 | in: 'path', 144 | schema: { 145 | type: 'string', 146 | }, 147 | }, 148 | ], 149 | get: { 150 | parameters: [ 151 | { 152 | name: 'p3', 153 | in: 'query', 154 | schema: { 155 | type: 'string', 156 | }, 157 | }, 158 | ], 159 | }, 160 | }, 161 | '/test2/{p1}/foo/{p2}/bar/{p3}': { 162 | parameters: [ 163 | { 164 | name: 'p1', 165 | in: 'path', 166 | schema: { 167 | type: 'string', 168 | }, 169 | }, 170 | ], 171 | get: { 172 | parameters: [ 173 | { 174 | name: 'p3', 175 | in: 'path', 176 | schema: { 177 | type: 'string', 178 | }, 179 | }, 180 | { 181 | name: 'p2', 182 | in: 'path', 183 | schema: { 184 | type: 'string', 185 | }, 186 | }, 187 | ], 188 | }, 189 | }, 190 | }, 191 | }; 192 | return linter.run(oasDoc).then((results) => { 193 | expect(results.length).toBe(2); 194 | expect(results[0].path.join('.')).toBe('paths./test1/{p1}/foo/{p2}.parameters'); 195 | expect(results[1].path.join('.')).toBe('paths./test2/{p1}/foo/{p2}/bar/{p3}.get.parameters'); 196 | }); 197 | }); 198 | 199 | test('az-parameter-order should find no oas3 errors', () => { 200 | const oasDoc = { 201 | openapi: '3.0.3', 202 | paths: { 203 | '/test1/{p1}/foo/{p2}': { 204 | parameters: [ 205 | { 206 | name: 'p1', 207 | in: 'path', 208 | schema: { 209 | type: 'string', 210 | }, 211 | }, 212 | { 213 | name: 'p2', 214 | in: 'path', 215 | schema: { 216 | type: 'string', 217 | }, 218 | }, 219 | ], 220 | get: { 221 | parameters: [ 222 | { 223 | name: 'p3', 224 | in: 'query', 225 | schema: { 226 | type: 'string', 227 | }, 228 | }, 229 | ], 230 | }, 231 | }, 232 | '/test2/{p1}/foo/{p2}/bar/{p3}': { 233 | parameters: [ 234 | { 235 | name: 'p1', 236 | in: 'path', 237 | schema: { 238 | type: 'string', 239 | }, 240 | }, 241 | ], 242 | get: { 243 | parameters: [ 244 | { 245 | name: 'p2', 246 | in: 'path', 247 | schema: { 248 | type: 'string', 249 | }, 250 | }, 251 | { 252 | name: 'p3', 253 | in: 'path', 254 | schema: { 255 | type: 'string', 256 | }, 257 | }, 258 | ], 259 | }, 260 | }, 261 | }, 262 | }; 263 | return linter.run(oasDoc).then((results) => { 264 | expect(results.length).toBe(0); 265 | }); 266 | }); 267 | -------------------------------------------------------------------------------- /test/patch-content-type.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-patch-content-type'); 7 | return linter; 8 | }); 9 | 10 | test('az-patch-content-type should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | consumes: [ 14 | 'application/json', 15 | 'application/merge-patch+json', 16 | ], 17 | paths: { 18 | '/test1': { 19 | put: { 20 | consumes: [ 21 | 'application/json', 22 | 'application/merge-patch+json', 23 | ], 24 | }, 25 | post: { 26 | consumes: [ 27 | 'application/json', 28 | 'application/merge-patch+json', 29 | ], 30 | }, 31 | patch: { 32 | }, 33 | }, 34 | '/test2': { 35 | patch: { 36 | consumes: [ 37 | 'application/json', 38 | ], 39 | }, 40 | }, 41 | }, 42 | }; 43 | return linter.run(oasDoc).then((results) => { 44 | expect(results.length).toBe(5); 45 | expect(results[0].path.join('.')).toBe('consumes'); 46 | expect(results[1].path.join('.')).toBe('paths./test1.put.consumes'); 47 | expect(results[2].path.join('.')).toBe('paths./test1.post.consumes'); 48 | expect(results[3].path.join('.')).toBe('paths./test1.patch'); 49 | expect(results[4].path.join('.')).toBe('paths./test2.patch.consumes'); 50 | }); 51 | }); 52 | 53 | test('az-patch-content-type should find no errors', () => { 54 | const oasDoc = { 55 | swagger: '2.0', 56 | consumes: [ 57 | 'application/json', 58 | ], 59 | paths: { 60 | '/test1': { 61 | put: { 62 | consumes: [ 63 | 'application/json', 64 | ], 65 | }, 66 | post: { 67 | }, 68 | patch: { 69 | consumes: [ 70 | 'application/merge-patch+json', 71 | ], 72 | }, 73 | }, 74 | }, 75 | }; 76 | return linter.run(oasDoc).then((results) => { 77 | expect(results.length).toBe(0); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/path-case-convention.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-path-case-convention'); 7 | return linter; 8 | }); 9 | 10 | test('az-path-case-convention should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | paths: { 14 | '/fooBar': {}, 15 | '/foo-bar/{id}/barBaz': {}, 16 | }, 17 | }; 18 | return linter.run(oasDoc).then((results) => { 19 | expect(results.length).toBe(2); 20 | expect(results[0].path.join('.')).toBe('paths./fooBar'); 21 | expect(results[1].path.join('.')).toBe('paths./foo-bar/{id}/barBaz'); 22 | }); 23 | }); 24 | 25 | test('az-path-case-convention should find no errors', () => { 26 | const oasDoc = { 27 | swagger: '2.0', 28 | paths: { 29 | '/': {}, 30 | '/:foobar': {}, 31 | '/abcdefghijklmnopqrstuvwxyz0123456789': {}, 32 | '/a0-b1-c2/d3-e4-f5/ghi-jkl-mno/pqrstuvwxyz': {}, 33 | '/foo/{bar}:bazQux': {}, 34 | }, 35 | }; 36 | return linter.run(oasDoc).then((results) => { 37 | expect(results.length).toBe(0); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/path-characters.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-path-characters'); 7 | return linter; 8 | }); 9 | 10 | test('az-path-characters should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | paths: { 14 | '/api[]': {}, 15 | '/foo:bar/{baz}': {}, 16 | '/foo/{bar}:baz/qux': {}, 17 | }, 18 | }; 19 | return linter.run(oasDoc).then((results) => { 20 | expect(results.length).toBe(3); 21 | expect(results[0].path.join('.')).toBe('paths./api[]'); 22 | expect(results[1].path.join('.')).toBe('paths./foo:bar/{baz}'); 23 | expect(results[2].path.join('.')).toBe('paths./foo/{bar}:baz/qux'); 24 | }); 25 | }); 26 | 27 | test('az-path-characters should find no errors', () => { 28 | const oasDoc = { 29 | swagger: '2.0', 30 | paths: { 31 | '/': {}, 32 | '/:foobar': {}, 33 | '/abcdefghijklmnopqrstuvwxyz0123456789': {}, 34 | '/A0.B1.C2/D3_E4_F5/GHI-JKL-MNO/~PQRSTUVWXYZ': {}, 35 | '/foo/{$#@&^}:goo': {}, 36 | }, 37 | }; 38 | return linter.run(oasDoc).then((results) => { 39 | expect(results.length).toBe(0); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/path-param-names.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-path-parameter-names'); 7 | return linter; 8 | }); 9 | 10 | test('az-path-parameter-names should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | paths: { 14 | '/foo/{p1}': {}, 15 | '/foo/{p2}/bar/{p3}': {}, 16 | '/bar/{p4}': {}, 17 | }, 18 | }; 19 | return linter.run(oasDoc).then((results) => { 20 | expect(results.length).toBe(2); 21 | expect(results[0].path.join('.')).toBe('paths./foo/{p2}/bar/{p3}'); 22 | expect(results[0].message).toContain('Inconsistent parameter names "p1" and "p2" for path segment "foo".'); 23 | expect(results[1].path.join('.')).toBe('paths./bar/{p4}'); 24 | expect(results[1].message).toContain('Inconsistent parameter names "p3" and "p4" for path segment "bar".'); 25 | }); 26 | }); 27 | 28 | test('az-path-parameter-names should find no errors', () => { 29 | const oasDoc = { 30 | swagger: '2.0', 31 | paths: { 32 | '/foo/{p1}': {}, 33 | '/foo/{p1}/bar/{p2}': {}, 34 | '/bar/{p2}': {}, 35 | }, 36 | }; 37 | return linter.run(oasDoc).then((results) => { 38 | expect(results.length).toBe(0); 39 | }); 40 | }); 41 | 42 | test('az-path-parameter-names should find oas3 errors', () => { 43 | const oasDoc = { 44 | openapi: '3.0', 45 | paths: { 46 | '/foo/{p1}': {}, 47 | '/foo/{p2}/bar/{p3}': {}, 48 | '/bar/{p4}': {}, 49 | }, 50 | }; 51 | return linter.run(oasDoc).then((results) => { 52 | expect(results.length).toBe(2); 53 | expect(results[0].path.join('.')).toBe('paths./foo/{p2}/bar/{p3}'); 54 | expect(results[0].message).toContain('Inconsistent parameter names "p1" and "p2" for path segment "foo".'); 55 | expect(results[1].path.join('.')).toBe('paths./bar/{p4}'); 56 | expect(results[1].message).toContain('Inconsistent parameter names "p3" and "p4" for path segment "bar".'); 57 | }); 58 | }); 59 | 60 | test('az-path-parameter-names should find no oas3 errors', () => { 61 | const oasDoc = { 62 | openapi: '3.0', 63 | paths: { 64 | '/foo/{p1}': {}, 65 | '/foo/{p1}/bar/{p2}': {}, 66 | '/bar/{p2}': {}, 67 | }, 68 | }; 69 | return linter.run(oasDoc).then((results) => { 70 | expect(results.length).toBe(0); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /test/property-default-not-allowed.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-property-default-not-allowed'); 7 | return linter; 8 | }); 9 | 10 | test('az-property-default-not-allowed should find errors', () => { 11 | const myOpenApiDocument = { 12 | swagger: '2.0', 13 | paths: { 14 | '/path1': { 15 | put: { 16 | parameters: [ 17 | { 18 | name: 'body', 19 | in: 'body', 20 | schema: { 21 | $ref: '#/definitions/MyBodyModel', 22 | }, 23 | required: true, 24 | }, 25 | ], 26 | responses: { 27 | 200: { 28 | description: 'OK', 29 | schema: { 30 | $ref: '#/definitions/MyResponseModel', 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | '/path2': { 37 | get: { 38 | responses: { 39 | 200: { 40 | description: 'OK', 41 | schema: { 42 | $ref: '#/definitions/AnotherModel', 43 | }, 44 | }, 45 | }, 46 | }, 47 | }, 48 | }, 49 | definitions: { 50 | MyBodyModel: { 51 | type: 'object', 52 | required: ['prop1'], 53 | properties: { 54 | prop1: { 55 | type: 'string', 56 | default: 'foo', 57 | }, 58 | }, 59 | }, 60 | MyResponseModel: { 61 | type: 'object', 62 | required: ['prop2'], 63 | properties: { 64 | prop2: { 65 | type: 'string', 66 | default: 'bar', 67 | }, 68 | prop3: { 69 | $ref: '#/definitions/MyNestedResponseModel', 70 | }, 71 | }, 72 | allOf: [ 73 | { 74 | required: ['prop5'], 75 | properties: { 76 | prop5: { 77 | type: 'string', 78 | default: 'qux', 79 | }, 80 | }, 81 | }, 82 | ], 83 | }, 84 | MyNestedResponseModel: { 85 | type: 'object', 86 | required: ['prop4'], 87 | properties: { 88 | prop4: { 89 | type: 'string', 90 | default: 'baz', 91 | }, 92 | }, 93 | }, 94 | AnotherModel: { 95 | type: 'object', 96 | required: ['foo'], 97 | allOf: [ 98 | { 99 | properties: { 100 | foo: { 101 | type: 'string', 102 | default: 'qux', 103 | }, 104 | }, 105 | }, 106 | ], 107 | }, 108 | }, 109 | }; 110 | return linter.run(myOpenApiDocument).then((results) => { 111 | expect(results.length).toBe(4); 112 | expect(results[0].path.join('.')).toBe('paths./path1.put.parameters.0.schema.properties.prop1.default'); 113 | expect(results[0].message).toBe('Schema property "prop1" is required and cannot have a default'); 114 | expect(results[1].path.join('.')).toBe('paths./path1.put.responses.200.schema.allOf.0.properties.prop5.default'); 115 | expect(results[1].message).toBe('Schema property "prop5" is required and cannot have a default'); 116 | expect(results[2].path.join('.')).toBe('paths./path1.put.responses.200.schema.properties.prop2.default'); 117 | expect(results[2].message).toBe('Schema property "prop2" is required and cannot have a default'); 118 | expect(results[3].path.join('.')).toBe('paths./path1.put.responses.200.schema.properties.prop3.properties.prop4.default'); 119 | expect(results[3].message).toBe('Schema property "prop4" is required and cannot have a default'); 120 | }); 121 | }); 122 | 123 | test('az-property-default-not-allowed should find no errors', () => { 124 | const myOpenApiDocument = { 125 | swagger: '2.0', 126 | paths: { 127 | '/path1': { 128 | put: { 129 | parameters: [ 130 | { 131 | name: 'body', 132 | in: 'body', 133 | schema: { 134 | $ref: '#/definitions/MyBodyModel', 135 | }, 136 | required: true, 137 | }, 138 | ], 139 | responses: { 140 | 200: { 141 | description: 'OK', 142 | schema: { 143 | $ref: '#/definitions/MyResponseModel', 144 | }, 145 | }, 146 | }, 147 | }, 148 | }, 149 | }, 150 | definitions: { 151 | MyBodyModel: { 152 | type: 'object', 153 | required: ['prop1'], 154 | properties: { 155 | prop1: { 156 | type: 'string', 157 | }, 158 | }, 159 | }, 160 | MyResponseModel: { 161 | type: 'object', 162 | properties: { 163 | prop2: { 164 | type: 'string', 165 | default: 'bar', 166 | }, 167 | prop3: { 168 | $ref: '#/definitions/MyNestedResponseModel', 169 | }, 170 | }, 171 | allOf: [ 172 | { 173 | required: ['prop5'], 174 | properties: { 175 | prop5: { 176 | type: 'string', 177 | }, 178 | }, 179 | }, 180 | ], 181 | }, 182 | MyNestedResponseModel: { 183 | type: 'object', 184 | required: ['prop4'], 185 | properties: { 186 | prop4: { 187 | type: 'string', 188 | }, 189 | }, 190 | }, 191 | }, 192 | }; 193 | return linter.run(myOpenApiDocument).then((results) => { 194 | expect(results.length).toBe(0); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /test/property-description.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-property-description'); 7 | return linter; 8 | }); 9 | 10 | // Test for missing property description in 11 | // - inline body parameter schema 12 | // - inline response body schema 13 | // - top-level schema in definitions 14 | // - inner schema in definitions 15 | 16 | test('az-property-description should find errors', () => { 17 | const oasDoc = { 18 | swagger: '2.0', 19 | paths: { 20 | '/test1': { 21 | post: { 22 | parameters: [ 23 | { 24 | name: 'version', 25 | in: 'body', 26 | schema: { 27 | type: 'object', 28 | properties: { 29 | prop1: { 30 | type: 'string', 31 | }, 32 | prop2: { 33 | type: 'object', 34 | properties: { 35 | prop3: { 36 | type: 'string', 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | }, 43 | ], 44 | responses: { 45 | 200: { 46 | description: 'Success', 47 | schema: { 48 | type: 'object', 49 | properties: { 50 | propA: { 51 | type: 'string', 52 | }, 53 | propB: { 54 | type: 'object', 55 | properties: { 56 | propC: { 57 | type: 'string', 58 | }, 59 | }, 60 | }, 61 | }, 62 | }, 63 | }, 64 | }, 65 | }, 66 | }, 67 | }, 68 | definitions: { 69 | Model1: { 70 | type: 'object', 71 | properties: { 72 | propW: { 73 | type: 'string', 74 | }, 75 | propX: { 76 | type: 'object', 77 | properties: { 78 | propY: { 79 | type: 'string', 80 | }, 81 | }, 82 | }, 83 | propZ: { 84 | $ref: '#/definitions/PropZ', 85 | }, 86 | }, 87 | }, 88 | PropZ: { 89 | type: 'string', 90 | }, 91 | }, 92 | }; 93 | return linter.run(oasDoc).then((results) => { 94 | expect(results.length).toBe(9); 95 | expect(results[0].path.join('.')).toBe('paths./test1.post.parameters.0.schema.properties.prop1'); 96 | expect(results[1].path.join('.')).toBe('paths./test1.post.parameters.0.schema.properties.prop2'); 97 | expect(results[2].path.join('.')).toBe('paths./test1.post.parameters.0.schema.properties.prop2.properties.prop3'); 98 | expect(results[3].path.join('.')).toBe('paths./test1.post.responses.200.schema.properties.propA'); 99 | expect(results[4].path.join('.')).toBe('paths./test1.post.responses.200.schema.properties.propB'); 100 | expect(results[5].path.join('.')).toBe('paths./test1.post.responses.200.schema.properties.propB.properties.propC'); 101 | expect(results[6].path.join('.')).toBe('definitions.Model1.properties.propW'); 102 | expect(results[7].path.join('.')).toBe('definitions.Model1.properties.propX'); 103 | expect(results[8].path.join('.')).toBe('definitions.Model1.properties.propX.properties.propY'); 104 | }); 105 | }); 106 | 107 | test('az-property-description should find no errors', () => { 108 | const oasDoc = { 109 | swagger: '2.0', 110 | paths: { 111 | '/test1': { 112 | post: { 113 | parameters: [ 114 | { 115 | name: 'version', 116 | in: 'body', 117 | schema: { 118 | type: 'object', 119 | properties: { 120 | prop1: { 121 | type: 'string', 122 | description: 'prop1', 123 | }, 124 | prop2: { 125 | type: 'object', 126 | description: 'prop2', 127 | properties: { 128 | prop3: { 129 | type: 'string', 130 | description: 'prop3', 131 | }, 132 | }, 133 | }, 134 | }, 135 | }, 136 | }, 137 | ], 138 | responses: { 139 | 200: { 140 | description: 'Success', 141 | schema: { 142 | type: 'object', 143 | properties: { 144 | propA: { 145 | type: 'string', 146 | description: 'propA', 147 | }, 148 | propB: { 149 | type: 'object', 150 | description: 'propB', 151 | properties: { 152 | propC: { 153 | type: 'string', 154 | description: 'propC', 155 | }, 156 | }, 157 | }, 158 | }, 159 | }, 160 | }, 161 | }, 162 | }, 163 | }, 164 | }, 165 | definitions: { 166 | Model1: { 167 | type: 'object', 168 | properties: { 169 | propW: { 170 | type: 'string', 171 | description: 'propW', 172 | }, 173 | propX: { 174 | type: 'object', 175 | description: 'propX', 176 | properties: { 177 | propY: { 178 | type: 'string', 179 | description: 'propY', 180 | }, 181 | }, 182 | }, 183 | propZ: { 184 | $ref: '#/definitions/PropZ', 185 | }, 186 | }, 187 | }, 188 | PropZ: { 189 | type: 'string', 190 | }, 191 | }, 192 | }; 193 | return linter.run(oasDoc).then((results) => { 194 | expect(results.length).toBe(0); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /test/put-path.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-put-path'); 7 | return linter; 8 | }); 9 | 10 | test('az-put-path should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | paths: { 14 | '/bar/baz': { 15 | put: {}, 16 | }, 17 | }, 18 | }; 19 | return linter.run(oasDoc).then((results) => { 20 | expect(results.length).toBe(1); 21 | expect(results[0].path.join('.')).toBe('paths./bar/baz'); 22 | }); 23 | }); 24 | 25 | test('az-put-path should find no errors', () => { 26 | const oasDoc = { 27 | swagger: '2.0', 28 | paths: { 29 | '/bar/{p1}': { 30 | put: { 31 | parameters: [ 32 | { 33 | name: 'p1', 34 | in: 'path', 35 | type: 'string', 36 | maxLength: 50, 37 | }, 38 | ], 39 | }, 40 | }, 41 | }, 42 | }; 43 | return linter.run(oasDoc).then((results) => { 44 | expect(results.length).toBe(0); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/put-request-and-response-body.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-put-request-and-response-body'); 7 | return linter; 8 | }); 9 | 10 | test('az-put-request-and-response-body should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | paths: { 14 | '/test1/{id}': { 15 | parameters: [ 16 | { 17 | name: 'id', 18 | in: 'path', 19 | type: 'string', 20 | }, 21 | ], 22 | put: { 23 | parameters: [ 24 | { 25 | name: 'body', 26 | in: 'body', 27 | schema: { 28 | $ref: '#/definitions/This', 29 | }, 30 | }, 31 | ], 32 | responses: { 33 | 201: { 34 | description: 'Created', 35 | schema: { 36 | $ref: '#/definitions/That', 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | }, 43 | definitions: { 44 | This: { 45 | description: 'This', 46 | type: 'object', 47 | }, 48 | That: { 49 | description: 'That', 50 | type: 'object', 51 | }, 52 | }, 53 | }; 54 | return linter.run(oasDoc).then((results) => { 55 | expect(results.length).toBe(1); 56 | expect(results[0].path.join('.')).toBe('paths./test1/{id}.put'); 57 | expect(results[0].message).toBe('A PUT operation should use the same schema for the request and response body.'); 58 | }); 59 | }); 60 | 61 | test('az-put-request-and-response-body should find no errors', () => { 62 | const oasDoc = { 63 | swagger: '2.0', 64 | paths: { 65 | '/test1/{id}': { 66 | parameters: [ 67 | { 68 | name: 'id', 69 | in: 'path', 70 | type: 'string', 71 | }, 72 | ], 73 | put: { 74 | parameters: [ 75 | { 76 | name: 'body', 77 | in: 'body', 78 | schema: { 79 | $ref: '#/definitions/This', 80 | }, 81 | }, 82 | ], 83 | responses: { 84 | 201: { 85 | description: 'Created', 86 | schema: { 87 | $ref: '#/definitions/This', 88 | }, 89 | }, 90 | }, 91 | }, 92 | }, 93 | }, 94 | definitions: { 95 | This: { 96 | description: 'This', 97 | type: 'object', 98 | }, 99 | }, 100 | }; 101 | return linter.run(oasDoc).then((results) => { 102 | expect(results.length).toBe(0); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /test/readonly-in-response-schema.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-readonly-in-response-schema'); 7 | return linter; 8 | }); 9 | 10 | test('az-readonly-in-response-schema should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | paths: { 14 | '/test1': { 15 | post: { 16 | parameters: [ 17 | { 18 | in: 'body', 19 | name: 'body', 20 | schema: { 21 | $ref: '#/definitions/Model1', 22 | }, 23 | }, 24 | ], 25 | responses: { 26 | 200: { 27 | description: 'Success', 28 | schema: { 29 | $ref: '#/definitions/Model2', 30 | }, 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | definitions: { 37 | Model1: { 38 | type: 'object', 39 | allOf: [ 40 | { 41 | $ref: '#/definitions/Model3', 42 | }, 43 | ], 44 | }, 45 | Model2: { 46 | type: 'object', 47 | properties: { 48 | id: { 49 | type: 'string', 50 | readOnly: true, 51 | }, 52 | }, 53 | }, 54 | Model3: { 55 | type: 'object', 56 | properties: { 57 | foo: { 58 | type: 'string', 59 | readOnly: true, 60 | }, 61 | }, 62 | }, 63 | }, 64 | }; 65 | return linter.run(oasDoc).then((results) => { 66 | expect(results.length).toBe(1); 67 | expect(results[0].path.join('.')).toBe('definitions.Model2.properties.id.readOnly'); 68 | }); 69 | }); 70 | 71 | test('az-readonly-in-response-schema should not find errors', () => { 72 | const oasDoc = { 73 | swagger: '2.0', 74 | paths: { 75 | '/test1': { 76 | post: { 77 | parameters: [ 78 | { 79 | in: 'body', 80 | name: 'body', 81 | schema: { 82 | $ref: '#/definitions/Model1', 83 | }, 84 | }, 85 | ], 86 | responses: { 87 | 200: { 88 | description: 'Success', 89 | schema: { 90 | $ref: '#/definitions/Model2', 91 | }, 92 | }, 93 | }, 94 | }, 95 | }, 96 | '/test2': { 97 | post: { 98 | parameters: [ 99 | { 100 | in: 'body', 101 | name: 'body', 102 | schema: { 103 | $ref: '#/definitions/Pet', 104 | }, 105 | }, 106 | ], 107 | responses: { 108 | 200: { 109 | description: 'Success', 110 | }, 111 | }, 112 | }, 113 | }, 114 | '/test3': { 115 | post: { 116 | responses: { 117 | 200: { 118 | description: 'Success', 119 | }, 120 | }, 121 | }, 122 | }, 123 | }, 124 | definitions: { 125 | Model1: { 126 | type: 'object', 127 | properties: { 128 | id: { 129 | type: 'string', 130 | readOnly: true, 131 | }, 132 | things: { 133 | type: 'array', 134 | items: { 135 | $ref: '#/definitions/Thing', 136 | }, 137 | }, 138 | tags: { 139 | additionalProperties: { 140 | $ref: '#/definitions/Tag', 141 | }, 142 | }, 143 | }, 144 | allOf: [ 145 | { 146 | $ref: '#/definitions/Model3', 147 | }, 148 | ], 149 | }, 150 | Model2: { 151 | type: 'object', 152 | properties: { 153 | id: { 154 | type: 'string', 155 | }, 156 | }, 157 | }, 158 | Model3: { 159 | type: 'object', 160 | properties: { 161 | foo: { 162 | type: 'string', 163 | readOnly: true, 164 | }, 165 | }, 166 | }, 167 | Thing: { 168 | type: 'object', 169 | properties: { 170 | id: { 171 | type: 'string', 172 | readOnly: true, 173 | }, 174 | }, 175 | }, 176 | Tag: { 177 | type: 'object', 178 | properties: { 179 | id: { 180 | type: 'string', 181 | readOnly: true, 182 | }, 183 | }, 184 | }, 185 | Pet: { 186 | discriminator: 'petType', 187 | }, 188 | Dog: { 189 | type: 'object', 190 | allOf: [{ $ref: '#/definitions/Pet' }], 191 | properties: { 192 | cute: { 193 | type: 'number', 194 | readOnly: true, 195 | }, 196 | }, 197 | }, 198 | Cat: { 199 | type: 'object', 200 | allOf: [{ $ref: '#/definitions/Pet' }], 201 | properties: { 202 | attitude: { 203 | type: 'string', 204 | readOnly: true, 205 | }, 206 | }, 207 | }, 208 | }, 209 | }; 210 | return linter.run(oasDoc).then((results) => { 211 | expect(results.length).toBe(0); 212 | }); 213 | }); 214 | -------------------------------------------------------------------------------- /test/request-body-optional.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-request-body-optional'); 7 | return linter; 8 | }); 9 | 10 | test('az-request-body-optional should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | paths: { 14 | '/test1': { 15 | put: { 16 | parameters: [ 17 | { 18 | name: 'body', 19 | in: 'body', 20 | type: 'string', 21 | }, 22 | ], 23 | }, 24 | }, 25 | '/test2': { 26 | patch: { 27 | parameters: [ 28 | { 29 | name: 'body', 30 | in: 'body', 31 | type: 'string', 32 | }, 33 | ], 34 | }, 35 | }, 36 | '/test3': { 37 | post: { 38 | parameters: [ 39 | { 40 | name: 'body', 41 | in: 'body', 42 | type: 'string', 43 | }, 44 | ], 45 | }, 46 | }, 47 | }, 48 | }; 49 | return linter.run(oasDoc).then((results) => { 50 | expect(results.length).toBe(3); 51 | expect(results[0].path.join('.')).toBe('paths./test1.put.parameters.0'); 52 | expect(results[1].path.join('.')).toBe('paths./test2.patch.parameters.0'); 53 | expect(results[2].path.join('.')).toBe('paths./test3.post.parameters.0'); 54 | }); 55 | }); 56 | 57 | test('az-request-body-optional should find no errors', () => { 58 | const oasDoc = { 59 | swagger: '2.0', 60 | paths: { 61 | '/test1': { 62 | put: { 63 | parameters: [ 64 | { 65 | name: 'body', 66 | in: 'body', 67 | type: 'string', 68 | required: true, 69 | }, 70 | ], 71 | }, 72 | }, 73 | '/test2': { 74 | patch: { 75 | parameters: [ 76 | { 77 | name: 'body', 78 | in: 'body', 79 | type: 'string', 80 | required: true, 81 | }, 82 | ], 83 | }, 84 | }, 85 | '/test3': { 86 | post: { 87 | parameters: [ 88 | { 89 | name: 'body', 90 | in: 'body', 91 | type: 'string', 92 | required: true, 93 | }, 94 | ], 95 | }, 96 | }, 97 | '/test4': { 98 | post: { 99 | parameters: [ 100 | { 101 | name: 'body', 102 | in: 'body', 103 | type: 'string', 104 | required: false, 105 | }, 106 | ], 107 | }, 108 | }, 109 | }, 110 | }; 111 | return linter.run(oasDoc).then((results) => { 112 | expect(results.length).toBe(0); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /test/security-definition-description.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-security-definition-description'); 7 | return linter; 8 | }); 9 | 10 | test('az-security-definition-description should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | securityDefinitions: { 14 | apim_key: { 15 | type: 'apiKey', 16 | name: 'Ocp-Apim-Subscription-Key', 17 | in: 'header', 18 | }, 19 | }, 20 | }; 21 | return linter.run(oasDoc).then((results) => { 22 | expect(results.length).toBe(1); 23 | expect(results[0].path.join('.')).toBe('securityDefinitions.apim_key'); 24 | }); 25 | }); 26 | 27 | test('az-security-definition-description should find oas3 errors', () => { 28 | const oasDoc = { 29 | openapi: '3.0.0', 30 | components: { 31 | securitySchemes: { 32 | jwt: { 33 | type: 'http', 34 | scheme: 'bearer', 35 | bearerFormat: 'JWT', 36 | }, 37 | }, 38 | }, 39 | }; 40 | return linter.run(oasDoc).then((results) => { 41 | expect(results.length).toBe(1); 42 | expect(results[0].path.join('.')).toBe('components.securitySchemes.jwt'); 43 | }); 44 | }); 45 | 46 | test('az-security-definition-description should find no errors', () => { 47 | const oasDoc = { 48 | swagger: '2.0', 49 | securityDefinitions: { 50 | apim_key: { 51 | type: 'apiKey', 52 | name: 'Ocp-Apim-Subscription-Key', 53 | in: 'header', 54 | description: 'API Key for your subscription', 55 | }, 56 | }, 57 | }; 58 | return linter.run(oasDoc).then((results) => { 59 | expect(results.length).toBe(0); 60 | }); 61 | }); 62 | 63 | test('az-security-definition-description should find no oas3 errors', () => { 64 | const oasDoc = { 65 | openapi: '3.0.0', 66 | components: { 67 | securitySchemes: { 68 | jwt: { 69 | type: 'http', 70 | scheme: 'bearer', 71 | bearerFormat: 'JWT', 72 | description: 'JSON Web Token with credentials for the service', 73 | }, 74 | }, 75 | }, 76 | }; 77 | return linter.run(oasDoc).then((results) => { 78 | expect(results.length).toBe(0); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /test/security-definitions.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-security-definitions'); 7 | return linter; 8 | }); 9 | 10 | test('az-security-definitions should find errors when securityDefinitions is missing', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | }; 14 | return linter.run(oasDoc).then((results) => { 15 | expect(results.length).toBe(1); 16 | expect(results[0].path.length).toBe(0); 17 | expect(results[0].message).toContain('At least one security scheme must be defined'); 18 | }); 19 | }); 20 | 21 | test('az-security-definitions should find errors when securityDefinitions has no entries', () => { 22 | const oasDoc = { 23 | swagger: '2.0', 24 | securityDefinitions: {}, 25 | }; 26 | return linter.run(oasDoc).then((results) => { 27 | expect(results.length).toBe(1); 28 | expect(results[0].path.join('.')).toBe('securityDefinitions'); 29 | expect(results[0].message).toContain('At least one security scheme must be defined'); 30 | }); 31 | }); 32 | 33 | // Test for security scheme with type: oauth2 34 | test('az-security-definitions should find errors when securityDefinitions has oauth2 scheme with no scopes', () => { 35 | const oasDoc = { 36 | swagger: '2.0', 37 | securityDefinitions: { 38 | oauth2: { 39 | type: 'oauth2', 40 | flow: 'implicit', 41 | authorizationUrl: 'https://example.com/oauth2/authorize', 42 | tokenUrl: 'https://example.com/oauth2/token', 43 | }, 44 | }, 45 | }; 46 | return linter.run(oasDoc).then((results) => { 47 | expect(results.length).toBe(1); 48 | expect(results[0].path.join('.')).toBe('securityDefinitions.oauth2'); 49 | expect(results[0].message).toContain('Security scheme with type: oauth2 should have non-empty "scopes" array.'); 50 | }); 51 | }); 52 | 53 | // Test for security scheme with type: oauth2 with empty scopes 54 | test('az-security-definitions should find errors when securityDefinitions has oauth2 scheme with empty scopes', () => { 55 | const oasDoc = { 56 | swagger: '2.0', 57 | securityDefinitions: { 58 | oauth2: { 59 | type: 'oauth2', 60 | flow: 'implicit', 61 | authorizationUrl: 'https://example.com/oauth2/authorize', 62 | tokenUrl: 'https://example.com/oauth2/token', 63 | scopes: {}, 64 | }, 65 | }, 66 | }; 67 | return linter.run(oasDoc).then((results) => { 68 | expect(results.length).toBe(1); 69 | expect(results[0].path.join('.')).toBe('securityDefinitions.oauth2.scopes'); 70 | expect(results[0].message).toContain('Security scheme with type: oauth2 should have non-empty "scopes" array.'); 71 | }); 72 | }); 73 | 74 | // Test for security scheme with type: oauth2 with scopes that do not match the pattern 75 | test('az-security-definitions should find errors when oauth2 scheme scopes do not match the pattern', () => { 76 | const oasDoc = { 77 | swagger: '2.0', 78 | securityDefinitions: { 79 | oauth2: { 80 | type: 'oauth2', 81 | flow: 'implicit', 82 | authorizationUrl: 'https://example.com/oauth2/authorize', 83 | tokenUrl: 'https://example.com/oauth2/token', 84 | scopes: { 85 | read: 'Read access to protected resources', 86 | write: 'Write access to protected resources', 87 | }, 88 | }, 89 | }, 90 | }; 91 | return linter.run(oasDoc).then((results) => { 92 | expect(results.length).toBe(2); 93 | expect(results[0].path.join('.')).toBe('securityDefinitions.oauth2.scopes.read'); 94 | expect(results[0].message).toContain('Oauth2 scope names should have the form: https:///'); 95 | expect(results[1].path.join('.')).toBe('securityDefinitions.oauth2.scopes.write'); 96 | }); 97 | }); 98 | 99 | // Test for security scheme with type: apiKey but not in: header 100 | test('az-security-definitions should find errors when apiKey scheme is not in header', () => { 101 | const oasDoc = { 102 | swagger: '2.0', 103 | securityDefinitions: { 104 | apiKey: { 105 | type: 'apiKey', 106 | in: 'query', 107 | name: 'api_key', 108 | }, 109 | }, 110 | }; 111 | return linter.run(oasDoc).then((results) => { 112 | expect(results.length).toBe(1); 113 | expect(results[0].path.join('.')).toBe('securityDefinitions.apiKey.in'); 114 | expect(results[0].message).toContain('Security scheme with type "apiKey" should specify "in: header".'); 115 | }); 116 | }); 117 | 118 | // Test for security scheme with unsupported type 119 | test('az-security-definitions should find errors when securityDefinitions has unsupported type', () => { 120 | const oasDoc = { 121 | swagger: '2.0', 122 | securityDefinitions: { 123 | unsupported: { 124 | type: 'basic', 125 | }, 126 | }, 127 | }; 128 | return linter.run(oasDoc).then((results) => { 129 | expect(results.length).toBe(1); 130 | expect(results[0].path.join('.')).toBe('securityDefinitions.unsupported.type'); 131 | expect(results[0].message).toContain('Security scheme must be type: oauth2 or type: apiKey.'); 132 | }); 133 | }); 134 | 135 | // Test multiple errors are caught even after earlier valid schemes 136 | test('az-security-definitions should find multiple errors after valid schemes', () => { 137 | const oasDoc = { 138 | swagger: '2.0', 139 | securityDefinitions: { 140 | ApiKey: { 141 | type: 'apiKey', 142 | in: 'header', 143 | name: 'api_key', 144 | description: 'API Key', 145 | }, 146 | OauthBad: { 147 | description: 'Oauth2 scheme with some invalid scopes', 148 | type: 'oauth2', 149 | flow: 'application', 150 | tokenUrl: 151 | 'https://login.microsoftonline.com/common/oauth2/authorize', 152 | scopes: { 153 | 'https://atlas.microsoft.com/.default': 'default permissions to user account', 154 | 'user impersonation': 'default permissions to user account', 155 | }, 156 | }, 157 | ApiKeyBad: { 158 | type: 'apiKey', 159 | in: 'query', 160 | name: 'api_key', 161 | description: 'API Key', 162 | }, 163 | BasicBad: { 164 | type: 'basic', 165 | }, 166 | }, 167 | }; 168 | return linter.run(oasDoc).then((results) => { 169 | expect(results.length).toBe(3); 170 | expect(results[0].path.join('.')).toBe('securityDefinitions.OauthBad.scopes.user impersonation'); 171 | expect(results[1].path.join('.')).toBe('securityDefinitions.ApiKeyBad.in'); 172 | expect(results[2].path.join('.')).toBe('securityDefinitions.BasicBad.type'); 173 | expect(results[2].message).toContain('Security scheme must be type: oauth2 or type: apiKey.'); 174 | }); 175 | }); 176 | 177 | test('az-security-definitions should find no errors', () => { 178 | const oasDoc = { 179 | swagger: '2.0', 180 | securityDefinitions: { 181 | AzureAuth: { 182 | description: 'Azure Active Directory OAuth2 Flow', 183 | type: 'oauth2', 184 | flow: 'application', 185 | tokenUrl: 186 | 'https://login.microsoftonline.com/common/oauth2/authorize', 187 | scopes: { 188 | 'https://atlas.microsoft.com/.default': 'default permissions to user account', 189 | }, 190 | }, 191 | ApiKey: { 192 | type: 'apiKey', 193 | in: 'header', 194 | name: 'api_key', 195 | description: 'API Key', 196 | }, 197 | }, 198 | }; 199 | return linter.run(oasDoc).then((results) => { 200 | expect(results.length).toBe(0); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /test/unused-definition.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-unused-definition'); 7 | return linter; 8 | }); 9 | 10 | test('az-unused-definition should find errors', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | paths: { 14 | '/test1': { 15 | post: { 16 | parameters: [ 17 | { 18 | in: 'body', 19 | name: 'body', 20 | schema: { 21 | type: 'string', 22 | }, 23 | }, 24 | ], 25 | responses: { 26 | 200: { 27 | description: 'Success', 28 | schema: { 29 | type: 'string', 30 | }, 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | definitions: { 37 | Model1: { 38 | type: 'object', 39 | allOf: [ 40 | { 41 | $ref: '#/definitions/Model3', 42 | }, 43 | ], 44 | }, 45 | Model2: { 46 | type: 'object', 47 | properties: { 48 | id: { 49 | type: 'string', 50 | }, 51 | }, 52 | }, 53 | Model3: { 54 | type: 'object', 55 | properties: { 56 | foo: { 57 | type: 'string', 58 | }, 59 | }, 60 | }, 61 | }, 62 | }; 63 | return linter.run(oasDoc).then((results) => { 64 | expect(results.length).toBe(1); 65 | // Note: Model3 is not flagged as unused because it is used in Model1, 66 | // even though Model1 is not used. And the new logic now filters out the 67 | // error for Model1 because it allOfs Model3. 68 | expect(results[0].path.join('.')).toBe('definitions.Model2'); 69 | }); 70 | }); 71 | 72 | test('az-unused-definition should not find errors', () => { 73 | const oasDoc = { 74 | swagger: '2.0', 75 | paths: { 76 | '/test1': { 77 | post: { 78 | parameters: [ 79 | { 80 | in: 'body', 81 | name: 'body', 82 | schema: { 83 | $ref: '#/definitions/Model1', 84 | }, 85 | }, 86 | ], 87 | responses: { 88 | 200: { 89 | description: 'Success', 90 | schema: { 91 | $ref: '#/definitions/Model2', 92 | }, 93 | }, 94 | }, 95 | }, 96 | }, 97 | '/test2': { 98 | post: { 99 | parameters: [ 100 | { 101 | in: 'body', 102 | name: 'body', 103 | schema: { 104 | $ref: '#/definitions/Model4', 105 | }, 106 | }, 107 | ], 108 | responses: { 109 | 200: { 110 | description: 'Success', 111 | schema: { 112 | $ref: '#/definitions/Model3', 113 | }, 114 | }, 115 | }, 116 | }, 117 | }, 118 | }, 119 | definitions: { 120 | Model1: { 121 | type: 'object', 122 | allOf: [ 123 | { 124 | $ref: '#/definitions/Model3', 125 | }, 126 | ], 127 | }, 128 | Model2: { 129 | type: 'object', 130 | properties: { 131 | id: { 132 | type: 'string', 133 | }, 134 | }, 135 | }, 136 | Model3: { 137 | type: 'object', 138 | properties: { 139 | foo: { 140 | type: 'string', 141 | }, 142 | }, 143 | }, 144 | Model4: { 145 | type: 'object', 146 | properties: { 147 | id: { 148 | type: 'string', 149 | }, 150 | }, 151 | }, 152 | Model5: { 153 | type: 'object', 154 | properties: { 155 | bar: { 156 | type: 'string', 157 | }, 158 | }, 159 | allOf: [ 160 | { 161 | $ref: '#/definitions/Model4', 162 | }, 163 | ], 164 | }, 165 | }, 166 | }; 167 | return linter.run(oasDoc).then((results) => { 168 | expect(results.length).toBe(0); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | const { Spectral } = require('@stoplight/spectral-core'); 2 | const { migrateRuleset } = require('@stoplight/spectral-ruleset-migrator'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | const AsyncFunction = (async () => {}).constructor; 7 | 8 | const rulesetFile = './spectral.yaml'; 9 | 10 | async function linterForRule(rule) { 11 | const linter = new Spectral(); 12 | 13 | const m = {}; 14 | const paths = [path.dirname(rulesetFile), __dirname, '..']; 15 | await AsyncFunction( 16 | 'module, require', 17 | await migrateRuleset(rulesetFile, { 18 | format: 'commonjs', 19 | fs, 20 | }), 21 | // eslint-disable-next-line import/no-dynamic-require,global-require 22 | )(m, (text) => require(require.resolve(text, { paths }))); 23 | const ruleset = m.exports; 24 | delete ruleset.extends; 25 | Object.keys(ruleset.rules).forEach((key) => { 26 | if (key !== rule) { 27 | delete ruleset.rules[key]; 28 | } 29 | }); 30 | linter.setRuleset(ruleset); 31 | return linter; 32 | } 33 | 34 | module.exports.linterForRule = linterForRule; 35 | -------------------------------------------------------------------------------- /test/version-convention.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-version-convention'); 7 | return linter; 8 | }); 9 | 10 | test('az-version-convention should find errors', () => { 11 | const myOpenApiDocument = { 12 | swagger: '2.0', 13 | info: { 14 | version: '3.0.1', 15 | }, 16 | }; 17 | return linter.run(myOpenApiDocument).then((results) => { 18 | expect(results.length).toBe(1); 19 | expect(results[0].path.join('.')).toBe('info.version'); 20 | }); 21 | }); 22 | 23 | test('az-version-convention should find no errors', () => { 24 | const myOpenApiDocument = { 25 | swagger: '2.0', 26 | info: { 27 | version: '2021-07-01', 28 | }, 29 | }; 30 | return linter.run(myOpenApiDocument).then((results) => { 31 | expect(results.length).toBe(0); 32 | }); 33 | }); 34 | 35 | test('az-version-convention allows -preview suffix', () => { 36 | const myOpenApiDocument = { 37 | swagger: '2.0', 38 | info: { 39 | version: '2021-07-01-preview', 40 | }, 41 | }; 42 | return linter.run(myOpenApiDocument).then((results) => { 43 | expect(results.length).toBe(0); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/version-policy.test.js: -------------------------------------------------------------------------------- 1 | const { linterForRule } = require('./utils'); 2 | 3 | let linter; 4 | 5 | beforeAll(async () => { 6 | linter = await linterForRule('az-version-policy'); 7 | return linter; 8 | }); 9 | 10 | test('az-version-policy should find version in basePath', () => { 11 | const oasDoc = { 12 | swagger: '2.0', 13 | basePath: '/v3/api', 14 | paths: { 15 | '/test1': { 16 | get: { 17 | responses: { 18 | default: { 19 | description: 'default', 20 | }, 21 | }, 22 | }, 23 | }, 24 | }, 25 | }; 26 | return linter.run(oasDoc).then((results) => { 27 | expect(results.length).toBe(2); 28 | expect(results[0].path.join('.')).toBe('basePath'); 29 | expect(results[1].path.join('.')).toBe('paths./test1.get'); 30 | }); 31 | }); 32 | 33 | test('az-version-policy should find errors', () => { 34 | const oasDoc = { 35 | swagger: '2.0', 36 | paths: { 37 | '/v1/test1': { 38 | get: { 39 | responses: { 40 | default: { 41 | description: 'default', 42 | }, 43 | }, 44 | }, 45 | }, 46 | '/test2': { 47 | get: { 48 | // no parameters 49 | responses: { 50 | default: { 51 | description: 'default', 52 | }, 53 | }, 54 | }, 55 | }, 56 | '/test3': { 57 | get: { 58 | parameters: [ 59 | { 60 | name: 'p1', 61 | in: 'query', 62 | type: 'string', 63 | }, 64 | ], 65 | responses: { 66 | default: { 67 | description: 'default', 68 | }, 69 | }, 70 | }, 71 | }, 72 | '/test4': { 73 | get: { 74 | parameters: [ 75 | { 76 | name: 'api-version', 77 | in: 'query', 78 | type: 'string', 79 | }, 80 | ], 81 | responses: { 82 | default: { 83 | description: 'default', 84 | }, 85 | }, 86 | }, 87 | }, 88 | }, 89 | }; 90 | return linter.run(oasDoc).then((results) => { 91 | expect(results.length).toBe(5); 92 | expect(results[0].path.join('.')).toBe('paths./v1/test1'); 93 | expect(results[1].path.join('.')).toBe('paths./v1/test1.get'); 94 | expect(results[2].path.join('.')).toBe('paths./test2.get'); 95 | expect(results[3].path.join('.')).toBe('paths./test3.get.parameters'); 96 | expect(results[4].path.join('.')).toBe('paths./test4.get.parameters.0'); 97 | }); 98 | }); 99 | 100 | test('az-version-policy should find no errors', () => { 101 | const oasDoc = { 102 | swagger: '2.0', 103 | paths: { 104 | '/test1': { 105 | get: { 106 | parameters: [ 107 | { 108 | name: 'api-version', 109 | in: 'query', 110 | type: 'string', 111 | required: true, 112 | }, 113 | ], 114 | responses: { 115 | default: { 116 | description: 'default', 117 | }, 118 | }, 119 | }, 120 | }, 121 | '/test2': { 122 | parameters: [ 123 | { 124 | name: 'api-version', 125 | in: 'query', 126 | type: 'string', 127 | required: true, 128 | }, 129 | ], 130 | get: { 131 | responses: { 132 | default: { 133 | description: 'default', 134 | }, 135 | }, 136 | }, 137 | }, 138 | }, 139 | }; 140 | return linter.run(oasDoc).then((results) => { 141 | expect(results.length).toBe(0); 142 | }); 143 | }); 144 | --------------------------------------------------------------------------------