diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 0000000..994baf2 --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,56 @@ +name: Code coverage (CODECOV) + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the master branch + +on: + push: + branches: [master] + paths-ignore: + - 'docs/**' + pull_request: + branches: [master] + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + codecov: + runs-on: ubuntu-20.04 + steps: + - name: Cancel previous workflow runs + uses: styfle/cancel-workflow-action@0.9.0 + with: + access_token: ${{ github.token }} + + - name: Load current commit + uses: actions/checkout@v2 + with: + ref: ${{ github.ref }} + + - name: Read nvmrc + id: read-nvmrc + run: echo "::set-output name=version::$(cat .nvmrc)" + shell: bash + + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: ${{ steps.read-nvmrc.outputs.version }} + + - name: Restore cache + uses: actions/cache@v2 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies + run: npm ci + + - name: Generate coverage + run: npm run test:coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml new file mode 100644 index 0000000..fa95734 --- /dev/null +++ b/.github/workflows/default.yml @@ -0,0 +1,49 @@ +name: Default pipeline + +on: + push: + branches: [master] + pull_request: + branches: [master] + + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Cancel previous workflow runs + uses: styfle/cancel-workflow-action@0.9.0 + with: + access_token: ${{ github.token }} + + - name: Load current commit + uses: actions/checkout@v2 + with: + ref: ${{ github.ref }} + + - name: Read nvmrc + id: read-nvmrc + run: echo "::set-output name=version::$(cat .nvmrc)" + shell: bash + + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: ${{ steps.read-nvmrc.outputs.version }} + + - name: Restore cache + uses: actions/cache@v2 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies 🔧 + run: npm ci + + - name: Static checks ⚙️ + run: npm run static-checks + + - name: Tests + run: npm run test:ci diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..da373f4 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,52 @@ +name: Build and Deploy docs +<<<<<<< HEAD +======= + +>>>>>>> d4131b83e733e1ccab6c10e9e184d2545b85aee4 +on: + push: + branches: [master] + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Cancel previous workflow runs + uses: styfle/cancel-workflow-action@0.9.0 + with: + access_token: ${{ github.token }} + + - name: Load current commit + uses: actions/checkout@v2 + with: + ref: ${{ github.ref }} + + - name: Read nvmrc + id: read-nvmrc + run: echo "::set-output name=version::$(cat .nvmrc)" + shell: bash + + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: ${{ steps.read-nvmrc.outputs.version }} + + - name: Restore cache + uses: actions/cache@v2 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies 🔧 + run: npm ci + + - name: Build docs 📖 + run: npm run docs:typedoc + + - name: Deploy 🚀 + uses: JamesIves/github-pages-deploy-action@4.1.4 + with: + branch: docs + folder: docs/rules diff --git a/.gitignore b/.gitignore index ec3e589..764a3b8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ coverage .idea .vscode +.now **/*.log @@ -16,4 +17,6 @@ coverage src/cli/spec ./temp/openapi.json -openapi.json \ No newline at end of file +openapi.json +tsconfig.tsbuildinfo +docs/rules \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..96d3e00 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 anyspec + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/after.png b/assets/after.png new file mode 100644 index 0000000..915711e Binary files /dev/null and b/assets/after.png differ diff --git a/assets/before.png b/assets/before.png new file mode 100644 index 0000000..46a2979 Binary files /dev/null and b/assets/before.png differ diff --git a/assets/osome.svg b/assets/osome.svg new file mode 100644 index 0000000..d99431c --- /dev/null +++ b/assets/osome.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/anyspec-next.md b/docs/anyspec-next.md new file mode 100644 index 0000000..69de2ee --- /dev/null +++ b/docs/anyspec-next.md @@ -0,0 +1,5 @@ +# What will be deleted in v2 + +* omitting fields from model +* enum vaues started by + or - +* enum values divided by spaces \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index 11ecb8d..908bf2f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,7 +14,6 @@ module.exports = { ], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], verbose: true, - testEnvironment: 'node', coveragePathIgnorePatterns: [ '/node_modules/', diff --git a/package-lock.json b/package-lock.json index 3a5264c..0d2866e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "ts-real-lang", + "name": "anyspec", "version": "1.0.0", "lockfileVersion": 1, "requires": true, @@ -2605,6 +2605,19 @@ "dev": true, "optional": true }, + "handlebars": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", + "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "dev": true, + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + } + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -3767,6 +3780,12 @@ "yallist": "^4.0.0" } }, + "lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true + }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -3815,6 +3834,12 @@ "object-visit": "^1.0.0" } }, + "marked": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-2.1.3.tgz", + "integrity": "sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA==", + "dev": true + }, "mem": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz", @@ -3939,6 +3964,12 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -4113,6 +4144,32 @@ "mimic-fn": "^2.1.0" } }, + "onigasm": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/onigasm/-/onigasm-2.2.5.tgz", + "integrity": "sha512-F+th54mPc0l1lp1ZcFMyL/jTs2Tlq4SqIHKIXGZOR/VkHkF9A7Fr5rRr5+ZG/lWeRsyrClLYRq7s/yFQ/XhWCA==", + "dev": true, + "requires": { + "lru-cache": "^5.1.1" + }, + "dependencies": { + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } + }, "optionator": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", @@ -4770,6 +4827,17 @@ "dev": true, "optional": true }, + "shiki": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.9.5.tgz", + "integrity": "sha512-XFn+rl3wIowDjzdr5DlHoHgQphXefgUTs2bNp/bZu4WF9gTrTLnKwio3f28VjiFG6Jpip7yQn/p4mMj6OrjrtQ==", + "dev": true, + "requires": { + "json5": "^2.2.0", + "onigasm": "^2.2.5", + "vscode-textmate": "5.2.0" + } + }, "signal-exit": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", @@ -5580,12 +5648,41 @@ "is-typedarray": "^1.0.0" } }, + "typedoc": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.21.4.tgz", + "integrity": "sha512-slZQhvD9U0d9KacktYAyuNMMOXJRFNHy+Gd8xY2Qrqq3eTTTv3frv3N4au/cFnab9t3T5WA0Orb6QUjMc+1bDA==", + "dev": true, + "requires": { + "glob": "^7.1.7", + "handlebars": "^4.7.7", + "lunr": "^2.3.9", + "marked": "^2.1.1", + "minimatch": "^3.0.0", + "progress": "^2.0.3", + "shiki": "^0.9.3", + "typedoc-default-themes": "^0.12.10" + } + }, + "typedoc-default-themes": { + "version": "0.12.10", + "resolved": "https://registry.npmjs.org/typedoc-default-themes/-/typedoc-default-themes-0.12.10.tgz", + "integrity": "sha512-fIS001cAYHkyQPidWXmHuhs8usjP5XVJjWB8oZGqkTowZaz3v7g3KDZeeqE82FBrmkAnIBOY3jgy7lnPnqATbA==", + "dev": true + }, "typescript": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.2.tgz", "integrity": "sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw==", "dev": true }, + "uglify-js": { + "version": "3.13.10", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.13.10.tgz", + "integrity": "sha512-57H3ACYFXeo1IaZ1w02sfA71wI60MGco/IQFjOqK+WtKoprh7Go2/yvd2HPtoJILO2Or84ncLccI4xoHMTSbGg==", + "dev": true, + "optional": true + }, "union-value": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", @@ -5712,6 +5809,12 @@ "spdx-expression-parse": "^3.0.0" } }, + "vscode-textmate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-5.2.0.tgz", + "integrity": "sha512-Uw5ooOQxRASHgu6C7GVvUxisKXfSgW4oFlO+aa+PAkgmH89O3CXxEEzNRNtHSqtXFTl0nAC1uYj0GMSH27uwtQ==", + "dev": true + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -5800,6 +5903,12 @@ "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "dev": true }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, "wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", diff --git a/package.json b/package.json index 7447df7..37b2e9f 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,31 @@ { - "name": "ts-real-lang", + "name": "anyspec", "version": "1.0.0", - "description": "", + "description": "Your best friend to deal with api spec", "main": "index.js", "scripts": { + "postinstall": "husky install", "build": "tsc --project tsconfig.build.json --outDir ./src/cli/dist", - "test": "NODE_PATH=./src STAGE=test jest --maxWorkers=4", - "test:watch": "NODE_PATH=./src STAGE=test jest --watch", - "debug:tests": "NODE_PATH=./src STAGE=test node --inspect-brk ./node_modules/.bin/jest", - "run:cli": "TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true NODE_PATH=./src node -r ts-node/register src/cli/bin.ts src/cli/examples/kek.models.tinyspec -o ./ ", + "test": "NODE_PATH=./src jest --maxWorkers=4", + "test:ci": "NODE_PATH=./src jest --silent --maxWorkers=4", + "test:watch": "NODE_PATH=./src jest --watch", + "debug:tests": "NODE_PATH=./src node --inspect-brk ./node_modules/.bin/jest", + "run:cli": "TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true NODE_PATH=./src node -r ts-node/register src/cli/bin.ts src/cli/examples/kek.models.tinyspec -o ./", "clean": "rm -rf src/cli/dist", "prepublish:cli": "cd src/cli && npm install && cd .. && npm run clean && npm run build", "publish:cli": "npm publish ./src/cli", - "test:cli": "NODE_PATH=./src TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true ts-node src/cli/bin.ts src/cli/spec -ns agent client", + "test:cli": "NODE_PATH=./src TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true ts-node src/cli/bin.ts src/cli/examples -ns agent client -c ./src/cli/anyspec.config", + "test:coverage": "NODE_PATH=./src jest --bail --collectCoverage", + "test:cli-printer": "NODE_PATH=./src TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true ts-node src/cli/autoformat.ts", "test:integration:anyspec:cli": "anyspec src/cli/examples/kek.models.tinyspec", "test:tinyspec:build": "tinyspec --json --add-nulls -s temp/spec/ -o ../", "lint": "eslint --max-warnings 0 'src/**/*.ts'", - "lint:fix": "eslint --fix 'src/**/*.ts'" + "lint:fix": "eslint --fix 'src/**/*.ts'", + "docs:typedoc": "typedoc --entryPoints src/validation/rules --exclude \"**/*+(index|.spec|.e2e|.test).ts\" --exclude \"**/__tests__/*\" --highlightTheme github-light --out docs/rules", + "static-checks": "npm run build && npm run lint" }, "author": "", - "license": "ISC", + "license": "MIT", "devDependencies": { "@anyspec/cli": "0.0.1-rc.8", "@types/jest": "^26.0.23", @@ -35,6 +41,7 @@ "tinyspec": "^2.4.9", "ts-jest": "^26.5.6", "ts-node": "^10.0.0", + "typedoc": "^0.21.4", "typescript": "^4.3.2" }, "dependencies": { @@ -42,4 +49,4 @@ "globby": "^11.0.4", "ora": "^5.4.1" } -} +} \ No newline at end of file diff --git a/readme.md b/readme.md index 1e1382b..bf3d0a4 100644 --- a/readme.md +++ b/readme.md @@ -1,91 +1,150 @@ -[](https://astexplorer.net/) +# anyspec [![codecov](https://codecov.io/gh/frolovdev/anyspec/branch/master/graph/badge.svg?token=8D8S09PRQI)](https://codecov.io/gh/frolovdev/anyspec) -## WIP, I call you when we are be ready +Anyspec is a [DSL (Domain Specific Language)](https://en.wikipedia.org/wiki/Domain-specific_language) for writing API specs with main compilation target to [Openapi (swagger)](https://swagger.io/specification/). -[Main link](https://excalidraw.com/#json=4790454524575744,zoP_ISTzjIbi1HhB6ErtWw) +The main problem we are trying to solve is the verbosity of open API. -## TODO +* **Write less code** - get rid of boileprate in your daily routine. +* **Enforce best practices** - use predefined or write your own rules for specs. +* **Prettify (WIP)** - format your code without pain. +* **Compilation (WIP)** - the result json is fully compatible with openapi specification. -- [x] write a visitor tests -- [x] write a base validator layer -- [ ] create cli -- [ ] After creating enum /Users/andreyfrolov/Documents/osome/anyspec/src/visitor.test.ts:538 + + + + Built by 2 engineers for Osome with love ❤️ + + + + + + -- [ ] After creating endpoints add similar case /Users/andreyfrolov/Documents/osome/anyspec/src/visitor.test.ts:538 -- [ ] lear how to visit in parallel +[We are hiring](https://osome.com/careers/positions/) +## Watch in action -### Parser endpoints - - - -### Validator - -- [] split up base validation and schema validation (sdl) - - -https://cuelang.org/docs/ - - - -https://tree-sitter.github.io/tree-sitter/ -https://chevrotain.io/ - -### IDEAS - -Генерация клиента для мобилы - - -### - - - -https://youtu.be/TeZqKnC2gvA - - - - -# Чего не должно быть во второй версии - -* удаление полей из модели -* енамы и строки начинающеся с символа - или + -* значения енамов через пробелы - - -# Чего делать во второй версии - - - -delete $TSFixMe - -delete $Maybe - -Allow to describe lambdas +Before +``` +// **Some description** +@token POST /documents DocumentNew + => { document: Document } +DocumentNew { + name: s, +} -https://swagger.io/docs/specification/2-0/describing-parameters/ +DocumentNew { + id: i, + name: s, +} +``` +After + +```json +{ + "swagger": "2.0", + "info": { + "title": "Test API", + "version": "{{version}}" + }, + "host": "{{host}}", + "basePath": "/api/v2", + "schemes": [ + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "securityDefinitions": { + "token": { + "name": "X-Access-Token", + "type": "apiKey", + "in": "header" + } + }, + "paths": { + "/documents": { + "post": { + "summary": "**Some description**", + "description": "**Some description**", + "operationId": "POST--documents", + "responses": { + "200": { + "description": "", + "schema": { + "type": "object", + "properties": { + "document": { + "$ref": "#/definitions/Document" + } + }, + "required": [ + "document" + ] + } + } + }, + "security": [ + { + "token": [] + } + ], + "parameters": [ + { + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/DocumentNew" + }, + "in": "body" + } + ] + } + } + }, + "definitions": { + "DocumentNew": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + } +} +``` -# Why tinyspec is bad +## Table of contents -Don't throw an error in this case +## List of rules -``` -`/industries`: - $L /industries ?branch? +[Watch docs](https://frolovdev.github.io/anyspec/modules.html) -``` +## Inspiration section -No consistency in enums +The main idea of library - DSL on top of openapi comes from [tinyspec](https://github.com/Ajaxy/tinyspec). The syntax constructions comes from tinyspec too. -``` -A ( a | b ) +Also authors were inspired and use a lot of findings and ideas from: -A ( "a" | "b" ) +* [Graphqljs implementation](https://github.com/graphql/graphql-js) +* [python lexer and parser](https://github.com/python) -A ( "a" | "b" | ) +## License -``` +The code in this project is released under the [MIT License](LICENSE). -To much dsl like $CRUDL +[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ffrolovdev%2Fanyspec.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ffrolovdev%2Fanyspec?ref=badge_large) diff --git a/src/cli/anyspec.config.js b/src/cli/anyspec.config.js new file mode 100644 index 0000000..bb8ed6a --- /dev/null +++ b/src/cli/anyspec.config.js @@ -0,0 +1,16 @@ +module.exports = { + rules: { + 'base/known-type-names': 'error', + 'base/no-explicit-string-rule': 'off', + 'base/endpoints-known-http-verbs': 'off', + 'recommended/endpoints-body-parameter-postfix': 'off', + 'recommended/endpoints-query-postfix': 'off', + 'recommended/endpoints-response-postfix': 'off', + 'recommended/body-model-name': 'off', + 'recommended/filter-postfix': 'off', + 'recommended/model-body-field-postfix': 'off', + 'recommended/postfix-for-create-models': 'off', + 'recommended/postfix-for-update-models': 'off', + 'recommended/endpoints-update-request-response-match': 'off', + }, +}; diff --git a/src/cli/autoformat.ts b/src/cli/autoformat.ts new file mode 100644 index 0000000..bd5642c --- /dev/null +++ b/src/cli/autoformat.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env node + +import { Command } from 'commander'; +import { default as getPath } from 'path'; +import { readFile, writeFile } from 'fs/promises'; +import { parse, Source } from '../language'; +import { printModels } from '../printer'; + +async function main() { + const program = new Command(); + program.arguments(''); + + program.parse(); + + try { + const { args } = program; + + const argPaths = args.map((arg) => getPath.resolve(process.cwd(), arg)); + + const argumentPath = argPaths[0]; + + const sources = await mapPathsToSources(argumentPath); + + const parsed = getParsed(sources); + + const printed = printModels(parsed); + + await writeFile(argumentPath, printed); + } catch (e) { + console.error(e); + } +} + +main(); + +// private + +function isEndpoint(val: string) { + return val.match(/\.endpoints\.tinyspec$/) !== null; +} + +function isModel(val: string) { + return val.match(/\.models\.tinyspec$/) !== null; +} + +async function mapPathsToSources(paths: string): Promise { + const specBodyFiles = await readFile(paths, { encoding: 'utf-8' }); + if (isEndpoint(paths)) { + return new Source({ body: specBodyFiles, sourceType: 'endpoints', name: specBodyFiles }); + } + + if (isModel(paths)) { + return new Source({ body: specBodyFiles, sourceType: 'models', name: specBodyFiles }); + } + + throw new Error(`File doesn't contain .endpoints.tinyspec or .models.tinyspec extension`); +} + +function getParsed(source: Source) { + try { + return parse(source); + } catch (error) { + throw error; + } +} diff --git a/src/cli/bin.ts b/src/cli/bin.ts index fb5fd42..baf74ba 100644 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -1,18 +1,20 @@ #!/usr/bin/env node import { Command } from 'commander'; -import { default as getPath } from 'path'; +import { default as nodePath } from 'path'; import { readFile } from 'fs/promises'; import { parse, Source, DocumentNode } from '../language'; import { AnySpecSchema } from '../runtypes'; -import { validate, baseRules } from '../validation'; +import { validate, rulesMap } from '../validation'; import { AnySpecError, printError } from '../error'; import { sync as glob } from 'globby'; import ora from 'ora'; import { concatAST } from '../language/concatAST'; +import { parseConfig, readConfig } from './config'; async function main() { const program = new Command(); + program .option('-o, --outDir ', 'path to a directory for a generated openapi') .option('-ns, --namespaces [namespaces...]', 'array of existed namespaces') @@ -21,6 +23,7 @@ async function main() { 'name of common namespace where shared definitions stored', 'common', ) + .option('-c, --config ', 'path to config file') .arguments(''); program.parse(); @@ -31,48 +34,67 @@ async function main() { commonNamespace: string; namespaces?: string[]; outDir?: string; + config?: string; }; - const { namespaces, outDir, commonNamespace } = options; + const { namespaces, outDir, commonNamespace, config: configPath } = options; if (!namespaces) { throw new Error('please provide namespaces'); } - const argPaths = args.map((arg) => getPath.resolve(process.cwd(), arg)); + const argPaths = args.map((arg) => nodePath.resolve(process.cwd(), arg)); const argumentPath = argPaths[0]; const processingSpinner = ora(`Processing spec: ${argumentPath}`).start(); const specFilePaths = glob(`${argumentPath}/**/*.tinyspec`); + try { + const sources = await mapPathsToSources(specFilePaths); - const sources = await mapPathsToSources(specFilePaths); + const { res: config, err: configErr } = readConfig(configPath); + if (configErr || !config) { + console.error(configErr); + processingSpinner.fail(); + return; + } - const groupedSources = groupSourcesByNamespaces({ sources, commonNamespace, namespaces }); + const groupedSources = groupSourcesByNamespaces({ sources, commonNamespace, namespaces }); - const { groupedParsedDocuments, parsingErrors } = getGroupedDocuments( - groupedSources, - (error: Error) => { - console.error('Unknown error during parsing', error); - processingSpinner.fail(); - process.exit(1); - }, - ); + const { groupedParsedDocuments, parsingErrors } = getGroupedDocuments( + groupedSources, + (error: Error) => { + console.error('Unknown error during parsing', error); + processingSpinner.fail(); + process.exit(1); + }, + ); + + if (parsingErrors.length > 0) { + for (const e of parsingErrors) { + console.error(printCliError(printError(e))); + } - if (parsingErrors.length > 0) { - for (const e of parsingErrors) { - console.error(printCliError(printError(e))); + processingSpinner.fail(); + return; } + const { enabledRules, invalidRules } = parseConfig(config); + + const enabledRulesFns = enabledRules.map((rule) => rulesMap[rule]); + const unitedASTs = groupedParsedDocuments.map((documents) => concatAST(documents)); + + const schemas = unitedASTs.map((ast) => new AnySpecSchema({ ast })); + const errors = schemas.map((s, index) => validate(s, unitedASTs[index], enabledRulesFns)); + + errors.flat().forEach((e) => console.error(printCliError(printError(e)))); + invalidRules.forEach((e) => console.error(printCliError(`Invalid Rule: ${e}`))); + + processingSpinner.succeed(); + } catch (e) { + console.error(e); processingSpinner.fail(); - return; } - - const unitedASTs = groupedParsedDocuments.map((documents) => concatAST(documents)); - const schemas = unitedASTs.map((ast) => new AnySpecSchema({ ast })); - const errors = schemas.map((s, index) => validate(s, unitedASTs[index], baseRules)); - errors.flat().forEach((e) => console.error(printCliError(printError(e)))); - processingSpinner.succeed(); } main(); @@ -127,9 +149,9 @@ function groupSourcesByNamespaces({ ); const commonSources = sources.filter((s) => commonRegexp.test(s.name)); - const namespaceSources = namespacesRegexps.map((regexp) => - sources.filter((s) => regexp.test(s.name)), - ); + const namespaceSources = namespacesRegexps + .map((regexp) => sources.filter((s) => regexp.test(s.name))) + .filter((sources) => sources.length > 0); return namespaceSources.map((sourceArray) => sourceArray.concat(commonSources)); } diff --git a/src/cli/config.ts b/src/cli/config.ts new file mode 100644 index 0000000..c1b65e3 --- /dev/null +++ b/src/cli/config.ts @@ -0,0 +1,40 @@ +import { rulesMap } from '../validation'; +import { default as nodePath } from 'path'; + +type Config = { rules: Record }; + +type ConfigRes = { res: Config; err: null } | { err: string; res: null }; + +const isConfig = (configFile: unknown): configFile is Config => { + return (configFile as Config).rules !== undefined; +}; + +export function readConfig(path?: string): ConfigRes { + const resolvedPath = resolveConfigPath(path); + try { + const configFile = require(resolvedPath); + + if (!isConfig(configFile)) { + return { err: `Invalid config file`, res: null }; + } + return { res: configFile, err: null }; + } catch (e) { + return { err: `Can't find anyspec.config.js in ${resolvedPath}`, res: null }; + } +} + +export function parseConfig({ rules }: Config): { enabledRules: string[]; invalidRules: string[] } { + const existingRules = Object.keys(rulesMap); + const enabled = Object.keys(rules).filter((key) => rules[key] === 'error'); + const invalidRules = enabled.filter((rule) => !existingRules.includes(rule)); + const validRules = enabled.filter((rule) => existingRules.includes(rule)); + return { enabledRules: validRules, invalidRules }; +} + +function resolveConfigPath(path?: string) { + if (path) { + return nodePath.isAbsolute(path) ? path : nodePath.resolve(process.cwd(), path); + } + + return nodePath.resolve(process.cwd(), 'anyspec.config'); +} diff --git a/src/cli/examples/kek.models.tinyspec b/src/cli/examples/kek.models.tinyspec deleted file mode 100644 index 36785a6..0000000 --- a/src/cli/examples/kek.models.tinyspec +++ /dev/null @@ -1,3 +0,0 @@ -AcDocument < Document { - a: s -} \ No newline at end of file diff --git a/src/cli/package-lock.json b/src/cli/package-lock.json index a1bf1d9..0e33657 100644 --- a/src/cli/package-lock.json +++ b/src/cli/package-lock.json @@ -1,13 +1,398 @@ { "name": "@anyspec/cli", - "version": "0.0.1-rc.8", + "version": "0.0.1-rc.17", "lockfileVersion": 1, "requires": true, "dependencies": { + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==" + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "requires": { + "fill-range": "^7.0.1" + } + }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "chalk": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", + "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "requires": { + "restore-cursor": "^3.1.0" + } + }, + "cli-spinners": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.0.tgz", + "integrity": "sha512-t+4/y50K/+4xcCRosKkA7W4gTr1MySvLV0q+PxmG7FJ5g+66ChKurYjxBCjHggHH3HA5Hh9cy+lcUGWDqVH+4Q==" + }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=" + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, "commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==" + }, + "defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "requires": { + "clone": "^1.0.2" + } + }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "requires": { + "path-type": "^4.0.0" + } + }, + "fast-glob": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.7.tgz", + "integrity": "sha512-rYGMRwip6lUMvYD3BTScMwT1HtAs2d71SMv66Vrxs0IekGZEjhM0pcMfjQPnknBt2zeCwQMEupiN02ZP4DiT1Q==", + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + } + }, + "fastq": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.11.1.tgz", + "integrity": "sha512-HOnr8Mc60eNYl1gzwp6r5RoUyAn5/glBolUzP/Ez6IFVPMPirxn/9phgL6zhOtaTy7ISwPvQ+wT+hfcRZh/bzw==", + "requires": { + "reusify": "^1.0.4" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "requires": { + "is-glob": "^4.0.1" + } + }, + "globby": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.4.tgz", + "integrity": "sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==", + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.1.1", + "ignore": "^5.1.4", + "merge2": "^1.3.0", + "slash": "^3.0.0" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "ignore": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", + "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==" + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==" + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==" + }, + "log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "requires": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + } + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" + }, + "micromatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", + "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.2.3" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "requires": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + } + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" + }, + "picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==" + }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" + }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "requires": { + "queue-microtask": "^1.2.2" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==" + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "requires": { + "is-number": "^7.0.0" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "requires": { + "defaults": "^1.0.3" + } } } } diff --git a/src/cli/package.json b/src/cli/package.json index 375ca84..fb90fba 100644 --- a/src/cli/package.json +++ b/src/cli/package.json @@ -1,6 +1,6 @@ { "name": "@anyspec/cli", - "version": "0.0.1-rc.8", + "version": "0.0.1-rc.17", "author": { "name": "frolovdev", "url": "https://github.com/frolovdev" @@ -35,7 +35,9 @@ "registry": "https://registry.npmjs.org" }, "dependencies": { - "commander": "^7.2.0" + "commander": "^7.2.0", + "globby": "^11.0.4", + "ora": "^5.4.1" }, "engines": { "node": ">=10.8.x" diff --git a/src/error/AnySpecError.ts b/src/error/AnySpecError.ts index da896b0..a2f5d45 100644 --- a/src/error/AnySpecError.ts +++ b/src/error/AnySpecError.ts @@ -1,6 +1,6 @@ import { Source, ASTNode, getLocation, SourceLocation } from '../language'; import { isObjectLike } from '../utils'; -import { printLocation, printSourceLocation } from '../printLocation'; +import { printLocation, printSourceLocation } from './printLocation'; export class AnySpecError extends Error { /** * An array of { line, column } locations within the source AnySpec document diff --git a/src/printLocation.ts b/src/error/printLocation.ts similarity index 99% rename from src/printLocation.ts rename to src/error/printLocation.ts index e3821fe..95a3019 100644 --- a/src/printLocation.ts +++ b/src/error/printLocation.ts @@ -1,4 +1,4 @@ -import { Source, Location, SourceLocation, getLocation } from './language'; +import { Source, Location, SourceLocation, getLocation } from '../language'; /** * Render a helpful description of the location in the EasySpec Source document. diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..eed8e5a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from './error/printLocation'; +export * from './visitor'; diff --git a/src/lexerEndpoints.test.ts b/src/language/__tests__/lexerEndpoints.test.ts similarity index 99% rename from src/lexerEndpoints.test.ts rename to src/language/__tests__/lexerEndpoints.test.ts index 39aa218..7d6cb78 100644 --- a/src/lexerEndpoints.test.ts +++ b/src/language/__tests__/lexerEndpoints.test.ts @@ -1,4 +1,4 @@ -import { TokenKind, Lexer, Source } from './language'; +import { TokenKind, Lexer, Source } from '..'; const getFullTokenList = (source: Source) => { const lexer = new Lexer(source); diff --git a/src/lexerModels.test.ts b/src/language/__tests__/lexerModels.test.ts similarity index 95% rename from src/lexerModels.test.ts rename to src/language/__tests__/lexerModels.test.ts index d80c893..10399d6 100644 --- a/src/lexerModels.test.ts +++ b/src/language/__tests__/lexerModels.test.ts @@ -1,6 +1,6 @@ -import { Source, Lexer, isPunctuatorTokenKind, Token, TokenKind } from './language'; +import { Source, Lexer, isPunctuatorTokenKind, Token, TokenKind } from '../../language'; import { inspect } from 'util'; -import { dedent } from './__testsUtils__/dedent'; +import { dedent } from '../../__testsUtils__/dedent'; function lexFirst(str: string) { const lexer = new Lexer(new Source({ body: str })); @@ -580,12 +580,31 @@ describe('isPunctuatorTokenKind', () => { }); describe('lexer understands enums', () => { - it('lexer understand normal enum', () => { - const enumString = new Source({ body: `A (f | b)` }); + it('lexer understand normal enum with description', () => { + const enumString = new Source({ + body: `// description + A (f | b)`, + }); + + const tokens = getFullTokenList(enumString); + + expect(tokens).toEqual(['Description', 'A', '(', 'f', '|', 'b', ')']); + }); + + it('lexer understand normal enum with ":"', () => { + const enumString = new Source({ body: `A (f:f | b:b )` }); const tokens = getFullTokenList(enumString); - expect(tokens).toEqual(['A', '(', 'f', '|', 'b', ')']); + expect(tokens).toEqual(['A', '(', 'f:f', '|', 'b:b', ')']); + }); + + it('lexer understand normal enum with ":" and ""', () => { + const enumString = new Source({ body: `A ("f:f" | "b:b" )` }); + + const tokens = getFullTokenList(enumString); + + expect(tokens).toEqual(['A', '(', 'f:f', '|', 'b:b', ')']); }); it('lexer understand normal enum with ":"', () => { diff --git a/src/parserEndpoints.test.ts b/src/language/__tests__/parserEndpoints.test.ts similarity index 99% rename from src/parserEndpoints.test.ts rename to src/language/__tests__/parserEndpoints.test.ts index 9472aa1..a1c6026 100644 --- a/src/parserEndpoints.test.ts +++ b/src/language/__tests__/parserEndpoints.test.ts @@ -1,5 +1,5 @@ -import { ASTNode, ASTNodeKind, parse as defaultParse, Source } from './language'; -import { toJSONDeep, log } from './utils'; +import { ASTNode, ASTNodeKind, parse as defaultParse, Source } from '../../language'; +import { toJSONDeep, log } from '../../utils'; const parse = (source: string | Source) => defaultParse(source, { noLocation: true }); diff --git a/src/parserEndpointsErrors.test.ts b/src/language/__tests__/parserEndpointsErrors.test.ts similarity index 98% rename from src/parserEndpointsErrors.test.ts rename to src/language/__tests__/parserEndpointsErrors.test.ts index d67b25a..84a991e 100644 --- a/src/parserEndpointsErrors.test.ts +++ b/src/language/__tests__/parserEndpointsErrors.test.ts @@ -1,4 +1,4 @@ -import { parse as defaultParse, Source } from './language'; +import { parse as defaultParse, Source } from '../'; const parse = (source: string | Source) => defaultParse(source, { noLocation: true }); diff --git a/src/parserEndpointsUrl.test.ts b/src/language/__tests__/parserEndpointsUrl.test.ts similarity index 99% rename from src/parserEndpointsUrl.test.ts rename to src/language/__tests__/parserEndpointsUrl.test.ts index 0a20273..3a03358 100644 --- a/src/parserEndpointsUrl.test.ts +++ b/src/language/__tests__/parserEndpointsUrl.test.ts @@ -1,5 +1,5 @@ -import { ASTNode, ASTNodeKind, parse as defaultParse, Source } from './language'; -import { toJSONDeep } from './utils'; +import { ASTNode, ASTNodeKind, parse as defaultParse, Source } from '../'; +import { toJSONDeep } from '../../utils'; const parse = (source: string | Source) => defaultParse(source, { noLocation: true }); diff --git a/src/parserModel.test.ts b/src/language/__tests__/parserModel.test.ts similarity index 99% rename from src/parserModel.test.ts rename to src/language/__tests__/parserModel.test.ts index 1517789..99e61e9 100644 --- a/src/parserModel.test.ts +++ b/src/language/__tests__/parserModel.test.ts @@ -5,9 +5,9 @@ import { ModelTypeDefinitionNode, parse as defaultParse, Source, -} from './language'; -import { AnySpecError } from './error/AnySpecError'; -import { toJSONDeep, log } from './utils'; +} from '../'; +import { AnySpecError } from '../../error/AnySpecError'; +import { toJSONDeep, log } from '../../utils'; const parse = (source: string | Source) => defaultParse(source, { noLocation: true }); @@ -1188,7 +1188,7 @@ describe(__filename, () => { }); }); - it('should parse strict nested types coorectly', () => { + it('should parse strict nested types correctly', () => { const model = ` AcDocument < Kek, Lel !{ pathParameters: !{ @@ -1258,8 +1258,10 @@ describe(__filename, () => { }); describe('enum', () => { - it('correctly parse model with named enum', () => { + it('correctly parse model with named enum and descriptions', () => { const model = ` + // лул + // kek A ( f | b | ) @@ -1286,6 +1288,7 @@ describe(__filename, () => { kind: ASTNodeKind.NAME, value: 'A', }, + description: { kind: ASTNodeKind.DESCRIPTION, value: 'лул\nkek' }, kind: ASTNodeKind.ENUM_TYPE_DEFINITION, }; diff --git a/src/source.test.ts b/src/language/__tests__/source.test.ts similarity index 97% rename from src/source.test.ts rename to src/language/__tests__/source.test.ts index 7e55aaa..776c4a6 100644 --- a/src/source.test.ts +++ b/src/language/__tests__/source.test.ts @@ -1,4 +1,4 @@ -import { Source } from './language'; +import { Source } from '../../language'; describe('Source', () => { it('asserts that a body was provided', () => { diff --git a/src/language/ast.ts b/src/language/ast.ts index 38d7aed..330e23e 100644 --- a/src/language/ast.ts +++ b/src/language/ast.ts @@ -253,6 +253,7 @@ export interface EnumTypeDefinitionNode { readonly kind: 'EnumTypeDefinition'; readonly name: NameNode; readonly loc?: Location; + readonly description?: DescriptionNode; readonly values: ReadonlyArray; } diff --git a/src/language/index.ts b/src/language/index.ts index 64222bd..0af1f74 100644 --- a/src/language/index.ts +++ b/src/language/index.ts @@ -5,3 +5,4 @@ export * from './parser'; export * from './source'; export * from './token'; export * from './location'; +export * from './printer'; diff --git a/src/language/parser.ts b/src/language/parser.ts index 59ed467..0a6f54f 100644 --- a/src/language/parser.ts +++ b/src/language/parser.ts @@ -176,7 +176,7 @@ export class ModelParser { parseDefinition(): TypeDefinitionNode { if (this.peek(TokenKind.DESCRIPTION)) { - return this.parseModelTypeDefinition(); + return this.parseTypeSystemDefinition(); } if (this.peek(TokenKind.NAME)) { @@ -223,17 +223,20 @@ export class ModelParser { parseTypeSystemDefinition(): TypeDefinitionNode { // Many definitions begin with a description and require a lookahead. + const description = this.parseDescription(); const braces = this.lexer.lookahead(); switch (braces.kind) { case TokenKind.BRACE_L: - return this.parseModelTypeDefinition(); + return this.parseModelTypeDefinition(description); + case TokenKind.PAREN_L: + return this.parseEnumTypeDefinition(description); } throw this.unexpected(); } - parseEnumTypeDefinition(): EnumTypeDefinitionNode { + parseEnumTypeDefinition(description?: DescriptionNode): EnumTypeDefinitionNode { const start = this.lexer.token; const name = this.parseName(); const kind = ASTNodeKind.ENUM_TYPE_DEFINITION; @@ -243,12 +246,12 @@ export class ModelParser { kind, name, values, + description, }); } - parseModelTypeDefinition(): ModelTypeDefinitionNode { + parseModelTypeDefinition(description?: DescriptionNode): ModelTypeDefinitionNode { const start = this.lexer.token; - const description = this.parseDescription(); const name = this.parseName(); const extendsModels = this.parseExtendsModels(); diff --git a/src/language/predicates.ts b/src/language/predicates.ts index 99cab42..df4977c 100644 --- a/src/language/predicates.ts +++ b/src/language/predicates.ts @@ -1,4 +1,11 @@ -import { ASTNode, ASTNodeKind, ModelDomainTypeDefinitionNode } from './ast'; +import { + ASTNode, + ASTNodeKind, + EndpointNamespaceTypeDefinitionNode, + EndpointParameterBodyNode, + EndpointTypeDefinitionNode, + ModelDomainTypeDefinitionNode, +} from './ast'; export function isModelDomainDefinitionNode(node: ASTNode): node is ModelDomainTypeDefinitionNode { return ( @@ -6,3 +13,19 @@ export function isModelDomainDefinitionNode(node: ASTNode): node is ModelDomainT node.kind === ASTNodeKind.ENUM_TYPE_DEFINITION ); } + +export function isEndpointNamespaceTypeDefinitionNode( + node: ASTNode, +): node is EndpointNamespaceTypeDefinitionNode { + return node.kind === ASTNodeKind.ENDPOINT_NAMESPACE_TYPE_DEFINITION; +} + +export function isEndpointTypeDefinitionNode(node: ASTNode): node is EndpointTypeDefinitionNode { + return node.kind === ASTNodeKind.ENDPOINT_TYPE_DEFINITION; +} + +export function isEndpointEndpointParameterBodyNode( + node: ASTNode, +): node is EndpointParameterBodyNode { + return node.kind === ASTNodeKind.ENDPOINT_PARAMETER_BODY; +} diff --git a/src/language/printer.ts b/src/language/printer.ts new file mode 100644 index 0000000..4798ca9 --- /dev/null +++ b/src/language/printer.ts @@ -0,0 +1,127 @@ +import { ASTReducer, visit } from '../visitor'; +import { ASTNode, ASTNodeKind } from './ast'; + +/** + * Converts an AST into a string, using one set of reasonable + * formatting rules. + */ + +const MAX_LINE_LENGTH = 80; + +export function printModels(ast: ASTNode): string { + return visit(ast, printDocASTReducerModel); +} + +const printDocASTReducerModel: ASTReducer = { + EndpointNamespaceTypeDefinition: { + leave: () => { + throw new Error("Can't parse AST with EndpointNamespaceTypeDefinition"); + }, + }, + + Name: { leave: (node) => node.value ?? '' }, + + NamedType: { leave: (node) => node.name }, + + Document: { + leave: (node) => join(node.definitions, '\n\n'), + }, + + ModelTypeDefinition: { + leave: ({ name, description, fields, strict, extendsModels }) => { + const extModels = extendsModels.length > 0 ? ` < ${extendsModels.join(', ')}` : ''; + const strct = strict ? '!' : ''; + if (fields.length === 0) { + return `${description ?? ''}${name}${extModels} ${strct}{}`; + } + return `${description ?? ''}${name}${extModels} ${strct}${block(fields)}`; + }, + }, + Description: { + leave: (node) => { + const descriptions = node.value.split('\n'); + return `${descriptions.map((description) => `// ${description}`).join('\n')}\n`; + }, + }, + FieldDefinition: { + leave: ({ name, type, optional, omitted }) => { + const ommtd = omitted ? '-' : ''; + const opt = optional ? '?' : ''; + if (type.length === 0) { + return `${ommtd}${name}${opt}`; + } + return `${ommtd}${name}${opt}: ${type}`; + }, + }, + ListType: { + leave: (node) => { + const arraySymbol = '[]'; + let list: string[] = []; + let currentNode: any = node; + while (currentNode.kind === ASTNodeKind.LIST_TYPE) { + list.push(arraySymbol); + currentNode = node.type; + } + return `${node.type}${list.join()}`; + }, + }, + EnumValueDefinition: { + leave: ({ name }) => name, + }, + EnumInlineTypeDefinition: { + leave: ({ values }) => { + const str = `( ${join(values, ' | ')} )`; + if (str.length > MAX_LINE_LENGTH) { + return enumBlock(values); + } + return str; + }, + }, + ObjectTypeDefinition: { + leave: ({ fields, strict }) => { + const strct = strict ? '!' : ''; + if (fields.length === 0) { + return `${strct}{}`; + } + return `${strct}${block(fields)}`; + }, + }, + EnumTypeDefinition: { + leave: ({ name, values }) => { + return `${name} ${enumBlock(values)}`; + }, + }, +}; + +/** + * Given maybeArray, print an empty string if it is null or empty, otherwise + * print all items together separated by separator if provided + */ +function join(maybeArray: $Maybe>, separator = ''): string { + return maybeArray?.filter((x) => x).join(separator) ?? ''; +} + +/** + * Given array, print each item on its own line, wrapped in an indented `{ }` block. + */ +function block(array: $Maybe>): string { + return wrap('{\n', indent(join(array, ',\n')), ',\n}'); +} + +/** + * Given array, print each item on its own line, wrapped in an indented `( )` block with `|` separator. + */ +function enumBlock(array: $Maybe>): string { + return wrap('(\n', indent(join(array, ' |\n')), '\n)'); +} + +/** + * If maybeString is not null or empty, then wrap with start and end, otherwise print an empty string. + */ +function wrap(start: string, maybeString: $Maybe, end: string = ''): string { + return maybeString != null && maybeString !== '' ? start + maybeString + end : ''; +} + +function indent(str: string): string { + return wrap(' ', str.replace(/\n/g, '\n ')); +} diff --git a/src/printer/index.ts b/src/printer/index.ts new file mode 100644 index 0000000..49495e8 --- /dev/null +++ b/src/printer/index.ts @@ -0,0 +1 @@ +export * from './printer'; diff --git a/src/printer/printer.ts b/src/printer/printer.ts new file mode 100644 index 0000000..a117dc7 --- /dev/null +++ b/src/printer/printer.ts @@ -0,0 +1,127 @@ +import { ASTReducer, visit } from '../visitor'; +import { ASTNode, ASTNodeKind } from '../language/ast'; + +/** + * Converts an AST into a string, using one set of reasonable + * formatting rules. + */ + +const MAX_LINE_LENGTH = 80; + +export function printModels(ast: ASTNode): string { + return visit(ast, printDocASTReducerModel); +} + +const printDocASTReducerModel: ASTReducer = { + EndpointNamespaceTypeDefinition: { + leave: () => { + throw new Error("Can't parse AST with EndpointNamespaceTypeDefinition"); + }, + }, + + Name: { leave: (node) => node.value ?? '' }, + + NamedType: { leave: (node) => node.name }, + + Document: { + leave: (node) => join(node.definitions, '\n\n'), + }, + + ModelTypeDefinition: { + leave: ({ name, description, fields, strict, extendsModels }) => { + const extModels = extendsModels.length > 0 ? ` < ${extendsModels.join(', ')}` : ''; + const strct = strict ? '!' : ''; + if (fields.length === 0) { + return `${description ?? ''}${name}${extModels} ${strct}{}`; + } + return `${description ?? ''}${name}${extModels} ${strct}${block(fields)}`; + }, + }, + Description: { + leave: (node) => { + const descriptions = node.value.split('\n'); + return `${descriptions.map((description) => `// ${description}`).join('\n')}\n`; + }, + }, + FieldDefinition: { + leave: ({ name, type, optional, omitted }) => { + const ommtd = omitted ? '-' : ''; + const opt = optional ? '?' : ''; + if (type.length === 0) { + return `${ommtd}${name}${opt}`; + } + return `${ommtd}${name}${opt}: ${type}`; + }, + }, + ListType: { + leave: (node) => { + const arraySymbol = '[]'; + let list: string[] = []; + let currentNode: any = node; + while (currentNode.kind === ASTNodeKind.LIST_TYPE) { + list.push(arraySymbol); + currentNode = node.type; + } + return `${node.type}${list.join()}`; + }, + }, + EnumValueDefinition: { + leave: ({ name }) => name, + }, + EnumInlineTypeDefinition: { + leave: ({ values }) => { + const str = `( ${join(values, ' | ')} )`; + if (str.length > MAX_LINE_LENGTH) { + return enumBlock(values); + } + return str; + }, + }, + ObjectTypeDefinition: { + leave: ({ fields, strict }) => { + const strct = strict ? '!' : ''; + if (fields.length === 0) { + return `${strct}{}`; + } + return `${strct}${block(fields)}`; + }, + }, + EnumTypeDefinition: { + leave: ({ name, values, description }) => { + return `${description ?? ''}${name} ${enumBlock(values)}`; + }, + }, +}; + +/** + * Given maybeArray, print an empty string if it is null or empty, otherwise + * print all items together separated by separator if provided + */ +function join(maybeArray: $Maybe>, separator = ''): string { + return maybeArray?.filter((x) => x).join(separator) ?? ''; +} + +/** + * Given array, print each item on its own line, wrapped in an indented `{ }` block. + */ +function block(array: $Maybe>): string { + return wrap('{\n', indent(join(array, ',\n')), ',\n}'); +} + +/** + * Given array, print each item on its own line, wrapped in an indented `( )` block with `|` separator. + */ +function enumBlock(array: $Maybe>): string { + return wrap('(\n', indent(join(array, ' |\n')), '\n)'); +} + +/** + * If maybeString is not null or empty, then wrap with start and end, otherwise print an empty string. + */ +function wrap(start: string, maybeString: $Maybe, end: string = ''): string { + return maybeString != null && maybeString !== '' ? start + maybeString + end : ''; +} + +function indent(str: string): string { + return wrap(' ', str.replace(/\n/g, '\n ')); +} diff --git a/src/printer/printerModel.test.ts b/src/printer/printerModel.test.ts new file mode 100644 index 0000000..2eb3c84 --- /dev/null +++ b/src/printer/printerModel.test.ts @@ -0,0 +1,1544 @@ +import { ASTNodeKind, EnumTypeDefinitionNode, ModelTypeDefinitionNode } from '../language'; +import { printModels } from './printer'; +import { dedent } from '../__testsUtils__'; + +describe(__filename, () => { + describe('models', () => { + it('correctly print model with description', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: false, + description: { kind: ASTNodeKind.DESCRIPTION, value: 'лул\nkek' }, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + expect(printed).toEqual(dedent` +// лул +// kek +AcDocument {} +`); + }); + + it('correctly print empty model without description', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument {} +`); + }); + + it('correctly print empty model without description', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument {} +`); + }); + + it('correctly print model fields', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument { + name, +} +`); + }); + + it('correctly print model fields with trailing comma', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + extendsModels: [], + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument { + name, + surname, +} +`); + }); + + it('correctly print model fields with s', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument { + name: s, + surname, +} +`); + }); + + it('correctly print optional model fields', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument { + name?: s, + surname, +} +`); + }); + + it('correctly print optional model fields without type', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument { + name?, + surname, +} +`); + }); + + it('correctly print field array type', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument { + name?: s[], + surname: b[][], +} +`); + }); + + it('correctly print strict mode models', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: true, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument !{ + name?: s[], + surname: b[], +} +`); + }); + + it('correctly print extends model', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + extendsModels: [ + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Kek' }, + }, + ], + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: false, + description: undefined, + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument < Kek { + name?: s[], + surname: b[], +} +`); + }); + + it('correctly print with multiple extends model and strict model', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + extendsModels: [ + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Kek' }, + }, + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Lel' }, + }, + ], + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: true, + description: undefined, + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument < Kek, Lel !{ + name?: s[], + surname: b[], +} +`); + }); + + it('correctly print model with inline enum', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: true, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + }, + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'type', + }, + type: { + kind: ASTNodeKind.ENUM_INLINE_TYPE_DEFINITION, + values: [ + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { kind: ASTNodeKind.NAME, value: 'standard' }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { kind: ASTNodeKind.NAME, value: 'service' }, + }, + ], + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + extendsModels: [ + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Kek' }, + }, + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Lel' }, + }, + ], + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: true, + description: undefined, + }, + ], + }; + const printed = printModels(ast); + expect(printed).toEqual(dedent` +AcDocument < Kek, Lel !{ + -name?: s[], + type?: ( standard | service ), + surname: b[], +} +`); + }); + it('correctly print model with inline type definitions', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: true, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + }, + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'type', + }, + type: { + kind: ASTNodeKind.ENUM_INLINE_TYPE_DEFINITION, + values: [ + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { kind: ASTNodeKind.NAME, value: 'standard' }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { kind: ASTNodeKind.NAME, value: 'service' }, + }, + ], + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'kek', + }, + type: { + strict: false, + kind: ASTNodeKind.OBJECT_TYPE_DEFINITION, + fields: [ + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'conversationId', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'i', + }, + }, + }, + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'users', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + strict: false, + kind: ASTNodeKind.OBJECT_TYPE_DEFINITION, + fields: [ + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'id', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'i', + }, + }, + }, + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'nickname', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'avatar', + }, + omitted: false, + optional: true, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + }, + }, + }, + ], + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + extendsModels: [ + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Kek' }, + }, + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Lel' }, + }, + ], + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: true, + description: undefined, + }, + ], + }; + const printed = printModels(ast); + expect(printed).toEqual(dedent` +AcDocument < Kek, Lel !{ + -name?: s[], + type?: ( standard | service ), + kek: { + conversationId: i, + users: { + id: i, + nickname, + avatar?, + }[], + }, + surname: b[], +} +`); + }); + it('should print strict nested types correctly', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + + definitions: [ + { + fields: [ + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'pathParameters', + }, + type: { + strict: true, + kind: ASTNodeKind.OBJECT_TYPE_DEFINITION, + fields: [ + { + kind: ASTNodeKind.FIELD_DEFINITION, + omitted: false, + optional: false, + name: { kind: ASTNodeKind.NAME, value: 'pathParameters' }, + type: { + strict: true, + kind: ASTNodeKind.OBJECT_TYPE_DEFINITION, + fields: [], + }, + }, + ], + }, + }, + ], + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: true, + description: undefined, + extendsModels: [ + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Kek' }, + }, + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Lel' }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument < Kek, Lel !{ + pathParameters: !{ + pathParameters: !{}, + }, +} +`); + }); + + it('correctly print multiple models', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + { + fields: [ + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument2', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument {} + +AcDocument2 { + name, +} +`); + }); + }); + describe('enums', () => { + it('correctly print model with short named enum and description', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + values: [ + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'f', + }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'A', + }, + kind: ASTNodeKind.ENUM_TYPE_DEFINITION, + description: { kind: ASTNodeKind.DESCRIPTION, value: 'лул\nkek' }, + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` + // лул + // kek + A ( + f | + b + ) +`); + }); + it('correctly print model with long named enum', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + values: [ + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'Branch Office Singapore', + }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: "Private Company 'Limited' by Shares (Pte. Ltd.)", + }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: '+ amount', + }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'b-office-singapore', + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'CompanyType', + }, + kind: ASTNodeKind.ENUM_TYPE_DEFINITION, + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +CompanyType ( + Branch Office Singapore | + Private Company 'Limited' by Shares (Pte. Ltd.) | + + amount | + b-office-singapore +) +`); + }); + it('correctly print model with complicated inline enums', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: true, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + }, + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'type', + }, + type: { + kind: ASTNodeKind.ENUM_INLINE_TYPE_DEFINITION, + values: [ + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { kind: ASTNodeKind.NAME, value: '+ amount' }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { kind: ASTNodeKind.NAME, value: '- amount' }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { kind: ASTNodeKind.NAME, value: 'summ +' }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: "Private Company 'Limited' by Shares (Pte. Ltd.)", + }, + }, + ], + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'kek', + }, + type: { + strict: false, + kind: ASTNodeKind.OBJECT_TYPE_DEFINITION, + fields: [ + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'conversationId', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'i', + }, + }, + }, + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'users', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + strict: false, + kind: ASTNodeKind.OBJECT_TYPE_DEFINITION, + fields: [ + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'id', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'i', + }, + }, + }, + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'nickname', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'avatar', + }, + omitted: false, + optional: true, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + }, + }, + }, + ], + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + extendsModels: [ + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Kek' }, + }, + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Lel' }, + }, + ], + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: true, + description: undefined, + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument < Kek, Lel !{ + -name?: s[], + type?: ( + + amount | + - amount | + summ + | + Private Company 'Limited' by Shares (Pte. Ltd.) + ), + kek: { + conversationId: i, + users: { + id: i, + nickname, + avatar?, + }[], + }, + surname: b[], +} +`); + }); + it('correctly print model that uses named enum', () => { + const EnumA: EnumTypeDefinitionNode = { + values: [ + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'f', + }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'A', + }, + kind: ASTNodeKind.ENUM_TYPE_DEFINITION, + }; + + const EnumB: EnumTypeDefinitionNode = { + values: [ + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'c', + }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'd', + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'B', + }, + kind: ASTNodeKind.ENUM_TYPE_DEFINITION, + }; + + const MyModel: ModelTypeDefinitionNode = { + fields: [ + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'color', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'A', + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'MyModel', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }; + + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [EnumA, MyModel, EnumB], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` + A ( + f | + b + ) + + MyModel { + color: A, + } + + B ( + c | + d + ) +`); + }); + }); + describe('throw error', () => { + it('should throw error if trying use printModels on ast with EndpointNamespaceTypeDefinition', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + kind: ASTNodeKind.ENDPOINT_NAMESPACE_TYPE_DEFINITION, + endpoints: [ + { + kind: ASTNodeKind.ENDPOINT_TYPE_DEFINITION, + verb: { + kind: ASTNodeKind.ENDPOINT_VERB, + name: { kind: ASTNodeKind.NAME, value: 'POST' }, + }, + url: { + kind: ASTNodeKind.ENDPOINT_URL, + name: { kind: ASTNodeKind.NAME, value: '/endpoint' }, + parameters: [ + { + kind: ASTNodeKind.ENDPOINT_PARAMETER, + type: { + kind: ASTNodeKind.ENDPOINT_PARAMETER_BODY, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'RequestModel' }, + }, + }, + }, + ], + }, + responses: [ + { + status: { + kind: ASTNodeKind.ENDPOINT_STATUS_CODE, + name: { + kind: ASTNodeKind.NAME, + value: '200', + }, + }, + kind: ASTNodeKind.ENDPOINT_RESPONSE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'ResponseModel', + }, + }, + }, + ], + }, + ], + }, + ], + }; + + expect(() => printModels(ast)).toThrow(); + }); + }); +}); diff --git a/src/printerModel.test.ts b/src/printerModel.test.ts new file mode 100644 index 0000000..dd1ee47 --- /dev/null +++ b/src/printerModel.test.ts @@ -0,0 +1,1545 @@ +import { + ASTNodeKind, + EnumTypeDefinitionNode, + ModelTypeDefinitionNode, + printModels, +} from './language'; +import { dedent } from './__testsUtils__'; + +describe(__filename, () => { + describe('models', () => { + it('correctly print model with description', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: false, + description: { kind: ASTNodeKind.DESCRIPTION, value: 'лул\nkek' }, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + expect(printed).toEqual(dedent` +// лул +// kek +AcDocument {} +`); + }); + + it('correctly print empty model without description', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument {} +`); + }); + + it('correctly print empty model without description', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument {} +`); + }); + + it('correctly print model fields', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument { + name, +} +`); + }); + + it('correctly print model fields with trailing comma', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + extendsModels: [], + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument { + name, + surname, +} +`); + }); + + it('correctly print model fields with s', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument { + name: s, + surname, +} +`); + }); + + it('correctly print optional model fields', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument { + name?: s, + surname, +} +`); + }); + + it('correctly print optional model fields without type', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument { + name?, + surname, +} +`); + }); + + it('correctly print field array type', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument { + name?: s[], + surname: b[][], +} +`); + }); + + it('correctly print strict mode models', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: true, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument !{ + name?: s[], + surname: b[], +} +`); + }); + + it('correctly print extends model', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + extendsModels: [ + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Kek' }, + }, + ], + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: false, + description: undefined, + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument < Kek { + name?: s[], + surname: b[], +} +`); + }); + + it('correctly print with multiple extends model and strict model', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + extendsModels: [ + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Kek' }, + }, + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Lel' }, + }, + ], + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: true, + description: undefined, + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument < Kek, Lel !{ + name?: s[], + surname: b[], +} +`); + }); + + it('correctly print model with inline enum', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: true, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + }, + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'type', + }, + type: { + kind: ASTNodeKind.ENUM_INLINE_TYPE_DEFINITION, + values: [ + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { kind: ASTNodeKind.NAME, value: 'standard' }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { kind: ASTNodeKind.NAME, value: 'service' }, + }, + ], + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + extendsModels: [ + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Kek' }, + }, + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Lel' }, + }, + ], + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: true, + description: undefined, + }, + ], + }; + const printed = printModels(ast); + expect(printed).toEqual(dedent` +AcDocument < Kek, Lel !{ + -name?: s[], + type?: ( standard | service ), + surname: b[], +} +`); + }); + it('correctly print model with inline type definitions', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: true, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + }, + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'type', + }, + type: { + kind: ASTNodeKind.ENUM_INLINE_TYPE_DEFINITION, + values: [ + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { kind: ASTNodeKind.NAME, value: 'standard' }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { kind: ASTNodeKind.NAME, value: 'service' }, + }, + ], + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'kek', + }, + type: { + strict: false, + kind: ASTNodeKind.OBJECT_TYPE_DEFINITION, + fields: [ + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'conversationId', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'i', + }, + }, + }, + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'users', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + strict: false, + kind: ASTNodeKind.OBJECT_TYPE_DEFINITION, + fields: [ + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'id', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'i', + }, + }, + }, + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'nickname', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'avatar', + }, + omitted: false, + optional: true, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + }, + }, + }, + ], + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + extendsModels: [ + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Kek' }, + }, + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Lel' }, + }, + ], + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: true, + description: undefined, + }, + ], + }; + const printed = printModels(ast); + expect(printed).toEqual(dedent` +AcDocument < Kek, Lel !{ + -name?: s[], + type?: ( standard | service ), + kek: { + conversationId: i, + users: { + id: i, + nickname, + avatar?, + }[], + }, + surname: b[], +} +`); + }); + it('should print strict nested types correctly', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + + definitions: [ + { + fields: [ + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'pathParameters', + }, + type: { + strict: true, + kind: ASTNodeKind.OBJECT_TYPE_DEFINITION, + fields: [ + { + kind: ASTNodeKind.FIELD_DEFINITION, + omitted: false, + optional: false, + name: { kind: ASTNodeKind.NAME, value: 'pathParameters' }, + type: { + strict: true, + kind: ASTNodeKind.OBJECT_TYPE_DEFINITION, + fields: [], + }, + }, + ], + }, + }, + ], + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: true, + description: undefined, + extendsModels: [ + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Kek' }, + }, + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Lel' }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument < Kek, Lel !{ + pathParameters: !{ + pathParameters: !{}, + }, +} +`); + }); + + it('correctly print multiple models', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + { + fields: [ + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument2', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }, + ], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument {} + +AcDocument2 { + name, +} +`); + }); + }); + describe('enums', () => { + it('correctly print model with short named enum', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + values: [ + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'f', + }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'A', + }, + kind: ASTNodeKind.ENUM_TYPE_DEFINITION, + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` + A ( + f | + b + ) +`); + }); + it('correctly print model with long named enum', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + values: [ + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'Branch Office Singapore', + }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: "Private Company 'Limited' by Shares (Pte. Ltd.)", + }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: '+ amount', + }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'b-office-singapore', + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'CompanyType', + }, + kind: ASTNodeKind.ENUM_TYPE_DEFINITION, + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +CompanyType ( + Branch Office Singapore | + Private Company 'Limited' by Shares (Pte. Ltd.) | + + amount | + b-office-singapore +) +`); + }); + it('correctly print model with complicated inline enums', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + fields: [ + { + omitted: true, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'name', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 's', + }, + }, + }, + }, + { + omitted: false, + optional: true, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'type', + }, + type: { + kind: ASTNodeKind.ENUM_INLINE_TYPE_DEFINITION, + values: [ + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { kind: ASTNodeKind.NAME, value: '+ amount' }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { kind: ASTNodeKind.NAME, value: '- amount' }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { kind: ASTNodeKind.NAME, value: 'summ +' }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: "Private Company 'Limited' by Shares (Pte. Ltd.)", + }, + }, + ], + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'kek', + }, + type: { + strict: false, + kind: ASTNodeKind.OBJECT_TYPE_DEFINITION, + fields: [ + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'conversationId', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'i', + }, + }, + }, + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'users', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + strict: false, + kind: ASTNodeKind.OBJECT_TYPE_DEFINITION, + fields: [ + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'id', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'i', + }, + }, + }, + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'nickname', + }, + omitted: false, + optional: false, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + { + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'avatar', + }, + omitted: false, + optional: true, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: undefined, + }, + }, + }, + ], + }, + }, + }, + ], + }, + }, + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'surname', + }, + type: { + kind: ASTNodeKind.LIST_TYPE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'AcDocument', + }, + extendsModels: [ + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Kek' }, + }, + { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'Lel' }, + }, + ], + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + strict: true, + description: undefined, + }, + ], + }; + const printed = printModels(ast); + + expect(printed).toEqual(dedent` +AcDocument < Kek, Lel !{ + -name?: s[], + type?: ( + + amount | + - amount | + summ + | + Private Company 'Limited' by Shares (Pte. Ltd.) + ), + kek: { + conversationId: i, + users: { + id: i, + nickname, + avatar?, + }[], + }, + surname: b[], +} +`); + }); + it('correctly print model that uses named enum', () => { + const EnumA: EnumTypeDefinitionNode = { + values: [ + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'f', + }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'b', + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'A', + }, + kind: ASTNodeKind.ENUM_TYPE_DEFINITION, + }; + + const EnumB: EnumTypeDefinitionNode = { + values: [ + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'c', + }, + }, + { + kind: ASTNodeKind.ENUM_VALUE_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'd', + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'B', + }, + kind: ASTNodeKind.ENUM_TYPE_DEFINITION, + }; + + const MyModel: ModelTypeDefinitionNode = { + fields: [ + { + omitted: false, + optional: false, + kind: ASTNodeKind.FIELD_DEFINITION, + name: { + kind: ASTNodeKind.NAME, + value: 'color', + }, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'A', + }, + }, + }, + ], + name: { + kind: ASTNodeKind.NAME, + value: 'MyModel', + }, + strict: false, + kind: ASTNodeKind.MODEL_TYPE_DEFINITION, + description: undefined, + extendsModels: [], + }; + + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [EnumA, MyModel, EnumB], + }; + + const printed = printModels(ast); + + expect(printed).toEqual(dedent` + A ( + f | + b + ) + + MyModel { + color: A, + } + + B ( + c | + d + ) +`); + }); + }); + describe('throw error', () => { + it('should throw error if trying use printModels on ast with EndpointNamespaceTypeDefinition', () => { + const ast = { + kind: ASTNodeKind.DOCUMENT, + definitions: [ + { + kind: ASTNodeKind.ENDPOINT_NAMESPACE_TYPE_DEFINITION, + endpoints: [ + { + kind: ASTNodeKind.ENDPOINT_TYPE_DEFINITION, + verb: { + kind: ASTNodeKind.ENDPOINT_VERB, + name: { kind: ASTNodeKind.NAME, value: 'POST' }, + }, + url: { + kind: ASTNodeKind.ENDPOINT_URL, + name: { kind: ASTNodeKind.NAME, value: '/endpoint' }, + parameters: [ + { + kind: ASTNodeKind.ENDPOINT_PARAMETER, + type: { + kind: ASTNodeKind.ENDPOINT_PARAMETER_BODY, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { kind: ASTNodeKind.NAME, value: 'RequestModel' }, + }, + }, + }, + ], + }, + responses: [ + { + status: { + kind: ASTNodeKind.ENDPOINT_STATUS_CODE, + name: { + kind: ASTNodeKind.NAME, + value: '200', + }, + }, + kind: ASTNodeKind.ENDPOINT_RESPONSE, + type: { + kind: ASTNodeKind.NAMED_TYPE, + name: { + kind: ASTNodeKind.NAME, + value: 'ResponseModel', + }, + }, + }, + ], + }, + ], + }, + ], + }; + + expect(() => printModels(ast)).toThrow(); + }); + }); +}); diff --git a/src/runtypes/specifiedScalarTypes.ts b/src/runtypes/specifiedScalarTypes.ts index 0fe5f41..2d83af3 100644 --- a/src/runtypes/specifiedScalarTypes.ts +++ b/src/runtypes/specifiedScalarTypes.ts @@ -1,20 +1,38 @@ -export const stringAliases = ['s', 'string']; +export const stringAliases = new Set(['s', 'string']); +export const integerAliases = new Set(['i', 'integer']); +export const booleanAliases = new Set(['b', 'boolean']); +export const objectAliases = new Set(['o', 'object']); +export const floatAliases = new Set(['f', 'float']); +export const dateAliases = new Set(['d', 'date', 'datetime']); +export const textAliases = new Set(['t', 'text']); +export const jsonAliases = new Set(['j', 'json']); -export const specifiedScalarTypes = [ - 'i', - 'integer', - ...stringAliases, - 'b', - 'boolean', - 'o', - 'object', - 'f', - 'float', - 'date', - 'd', - 'datetime', - 't', - 'text', - 'j', - 'json', +export const scalarAliases = [ + stringAliases, + integerAliases, + booleanAliases, + objectAliases, + floatAliases, + dateAliases, + textAliases, + jsonAliases, ]; + +export const specifiedScalarTypes = new Set([ + ...integerAliases, + ...stringAliases, + ...booleanAliases, + ...objectAliases, + ...floatAliases, + ...dateAliases, + ...textAliases, + ...jsonAliases, +]); + +export function getNormalizedScalar(scalar: string): string | undefined { + const scalars = Array.from(scalarAliases.find((alias) => alias.has(scalar)) ?? []); + + if (scalars.length !== 0) { + return scalars[0]; + } +} diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 0c70138..7fcd12a 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -1,5 +1 @@ -type $TSFixMe = any; - -type $TSFixMeFunction = (...args: any[]) => any; - type $Maybe = null | undefined | T; diff --git a/src/validation/baseRules.ts b/src/validation/baseRules.ts deleted file mode 100644 index d8b77b3..0000000 --- a/src/validation/baseRules.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { KnownTypeNamesRule } from './rules/knownTypeNames.rule'; -import { NoExplicitStringRule } from './rules/noExplicitString.rule'; -import { ValidationRule } from './validationContext'; - -/** - * This set includes all validation rules defined by the base spec. - * - * The order of the rules in this list has been adjusted to lead to the - * most clear output when encountering multiple validation errors. - */ -export const baseRules: ReadonlyArray = [NoExplicitStringRule, KnownTypeNamesRule]; diff --git a/src/validation/index.ts b/src/validation/index.ts index b49c5b0..cc2b213 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -1,2 +1,3 @@ export { validate } from './validate'; -export { baseRules } from './baseRules'; + +export { rulesMap } from './rules/rulesMap'; diff --git a/src/validation/rules/__tests__/endpointsKnownHttpVerbs.rule.test.ts b/src/validation/rules/__tests__/endpointsKnownHttpVerbs.rule.test.ts index e81f630..a85d482 100644 --- a/src/validation/rules/__tests__/endpointsKnownHttpVerbs.rule.test.ts +++ b/src/validation/rules/__tests__/endpointsKnownHttpVerbs.rule.test.ts @@ -1,9 +1,9 @@ import { toJSONDeep } from '../../../utils'; -import { EndpointsKnownHttpVerbs } from '../base/endpointsKnownHttpVerbs.rule'; +import { endpointsKnownHttpVerbs } from '../base/endpointsKnownHttpVerbs.rule'; import { expectValidationErrors } from './fixtures'; function getErrors(queryStr: string) { - return expectValidationErrors(EndpointsKnownHttpVerbs, queryStr, 'endpoints'); + return expectValidationErrors(endpointsKnownHttpVerbs, queryStr, 'endpoints'); } function expectValid(queryStr: string) { diff --git a/src/validation/rules/__tests__/endpointsRecommendedBodyParameterPostfix.rule.test.ts b/src/validation/rules/__tests__/endpointsRecommendedBodyParameterPostfix.rule.test.ts index 992c86a..3580df6 100644 --- a/src/validation/rules/__tests__/endpointsRecommendedBodyParameterPostfix.rule.test.ts +++ b/src/validation/rules/__tests__/endpointsRecommendedBodyParameterPostfix.rule.test.ts @@ -1,9 +1,9 @@ import { toJSONDeep } from '../../../utils'; -import { EndpointsRecommendedQueryPostfix } from '../recommended/endpointsRecommendedBodyParameterPostfix.rule'; +import { endpointsRecommendedBodyParameterPostfix } from '../recommended/endpointsRecommendedBodyParameterPostfix.rule'; import { expectValidationErrors } from './fixtures'; function getErrors(queryStr: string) { - return expectValidationErrors(EndpointsRecommendedQueryPostfix, queryStr, 'endpoints'); + return expectValidationErrors(endpointsRecommendedBodyParameterPostfix, queryStr, 'endpoints'); } function expectValid(queryStr: string) { diff --git a/src/validation/rules/__tests__/endpointsRecommendedQueryPostfix.rule.test.ts b/src/validation/rules/__tests__/endpointsRecommendedQueryPostfix.rule.test.ts index 2500e1c..af8d9cf 100644 --- a/src/validation/rules/__tests__/endpointsRecommendedQueryPostfix.rule.test.ts +++ b/src/validation/rules/__tests__/endpointsRecommendedQueryPostfix.rule.test.ts @@ -1,9 +1,9 @@ import { toJSONDeep } from '../../../utils'; -import { EndpointsRecommendedQueryPostfix } from '../recommended/endpointsRecommendedQueryPostfix.rule'; +import { endpointsRecommendedQueryPostfix } from '../recommended/endpointsRecommendedQueryPostfix.rule'; import { expectValidationErrors } from './fixtures'; function getErrors(queryStr: string) { - return expectValidationErrors(EndpointsRecommendedQueryPostfix, queryStr, 'endpoints'); + return expectValidationErrors(endpointsRecommendedQueryPostfix, queryStr, 'endpoints'); } function expectValid(queryStr: string) { diff --git a/src/validation/rules/__tests__/endpointsRecommendedResponsePostfix.rule.test.ts b/src/validation/rules/__tests__/endpointsRecommendedResponsePostfix.rule.test.ts index 0b445c6..98ce64f 100644 --- a/src/validation/rules/__tests__/endpointsRecommendedResponsePostfix.rule.test.ts +++ b/src/validation/rules/__tests__/endpointsRecommendedResponsePostfix.rule.test.ts @@ -1,9 +1,9 @@ import { toJSONDeep } from '../../../utils'; -import { EndpointsRecommendedResponsePostfix } from '../recommended/endpointsRecommendedResponsePostfix.rule'; +import { endpointsRecommendedResponsePostfix } from '../recommended/endpointsRecommendedResponsePostfix.rule'; import { expectValidationErrors } from './fixtures'; function getErrors(queryStr: string) { - return expectValidationErrors(EndpointsRecommendedResponsePostfix, queryStr, 'endpoints'); + return expectValidationErrors(endpointsRecommendedResponsePostfix, queryStr, 'endpoints'); } function expectValid(queryStr: string) { diff --git a/src/validation/rules/__tests__/endpointsUpdateRequestResponseMatch.rule.test.ts b/src/validation/rules/__tests__/endpointsUpdateRequestResponseMatch.rule.test.ts new file mode 100644 index 0000000..ee731c0 --- /dev/null +++ b/src/validation/rules/__tests__/endpointsUpdateRequestResponseMatch.rule.test.ts @@ -0,0 +1,468 @@ +import { validate } from '../..'; +import { ASTNodeKind, parse, Source } from '../../../language'; +import { concatAST } from '../../../language/concatAST'; +import { AnySpecSchema } from '../../../runtypes'; +import { toJSONDeep } from '../../../utils'; +import { endpointsUpdateRequestResponseMatch } from '../recommended/endpointsUpdateRequestResponseMatch.rule'; + +describe(__filename, () => { + describe('valid', () => { + it('should be valid', () => { + const endpointString = ` +PATCH /endpoint ConnectionUpdateRequestBody + => ConnectionResponse +`; + + const modelString = ` + ConnectionUpdateRequestBody { + field: string, + field2: string, + } + ConnectionResponse { + field: string, + field2: string, + } + `; + + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const sourceModels = new Source({ + body: modelString, + name: 'endpoints-model', + sourceType: 'models', + }); + + const astEndpoints = parse(sourceEndpoints); + const astModels = parse(sourceModels); + + const combined = concatAST([astEndpoints, astModels]); + + const schema = new AnySpecSchema({ ast: combined }); + + const errors = validate(schema, combined, [endpointsUpdateRequestResponseMatch]); + + expect(errors).toEqual([]); + }); + + it('should be valid with shorten and default type names', () => { + const endpointString = ` +PATCH /endpoint ConnectionUpdateRequestBody + => ConnectionResponse +`; + + const modelString = ` + ConnectionUpdateRequestBody { + field, + field2: i, + field3: b, + field4: o, + field5: f, + field6: d, + field7: t, + field8: j, + field9: s, + } + ConnectionResponse { + field: string, + field2: integer, + field3: boolean, + field4: object, + field5: float, + field6: date, + field7: text, + field8: json, + field9: string, + } + `; + + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const sourceModels = new Source({ + body: modelString, + name: 'endpoints-model', + sourceType: 'models', + }); + + const astEndpoints = parse(sourceEndpoints); + const astModels = parse(sourceModels); + + const combined = concatAST([astEndpoints, astModels]); + + const schema = new AnySpecSchema({ ast: combined }); + + const errors = validate(schema, combined, [endpointsUpdateRequestResponseMatch]); + + expect(errors).toEqual([]); + }); + + it('should be valid with inline model in request', () => { + const endpointString = ` +PATCH /endpoint { field: string, field2: string } + => ConnectionResponse +`; + + const modelString = ` + ConnectionResponse { + field: string, + field2: string, + } + `; + + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const sourceModels = new Source({ + body: modelString, + name: 'endpoints-model', + sourceType: 'models', + }); + + const astEndpoints = parse(sourceEndpoints); + const astModels = parse(sourceModels); + + const combined = concatAST([astEndpoints, astModels]); + + const schema = new AnySpecSchema({ ast: combined }); + + const errors = validate(schema, combined, [endpointsUpdateRequestResponseMatch]); + + expect(errors).toEqual([]); + }); + + it('should be valid with inline model in response', () => { + const endpointString = ` +PATCH /endpoint ConnectionUpdateRequestBody + => { field: string, field2: string } +`; + + const modelString = ` + ConnectionUpdateRequestBody { + field: string, + field2: string, + } + `; + + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const sourceModels = new Source({ + body: modelString, + name: 'endpoints-model', + sourceType: 'models', + }); + + const astEndpoints = parse(sourceEndpoints); + const astModels = parse(sourceModels); + + const combined = concatAST([astEndpoints, astModels]); + + const schema = new AnySpecSchema({ ast: combined }); + + const errors = validate(schema, combined, [endpointsUpdateRequestResponseMatch]); + + expect(errors).toEqual([]); + }); + + it('should be valid with inline models', () => { + const endpointString = ` +PATCH /endpoint { field: string, field2: string } + => { field: string, field2: string } +`; + + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const astEndpoints = parse(sourceEndpoints); + + const schema = new AnySpecSchema({ ast: astEndpoints }); + + const errors = validate(schema, astEndpoints, [endpointsUpdateRequestResponseMatch]); + + expect(errors).toEqual([]); + }); + + it('should ignore inline types', () => { + const endpointString = ` +PATCH /endpoint ConnectionUpdateRequestBody + => ConnectionResponse +`; + + const modelString = ` + ConnectionUpdateRequestBody { + field: {a: s}, + field2: string, + } + ConnectionResponse { + field: {c: number}, + field2: string, + } + `; + + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const sourceModels = new Source({ + body: modelString, + name: 'endpoints-model', + sourceType: 'models', + }); + + const astEndpoints = parse(sourceEndpoints); + const astModels = parse(sourceModels); + + const combined = concatAST([astEndpoints, astModels]); + + const schema = new AnySpecSchema({ ast: combined }); + + const errors = validate(schema, combined, [endpointsUpdateRequestResponseMatch]); + + expect(errors).toEqual([]); + }); + it('should ignore named model types', () => { + const endpointString = ` +PATCH /endpoint ModelUpdate + => ModelResponse +`; + + const modelString = ` +ModelResponse { + entity: Model, +} + +Model { + field1: number, + field2: number, +} + +ModelUpdate { + entity: { + field1: string, + field2: number, + }, +} + `; + + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const sourceModels = new Source({ + body: modelString, + name: 'endpoints-model', + sourceType: 'models', + }); + + const astEndpoints = parse(sourceEndpoints); + const astModels = parse(sourceModels); + + const combined = concatAST([astEndpoints, astModels]); + + const schema = new AnySpecSchema({ ast: combined }); + + const errors = validate(schema, combined, [endpointsUpdateRequestResponseMatch]); + + expect(errors).toEqual([]); + }); + }); + describe('invalid', () => { + it('should be invalid', () => { + const endpointString = ` +PATCH /endpoint ConnectionUpdateRequestBody + => ConnectionResponse +`; + + const modelString = ` + ConnectionUpdateRequestBody { + field: number, + field2: string, + } + ConnectionResponse { + field: string, + field2: string, + } + `; + + // TODO: remove after #58 + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const sourceModels = new Source({ + body: modelString, + name: 'endpoints-model', + sourceType: 'models', + }); + + const astEndpoints = parse(sourceEndpoints); + const astModels = parse(sourceModels); + + const combined = concatAST([astEndpoints, astModels]); + + const schema = new AnySpecSchema({ ast: combined }); + + const errors = validate(schema, combined, [endpointsUpdateRequestResponseMatch]); + + expect(toJSONDeep(errors)).toEqual([ + { + locations: [{ line: 3, column: 8 }], + message: 'In PATCH endpoints Response should match with RequestBody', + }, + ]); + }); + it('should be invalid with inline model in request', () => { + const endpointString = ` +PATCH /endpoint { field: number, field2: string } + => ConnectionResponse +`; + + const modelString = ` + ConnectionResponse { + field: string, + field2: string, + } + `; + + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const sourceModels = new Source({ + body: modelString, + name: 'endpoints-model', + sourceType: 'models', + }); + + const astEndpoints = parse(sourceEndpoints); + const astModels = parse(sourceModels); + + const combined = concatAST([astEndpoints, astModels]); + + const schema = new AnySpecSchema({ ast: combined }); + + const errors = validate(schema, combined, [endpointsUpdateRequestResponseMatch]); + + expect(toJSONDeep(errors)).toEqual([ + { + locations: [{ line: 3, column: 8 }], + message: 'In PATCH endpoints Response should match with RequestBody', + }, + ]); + }); + it('should be invalid with inline model in response', () => { + const endpointString = ` +PATCH /endpoint ConnectionUpdateRequestBody + => { field: string, field2: string } +`; + + const modelString = ` + ConnectionUpdateRequestBody { + field: string, + field2: number, + } + `; + + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const sourceModels = new Source({ + body: modelString, + name: 'endpoints-model', + sourceType: 'models', + }); + + const astEndpoints = parse(sourceEndpoints); + const astModels = parse(sourceModels); + + const combined = concatAST([astEndpoints, astModels]); + + const schema = new AnySpecSchema({ ast: combined }); + + const errors = validate(schema, combined, [endpointsUpdateRequestResponseMatch]); + + expect(toJSONDeep(errors)).toEqual([ + { + locations: [{ line: 3, column: 8 }], + message: 'In PATCH endpoints Response should match with RequestBody', + }, + ]); + }); + it('should be invalid with inline models', () => { + const endpointString = ` +PATCH /endpoint { field: string, field2: number } + => { field: number, field2: string } +`; + + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const astEndpoints = parse(sourceEndpoints); + + const schema = new AnySpecSchema({ ast: astEndpoints }); + + const errors = validate(schema, astEndpoints, [endpointsUpdateRequestResponseMatch]); + + expect(toJSONDeep(errors)).toEqual([ + { + locations: [{ line: 3, column: 8 }], + message: 'In PATCH endpoints Response should match with RequestBody', + }, + ]); + }); + it('should be invalid with inline models with non matching fields', () => { + const endpointString = ` +PATCH /endpoint { field: string, field2: string } + => { field: string, field3: string } +`; + + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const astEndpoints = parse(sourceEndpoints); + + const schema = new AnySpecSchema({ ast: astEndpoints }); + + const errors = validate(schema, astEndpoints, [endpointsUpdateRequestResponseMatch]); + expect(toJSONDeep(errors)).toEqual([ + { + locations: [{ line: 3, column: 8 }], + message: 'In PATCH endpoints Response should match with RequestBody', + }, + ]); + }); + }); +}); diff --git a/src/validation/rules/__tests__/knownTypeNames.rule.test.ts b/src/validation/rules/__tests__/knownTypeNames.rule.test.ts index f8e61a7..1f1f722 100644 --- a/src/validation/rules/__tests__/knownTypeNames.rule.test.ts +++ b/src/validation/rules/__tests__/knownTypeNames.rule.test.ts @@ -1,10 +1,13 @@ -import { specifiedScalarTypes } from '../../../runtypes'; +import { validate } from '../..'; +import { ASTNodeKind, parse, Source } from '../../../language'; +import { concatAST } from '../../../language/concatAST'; +import { AnySpecSchema, specifiedScalarTypes } from '../../../runtypes'; import { toJSONDeep } from '../../../utils'; -import { KnownTypeNamesRule } from '../knownTypeNames.rule'; +import { knownTypeNamesRule } from '../base/knownTypeNames.rule'; import { expectValidationErrors } from './fixtures'; function getErrors(queryStr: string) { - return expectValidationErrors(KnownTypeNamesRule, queryStr); + return expectValidationErrors(knownTypeNamesRule, queryStr); } function expectValid(queryStr: string) { @@ -22,7 +25,7 @@ describe(__filename, () => { `); }); - it('all knwon types are valid', () => { + it('all known types are valid', () => { specifiedScalarTypes.forEach((type) => expectValid(` Doc { @@ -32,7 +35,7 @@ describe(__filename, () => { ); }); - it('unkown type names are invalid', () => { + it('unknown type names are invalid', () => { const errors = getErrors(` Doc { name: ew, @@ -101,4 +104,79 @@ describe(__filename, () => { }, ]); }); + + it('known-type-names rule are work with endpoints, valid', () => { + const endpointString = ` +POST /endpoint RequestModel + => {a: string, c: b, s} +`; + const modelString = ` +RequestModel {a: string, c: b, s} +`; + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const sourceModels = new Source({ + body: modelString, + name: 'endpoints-model', + sourceType: 'models', + }); + + const astEndpoints = parse(sourceEndpoints); + const astModels = parse(sourceModels); + + const combined = concatAST([astEndpoints, astModels]); + + const schema = new AnySpecSchema({ ast: combined }); + + const errors = validate(schema, combined, [knownTypeNamesRule]); + + expect(errors).toEqual([]); + }); + + it('known-type-names rule are work with endpoints, invalid', () => { + const endpointString = ` +POST /endpoint Model + => RespModel +`; + const modelString = ` +RequestModel {a: string, c: b, s} +ResponseModel {a: string, c: b, s} +`; + + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const sourceModels = new Source({ + body: modelString, + name: 'endpoints-model', + sourceType: 'models', + }); + + const astEndpoints = parse(sourceEndpoints); + const astModels = parse(sourceModels); + + const combined = concatAST([astEndpoints, astModels]); + + const schema = new AnySpecSchema({ ast: combined }); + + const errors = validate(schema, combined, [knownTypeNamesRule]); + + expect(toJSONDeep(errors)).toMatchObject([ + { + locations: [{ line: 2, column: 16 }], + message: 'Unknown type "Model".', + }, + { + locations: [{ line: 3, column: 6 }], + message: 'Unknown type "RespModel". Did you mean "RequestModel" or "ResponseModel"?', + }, + ]); + }); }); diff --git a/src/validation/rules/__tests__/noExplicitString.rule.test.ts b/src/validation/rules/__tests__/noExplicitString.rule.test.ts new file mode 100644 index 0000000..51ca8a5 --- /dev/null +++ b/src/validation/rules/__tests__/noExplicitString.rule.test.ts @@ -0,0 +1,40 @@ +import { toJSONDeep } from '../../../utils'; +import { noExplicitStringRule } from '../base'; +import { expectValidationErrors } from './fixtures'; + +function getErrors(queryStr: string) { + return expectValidationErrors(noExplicitStringRule, queryStr); +} + +function expectValid(queryStr: string) { + const errors = getErrors(queryStr); + + expect(errors).toEqual([]); +} + +describe(__filename, () => { + it('should be valid', () => { + expectValid(` + AcDocument { + field + } +`); + }); + + it('should be invalid', () => { + const errors = getErrors( + ` + Document { + field: s + } +`, + ); + + expect(toJSONDeep(errors)).toMatchObject([ + { + locations: [{ line: 3, column: 16 }], + message: 'No need to explicitly specify string type since it is the default', + }, + ]); + }); +}); diff --git a/src/validation/rules/__tests__/recommendedBodyModelName.rule.test.ts b/src/validation/rules/__tests__/recommendedBodyModelName.rule.test.ts index e65a9b8..d2643dc 100644 --- a/src/validation/rules/__tests__/recommendedBodyModelName.rule.test.ts +++ b/src/validation/rules/__tests__/recommendedBodyModelName.rule.test.ts @@ -1,9 +1,9 @@ import { toJSONDeep } from '../../../utils'; -import { RecommendedBodyModelName } from '../recommended/recommendedBodyModelName.rule'; +import { recommendedBodyModelName } from '../recommended/recommendedBodyModelName.rule'; import { expectValidationErrors } from './fixtures'; function getErrors(queryStr: string) { - return expectValidationErrors(RecommendedBodyModelName, queryStr); + return expectValidationErrors(recommendedBodyModelName, queryStr); } function expectValid(queryStr: string) { @@ -23,6 +23,26 @@ describe(__filename, () => { `); }); + it('should be valid v2', () => { + expectValid(` + Model { + body, + } + + ModelRequestBody {} +`); + }); + + it('should be valid v3', () => { + expectValid(` + Model { + body: s, + } + + ModelRequestBody {} +`); + }); + it('should be invalid', () => { const errors = getErrors( ` diff --git a/src/validation/rules/__tests__/recommendedFilterPostfix.rule.test.ts b/src/validation/rules/__tests__/recommendedFilterPostfix.rule.test.ts index 422ac1e..e9647dc 100644 --- a/src/validation/rules/__tests__/recommendedFilterPostfix.rule.test.ts +++ b/src/validation/rules/__tests__/recommendedFilterPostfix.rule.test.ts @@ -1,9 +1,9 @@ import { toJSONDeep } from '../../../utils'; -import { RecommendedFilterPostfix } from '../recommended/recommendedFilterPostfix.rule'; +import { recommendedFilterPostfix } from '../recommended/recommendedFilterPostfix.rule'; import { expectValidationErrors } from './fixtures'; function getErrors(queryStr: string) { - return expectValidationErrors(RecommendedFilterPostfix, queryStr); + return expectValidationErrors(recommendedFilterPostfix, queryStr); } function expectValid(queryStr: string) { @@ -23,6 +23,20 @@ describe(__filename, () => { `); }); + it('should be valid v2', () => { + expectValid(` + BkConnectionIndexRequestQuery !{ + filter, + } +`); + }); + it('should be valid v3', () => { + expectValid(` + BkConnectionIndexRequestQuery !{ + filter: s, + } +`); + }); it('should be invalid', () => { const errors = getErrors( ` diff --git a/src/validation/rules/__tests__/recommendedModelBodyFieldPostfix.rule.test.ts b/src/validation/rules/__tests__/recommendedModelBodyFieldPostfix.rule.test.ts index 4bd3b81..e0a1922 100644 --- a/src/validation/rules/__tests__/recommendedModelBodyFieldPostfix.rule.test.ts +++ b/src/validation/rules/__tests__/recommendedModelBodyFieldPostfix.rule.test.ts @@ -1,9 +1,9 @@ import { toJSONDeep } from '../../../utils'; -import { RecommendedModelBodyFieldPostfix } from '../recommended/recommendedModelBodyFieldPostfix.rule'; +import { recommendedModelBodyFieldPostfix } from '../recommended/recommendedModelBodyFieldPostfix.rule'; import { expectValidationErrors } from './fixtures'; function getErrors(queryStr: string) { - return expectValidationErrors(RecommendedModelBodyFieldPostfix, queryStr); + return expectValidationErrors(recommendedModelBodyFieldPostfix, queryStr); } function expectValid(queryStr: string) { @@ -23,6 +23,26 @@ describe(__filename, () => { `); }); + it('should be valid v2', () => { + expectValid(` + RequestQuery { + body, + } + + RequestQueryRequestBody {} +`); + }); + + it('should be valid v3', () => { + expectValid(` + RequestQuery { + body: s, + } + + RequestQueryRequestBody {} +`); + }); + it('should be invalid', () => { const errors = getErrors( ` diff --git a/src/validation/rules/__tests__/recommendedPostfixForCreateModels.rule.test.ts b/src/validation/rules/__tests__/recommendedPostfixForCreateModels.rule.test.ts index ecd0a92..da84ab8 100644 --- a/src/validation/rules/__tests__/recommendedPostfixForCreateModels.rule.test.ts +++ b/src/validation/rules/__tests__/recommendedPostfixForCreateModels.rule.test.ts @@ -1,8 +1,9 @@ import { validate } from '../..'; import { ASTNodeKind, parse, Source } from '../../../language'; +import { concatAST } from '../../../language/concatAST'; import { AnySpecSchema } from '../../../runtypes'; import { toJSONDeep } from '../../../utils'; -import { RecommendedPostfixForCreateModels } from '../recommended/recommendedPostfixForCreateModels.rule'; +import { recommendedPostfixForCreateModels } from '../recommended/recommendedPostfixForCreateModels.rule'; describe(__filename, () => { it('should be valid', () => { @@ -32,14 +33,48 @@ RequestModel { const astEndpoints = parse(sourceEndpoints); const astModels = parse(sourceModels); - const combined = { - kind: ASTNodeKind.DOCUMENT, - definitions: [...astEndpoints.definitions, ...astModels.definitions], - }; + const combined = concatAST([astEndpoints, astModels]); const schema = new AnySpecSchema({ ast: combined }); - const errors = validate(schema, combined, [RecommendedPostfixForCreateModels]); + const errors = validate(schema, combined, [recommendedPostfixForCreateModels]); + + expect(errors).toEqual([]); + }); + + it('should be valid with scalar types', () => { + const endpointString = ` +POST /endpoint RequestModel + => ResponseModel +`; + + const modelString = ` +RequestModel { + connection: s, + referralCode +} +`; + + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const sourceModels = new Source({ + body: modelString, + name: 'endpoints-model', + sourceType: 'models', + }); + + const astEndpoints = parse(sourceEndpoints); + const astModels = parse(sourceModels); + + const combined = concatAST([astEndpoints, astModels]); + + const schema = new AnySpecSchema({ ast: combined }); + + const errors = validate(schema, combined, [recommendedPostfixForCreateModels]); expect(errors).toEqual([]); }); @@ -71,15 +106,10 @@ RequestModel { const astEndpoints = parse(sourceEndpoints); const astModels = parse(sourceModels); - // TODO: delete after @frolovdev create schema merge functionality #58 - const combined = { - kind: ASTNodeKind.DOCUMENT, - definitions: [...astEndpoints.definitions, ...astModels.definitions], - }; - + const combined = concatAST([astEndpoints, astModels]); const schema = new AnySpecSchema({ ast: combined }); - const errors = validate(schema, combined, [RecommendedPostfixForCreateModels]); + const errors = validate(schema, combined, [recommendedPostfixForCreateModels]); expect(toJSONDeep(errors)).toEqual([ { diff --git a/src/validation/rules/__tests__/recommendedPostfixForUpdateModels.rule.test.ts b/src/validation/rules/__tests__/recommendedPostfixForUpdateModels.rule.test.ts index 202ae67..244a59e 100644 --- a/src/validation/rules/__tests__/recommendedPostfixForUpdateModels.rule.test.ts +++ b/src/validation/rules/__tests__/recommendedPostfixForUpdateModels.rule.test.ts @@ -1,8 +1,9 @@ import { validate } from '../..'; import { ASTNodeKind, parse, Source } from '../../../language'; +import { concatAST } from '../../../language/concatAST'; import { AnySpecSchema } from '../../../runtypes'; import { toJSONDeep } from '../../../utils'; -import { RecommendedPostfixForUpdateModels } from '../recommended/recommendedPostfixForUpdateModels.rule'; +import { recommendedPostfixForUpdateModels } from '../recommended/recommendedPostfixForUpdateModels.rule'; describe(__filename, () => { it('should be valid', () => { @@ -32,14 +33,47 @@ RequestModel { const astEndpoints = parse(sourceEndpoints); const astModels = parse(sourceModels); - const combined = { - kind: ASTNodeKind.DOCUMENT, - definitions: [...astEndpoints.definitions, ...astModels.definitions], - }; + const combined = concatAST([astEndpoints, astModels]); + const schema = new AnySpecSchema({ ast: combined }); + + const errors = validate(schema, combined, [recommendedPostfixForUpdateModels]); + + expect(errors).toEqual([]); + }); + + it('should be valid with scalar types', () => { + const endpointString = ` +PATCH /endpoint RequestModel + => ResponseModel +`; + + const modelString = ` +RequestModel { + connection: s, + referralCode +} +`; + + const sourceEndpoints = new Source({ + body: endpointString, + name: 'endpoints-source', + sourceType: 'endpoints', + }); + + const sourceModels = new Source({ + body: modelString, + name: 'endpoints-model', + sourceType: 'models', + }); + + const astEndpoints = parse(sourceEndpoints); + const astModels = parse(sourceModels); + + const combined = concatAST([astEndpoints, astModels]); const schema = new AnySpecSchema({ ast: combined }); - const errors = validate(schema, combined, [RecommendedPostfixForUpdateModels]); + const errors = validate(schema, combined, [recommendedPostfixForUpdateModels]); expect(errors).toEqual([]); }); @@ -72,14 +106,11 @@ RequestModel { const astEndpoints = parse(sourceEndpoints); const astModels = parse(sourceModels); - const combined = { - kind: ASTNodeKind.DOCUMENT, - definitions: [...astEndpoints.definitions, ...astModels.definitions], - }; + const combined = concatAST([astEndpoints, astModels]); const schema = new AnySpecSchema({ ast: combined }); - const errors = validate(schema, combined, [RecommendedPostfixForUpdateModels]); + const errors = validate(schema, combined, [recommendedPostfixForUpdateModels]); expect(toJSONDeep(errors)).toEqual([ { diff --git a/src/validation/rules/base/endpointsKnownHttpVerbs.rule.ts b/src/validation/rules/base/endpointsKnownHttpVerbs.rule.ts index 81a1180..428b3a3 100644 --- a/src/validation/rules/base/endpointsKnownHttpVerbs.rule.ts +++ b/src/validation/rules/base/endpointsKnownHttpVerbs.rule.ts @@ -15,7 +15,13 @@ const HTTP_REQUEST_METHODS = [ 'PATCH', ]; -export function EndpointsKnownHttpVerbs(context: ValidationContext): ASTVisitor { +/** + * verb in front of endpoint should be one of available HTTP methods + * + * [https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods) + * + */ +export function endpointsKnownHttpVerbs(context: ValidationContext): ASTVisitor { const set = new Set(HTTP_REQUEST_METHODS); return { diff --git a/src/validation/rules/base/index.ts b/src/validation/rules/base/index.ts new file mode 100644 index 0000000..f9111c5 --- /dev/null +++ b/src/validation/rules/base/index.ts @@ -0,0 +1,3 @@ +export * from './endpointsKnownHttpVerbs.rule'; +export * from './knownTypeNames.rule'; +export * from './noExplicitString.rule'; diff --git a/src/validation/rules/knownTypeNames.rule.ts b/src/validation/rules/base/knownTypeNames.rule.ts similarity index 57% rename from src/validation/rules/knownTypeNames.rule.ts rename to src/validation/rules/base/knownTypeNames.rule.ts index e8cdeb3..7aac97c 100644 --- a/src/validation/rules/knownTypeNames.rule.ts +++ b/src/validation/rules/base/knownTypeNames.rule.ts @@ -1,11 +1,15 @@ -import { didYouMean, suggestionList } from '../../utils'; +import { didYouMean, suggestionList } from '../../../utils'; +import { AnySpecError } from '../../../error'; +import { + ASTNode, + NamedTypeNode, + isModelDomainDefinitionNode, + isEndpointNamespaceTypeDefinitionNode, +} from '../../../language'; +import { ASTVisitor } from '../../../visitor'; +import { specifiedScalarTypes } from '../../../runtypes'; -import { AnySpecError } from '../../error'; -import { ASTNode, NamedTypeNode, isModelDomainDefinitionNode } from '../../language'; -import { ASTVisitor } from '../../visitor'; -import { specifiedScalarTypes } from '../../runtypes'; - -import { ValidationContext } from '../validationContext'; +import { ValidationContext } from '../../validationContext'; const standardTypeNames = specifiedScalarTypes; /** @@ -14,32 +18,31 @@ const standardTypeNames = specifiedScalarTypes; * An AnySpec document is only valid if referenced types (specifically * variable definitions) are defined by the type schema. */ -export function KnownTypeNamesRule(context: ValidationContext): ASTVisitor { - const existingTypesMap: Record = {}; - +export function knownTypeNamesRule(context: ValidationContext): ASTVisitor { const definedTypes: Record = {}; + for (const def of context.getDocument().definitions) { if (isModelDomainDefinitionNode(def)) { definedTypes[def.name.value] = true; } } - const typeNames = [...Object.keys(existingTypesMap), ...Object.keys(definedTypes)]; + const typeNames = [...Object.keys(definedTypes)]; return { NamedType(node, _1, parent, _2, ancestors) { const typeName = defaultNamedTypeCast(node); - if (!existingTypesMap[typeName] && !definedTypes[typeName]) { + if (!definedTypes[typeName]) { const definitionNode = ancestors[2] ?? parent; const isSDL = definitionNode != null && isSDLNode(definitionNode); - if (isSDL && standardTypeNames.includes(typeName)) { + if (isSDL && standardTypeNames.has(typeName)) { return; } const suggestedTypes = suggestionList( typeName, - isSDL ? standardTypeNames.concat(typeNames) : typeNames, + isSDL ? [...standardTypeNames].concat(typeNames) : typeNames, ); context.reportError( new AnySpecError(`Unknown type "${typeName}".` + didYouMean(suggestedTypes), node), @@ -50,7 +53,10 @@ export function KnownTypeNamesRule(context: ValidationContext): ASTVisitor { } function isSDLNode(value: ASTNode | ReadonlyArray): boolean { - return 'kind' in value && isModelDomainDefinitionNode(value); + return ( + 'kind' in value && + (isModelDomainDefinitionNode(value) || isEndpointNamespaceTypeDefinitionNode(value)) + ); } function defaultNamedTypeCast(node: NamedTypeNode) { diff --git a/src/validation/rules/base/noExplicitString.rule.ts b/src/validation/rules/base/noExplicitString.rule.ts new file mode 100644 index 0000000..82d3d27 --- /dev/null +++ b/src/validation/rules/base/noExplicitString.rule.ts @@ -0,0 +1,45 @@ +import { ASTVisitor } from '../../../visitor'; + +import { ValidationContext } from '../../validationContext'; +import { stringAliases } from '../../../runtypes/specifiedScalarTypes'; +import { ASTNodeKind } from '../../../language'; +import { AnySpecError } from '../../../error'; + +/** + * + * good ✅ + * + * ``` + * AcDocument { + * field + * } + * ``` + * + * bad ❌ + * + * ``` + * Document { + * field: s + * } + * ``` + * + */ +export function noExplicitStringRule(context: ValidationContext): ASTVisitor { + return { + FieldDefinition(node, _1, parent, _2, ancestors) { + if (node.type.kind === ASTNodeKind.NAMED_TYPE) { + if (!node.type.name.value) { + return; + } + if (stringAliases.has(node.type.name.value)) { + context.reportError( + new AnySpecError( + `No need to explicitly specify string type since it is the default`, + node.type, + ), + ); + } + } + }, + }; +} diff --git a/src/validation/rules/experimental/allowOnlyShorthandProperties.rule.ts b/src/validation/rules/experimental/allowOnlyShorthandProperties.rule.ts index 046f028..acd8b33 100644 --- a/src/validation/rules/experimental/allowOnlyShorthandProperties.rule.ts +++ b/src/validation/rules/experimental/allowOnlyShorthandProperties.rule.ts @@ -1,21 +1,24 @@ /** - * + * WIP: it's an experimental module, never use it! * * good ✅ * + * ``` * AcDocument { * field: d, * field2: s, * } + * ``` * * bad ❌ * + * ``` * Document { * field: string, * field2: datetime * } + * ``` * * */ - -class AllowOnlyShorthandProperties {} +class allowOnlyShorthandProperties {} diff --git a/src/validation/rules/experimental/banUsingTypeDefinitionBeforeDeclaration.ts b/src/validation/rules/experimental/banUsingTypeDefinitionBeforeDeclaration.ts index 300acc4..c43ea4c 100644 --- a/src/validation/rules/experimental/banUsingTypeDefinitionBeforeDeclaration.ts +++ b/src/validation/rules/experimental/banUsingTypeDefinitionBeforeDeclaration.ts @@ -1,26 +1,27 @@ /** - * @description * - * rule should work only in file + * WIP: it's an experimental module, never use it! * * good ✅ * + * ``` * User {} * * AcDocument { * field: User, * field2: s, * } + * ``` * * bad ❌ * - * + * ``` * AcDocument { * field: User, * field2: s, * } * * User {} + * ``` * */ -class BanUsingTypeDefinitionBeforeDeclaration {} diff --git a/src/validation/rules/experimental/enumNoSspecialSymbols.ts b/src/validation/rules/experimental/enumNoSspecialSymbols.ts index 7cf96b7..cc2897e 100644 --- a/src/validation/rules/experimental/enumNoSspecialSymbols.ts +++ b/src/validation/rules/experimental/enumNoSspecialSymbols.ts @@ -1,11 +1,17 @@ /** + * + * WIP: it's an experimental module, never use it! * * good ✅ + * ``` * User ( kek | lel | lol) * + * ``` * * bad ❌ * + * ``` * User (+date | -date) + * ``` * */ diff --git a/src/validation/rules/experimental/typeDefinitionShouldStartByNamesapceName.rule.ts b/src/validation/rules/experimental/typeDefinitionShouldStartByNamesapceName.rule.ts index 88d6cdc..6f4d679 100644 --- a/src/validation/rules/experimental/typeDefinitionShouldStartByNamesapceName.rule.ts +++ b/src/validation/rules/experimental/typeDefinitionShouldStartByNamesapceName.rule.ts @@ -1,14 +1,19 @@ /** + * + * WIP: it's an experimental module, never use it! + * * if we pass namesapce, we allow in out directory start type definition with only this prefix * TypeDefinitionShouldStartByNamesapceName(Ac) * good ✅ - * + * ``` * AcDocument {} + * ``` * * bad ❌ * + * ``` * Document {} + * ``` * * */ -class TypeDefinitionShouldStartByNamesapceName {} diff --git a/src/validation/rules/experimental/uniqTypeDefinition.rule.ts b/src/validation/rules/experimental/uniqTypeDefinition.rule.ts index be2b60b..cf884fc 100644 --- a/src/validation/rules/experimental/uniqTypeDefinition.rule.ts +++ b/src/validation/rules/experimental/uniqTypeDefinition.rule.ts @@ -1,15 +1,22 @@ /** + * + * WIP: it's an experimental module, never use it! * * good ✅ + * ``` * User {} * + * * AcDocument {} * + * ``` * bad ❌ * + * ``` * Document {} * * Document {} * + * ``` * */ diff --git a/src/validation/rules/noExplicitString.rule.ts b/src/validation/rules/noExplicitString.rule.ts deleted file mode 100644 index 0a147e9..0000000 --- a/src/validation/rules/noExplicitString.rule.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { AnySpecError } from '../../error'; -import { NamedTypeNode } from '../../language'; -import { ASTVisitor } from '../../visitor'; - -import { ValidationContext } from '../validationContext'; -import { stringAliases } from '../../runtypes/specifiedScalarTypes'; - -/** - * - * - * good ✅ - * - * AcDocument { - * field - * } - * - * bad ❌ - * - * Document { - * field: s - * } - * - * - */ -export function NoExplicitStringRule(context: ValidationContext): ASTVisitor { - return { - NamedType(node, _1, parent, _2, ancestors) { - const castedValue = defaultNamedTypeCast(node); - if (stringAliases.includes(castedValue)) { - context.reportError( - new AnySpecError( - `No need to explicitly specify string type since it is the default`, - node, - ), - ); - } - }, - }; -} - -function defaultNamedTypeCast(node: NamedTypeNode) { - return node.name.value ? node.name.value : 'string'; -} diff --git a/src/validation/rules/recommended/endpointsRecommendedBodyParameterPostfix.rule.ts b/src/validation/rules/recommended/endpointsRecommendedBodyParameterPostfix.rule.ts index 91fef1e..c221438 100644 --- a/src/validation/rules/recommended/endpointsRecommendedBodyParameterPostfix.rule.ts +++ b/src/validation/rules/recommended/endpointsRecommendedBodyParameterPostfix.rule.ts @@ -12,20 +12,20 @@ const prefixMap = { * * * good ✅ - * + * ``` * POST /endpoint RequestCreateRequestBody * PATCH /endpoint2 RequestUpdateRequestBody - * + * ``` * bad ❌ - * + * ``` * POST /endpoint Request * PATCH /endpoint2 Request - * + * ``` */ -export function EndpointsRecommendedQueryPostfix(context: ValidationContext): ASTVisitor { +export function endpointsRecommendedBodyParameterPostfix(context: ValidationContext): ASTVisitor { const isInPrefixMap = (verb: string): verb is keyof typeof prefixMap => { - const keys = Object.keys(prefixMap); - return keys.includes(verb); + const keys = new Set(Object.keys(prefixMap)); + return keys.has(verb); }; return { EndpointTypeDefinition(node) { diff --git a/src/validation/rules/recommended/endpointsRecommendedQueryPostfix.rule.ts b/src/validation/rules/recommended/endpointsRecommendedQueryPostfix.rule.ts index e77d44e..6808c49 100644 --- a/src/validation/rules/recommended/endpointsRecommendedQueryPostfix.rule.ts +++ b/src/validation/rules/recommended/endpointsRecommendedQueryPostfix.rule.ts @@ -8,15 +8,16 @@ const POSTFIX = 'RequestQuery'; * * * good ✅ - * + * ``` * POST /endpoint?SomeTypeRequestQuery + * ``` * * bad ❌ - * + * ``` * POST /endpoint?SomeType - * + * ``` */ -export function EndpointsRecommendedQueryPostfix(context: ValidationContext): ASTVisitor { +export function endpointsRecommendedQueryPostfix(context: ValidationContext): ASTVisitor { return { EndpointParameterQuery(node) { if (!node.type.name.value?.endsWith(POSTFIX)) { diff --git a/src/validation/rules/recommended/endpointsRecommendedResponsePostfix.rule.ts b/src/validation/rules/recommended/endpointsRecommendedResponsePostfix.rule.ts index e77a4b4..f8c7c9b 100644 --- a/src/validation/rules/recommended/endpointsRecommendedResponsePostfix.rule.ts +++ b/src/validation/rules/recommended/endpointsRecommendedResponsePostfix.rule.ts @@ -9,17 +9,19 @@ const POSTFIX = 'Response'; * * * good ✅ - * + * ``` * GET /endpoint * => SomeTypeResponse + * ``` * * bad ❌ * + * ``` * GET /endpoint * => SomeType - * + * ``` */ -export function EndpointsRecommendedResponsePostfix(context: ValidationContext): ASTVisitor { +export function endpointsRecommendedResponsePostfix(context: ValidationContext): ASTVisitor { return { EndpointResponse(node) { if (node.type?.kind === ASTNodeKind.NAMED_TYPE) { diff --git a/src/validation/rules/recommended/endpointsUpdateRequestResponseMatch.rule.ts b/src/validation/rules/recommended/endpointsUpdateRequestResponseMatch.rule.ts new file mode 100644 index 0000000..55a161c --- /dev/null +++ b/src/validation/rules/recommended/endpointsUpdateRequestResponseMatch.rule.ts @@ -0,0 +1,196 @@ +import { getNormalizedScalar } from './../../../runtypes/specifiedScalarTypes'; +import { AnySpecError } from '../../../error'; +import { + ASTNode, + ASTNodeKind, + EndpointParameterBodyNode, + EndpointTypeDefinitionNode, + FieldDefinitionNode, + isEndpointTypeDefinitionNode, + ModelTypeDefinitionNode, + TypeNode, +} from '../../../language'; +import { specifiedScalarTypes } from '../../../runtypes'; +import { ASTVisitor, visit } from '../../../visitor'; +import { ValidationContext } from '../../validationContext'; + +/** + * + * In PATCH RequestBody primitive types should match with Response primitive types + * + * good ✅ + * ``` + * PATCH /endpoint ConnectionUpdateRequestBody + * => ConnectionResponse + * ``` + * ``` + * ConnectionUpdateRequestBody { + * field: string + * field2: string + * } + * + * ConnectionResponse { + * field: string + * field2: string + * } + * ``` + * + * bad ❌ + * ``` + * PATCH /endpoint ConnectionCreateRequestBody + * => ConnectionResponse + * ``` + * ``` + * ConnectionCreateRequestBody { + * field: string + * field2: number + * } + * + * ConnectionResponse { + * field: number + * field2: number + * } + * ``` + */ +export function endpointsUpdateRequestResponseMatch(context: ValidationContext): ASTVisitor { + const findModelDefinition = (name?: string): ModelTypeDefinitionNode | undefined => { + let definition: ModelTypeDefinitionNode | undefined = undefined; + visit(context.getDocument(), { + ModelTypeDefinition(node) { + if (node.name.value === name) { + definition = node; + } + }, + }); + return definition; + }; + + return { + EndpointResponse(responseNode, _1, parent, _2, ancestors) { + const endpointTypeDefinition = findEndpointTypeDefinitionNode(ancestors); + const requestNodeParameterBody = findEndpointParameterBodyNode(endpointTypeDefinition); + + if (endpointTypeDefinition.verb.name.value !== 'PATCH') { + return; + } + + if ( + responseNode.type?.kind === ASTNodeKind.ENUM_INLINE_TYPE_DEFINITION || + responseNode.type?.kind === ASTNodeKind.LIST_TYPE + ) { + return; + } + + if ( + requestNodeParameterBody?.type.kind === ASTNodeKind.ENUM_INLINE_TYPE_DEFINITION || + requestNodeParameterBody?.type.kind === ASTNodeKind.LIST_TYPE + ) { + return; + } + + const responseNodeBody = responseNode.type; + + const requestTypeNode = requestNodeParameterBody?.type; + + const requestFieldDefinitions = + requestTypeNode?.kind === ASTNodeKind.OBJECT_TYPE_DEFINITION + ? requestTypeNode.fields + : findModelDefinition(requestTypeNode?.name.value)?.fields ?? []; + + const responseFieldDefinitions = + responseNodeBody?.kind === ASTNodeKind.OBJECT_TYPE_DEFINITION + ? responseNodeBody.fields + : findModelDefinition(responseNodeBody?.name.value)?.fields ?? []; + + if (!isFieldDefinitionsMatches(requestFieldDefinitions, responseFieldDefinitions)) { + context.reportError( + new AnySpecError( + `In PATCH endpoints Response should match with RequestBody`, + responseNode.type, + ), + ); + } + }, + }; +} + +// find EndpointTypeDefinitionNode in ancestors +function findEndpointTypeDefinitionNode( + ancestors: readonly (ASTNode | readonly ASTNode[])[], +): EndpointTypeDefinitionNode { + const isManyASTNodes = (node: ASTNode | readonly ASTNode[]): node is readonly ASTNode[] => + Array.isArray(node); + + const [endpointTypeDefinition] = ancestors.filter((ancestor) => { + if (isManyASTNodes(ancestor)) { + return false; + } + return isEndpointTypeDefinitionNode(ancestor); + }) as EndpointTypeDefinitionNode[]; + + return endpointTypeDefinition; +} + +function findEndpointParameterBodyNode( + endpointNode: EndpointTypeDefinitionNode, +): EndpointParameterBodyNode | undefined { + const [requestNodeParameter] = endpointNode.url.parameters.filter( + (parameter) => parameter.type.kind === ASTNodeKind.ENDPOINT_PARAMETER_BODY, + ); + + const endpointParameterType = requestNodeParameter.type; + + if (endpointParameterType.kind === ASTNodeKind.ENDPOINT_PARAMETER_BODY) { + return endpointParameterType; + } +} + +function isFieldDefinitionsMatches( + fields1: readonly FieldDefinitionNode[], + fields2: readonly FieldDefinitionNode[], +): boolean { + return fields1.every((f1) => { + const f1Name = f1.name.value; + const f1Type = f1.type; + + const [f2] = fields2.filter((f) => f.name.value === f1Name); + + if (!f2) { + return false; + } + const f2Type = f2.type; + + return isNamedTypesPrimitiveMatch(f1Type, f2Type); + }); +} + +function isNamedTypesPrimitiveMatch(t1: TypeNode, t2: TypeNode) { + if ( + t1.kind === ASTNodeKind.OBJECT_TYPE_DEFINITION || + t1.kind === ASTNodeKind.ENUM_INLINE_TYPE_DEFINITION || + t1.kind === ASTNodeKind.LIST_TYPE + ) { + // can check only primitive types + // skip this - { field: { model } | ( enum ) | *[] } + return true; + } + + if ( + t2.kind === ASTNodeKind.OBJECT_TYPE_DEFINITION || + t2.kind === ASTNodeKind.ENUM_INLINE_TYPE_DEFINITION || + t2.kind === ASTNodeKind.LIST_TYPE + ) { + // can check only primitive types + // skip this - { field: { model } | ( enum ) | *[] } + return true; + } + + // if no type name its default string type + const t1NameValue = t1.name.value ?? 's'; + const t2NameValue = t2.name.value ?? 's'; + + const normalizedScalar1 = getNormalizedScalar(t1NameValue); + const normalizedScalar2 = getNormalizedScalar(t2NameValue); + + return normalizedScalar1 === normalizedScalar2; +} diff --git a/src/validation/rules/recommended/index.ts b/src/validation/rules/recommended/index.ts new file mode 100644 index 0000000..c208581 --- /dev/null +++ b/src/validation/rules/recommended/index.ts @@ -0,0 +1,9 @@ +export * from './endpointsRecommendedBodyParameterPostfix.rule'; +export * from './endpointsRecommendedQueryPostfix.rule'; +export * from './endpointsRecommendedResponsePostfix.rule'; +export * from './recommendedBodyModelName.rule'; +export * from './recommendedFilterPostfix.rule'; +export * from './recommendedModelBodyFieldPostfix.rule'; +export * from './recommendedPostfixForCreateModels.rule'; +export * from './recommendedPostfixForUpdateModels.rule'; +export * from './endpointsUpdateRequestResponseMatch.rule'; diff --git a/src/validation/rules/recommended/recommendedBodyModelName.rule.ts b/src/validation/rules/recommended/recommendedBodyModelName.rule.ts index 4b44a14..a043418 100644 --- a/src/validation/rules/recommended/recommendedBodyModelName.rule.ts +++ b/src/validation/rules/recommended/recommendedBodyModelName.rule.ts @@ -1,5 +1,6 @@ import { AnySpecError } from '../../../error'; import { ASTNodeKind } from '../../../language'; +import { specifiedScalarTypes } from '../../../runtypes'; import { ASTVisitor } from '../../../visitor'; import { ValidationContext } from '../../validationContext'; @@ -8,19 +9,22 @@ import { ValidationContext } from '../../validationContext'; * if model contains `body` field ensure that model name is substring of body parameter type * * good ✅ - * + * ``` * Model { * body: ModelRequestBody, * } + * ``` * * bad ❌ * + * ``` * Other { * body: ModelRequestBody, * } + * ``` * */ -export function RecommendedBodyModelName(context: ValidationContext): ASTVisitor { +export function recommendedBodyModelName(context: ValidationContext): ASTVisitor { return { ModelTypeDefinition(node) { if (!node.fields.some((fieldDefinition) => fieldDefinition.name.value === 'body')) { @@ -38,6 +42,13 @@ export function RecommendedBodyModelName(context: ValidationContext): ASTVisitor return; } + if (!fieldTypeName) { + return; + } + if (specifiedScalarTypes.has(fieldTypeName)) { + return; + } + if (!fieldTypeName?.includes(modelName)) { context.reportError( new AnySpecError( diff --git a/src/validation/rules/recommended/recommendedFilterPostfix.rule.ts b/src/validation/rules/recommended/recommendedFilterPostfix.rule.ts index 0de408a..da18a96 100644 --- a/src/validation/rules/recommended/recommendedFilterPostfix.rule.ts +++ b/src/validation/rules/recommended/recommendedFilterPostfix.rule.ts @@ -1,5 +1,6 @@ import { AnySpecError } from '../../../error'; import { ASTNodeKind } from '../../../language'; +import { specifiedScalarTypes } from '../../../runtypes'; import { ASTVisitor } from '../../../visitor'; import { ValidationContext } from '../../validationContext'; @@ -10,22 +11,32 @@ const POSTFIX = 'Filter'; * * good ✅ * + * ``` * RequestQuery { * filter: BkConnectionFilter, * } + * ``` * * bad ❌ * + * ``` * RequestQuery { * filter: BkConnection, * } + * ``` * */ -export function RecommendedFilterPostfix(context: ValidationContext): ASTVisitor { +export function recommendedFilterPostfix(context: ValidationContext): ASTVisitor { return { FieldDefinition(node) { if (node.name.value === 'filter') { if (node.type.kind === ASTNodeKind.NAMED_TYPE) { + if (!node.type.name.value) { + return; + } + if (specifiedScalarTypes.has(node.type.name.value)) { + return; + } if (!node.type.name.value?.endsWith(POSTFIX)) { context.reportError( new AnySpecError( diff --git a/src/validation/rules/recommended/recommendedModelBodyFieldPostfix.rule.ts b/src/validation/rules/recommended/recommendedModelBodyFieldPostfix.rule.ts index 83f6000..a72c9de 100644 --- a/src/validation/rules/recommended/recommendedModelBodyFieldPostfix.rule.ts +++ b/src/validation/rules/recommended/recommendedModelBodyFieldPostfix.rule.ts @@ -1,5 +1,6 @@ import { AnySpecError } from '../../../error'; import { ASTNodeKind } from '../../../language'; +import { specifiedScalarTypes } from '../../../runtypes'; import { ASTVisitor } from '../../../visitor'; import { ValidationContext } from '../../validationContext'; @@ -10,22 +11,32 @@ const POSTFIX = 'RequestBody'; * * good ✅ * + * ``` * RequestQuery { * body: BkConnectionRequestBody, * } + * ``` * * bad ❌ * + * ``` * RequestQuery { * body: BkConnection, * } + * ``` * */ -export function RecommendedModelBodyFieldPostfix(context: ValidationContext): ASTVisitor { +export function recommendedModelBodyFieldPostfix(context: ValidationContext): ASTVisitor { return { FieldDefinition(node) { if (node.name.value === 'body') { if (node.type.kind === ASTNodeKind.NAMED_TYPE) { + if (!node.type.name.value) { + return; + } + if (specifiedScalarTypes.has(node.type.name.value)) { + return; + } if (!node.type.name.value?.endsWith(POSTFIX)) { context.reportError( new AnySpecError( diff --git a/src/validation/rules/recommended/recommendedPostfixForCreateModels.rule.ts b/src/validation/rules/recommended/recommendedPostfixForCreateModels.rule.ts index bd52c0d..15c17b2 100644 --- a/src/validation/rules/recommended/recommendedPostfixForCreateModels.rule.ts +++ b/src/validation/rules/recommended/recommendedPostfixForCreateModels.rule.ts @@ -1,5 +1,6 @@ import { AnySpecError } from '../../../error'; import { ASTNodeKind } from '../../../language'; +import { specifiedScalarTypes } from '../../../runtypes'; import { ASTVisitor, visit } from '../../../visitor'; import { ValidationContext } from '../../validationContext'; @@ -11,24 +12,28 @@ import { ValidationContext } from '../../validationContext'; * * good ✅ * + * ``` * POST /connections ConnectionCreateRequestBody * => ConnectionResponse` * * ConnectionCreateRequestBody { * connection: BkConnectionNew * } + * ``` * * bad ❌ * + * ``` * POST /connections ConnectionCreateRequestBody * => ConnectionResponse` * * ConnectionCreateRequestBody { * connection: BkConnection * } + * ``` * */ -export function RecommendedPostfixForCreateModels(context: ValidationContext): ASTVisitor { +export function recommendedPostfixForCreateModels(context: ValidationContext): ASTVisitor { let bodyParameters: string[] = []; // TODO: Rewrite after introducing type info #59 @@ -49,12 +54,15 @@ export function RecommendedPostfixForCreateModels(context: ValidationContext): A }); return { ModelTypeDefinition(node) { - if (!bodyParameters.includes(node.name.value)) { + const bodyParametersSet = new Set(bodyParameters); + if (!bodyParametersSet.has(node.name.value)) { return; } node.fields.forEach((fieldDefinition) => { if ( fieldDefinition.type.kind === ASTNodeKind.NAMED_TYPE && + fieldDefinition.type.name.value && + !specifiedScalarTypes.has(fieldDefinition.type.name.value) && !fieldDefinition.type.name.value?.endsWith('New') ) { context.reportError( diff --git a/src/validation/rules/recommended/recommendedPostfixForUpdateModels.rule.ts b/src/validation/rules/recommended/recommendedPostfixForUpdateModels.rule.ts index be7d3ea..f2d89ad 100644 --- a/src/validation/rules/recommended/recommendedPostfixForUpdateModels.rule.ts +++ b/src/validation/rules/recommended/recommendedPostfixForUpdateModels.rule.ts @@ -1,5 +1,6 @@ import { AnySpecError } from '../../../error'; import { ASTNodeKind } from '../../../language'; +import { specifiedScalarTypes } from '../../../runtypes'; import { ASTVisitor, visit } from '../../../visitor'; import { ValidationContext } from '../../validationContext'; @@ -11,24 +12,28 @@ import { ValidationContext } from '../../validationContext'; * * good ✅ * + * ``` * PATCH /connections ConnectionCreateRequestBody * => ConnectionResponse` * * ConnectionCreateRequestBody { * connection: ConnectionUpdate * } + * ``` * * bad ❌ * + * ``` * PATCH /connections ConnectionCreateRequestBody * => ConnectionResponse` * * ConnectionCreateRequestBody { * connection: Connection * } + * ``` * */ -export function RecommendedPostfixForUpdateModels(context: ValidationContext): ASTVisitor { +export function recommendedPostfixForUpdateModels(context: ValidationContext): ASTVisitor { let bodyParameters: string[] = []; // TODO: Rewrite after introducing type info #59 @@ -49,12 +54,15 @@ export function RecommendedPostfixForUpdateModels(context: ValidationContext): A }); return { ModelTypeDefinition(node) { - if (!bodyParameters.includes(node.name.value)) { + const bodyParametersSet = new Set(bodyParameters); + if (!bodyParametersSet.has(node.name.value)) { return; } node.fields.forEach((fieldDefinition) => { if ( fieldDefinition.type.kind === ASTNodeKind.NAMED_TYPE && + fieldDefinition.type.name.value && + !specifiedScalarTypes.has(fieldDefinition.type.name.value) && !fieldDefinition.type.name.value?.endsWith('Update') ) { context.reportError( diff --git a/src/validation/rules/rulesMap.ts b/src/validation/rules/rulesMap.ts new file mode 100644 index 0000000..319b987 --- /dev/null +++ b/src/validation/rules/rulesMap.ts @@ -0,0 +1,24 @@ +import { ASTVisitor } from '../../visitor'; +import { ValidationContext } from '../validationContext'; +import * as base from './base'; +import * as recommended from './recommended'; + +/** + * This record includes all available validation rules. + */ +export const rulesMap: Record ASTVisitor> = { + 'base/known-type-names': base.knownTypeNamesRule, + 'base/no-explicit-string-rule': base.noExplicitStringRule, + 'base/endpoints-known-http-verbs': base.endpointsKnownHttpVerbs, + 'recommended/endpoints-body-parameter-postfix': + recommended.endpointsRecommendedBodyParameterPostfix, + 'recommended/endpoints-query-postfix': recommended.endpointsRecommendedQueryPostfix, + 'recommended/endpoints-response-postfix': recommended.endpointsRecommendedResponsePostfix, + 'recommended/body-model-name': recommended.recommendedBodyModelName, + 'recommended/filter-postfix': recommended.recommendedFilterPostfix, + 'recommended/model-body-field-postfix': recommended.recommendedModelBodyFieldPostfix, + 'recommended/postfix-for-create-models': recommended.recommendedPostfixForCreateModels, + 'recommended/postfix-for-update-models': recommended.recommendedPostfixForUpdateModels, + 'recommended/endpoint-update-request-response-match': + recommended.endpointsUpdateRequestResponseMatch, +}; diff --git a/src/validation/validate.ts b/src/validation/validate.ts index 107320a..576c489 100644 --- a/src/validation/validate.ts +++ b/src/validation/validate.ts @@ -4,9 +4,8 @@ import { visit } from '../visitor'; import { AnySpecSchema } from '../runtypes'; -import { ValidationRule } from './ValidationContext'; -import { baseRules } from './baseRules'; -import { ValidationContext } from './ValidationContext'; +import { ValidationRule } from './validationContext'; +import { ValidationContext } from './validationContext'; import { assert } from '../utils'; /** @@ -28,7 +27,7 @@ import { assert } from '../utils'; export function validate( schema: AnySpecSchema, documentAST: DocumentNode, - rules: ReadonlyArray = baseRules, + rules: ReadonlyArray, options: { maxErrors?: number } = { maxErrors: undefined }, ): ReadonlyArray { assert(documentAST, 'Must provide document.'); diff --git a/src/visitor.ts b/src/visitor.ts index d5a09d6..25a1166 100644 --- a/src/visitor.ts +++ b/src/visitor.ts @@ -90,9 +90,11 @@ const QueryDocumentKeys = { EndpointNamespaceTypeDefinition: ['tag', 'endpoints'], EndpointTypeDefinition: ['verb', 'url', 'responses'], EndpointResponse: ['type'], + EnumTypeDefinition: ['name', 'values', 'description'], EndpointVerb: ['name'], EndpointUrl: ['name', 'parameters'], EndpointParameter: ['type'], + EndpointParameterBody: ['type'], } as const; export const BREAK = { diff --git a/temp/openapi.json b/temp/openapi.json index 71b066c..429e6b7 100644 --- a/temp/openapi.json +++ b/temp/openapi.json @@ -23,97 +23,60 @@ } }, "paths": { - "/industries": { - "get": { - "summary": "**List** _industries_", - "description": "**List** _industries_", - "operationId": "GET--industries", + "/documents": { + "post": { + "summary": "**Send**", + "description": "**Send**", + "operationId": "POST--documents", "responses": { "200": { "description": "", "schema": { "type": "object", "properties": { - "industries": { - "type": "array", - "items": { - "$ref": "#/definitions/Industry" - } + "document": { + "$ref": "#/definitions/Document" } }, "required": [ - "industries" + "document" ] } } }, - "tags": [ - "`/industries`" + "security": [ + { + "token": [] + } + ], + "parameters": [ + { + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/DocumentNew" + }, + "in": "body" + } ] } } }, - "tags": [ - { - "name": "`/industries`" - } - ], "definitions": { - "Document": { - "type": "object" - }, - "PermissionType": { - "enum": [ - "own:companies:write", - "own:documents:write", - "own:conversations:write", - "" - ] - }, - "C": { + "DocumentNew": { "type": "object", "properties": { - "pathParameters": { - "type": "object", - "properties": { - "a": { - "type": "string" - } - }, - "required": [ - "a" - ], - "additionalProperties": false + "id": { + "type": "integer" + }, + "name": { + "type": "string" } }, "required": [ - "pathParameters" + "id", + "name" ] - }, - "WSEventsFromServerForAgent": { - "type": "object", - "properties": { - "messages:New": { - "anyOf": [ - { - "$ref": "#/definitions/WSOnNewMessageForAgent" - }, - { - "type": "null" - } - ] - }, - "messages:update": { - "anyOf": [ - { - "$ref": "#/definitions/WSOnUpdateMessageForAgent" - }, - { - "type": "null" - } - ] - } - } } } } \ No newline at end of file diff --git a/temp/spec/endpoints/test.endpoints.tinyspec b/temp/spec/endpoints/test.endpoints.tinyspec index f7b4d4b..c10dade 100644 --- a/temp/spec/endpoints/test.endpoints.tinyspec +++ b/temp/spec/endpoints/test.endpoints.tinyspec @@ -1,4 +1,4 @@ - -`/industries`: - $L /industries ?branch? \ No newline at end of file +// **Send** +@token POST /documents DocumentNew + => { document: Document } \ No newline at end of file diff --git a/temp/spec/models/kek.models.tinyspec b/temp/spec/models/kek.models.tinyspec index 0312fa6..3c0253a 100644 --- a/temp/spec/models/kek.models.tinyspec +++ b/temp/spec/models/kek.models.tinyspec @@ -1,21 +1,8 @@ -Document {} - -PermissionType ( - "own:companies:write" | - own:documents:write | - own:conversations:write | -) - -C { - pathParameters: !{ - a: string - } +DocumentNew { + name: s, } -CompanyBankStatementSummaryQuery {} - - -WSEventsFromServerForAgent { - messages:new?: WSOnNewMessageForAgent, - messages:update?: WSOnUpdateMessageForAgent, +DocumentNew { + id: i, + name: s, }