├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ ├── publish-packages.yml │ ├── release-packages.yml │ ├── set-coverage.yml │ └── tag-global.yml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.json ├── .npmignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── SECURITY.md ├── codecov.yml ├── contract-generate.gif ├── package.json ├── packages ├── cli │ ├── .eslintrc │ ├── bin │ │ └── cli.js │ ├── package.json │ ├── src │ │ ├── commands.ts │ │ ├── commands │ │ │ └── generate.command.ts │ │ └── index.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── contract │ ├── .DS_Store │ ├── .eslintrc │ ├── .gitignore │ ├── contract │ │ └── index.d.ts │ ├── package.json │ ├── tsconfig.build.json │ └── tsconfig.json └── generators │ ├── contract │ ├── .eslintrc │ ├── contract.template.hbs │ ├── index.ts │ ├── package.json │ ├── src │ │ ├── command.ts │ │ ├── generator.ts │ │ ├── handlebars-helpers.ts │ │ └── index.ts │ ├── tsconfig.build.json │ └── tsconfig.json │ ├── diff │ ├── .eslintrc │ ├── index.ts │ ├── package.json │ ├── src │ │ ├── diff-highlighter.ts │ │ ├── generator.spec.ts │ │ ├── generator.ts │ │ └── index.ts │ ├── tsconfig.build.json │ └── tsconfig.json │ └── spec │ ├── .eslintrc │ ├── index.ts │ ├── package.json │ ├── src │ ├── generator.ts │ └── index.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── spec-graduate.gif ├── tsconfig.build.json ├── tsconfig.json └── vitest.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | coverage/* 3 | dist 4 | dist/* 5 | *.js 6 | *.d.ts 7 | index.ts 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:prettier/recommended" 6 | ], 7 | "settings": { 8 | "import/parsers": { 9 | "@typescript-eslint/parser": [ 10 | ".ts" 11 | ] 12 | }, 13 | "import/resolver": { 14 | "node": { 15 | "paths": [ 16 | "packages/" 17 | ], 18 | "extensions": [ 19 | ".ts" 20 | ] 21 | }, 22 | "typescript": { 23 | "alwaysTryTypes": true 24 | } 25 | } 26 | }, 27 | "plugins": [ 28 | "prettier", 29 | "import" 30 | ], 31 | "parserOptions": { 32 | "ecmaVersion": 8, 33 | "project": [ 34 | "./tsconfig.json" 35 | ] 36 | }, 37 | "rules": { 38 | "no-useless-constructor": "off", 39 | "react/forbid-prop-types": 0, 40 | "no-unused-expressions": 0, 41 | "one-var": 0, 42 | "no-underscore-dangle": [ 43 | 0, 44 | { 45 | "allow": [] 46 | } 47 | ], 48 | "global-require": 0, 49 | "new-cap": "off", 50 | "@typescript-eslint/no-unsafe-declaration-merging": "off", 51 | "@typescript-eslint/no-namespace": "off", 52 | "@typescript-eslint/no-explicit-any": 0, 53 | "@typescript-eslint/explicit-function-return-type": 0, 54 | "import/extensions": 0, 55 | "@typescript-eslint/camelcase": 0, 56 | "camelcase": "off", 57 | "consistent-return": 0, 58 | "import/prefer-default-export": 0, 59 | "lines-between-class-members": "off", 60 | "import/no-extraneous-dependencies": [ 61 | "error", 62 | { 63 | "devDependencies": true, 64 | "optionalDependencies": false, 65 | "peerDependencies": false, 66 | "packageDir": "./" 67 | } 68 | ], 69 | "@typescript-eslint/consistent-type-imports": [ 70 | "error", 71 | { 72 | "prefer": "type-imports", 73 | "disallowTypeAnnotations": false 74 | } 75 | ] 76 | }, 77 | "env": { 78 | "node": true 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/** linguist-vendored 2 | /.yarn/releases/* binary 3 | /.yarn/plugins/**/* binary 4 | /.pnp.* binary linguist-generated 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | on: [pull_request] 3 | 4 | permissions: 5 | id-token: write 6 | contents: read 7 | checks: write 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [18.x, 20.x, 22.x] 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | 21 | - name: Use Node ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - name: Install pnpm 26 | uses: pnpm/action-setup@v4 27 | with: 28 | version: 9.15.4 29 | - uses: actions/cache@v2 30 | with: 31 | path: '**/node_modules' 32 | key: ${{ runner.os }}-modules-${{ hashFiles('**/pnpm-lock.yaml') }} 33 | 34 | - name: PNPM 35 | run: pnpm install --frozen-lockfile 36 | 37 | - name: Build 38 | run: pnpm run build 39 | 40 | lint: 41 | name: Lint 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Checkout 45 | uses: actions/checkout@v4 46 | - name: Setup Node 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: '22.x' 50 | - name: Install pnpm 51 | uses: pnpm/action-setup@v4 52 | with: 53 | version: 9.15.4 54 | 55 | - uses: actions/cache@v2 56 | with: 57 | path: '**/node_modules' 58 | key: ${{ runner.os }}-modules-${{ hashFiles('**/pnpm-lock.yaml') }} 59 | 60 | - name: PNPM 61 | run: pnpm install --frozen-lockfile 62 | 63 | - name: Lint 64 | run: pnpm run lint 65 | 66 | - name: Validate Packages 67 | run: pnpm manypkg check 68 | 69 | test: 70 | name: Test 71 | runs-on: ubuntu-latest 72 | strategy: 73 | matrix: 74 | project: ['doubles.jest', 'doubles.sinon', 'core.unit', 'doubles.vitest', 'di.nestjs', 'di.inversify', 'unit'] 75 | steps: 76 | - name: Checkout 77 | uses: actions/checkout@v4 78 | 79 | - name: Setup Node 80 | uses: actions/setup-node@v4 81 | with: 82 | node-version: '22.x' 83 | - name: Install pnpm 84 | uses: pnpm/action-setup@v4 85 | with: 86 | version: 9.15.4 87 | 88 | - uses: actions/cache@v2 89 | with: 90 | path: '**/node_modules' 91 | key: ${{ runner.os }}-modules-${{ hashFiles('**/pnpm-lock.yaml') }} 92 | 93 | - name: PNPM 94 | run: pnpm install --frozen-lockfile 95 | 96 | - name: Create Coverage Directory 97 | run: mkdir -p ${{ github.workspace }}/coverage 98 | 99 | - name: Test 100 | run: pnpm --filter @contractual/${{ matrix.project }} run test 101 | env: 102 | JEST_JUNIT_OUTPUT_NAME: ${{ matrix.project }}.xml 103 | JEST_JUNIT_OUTPUT_DIR: ${{ github.workspace }}/test-reports 104 | JUNIT_OUTPUT_NAME: ${{ matrix.project }} 105 | JUNIT_OUTPUT_DIR: ${{ github.workspace }}/test-reports 106 | COVERAGE_DIR: ${{ github.workspace }}/coverage 107 | COVERAGE_FILE: coverage-${{ matrix.project }}.xml 108 | 109 | - name: Tests Results 110 | uses: dorny/test-reporter@v1 111 | if: always() 112 | with: 113 | reporter: 'jest-junit' 114 | name: Tests Results (${{ matrix.project }}) 115 | path: ${{ github.workspace }}/test-reports/${{ matrix.project }}.xml 116 | fail-on-error: false 117 | 118 | # - name: Upload Report to Codecov 119 | # uses: codecov/codecov-action@v3 120 | # with: 121 | # name: codecov-umbrella 122 | # flags: ${{ matrix.project }} 123 | # token: ${{ secrets.CODECOV_TOKEN }} 124 | # fail_ci_if_error: true 125 | # files: ${{ github.workspace }}/coverage/coverage-${{ matrix.project }}.xml 126 | # verbose: true 127 | -------------------------------------------------------------------------------- /.github/workflows/publish-packages.yml: -------------------------------------------------------------------------------- 1 | name: Publish Packages 2 | env: 3 | CI: true 4 | 5 | permissions: 6 | id-token: write 7 | contents: write 8 | 9 | on: 10 | workflow_dispatch: 11 | inputs: 12 | dist_tag: 13 | description: 'Distribution Tag' 14 | type: choice 15 | options: 16 | - 'next' 17 | - 'latest' 18 | - 'rc' 19 | - 'dev' 20 | - 'alpha' 21 | - 'beta' 22 | required: true 23 | default: 'dev' 24 | strategy: 25 | description: 'Release Strategy' 26 | type: choice 27 | options: 28 | - 'from-git' 29 | - 'from-package' 30 | required: true 31 | default: 'from-package' 32 | 33 | jobs: 34 | e2e: 35 | name: Build and Test 36 | runs-on: ubuntu-latest 37 | strategy: 38 | matrix: 39 | e2e-project: ['jest/nestjs', 'sinon/nestjs', 'vitest/nestjs', 'jest/inversify', 'sinon/inversify', 'vitest/inversify'] 40 | node-version: [16.x, 18.x, 20.x] 41 | exclude: 42 | - e2e-project: 'vitest/inversify' 43 | node-version: '16.x' 44 | - e2e-project: 'vitest/nestjs' 45 | node-version: '16.x' 46 | steps: 47 | - name: Checkout 48 | uses: actions/checkout@v4 49 | with: 50 | ref: ${{ github.event.inputs.target_branch }} 51 | 52 | - name: Remove Vitest If Needed 53 | if: ${{ matrix.node-version == '16.x' }} 54 | run: | 55 | rm -rf packages/doubles/vitest 56 | git config --global user.email "ci@suites.dev" 57 | git config --global user.name "Suites CI" 58 | git add . 59 | git commit -am "remove vitest temp" 60 | 61 | - name: Setup Node ${{ matrix.node-version }} 62 | uses: actions/setup-node@v4 63 | with: 64 | node-version: ${{ matrix.node-version }} 65 | - name: Install pnpm 66 | uses: pnpm/action-setup@v4 67 | with: 68 | version: 9.15.4 69 | 70 | - name: PNPM 71 | run: pnpm install --frozen-lockfile 72 | 73 | - name: Run Verdaccio Docker 74 | run: | 75 | docker run -d --name verdaccio \ 76 | -p 4873:4873 \ 77 | -v ${{ github.workspace }}/e2e/config.yaml:/verdaccio/conf/config.yaml \ 78 | verdaccio/verdaccio 79 | 80 | - name: Build 81 | run: pnpm build 82 | 83 | - name: Setup Registry 84 | uses: actions/setup-node@v4 85 | with: 86 | node-version: ${{ matrix.node-version }} 87 | registry-url: http://localhost:4873 88 | scope: '@suites' 89 | always-auth: false 90 | 91 | - name: Install jq 92 | run: sudo apt-get install jq 93 | 94 | - name: Remove provenance from publishConfig 95 | run: | 96 | find packages -name 'package.json' | while read filename; do 97 | jq 'del(.publishConfig.provenance)' "$filename" > temp.json && mv temp.json "$filename" 98 | done 99 | 100 | - name: Commit Change 101 | run: | 102 | git config --global user.email "e2e@suites.dev" 103 | git config --global user.name "Suites e2e" 104 | git add . 105 | git commit -am "remove provenance" 106 | 107 | - name: Publish Packages 108 | run: | 109 | pnpm publish -r \ 110 | --no-git-checks \ 111 | --access public \ 112 | --tag ci \ 113 | --force 114 | - name: Setup and Test 115 | run: | 116 | IFS='/' read -r library framework <<< "${{ matrix.e2e-project }}" 117 | echo "FRAMEWORK=$framework" >> $GITHUB_ENV 118 | echo "LIBRARY=$library" >> $GITHUB_ENV 119 | 120 | - name: Clean Source 121 | run: | 122 | rm -rf packages 123 | rm -rf node_modules 124 | 125 | - name: Install Dependencies 126 | run: | 127 | cd "$PWD/e2e/$LIBRARY/$FRAMEWORK" 128 | npm install --no-cache --no-package-lock 129 | npm install --dev --no-package-lock @types/node@${{ matrix.node-version }} 130 | 131 | - name: Execute Test 132 | run: | 133 | cd "$PWD/e2e/$LIBRARY/$FRAMEWORK" 134 | npm test 135 | 136 | publish: 137 | name: Publish Packages 138 | needs: [e2e] 139 | runs-on: ubuntu-latest 140 | steps: 141 | - name: Checkout 142 | uses: actions/checkout@v4 143 | 144 | - name: Setup Registry 145 | uses: actions/setup-node@v4 146 | with: 147 | registry-url: https://registry.npmjs.org/ 148 | scope: '@suites' 149 | always-auth: true 150 | - name: Install pnpm 151 | uses: pnpm/action-setup@v4 152 | with: 153 | version: 9.15.4 154 | 155 | - name: pnpm 156 | run: pnpm install --frozen-lockfile 157 | 158 | - name: Build 159 | run: pnpm build 160 | 161 | - name: Publish Packages 162 | run: pnpm publish -r ${{ github.event.inputs.strategy }} --access public --tag ${{ github.event.inputs.dist_tag }} 163 | env: 164 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 165 | -------------------------------------------------------------------------------- /.github/workflows/release-packages.yml: -------------------------------------------------------------------------------- 1 | name: Prepare Release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | release_type: 6 | description: 'Release Type' 7 | type: choice 8 | options: 9 | - 'graduate' 10 | - 'prerelease' 11 | required: true 12 | default: 'prerelease' 13 | target_branch: 14 | description: 'Target Branch' 15 | type: choice 16 | options: 17 | - 'master' 18 | - 'next' 19 | required: true 20 | default: 'next' 21 | preid: 22 | description: 'Prefix Id' 23 | type: choice 24 | options: 25 | - 'latest' 26 | - 'next' 27 | - 'rc' 28 | - 'dev' 29 | - 'alpha' 30 | - 'beta' 31 | required: true 32 | default: 'next' 33 | exact_version: 34 | description: 'Exact Version' 35 | type: string 36 | required: false 37 | 38 | permissions: 39 | contents: write 40 | id-token: write 41 | 42 | jobs: 43 | tag-version: 44 | name: Tag Version 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: Checkout 48 | uses: actions/checkout@v4 49 | with: 50 | ref: ${{ github.event.inputs.target_branch }} 51 | fetch-depth: 0 52 | 53 | - name: Config Git User 54 | run: | 55 | git config --global user.name "${{ github.actor }}" 56 | git config --global user.email "${{ github.actor }}@users.noreply.github.com" 57 | 58 | - name: Prerelease Version (Exact Version) 59 | if: ${{ github.event.inputs.release_type == 'prerelease' && github.event.inputs.exact_version }} 60 | run: | 61 | pnpm version ${{ github.event.inputs.exact_version }} \ 62 | --workspaces-update \ 63 | --no-git-tag-version \ 64 | --no-commit-hooks 65 | env: 66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | 68 | - name: Prerelease Version 69 | if: ${{ github.event.inputs.release_type == 'prerelease' && !github.event.inputs.exact_version }} 70 | run: | 71 | pnpm version prerelease \ 72 | --preid ${{ github.event.inputs.preid }} \ 73 | --workspaces-update \ 74 | --no-git-tag-version \ 75 | --no-commit-hooks 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | 79 | - name: Graduate Version 80 | if: ${{ github.event.inputs.release_type == 'graduate' }} 81 | run: pnpm version --workspaces-update from-git 82 | env: 83 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 84 | 85 | - name: Push Changes to Branch 86 | run: | 87 | git push origin ${{ github.event.inputs.target_branch }} --no-verify 88 | git push origin --tags 89 | env: 90 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 91 | -------------------------------------------------------------------------------- /.github/workflows/set-coverage.yml: -------------------------------------------------------------------------------- 1 | name: Test & Coverage 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | permissions: 8 | id-token: write 9 | contents: read 10 | checks: write 11 | 12 | jobs: 13 | test: 14 | name: Test 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | project: ['doubles.jest', 'doubles.sinon', 'core.unit', 'doubles.vitest', 'di.nestjs', 'di.inversify', 'unit'] 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup Node 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: '18.x' 27 | 28 | - uses: actions/cache@v2 29 | with: 30 | path: '**/node_modules' 31 | key: ${{ runner.os }}-modules-${{ hashFiles('**/pnpm-lock.yaml') }} 32 | 33 | - name: pnpm 34 | run: pnpm install --frozen-lockfile 35 | 36 | - name: Create Coverage Directory 37 | run: mkdir -p ${{ github.workspace }}/coverage 38 | 39 | - name: Test 40 | run: pnpm --filter @contractual/${{ matrix.project }} run test 41 | env: 42 | JEST_JUNIT_OUTPUT_NAME: ${{ matrix.project }}.xml 43 | JEST_JUNIT_OUTPUT_DIR: ${{ github.workspace }}/test-reports 44 | JUNIT_OUTPUT_NAME: ${{ matrix.project }} 45 | JUNIT_OUTPUT_DIR: ${{ github.workspace }}/test-reports 46 | COVERAGE_DIR: ${{ github.workspace }}/coverage 47 | COVERAGE_FILE: coverage-${{ matrix.project }}.xml 48 | 49 | - name: Tests Results 50 | uses: dorny/test-reporter@v1 51 | if: always() 52 | with: 53 | reporter: 'jest-junit' 54 | name: Tests Results (${{ matrix.project }}) 55 | path: ${{ github.workspace }}/test-reports/${{ matrix.project }}.xml 56 | fail-on-error: false 57 | 58 | - name: Upload Report to Codecov 59 | uses: codecov/codecov-action@v3 60 | with: 61 | name: codecov-umbrella 62 | flags: ${{ matrix.project }} 63 | token: ${{ secrets.CODECOV_TOKEN }} 64 | fail_ci_if_error: true 65 | files: ${{ github.workspace }}/coverage/coverage-${{ matrix.project }}.xml 66 | verbose: true -------------------------------------------------------------------------------- /.github/workflows/tag-global.yml: -------------------------------------------------------------------------------- 1 | name: Tag and Push 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tag_name: 7 | description: 'Tag Name (e.g. v1.0.0)' 8 | required: true 9 | 10 | permissions: 11 | contents: write 12 | id-token: write 13 | 14 | jobs: 15 | tag-and-push: 16 | name: Tag and Push 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout Code 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Config Git 26 | run: | 27 | git config --global user.email "omer.moradd@gmail.com" 28 | git config --global user.name "Omer Morad" 29 | 30 | - name: Tag and Push 31 | run: | 32 | git tag -a ${{ inputs.tag_name }} -m "Tag Version ${{ inputs.tag_name }}" 33 | git push origin ${{ inputs.tag_name }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | .idea 133 | .DS_Store 134 | junit.xml 135 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm lint-staged -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages/**/*.ts": [ 3 | "eslint --fix" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .npmignore 2 | node_modules 3 | .npmrc 4 | .eslintrc 5 | .eslintignore 6 | commitlint.config.js 7 | yarn.lock 8 | tsconfig.json 9 | tsconfig.build.json 10 | jest.config.json 11 | .gitignore 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true, 4 | "printWidth": 100, 5 | "tabWidth": 2, 6 | "semi": true, 7 | "endOfLine": "auto" 8 | } 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | omer.moradd@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2025 Omer Morad 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Contractual

2 | 3 | Contractual is a tool for managing API and data schemas as structured contracts. It ensures that schemas 4 | are defined, versioned, and enforced across teams, whether for REST APIs, event-driven systems, or structured data 5 | exchanges. 6 | 7 | Common use cases include: \ 8 | 🔹 Keeping API Contracts in Sync Between Backend and Frontend \ 9 | 🔹 Generating Type-Safe Clients and Server Contracts \ 10 | 🔹 Preventing Breaking Changes and Detecting Schema Drift \ 11 | 🔹 Ensuring Consistency Between Backend and Data Teams \ 12 | 🔹 Generating Language-Specific Types from a Shared Contract 13 | 14 | By treating schemas as first-class entities, Contractual eliminates uncertainty at integration points, enabling backend, 15 | frontend, and data engineering teams to maintain predictable and enforceable APIs and structured data across the entire 16 | stack. 17 | 18 | > Initially built for the **Node.js and TypeScript ecosystem**, Contractual is planned to support additional 19 | > languages. 20 | 21 | ## 🚀 In Practice 22 | 23 | ### Install Contractual 24 | 25 | To get started, install the Contractual CLI globally: 26 | 27 | ```bash 28 | npm i -g @contractual/cli 29 | ``` 30 | 31 | ### Initialize Your Project 32 | 33 | Run the `init` command to scaffold a new project: 34 | 35 | ```bash 36 | contractual init 37 | ``` 38 | 39 | This command creates the following project structure: 40 | 41 | ``` 42 | frontend/ # Your frontend application 43 | server/ # Your server application 44 | contractual/ # Contractual files 45 | ├── api.tsp # TypeSpec API definition 46 | ├── specs/ # OpenAPI auto-generated specs 47 | ``` 48 | 49 | > Contractual works seamlessly with **monorepos**, **monoliths**, and distributed repositories. 50 | 51 | ### Define Your API 52 | 53 | Write your API definition in the `api.tsp` file. For example: 54 | 55 | ```tsp 56 | import "@typespec/http"; 57 | import "@typespec/openapi"; 58 | import "@typespec/openapi3"; 59 | 60 | using TypeSpec.Http; 61 | 62 | @service({ 63 | title: "Petstore API", 64 | }) 65 | namespace PetstoreAPI; 66 | 67 | model Pet { 68 | id: string; 69 | name: string; 70 | } 71 | 72 | @route("/pet") 73 | @post 74 | op addPet(@body body: Pet): Pet; 75 | ``` 76 | 77 | > You can experiment and validate your API definitions [using the TypeSpec playground](https://typespec.io/playground/) 78 | 79 | ### Manage API Changes 80 | 81 | #### Save the Current State of Your API 82 | 83 | Run the `spec graduate` command to save the current state of your OpenAPI spec: 84 | 85 | ```bash 86 | contractual spec graduate 87 | ``` 88 | 89 | This will generate a new OpenAPI (3.1.0) YAML file with versioning, enabling to track API changes over time. The 90 | updated structure will look like this: 91 | 92 | ``` 93 | contractual/ 94 | ├── api.tsp # TypeSpec API definition 95 | ├── specs/ # OpenAPI auto-generated specs 96 | │ ├── openapi-v1.0.0.yaml 97 | client/ # Generated API clients 98 | server/ # Server contracts 99 | e2e/ # Type-safe API-driven tests 100 | ``` 101 | 102 | > You can track API evolution and changes easily with clear, versioned OpenAPI specs. 103 | 104 | Here’s a quick video showing how this works: 105 | 106 |
107 | 108 |
109 | 110 | ### Generate Contracts 111 | 112 | Run the `contract generate` command to generate type-safe clients, server contracts, and updated OpenAPI specs: 113 | 114 | ```bash 115 | contractual contract generate 116 | ``` 117 | 118 | This command creates: 119 | 120 | - **Type-safe client libraries** [using **ts-rest**](https://ts-rest.com), integrated with **Zod** for runtime 121 | validation. 122 | - **Server contracts** for frameworks like **Express**, **Fastify**, and **NestJS**. 123 | - **Updated OpenAPI specs**. 124 | 125 | Here’s a short video showing contract generation in action: 126 | 127 |
128 | 129 |
130 | ``` 131 | 132 | ## 🔍 Why Contractual? 133 | 134 | Maintaining the consistency of schemas across various services presents significant challenges. As systems evolve, 135 | type-definitions and schemas drift, unnoticed breaking changes occur, and different teams find it challenging to 136 | synchronize. APIs, event schemas, and structured data formats often become disconnected from their original intent, 137 | leading to brittle integrations, manual fixes, and unexpected failures. 138 | 139 | **Some of the biggest pain points teams face include:** 140 | 141 | - **Schema Drift & Misalignment:** APIs and data contracts become inconsistent across teams, leading to mismatches, broken integrations, and regressions. 142 | 143 | - **Untracked Changes & Breaking Updates:** Without tracking modifications, updates can unexpectedly break consumers, causing downtime and costly debugging. 144 | 145 | - **Scattered Schemas & Code Maintenance:** Outdated documentation and manually managed type definitions create unreliable integrations and make maintaining entity models error-prone. 146 | 147 | ## 🔑 The Contract-First Approach 148 | Most teams take a **code-first** approach to API development, where schemas are generated after implementation. This often results in **misalignment between services, outdated documentation, and accidental breaking changes.** Backend teams define APIs, frontend teams consume them, and data engineers rely on structured data formats—all of which can drift over time when schemas are an afterthought. 149 | 150 | A **contract-first** approach flips this process: schemas are designed before any implementation begins, ensuring that API structures, event definitions, and data formats remain stable and predictable. This approach allows teams to: 151 | 152 | - Define schemas upfront and enforce them as the single source of truth. 153 | 154 | - Track changes and prevent breaking updates before they impact consumers. 155 | 156 | - Generate type-safe clients and server contracts in multiple languages, reducing friction between teams. 157 | 158 | ## 📘 Roadmap 159 | 160 | Want to contribute? Check out the alpha version [Roadmap](https://github.com/contractual-dev/contractual/issues/8) and 161 | join the journey! 🚀 162 | 163 | ## ❤️ Join the Community 164 | 165 | Contractual is open-source, and we’re looking for contributors to help shape its future, if you’re interested in 166 | collaborating, please reach out. 167 | 168 | 📩 **Feedback or Questions?** Reach out 169 | via [GitHub Discussions](https://github.com/contractual-dev/contractual/discussions). 170 | 171 | ## 🔒 License 172 | 173 | Licensed under [MIT](LICENSE). 174 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please report security issues to `omer.moradd@gmail.com`. 6 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 90% 6 | patch: 7 | default: 8 | target: 90% 9 | -------------------------------------------------------------------------------- /contract-generate.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contractual-dev/contractual/3878028f9b0446b6ba4613b83c2dd832fd52f07c/contract-generate.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contractual-monorepo", 3 | "private": true, 4 | "version": "0.0.0", 5 | "license": "Apache-2.0", 6 | "type": "module", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/contractual-dev/contractual.git" 10 | }, 11 | "engines": { 12 | "node": "^18.12.0 || >=20.0.0" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/contractual-dev/contractual.git" 16 | }, 17 | "homepage": "https://contractual.dev", 18 | "contributors": [ 19 | { 20 | "name": "Omer Morad", 21 | "email": "omer.moradd@gmail.com" 22 | } 23 | ], 24 | "scripts": { 25 | "build": "pnpm -r run build", 26 | "tester": "pnpm -r run test", 27 | "lint": "pnpm -r run lint", 28 | "prepare": "husky" 29 | }, 30 | "dependencies": { 31 | "@manypkg/cli": "^0.21.4", 32 | "@types/node": "^22.10.2", 33 | "@typescript-eslint/eslint-plugin": "^6.7.5", 34 | "@typescript-eslint/parser": "^6.7.5", 35 | "@vitest/coverage-c8": "^0.33.0", 36 | "@vitest/coverage-v8": "3.0.3", 37 | "braces": "3.0.3", 38 | "eslint": "^8.57.0", 39 | "eslint-config-prettier": "^9.0.0", 40 | "eslint-import-resolver-typescript": "^3.6.1", 41 | "eslint-plugin-import": "^2.29.1", 42 | "eslint-plugin-prettier": "^5.0.1", 43 | "follow-redirects": "1.15.6", 44 | "ip": "2.0.1", 45 | "lerna": "^7.3.1", 46 | "lint-staged": "^14.0.1", 47 | "madge": "^7.0.0", 48 | "micromatch": "4.0.8", 49 | "prettier": "^3.2.5", 50 | "rimraf": "^5.0.5", 51 | "rxjs": "^7.8.1", 52 | "tar": "6.2.0", 53 | "ts-jest": "^29.1.3", 54 | "ts-node": "^10.9.1", 55 | "typescript": "~5.7.2", 56 | "vitest": "^3.0.3" 57 | }, 58 | "workspaces": [ 59 | "packages/generators/*", 60 | "packages/providers/*", 61 | "packages/types/*", 62 | "packages/*" 63 | ], 64 | "lint-staged": { 65 | "*.ts": [ 66 | "eslint --ext .ts --fix" 67 | ] 68 | }, 69 | "jest-junit": { 70 | "outputDirectory": "test-reports", 71 | "ancestorSeparator": " › ", 72 | "uniqueOutputName": "true", 73 | "suiteNameTemplate": "{filepath}", 74 | "classNameTemplate": "{classname}", 75 | "titleTemplate": "{title}" 76 | }, 77 | "packageManager": "pnpm@9.15.4", 78 | "devDependencies": { 79 | "husky": "^8.0.3" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/cli/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc", 3 | "ignorePatterns": [ 4 | "bin/" 5 | ] 6 | } -------------------------------------------------------------------------------- /packages/cli/bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import '../dist/index.js'; 3 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@contractual/cli", 3 | "private": false, 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "type": "module", 7 | "bin": { 8 | "contractual": "./bin/cli.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/contractual-dev/contractual.git", 13 | "directory": "packages/cli" 14 | }, 15 | "homepage": "https://contractual.dev", 16 | "bugs": { 17 | "url": "https://github.com/contractual-dev/contractual/issues" 18 | }, 19 | "contributors": [ 20 | { 21 | "name": "Omer Morad", 22 | "email": "omer.moradd@gmail.com" 23 | } 24 | ], 25 | "funding": [ 26 | { 27 | "type": "github", 28 | "url": "https://github.com/sponsors/contractual-dev" 29 | }, 30 | { 31 | "type": "opencollective", 32 | "url": "https://opencollective.com/contractual-dev" 33 | } 34 | ], 35 | "engines": { 36 | "node": ">=18.12.0" 37 | }, 38 | "scripts": { 39 | "prebuild": "pnpm rimraf dist", 40 | "build": "tsc -p tsconfig.build.json", 41 | "build:watch": "tsc -p tsconfig.build.json --watch", 42 | "tester": "jest --coverage --verbose", 43 | "lint": "eslint '{src,test}/**/*.ts'" 44 | }, 45 | "files": [ 46 | "bin", 47 | "dist", 48 | "README.md" 49 | ], 50 | "dependencies": { 51 | "@contractual/generators.contract": "workspace:*", 52 | "@contractual/generators.diff": "workspace:*", 53 | "@contractual/generators.spec": "workspace:*", 54 | "chalk": "^5.4.1", 55 | "commander": "^12.1.0", 56 | "inquirer": "^12.3.2", 57 | "ora": "^8.1.1" 58 | }, 59 | "publishConfig": { 60 | "access": "public", 61 | "provenance": true 62 | }, 63 | "devDependencies": { 64 | "@vitest/coverage-c8": "^0.33.0", 65 | "vitest": "^3.0.3" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/cli/src/commands.ts: -------------------------------------------------------------------------------- 1 | import { Command } from 'commander'; 2 | import { graduateSpec, generateContract } from './commands/generate.command.js'; 3 | 4 | const program = new Command(); 5 | program.name('contractual'); 6 | 7 | const generateContractCommand = new Command('generate') 8 | .description('Generate resources') 9 | .command('contract') 10 | .description('Generate a contract based on the provided OpenAPI file') 11 | .action(() => { 12 | return generateContract(); 13 | }); 14 | 15 | const graduateSpecCommand = new Command('graduate').command('spec').action(() => { 16 | return graduateSpec(); 17 | }); 18 | 19 | program.addCommand(graduateSpecCommand); 20 | program.addCommand(generateContractCommand); 21 | 22 | program.parse(process.argv); 23 | -------------------------------------------------------------------------------- /packages/cli/src/commands/generate.command.ts: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer'; 2 | import { createContractCommandHandler } from '@contractual/generators.contract'; 3 | import ora from 'ora'; 4 | import chalk from 'chalk'; 5 | import path from 'node:path'; 6 | import process from 'node:process'; 7 | import { 8 | generateSpecification, 9 | getLatestVersion, 10 | initializePaths, 11 | } from '@contractual/generators.spec'; 12 | 13 | export function generateContract() { 14 | return createContractCommandHandler( 15 | ora, 16 | chalk, 17 | console, 18 | path.resolve( 19 | process.cwd(), 20 | 'contractual', 21 | 'specs', 22 | `openapi-v${getLatestVersion(initializePaths().configFilePath)}.yaml` 23 | ) 24 | ).handle(); 25 | } 26 | 27 | export function graduateSpec() { 28 | return generateSpecification(inquirer); 29 | } 30 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './commands.js'; 2 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "baseUrl": ".", 6 | "outDir": "dist", 7 | "esModuleInterop": true, 8 | }, 9 | "exclude": [ 10 | "index.ts", 11 | "dist", 12 | "node_modules", 13 | "test", 14 | "**/*.spec.ts", 15 | "**/*.test.ts", 16 | "jest.config.ts" 17 | ] 18 | } -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } -------------------------------------------------------------------------------- /packages/contract/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contractual-dev/contractual/3878028f9b0446b6ba4613b83c2dd832fd52f07c/packages/contract/.DS_Store -------------------------------------------------------------------------------- /packages/contract/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc" 3 | } -------------------------------------------------------------------------------- /packages/contract/.gitignore: -------------------------------------------------------------------------------- 1 | contract/*.js -------------------------------------------------------------------------------- /packages/contract/contract/index.d.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | export declare const CreatePetRequest: z.ZodObject<{ 3 | name: z.ZodString; 4 | status: z.ZodOptional>; 5 | }, "strip", z.ZodTypeAny, { 6 | name?: string; 7 | status?: "available" | "pending" | "sold"; 8 | }, { 9 | name?: string; 10 | status?: "available" | "pending" | "sold"; 11 | }>; 12 | export declare const CreatePetResponse: z.ZodObject<{ 13 | id: z.ZodNumber; 14 | }, "strip", z.ZodTypeAny, { 15 | id?: number; 16 | }, { 17 | id?: number; 18 | }>; 19 | export declare const UpdatePetRequest: z.ZodObject<{ 20 | name: z.ZodString; 21 | status: z.ZodOptional>; 22 | }, "strip", z.ZodTypeAny, { 23 | name?: string; 24 | status?: "available" | "pending" | "sold"; 25 | }, { 26 | name?: string; 27 | status?: "available" | "pending" | "sold"; 28 | }>; 29 | export declare const GetPetResponse: z.ZodObject<{ 30 | id: z.ZodNumber; 31 | name: z.ZodString; 32 | status: z.ZodEnum<["available", "pending", "sold"]>; 33 | }, "strip", z.ZodTypeAny, { 34 | name?: string; 35 | status?: "available" | "pending" | "sold"; 36 | id?: number; 37 | }, { 38 | name?: string; 39 | status?: "available" | "pending" | "sold"; 40 | id?: number; 41 | }>; 42 | export declare const ApiContract: { 43 | addPet: { 44 | method: "POST"; 45 | path: string; 46 | description: string; 47 | body: z.ZodObject<{ 48 | name: z.ZodString; 49 | status: z.ZodOptional>; 50 | }, "strip", z.ZodTypeAny, { 51 | name?: string; 52 | status?: "available" | "pending" | "sold"; 53 | }, { 54 | name?: string; 55 | status?: "available" | "pending" | "sold"; 56 | }>; 57 | responses: { 58 | 200: z.ZodObject<{ 59 | id: z.ZodNumber; 60 | }, "strip", z.ZodTypeAny, { 61 | id?: number; 62 | }, { 63 | id?: number; 64 | }>; 65 | }; 66 | }; 67 | updatePet: { 68 | method: "PUT"; 69 | path: string; 70 | description: string; 71 | body: z.ZodObject<{ 72 | name: z.ZodString; 73 | status: z.ZodOptional>; 74 | }, "strip", z.ZodTypeAny, { 75 | name?: string; 76 | status?: "available" | "pending" | "sold"; 77 | }, { 78 | name?: string; 79 | status?: "available" | "pending" | "sold"; 80 | }>; 81 | responses: { 82 | 200: z.ZodObject<{ 83 | id: z.ZodNumber; 84 | name: z.ZodString; 85 | status: z.ZodEnum<["available", "pending", "sold"]>; 86 | }, "strip", z.ZodTypeAny, { 87 | name?: string; 88 | status?: "available" | "pending" | "sold"; 89 | id?: number; 90 | }, { 91 | name?: string; 92 | status?: "available" | "pending" | "sold"; 93 | id?: number; 94 | }>; 95 | }; 96 | }; 97 | getPetById: { 98 | method: "GET"; 99 | path: string; 100 | description: string; 101 | pathParams: z.ZodObject<{ 102 | petId: z.ZodNumber; 103 | }, "strip", z.ZodTypeAny, { 104 | petId?: number; 105 | }, { 106 | petId?: number; 107 | }>; 108 | responses: { 109 | 200: z.ZodObject<{ 110 | id: z.ZodNumber; 111 | name: z.ZodString; 112 | status: z.ZodEnum<["available", "pending", "sold"]>; 113 | }, "strip", z.ZodTypeAny, { 114 | name?: string; 115 | status?: "available" | "pending" | "sold"; 116 | id?: number; 117 | }, { 118 | name?: string; 119 | status?: "available" | "pending" | "sold"; 120 | id?: number; 121 | }>; 122 | }; 123 | }; 124 | }; 125 | export declare const ApiOperations: { 126 | 'add pet': "addPet"; 127 | 'update pet': "updatePet"; 128 | 'get pet by id': "getPetById"; 129 | }; 130 | export declare const contract: { 131 | addPet: { 132 | description: string; 133 | body: z.ZodObject<{ 134 | name: z.ZodString; 135 | status: z.ZodOptional>; 136 | }, "strip", z.ZodTypeAny, { 137 | name?: string; 138 | status?: "available" | "pending" | "sold"; 139 | }, { 140 | name?: string; 141 | status?: "available" | "pending" | "sold"; 142 | }>; 143 | method: "POST"; 144 | path: string; 145 | responses: { 146 | 200: z.ZodObject<{ 147 | id: z.ZodNumber; 148 | }, "strip", z.ZodTypeAny, { 149 | id?: number; 150 | }, { 151 | id?: number; 152 | }>; 153 | }; 154 | }; 155 | updatePet: { 156 | description: string; 157 | body: z.ZodObject<{ 158 | name: z.ZodString; 159 | status: z.ZodOptional>; 160 | }, "strip", z.ZodTypeAny, { 161 | name?: string; 162 | status?: "available" | "pending" | "sold"; 163 | }, { 164 | name?: string; 165 | status?: "available" | "pending" | "sold"; 166 | }>; 167 | method: "PUT"; 168 | path: string; 169 | responses: { 170 | 200: z.ZodObject<{ 171 | id: z.ZodNumber; 172 | name: z.ZodString; 173 | status: z.ZodEnum<["available", "pending", "sold"]>; 174 | }, "strip", z.ZodTypeAny, { 175 | name?: string; 176 | status?: "available" | "pending" | "sold"; 177 | id?: number; 178 | }, { 179 | name?: string; 180 | status?: "available" | "pending" | "sold"; 181 | id?: number; 182 | }>; 183 | }; 184 | }; 185 | getPetById: { 186 | description: string; 187 | pathParams: z.ZodObject<{ 188 | petId: z.ZodNumber; 189 | }, "strip", z.ZodTypeAny, { 190 | petId?: number; 191 | }, { 192 | petId?: number; 193 | }>; 194 | method: "GET"; 195 | path: string; 196 | responses: { 197 | 200: z.ZodObject<{ 198 | id: z.ZodNumber; 199 | name: z.ZodString; 200 | status: z.ZodEnum<["available", "pending", "sold"]>; 201 | }, "strip", z.ZodTypeAny, { 202 | name?: string; 203 | status?: "available" | "pending" | "sold"; 204 | id?: number; 205 | }, { 206 | name?: string; 207 | status?: "available" | "pending" | "sold"; 208 | id?: number; 209 | }>; 210 | }; 211 | }; 212 | }; 213 | -------------------------------------------------------------------------------- /packages/contract/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@contractual/contract", 3 | "private": false, 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "type": "module", 7 | "exports": { 8 | "./contract": { 9 | "import": "./contract/index.js", 10 | "require": "./contract/index.js" 11 | } 12 | }, 13 | "typings": "contract/index.d.ts", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/contractual-dev/contractual.git", 17 | "directory": "packages/contract" 18 | }, 19 | "homepage": "https://contractual.dev", 20 | "bugs": { 21 | "url": "https://github.com/contractual-dev/contractual/issues" 22 | }, 23 | "contributors": [ 24 | { 25 | "name": "Omer Morad", 26 | "email": "omer.moradd@gmail.com" 27 | } 28 | ], 29 | "funding": [ 30 | { 31 | "type": "github", 32 | "url": "https://github.com/sponsors/contractual-dev" 33 | }, 34 | { 35 | "type": "opencollective", 36 | "url": "https://opencollective.com/contractual-dev" 37 | } 38 | ], 39 | "engines": { 40 | "node": ">=18.12.0" 41 | }, 42 | "scripts": { 43 | "prebuild": "pnpm rimraf dist", 44 | "build": "exit 0", 45 | "build:watch": "tsc -p tsconfig.build.json --watch", 46 | "test": "vitest run", 47 | "lint": "exit 0" 48 | }, 49 | "files": [ 50 | "contract", 51 | "dist", 52 | "README.md" 53 | ], 54 | "dependencies": { 55 | "@ts-rest/core": "^3.51.0", 56 | "axios": "^1.7.9", 57 | "zod": "^3.24.1" 58 | }, 59 | "publishConfig": { 60 | "access": "public", 61 | "provenance": true 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/contract/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.build.json", 3 | "compilerOptions": { 4 | "rootDir": "contract", 5 | "baseUrl": ".", 6 | "outDir": "dist", 7 | "esModuleInterop": true}, 8 | "exclude": [ 9 | "index.ts", 10 | "contract", 11 | "dist", 12 | "node_modules", 13 | "test", 14 | "**/*.spec.ts", 15 | "jest.config.ts" 16 | ] 17 | } -------------------------------------------------------------------------------- /packages/contract/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } -------------------------------------------------------------------------------- /packages/generators/contract/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../.eslintrc" 3 | } -------------------------------------------------------------------------------- /packages/generators/contract/contract.template.hbs: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import type { AppRouter } from '@ts-rest/core'; 3 | import { initContract } from '@ts-rest/core'; 4 | 5 | {{#if imports}} 6 | {{#each imports}} 7 | import { {{{@key}}} } from "./{{{this}}}" 8 | {{/each}} 9 | {{/if}} 10 | 11 | {{#if schemas}} 12 | {{#each schemas}} 13 | export const {{@key}} = {{{this}}}; 14 | 15 | {{/each}} 16 | {{/if}} 17 | 18 | export const ApiContract = { 19 | {{#each endpoints}} 20 | {{alias}}: { 21 | method: '{{toUpperCase method}}' as const, 22 | path: "{{path}}", 23 | {{#if description}} 24 | description: `{{description}}`, 25 | {{/if}} 26 | {{#if parameters}} 27 | {{#includesType parameters "Path"}} 28 | pathParams: z.object({ 29 | {{#each parameters}}{{#ifeq type "Path"}}'{{name}}': {{{schema}}},{{/ifeq}}{{/each}} 30 | }), 31 | {{/includesType}} 32 | {{#includesType parameters "Query"}} 33 | query: z.object({ 34 | {{#each parameters}}{{#ifeq type "Query"}}'{{name}}': {{{schema}}},{{/ifeq}}{{/each}} 35 | }), 36 | {{/includesType}} 37 | {{#includesType parameters "Body"}} 38 | body: {{#each parameters}}{{#ifeq type "Body"}}{{{schema}}}{{/ifeq}}{{/each}}, 39 | {{/includesType}} 40 | {{/if}} 41 | responses: { 42 | {{#each responses}} 43 | [{{statusCode}}]: {{{schema}}}, 44 | {{/each}} 45 | }, 46 | }, 47 | {{/each}} 48 | } satisfies AppRouter; 49 | 50 | export const ApiOperations = { 51 | {{#each endpoints}} 52 | '{{toPlainWords alias}}': '{{alias}}', 53 | {{/each}} 54 | } satisfies Record; 55 | 56 | export const contract = initContract().router(ApiContract); 57 | -------------------------------------------------------------------------------- /packages/generators/contract/index.ts: -------------------------------------------------------------------------------- 1 | export * from './src'; 2 | -------------------------------------------------------------------------------- /packages/generators/contract/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@contractual/generators.contract", 3 | "private": false, 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "type": "module", 7 | "module": "dist/index.js", 8 | "main": "dist/index.js", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/contractual-dev/contractual.git", 12 | "directory": "packages/generators/client" 13 | }, 14 | "exports": { 15 | ".": { 16 | "import": "./dist/index.js", 17 | "require": "./dist/index.js" 18 | } 19 | }, 20 | "homepage": "https://contractual.dev", 21 | "bugs": { 22 | "url": "https://github.com/contractual-dev/contractual/issues" 23 | }, 24 | "contributors": [ 25 | { 26 | "name": "Omer Morad", 27 | "email": "omer.moradd@gmail.com" 28 | } 29 | ], 30 | "funding": [ 31 | { 32 | "type": "github", 33 | "url": "https://github.com/sponsors/contractual-dev" 34 | }, 35 | { 36 | "type": "opencollective", 37 | "url": "https://opencollective.com/contractual-dev" 38 | } 39 | ], 40 | "engines": { 41 | "node": ">=18.12.0" 42 | }, 43 | "scripts": { 44 | "prebuild": "pnpm rimraf dist", 45 | "build": "tsc -p tsconfig.build.json", 46 | "build:watch": "tsc -p tsconfig.build.json --watch", 47 | "tester": "jest --coverage --verbose", 48 | "lint": "eslint '{src,test}/**/*.ts'" 49 | }, 50 | "files": [ 51 | "client.template.hbs", 52 | "dist", 53 | "README.md" 54 | ], 55 | "dependencies": { 56 | "@apidevtools/swagger-parser": "^10.1.1", 57 | "chalk": "^5.4.1", 58 | "handlebars": "^4.7.8", 59 | "openapi-types": "^12.1.3", 60 | "openapi-zod-client": "^1.18.2", 61 | "openapi3-ts": "^4.4.0" 62 | }, 63 | "devDependencies": { 64 | "ora": "^8.1.1", 65 | "typescript": "~5.7.2" 66 | }, 67 | "peerDependencies": { 68 | "typescript": ">=5.x" 69 | }, 70 | "publishConfig": { 71 | "access": "public", 72 | "provenance": true 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/generators/contract/src/command.ts: -------------------------------------------------------------------------------- 1 | import type ora from 'ora'; 2 | import { ContractCommandHandler, ContractFileGenerator, FileSystemHandler, SpinnerFactory } from './generator.js'; 3 | import * as fs from 'node:fs'; 4 | import SwaggerParser from '@apidevtools/swagger-parser'; 5 | import { generateZodClientFromOpenAPI } from 'openapi-zod-client'; 6 | import { createProgram } from 'typescript'; 7 | import type chalk from 'chalk'; 8 | 9 | export const createContractCommandHandler = ( 10 | spinner: typeof ora, 11 | chalker: typeof chalk, 12 | logger: Console, 13 | apiSpecPath: string 14 | ) => { 15 | const spinnerFactory = new SpinnerFactory(spinner); 16 | 17 | const fileGenerator = new ContractFileGenerator( 18 | spinnerFactory, 19 | generateZodClientFromOpenAPI, 20 | createProgram, 21 | new FileSystemHandler(fs) 22 | ); 23 | 24 | return new ContractCommandHandler( 25 | spinnerFactory, 26 | new FileSystemHandler(fs), 27 | SwaggerParser, 28 | fileGenerator, 29 | logger, 30 | chalker, 31 | apiSpecPath 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /packages/generators/contract/src/generator.ts: -------------------------------------------------------------------------------- 1 | import type SwaggerParser from '@apidevtools/swagger-parser'; 2 | import * as path from 'node:path'; 3 | import type { generateZodClientFromOpenAPI } from 'openapi-zod-client'; 4 | import type { OpenAPIObject } from 'openapi3-ts/oas30'; 5 | import * as process from 'node:process'; 6 | import type { createProgram } from 'typescript'; 7 | import { ModuleKind, ModuleResolutionKind, ScriptTarget } from 'typescript'; 8 | import { createHandlebars } from './handlebars-helpers.js'; 9 | import type ora from 'ora'; 10 | import type * as fs from 'node:fs'; 11 | import type chalk from 'chalk'; 12 | 13 | export class SpinnerFactory { 14 | public constructor(private readonly spinner: typeof ora) {} 15 | 16 | public createSpinner(text: string) { 17 | return this.spinner(text); 18 | } 19 | } 20 | 21 | export class FileSystemHandler { 22 | public constructor(private readonly fileSystem: typeof fs) {} 23 | 24 | public exists(path: string): boolean { 25 | return this.fileSystem.existsSync(path); 26 | } 27 | 28 | public removeFile(path: string): void { 29 | this.fileSystem.rmSync(path); 30 | } 31 | } 32 | 33 | export class ContractFileGenerator { 34 | public constructor( 35 | private readonly spinnerFactory: SpinnerFactory, 36 | private readonly zodGenerator: typeof generateZodClientFromOpenAPI, 37 | private readonly programFactory: typeof createProgram, 38 | private readonly fileSystemHandler: FileSystemHandler 39 | ) {} 40 | 41 | public async generateFiles(openapiDocument: OpenAPIObject, destinationPath: string) { 42 | const spinner = this.spinnerFactory.createSpinner('Generating contract files..').start(); 43 | const indexTsDestinationPath = path.resolve(destinationPath, 'index.ts'); 44 | 45 | try { 46 | await this.zodGenerator({ 47 | distPath: indexTsDestinationPath, 48 | openApiDoc: openapiDocument, 49 | templatePath: path.resolve( 50 | path.dirname(new URL(import.meta.url).pathname), 51 | '..', 52 | 'contract.template.hbs' 53 | ), 54 | prettierConfig: { 55 | tabWidth: 2, 56 | semi: true, 57 | singleQuote: true, 58 | trailingComma: 'all', 59 | bracketSpacing: true, 60 | arrowParens: 'always', 61 | }, 62 | options: { 63 | additionalPropertiesDefaultValue: false, 64 | withAllResponses: true, 65 | withAlias: true, 66 | withDescription: false, 67 | withDefaultValues: false, 68 | }, 69 | handlebars: createHandlebars(), 70 | }); 71 | 72 | spinner.succeed('Generated Contractual contract'); 73 | return indexTsDestinationPath; 74 | } catch (error) { 75 | spinner.fail('Failed to generate contract files.'); 76 | throw error; 77 | } 78 | } 79 | 80 | public async compileFiles(destinationPath: string) { 81 | const spinner = this.spinnerFactory.createSpinner('Compiling contract..').start(); 82 | const indexTsDestinationPath = path.resolve(destinationPath, 'index.ts'); 83 | 84 | try { 85 | this.programFactory([indexTsDestinationPath], { 86 | module: ModuleKind.ESNext, 87 | target: ScriptTarget.ESNext, 88 | skipLibCheck: true, 89 | declaration: true, 90 | noImplicitAny: true, 91 | moduleResolution: ModuleResolutionKind.Bundler, 92 | outDir: destinationPath, 93 | }).emit(); 94 | 95 | spinner.succeed('Compilation completed successfully.'); 96 | this.fileSystemHandler.removeFile(indexTsDestinationPath); 97 | } catch (error) { 98 | spinner.fail('Failed to compile contract.'); 99 | throw error; 100 | } 101 | } 102 | } 103 | 104 | export class ContractCommandHandler { 105 | public constructor( 106 | private readonly spinnerFactory: SpinnerFactory, 107 | private readonly fileSystemHandler: FileSystemHandler, 108 | private readonly swaggerParser: typeof SwaggerParser, 109 | private readonly fileGenerator: ContractFileGenerator, 110 | private readonly logger: Console, 111 | private readonly chalker: typeof chalk, 112 | private readonly apiSpecPathToReadFrom: string 113 | ) {} 114 | 115 | public async handle() { 116 | try { 117 | if (!this.fileSystemHandler.exists(this.apiSpecPathToReadFrom)) { 118 | this.logError( 119 | 'Could not find Contractual schema that is required for this command. You can provide it with `--spec` argument' 120 | ); 121 | 122 | return; 123 | } 124 | 125 | this.logger.log( 126 | this.chalker.gray(`Contractual schema loaded from ${this.apiSpecPathToReadFrom}`) 127 | ); 128 | 129 | const openapiDocument = await this.parseSpec(this.apiSpecPathToReadFrom); 130 | 131 | const destinationPath = path.resolve( 132 | path.dirname(new URL(import.meta.url).pathname), 133 | '../../..', 134 | 'contract/contract' 135 | ); 136 | 137 | await this.fileGenerator.generateFiles(openapiDocument, destinationPath); 138 | await this.fileGenerator.compileFiles(destinationPath); 139 | 140 | this.logger.log(this.chalker.green('Done')); 141 | } catch (error) { 142 | this.logError('Error occurred during contract generation.', error); 143 | } 144 | } 145 | 146 | private async parseSpec(apiSpecPath: string): Promise { 147 | const spinner = this.spinnerFactory.createSpinner('Parsing TypeSpec file..').start(); 148 | 149 | return this.swaggerParser 150 | .parse(apiSpecPath) 151 | .then((document) => { 152 | if (!('openapi' in document)) { 153 | throw new Error('Invalid OpenAPI document: Missing "openapi" property.'); 154 | } 155 | 156 | spinner.succeed('TypeSpec file parsed successfully.'); 157 | 158 | return document as OpenAPIObject; 159 | }) 160 | .catch((error) => { 161 | spinner.fail('Failed to parse TypeSpec file.'); 162 | 163 | throw error instanceof Error ? new Error(error.message) : error; 164 | }); 165 | } 166 | 167 | private logError(message: string, error?: unknown) { 168 | this.logger.log(`${this.chalker.red('Error')}: ${message}`); 169 | 170 | if (error) { 171 | this.logger.error('Error details:', error); 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /packages/generators/contract/src/handlebars-helpers.ts: -------------------------------------------------------------------------------- 1 | import handlebars from 'handlebars'; 2 | 3 | const { create } = handlebars; 4 | 5 | export function createHandlebars() { 6 | const instance = create(); 7 | 8 | instance.registerHelper('ifNotEmptyObj', function (obj, options) { 9 | if (typeof obj === 'object' && Object.keys(obj).length > 0) { 10 | return options.fn(this); 11 | } 12 | 13 | return options.inverse(this); 14 | }); 15 | 16 | instance.registerHelper('toUpperCase', (input: string) => input.toUpperCase()); 17 | 18 | instance.registerHelper('ifNotEq', function (a, b, options) { 19 | if (a !== b) { 20 | return options.fn(this); 21 | } 22 | 23 | return options.inverse(this); 24 | }); 25 | 26 | instance.registerHelper('ifeq', function (a, b, options) { 27 | if (a === b) { 28 | return options.fn(this); 29 | } 30 | 31 | return options.inverse(this); 32 | }); 33 | 34 | instance.registerHelper('includesType', function (arr, val, options) { 35 | if (Array.isArray(arr) && arr.length > 0 && arr.some((v) => v.type === val)) { 36 | return options.fn(this); 37 | } 38 | 39 | return options.inverse(this); 40 | }); 41 | 42 | instance.registerHelper('toPlainWords', function (str) { 43 | return str.replace(/([A-Z])/g, ' $1').toLowerCase(); 44 | }); 45 | 46 | instance.registerHelper('toDashes', function (str) { 47 | return str.replace(/([A-Z])/g, '-$1').toLowerCase(); 48 | }); 49 | 50 | return instance; 51 | } 52 | -------------------------------------------------------------------------------- /packages/generators/contract/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './command.js'; 2 | -------------------------------------------------------------------------------- /packages/generators/contract/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.build.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "baseUrl": ".", 6 | "outDir": "dist", 7 | "skipLibCheck": true 8 | }, 9 | "exclude": [ 10 | "dist", 11 | "index.ts", 12 | "node_modules", 13 | "test", 14 | "**/*.spec.ts", 15 | "jest.config.ts" 16 | ] 17 | } -------------------------------------------------------------------------------- /packages/generators/contract/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json" 3 | } -------------------------------------------------------------------------------- /packages/generators/diff/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../.eslintrc" 3 | } -------------------------------------------------------------------------------- /packages/generators/diff/index.ts: -------------------------------------------------------------------------------- 1 | export * from './src'; 2 | -------------------------------------------------------------------------------- /packages/generators/diff/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@contractual/generators.diff", 3 | "private": false, 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "type": "module", 7 | "module": "dist/index.js", 8 | "main": "dist/index.js", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/contractual-dev/contractual.git", 12 | "directory": "packages/generators/diff" 13 | }, 14 | "exports": { 15 | ".": { 16 | "import": "./dist/index.js", 17 | "require": "./dist/index.js" 18 | } 19 | }, 20 | "homepage": "https://contractual.dev", 21 | "bugs": { 22 | "url": "https://github.com/contractual-dev/contractual/issues" 23 | }, 24 | "contributors": [ 25 | { 26 | "name": "Omer Morad", 27 | "email": "omer.moradd@gmail.com" 28 | } 29 | ], 30 | "funding": [ 31 | { 32 | "type": "github", 33 | "url": "https://github.com/sponsors/contractual-dev" 34 | }, 35 | { 36 | "type": "opencollective", 37 | "url": "https://opencollective.com/contractual-dev" 38 | } 39 | ], 40 | "engines": { 41 | "node": ">=18.12.0" 42 | }, 43 | "scripts": { 44 | "prebuild": "pnpm rimraf dist", 45 | "build": "tsc -p tsconfig.build.json", 46 | "test": "pnpm vitest run --watch", 47 | "build:watch": "tsc -p tsconfig.build.json --watch", 48 | "lint": "eslint '{src,test}/**/*.ts'" 49 | }, 50 | "files": [ 51 | "dist", 52 | "README.md" 53 | ], 54 | "publishConfig": { 55 | "access": "public", 56 | "provenance": true 57 | }, 58 | "dependencies": { 59 | "chalk": "^5.4.1", 60 | "openapi-diff": "^0.23.7", 61 | "openapi-types": "^12.1.3", 62 | "semver": "^7.6.3", 63 | "table": "^6.9.0" 64 | }, 65 | "devDependencies": { 66 | "@types/semver": "^7.5.8", 67 | "@vitest/coverage-c8": "^0.33.0", 68 | "typescript": "~5.7.2", 69 | "vitest": "^3.0.3" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/generators/diff/src/diff-highlighter.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { table } from 'table'; 3 | import type OpenApiDiff from 'openapi-diff'; 4 | import { type DiffOutcome } from 'openapi-diff'; 5 | import type { TableUserConfig } from 'table/dist/src/types/api.js'; 6 | 7 | async function printOpenApiDiff(diffOutcome: DiffOutcome) { 8 | try { 9 | if ('breakingDifferencesFound' in diffOutcome && diffOutcome.breakingDifferencesFound) { 10 | console.log(chalk.red.bold('\nBreaking Differences Found:\n')); 11 | console.log(formatDiffs(diffOutcome.breakingDifferences, chalk.red)); 12 | } 13 | 14 | if (diffOutcome.nonBreakingDifferences.length > 0) { 15 | console.log(chalk.green.bold('\nNon-Breaking Differences:\n')); 16 | console.log(formatDiffs(diffOutcome.nonBreakingDifferences, chalk.green)); 17 | } 18 | 19 | if (diffOutcome.unclassifiedDifferences.length > 0) { 20 | console.log(chalk.yellow.bold('\nUnclassified Differences:\n')); 21 | console.log(formatDiffs(diffOutcome.unclassifiedDifferences, chalk.yellow)); 22 | } 23 | 24 | if ( 25 | !('breakingDifferencesFound' in diffOutcome) || 26 | (!diffOutcome.breakingDifferencesFound && 27 | diffOutcome.nonBreakingDifferences.length === 0 && 28 | diffOutcome.unclassifiedDifferences.length === 0) 29 | ) { 30 | console.log( 31 | chalk.green.bold('\nNo Differences Found! Specifications are identical or compatible.\n') 32 | ); 33 | } 34 | } catch (error) { 35 | console.error(chalk.red.bold('\nError performing diff:\n')); 36 | console.error(error); 37 | } 38 | } 39 | 40 | function formatDiffs( 41 | diffs: OpenApiDiff.DiffResult[], 42 | colorFn: (text: string) => string 43 | ): string { 44 | const formattedDiffs = diffs.map((diff) => [ 45 | colorFn(diff.code), 46 | colorFn(diff.entity), 47 | chalk.bold(diff.action === 'add' ? '➕ Add' : '❌ Remove'), 48 | diff.sourceSpecEntityDetails.map((d) => `${d.location}`).join(', '), 49 | diff.destinationSpecEntityDetails.map((d) => `${d.location}`).join(', '), 50 | ]); 51 | 52 | const tableConfig: TableUserConfig = { 53 | columns: { 54 | 0: { alignment: 'left', width: 20 }, 55 | 1: { alignment: 'left', width: 20 }, 56 | 2: { alignment: 'center', width: 10 }, 57 | 3: { alignment: 'left', width: 30 }, 58 | 4: { alignment: 'left', width: 30 }, 59 | }, 60 | }; 61 | 62 | return table( 63 | [ 64 | [ 65 | chalk.underline.bold('Code'), 66 | chalk.underline.bold('Entity'), 67 | chalk.underline.bold('Action'), 68 | chalk.underline.bold('Source Location'), 69 | chalk.underline.bold('Destination Location'), 70 | ], 71 | ...formattedDiffs, 72 | ], 73 | tableConfig 74 | ); 75 | } 76 | 77 | export { printOpenApiDiff }; 78 | -------------------------------------------------------------------------------- /packages/generators/diff/src/generator.spec.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, describe, expect, it } from 'vitest'; 2 | import * as fs from 'node:fs'; 3 | import * as path from 'node:path'; 4 | import { diffSpecs } from './generator'; 5 | 6 | const dummyOpenApiSpecBreaking = ` 7 | openapi: 3.0.0 8 | info: 9 | title: Petstore API 10 | description: A simple API for managing a pet store. 11 | version: 0.0.0 12 | tags: [] 13 | paths: 14 | /pet: 15 | post: 16 | operationId: addPet 17 | description: Add a new pet to the store. 18 | parameters: [] 19 | responses: 20 | '200': 21 | description: The request has succeeded. 22 | content: 23 | application/json: 24 | schema: 25 | $ref: '#/components/schemas/CreatePetResponse' 26 | requestBody: 27 | required: true 28 | content: 29 | application/json: 30 | schema: 31 | $ref: '#/components/schemas/AddPetBody' 32 | /pet/{petId}: 33 | get: 34 | operationId: getPetById 35 | description: Retrieve a pet by ID. 36 | parameters: 37 | - name: petId 38 | in: path 39 | required: true 40 | schema: 41 | type: integer 42 | format: int32 43 | responses: 44 | '200': 45 | description: The request has succeeded. 46 | content: 47 | application/json: 48 | schema: 49 | $ref: '#/components/schemas/GetPetResponse' 50 | /store/order: 51 | post: 52 | operationId: placeOrder 53 | parameters: [] 54 | responses: 55 | '200': 56 | description: The request has succeeded. 57 | content: 58 | application/json: 59 | schema: 60 | $ref: '#/components/schemas/CreateOrderResponse' 61 | requestBody: 62 | required: true 63 | content: 64 | application/json: 65 | schema: 66 | $ref: '#/components/schemas/PlaceOrderBody' 67 | /store/order/{orderId}: 68 | get: 69 | operationId: getOrderById 70 | description: Retrieve an order by ID. 71 | parameters: 72 | - name: orderId 73 | in: path 74 | required: true 75 | schema: 76 | type: integer 77 | format: int32 78 | responses: 79 | '200': 80 | description: The request has succeeded. 81 | content: 82 | application/json: 83 | schema: 84 | $ref: '#/components/schemas/GetOrderResponse' 85 | /user: 86 | post: 87 | operationId: createUser 88 | parameters: [] 89 | responses: 90 | '200': 91 | description: The request has succeeded. 92 | content: 93 | application/json: 94 | schema: 95 | $ref: '#/components/schemas/CreateUserResponse' 96 | requestBody: 97 | required: true 98 | content: 99 | application/json: 100 | schema: 101 | $ref: '#/components/schemas/CreateUserBody' 102 | /user/{username}: 103 | get: 104 | operationId: getUserByUsername 105 | description: Retrieve a user by username. 106 | parameters: 107 | - name: username 108 | in: path 109 | required: true 110 | schema: 111 | type: string 112 | responses: 113 | '200': 114 | description: The request has succeeded. 115 | content: 116 | application/json: 117 | schema: 118 | $ref: '#/components/schemas/GetUserResponse' 119 | components: 120 | schemas: 121 | AddPetBody: 122 | type: object 123 | required: 124 | - name 125 | properties: 126 | name: 127 | type: string 128 | description: Name of the pet. 129 | category: 130 | type: string 131 | description: Category of the pet (e.g., dog, cat, bird). 132 | tags: 133 | type: array 134 | items: 135 | type: string 136 | description: List of tags associated with the pet. 137 | status: 138 | type: string 139 | enum: 140 | - available 141 | - pending 142 | - sold 143 | description: The current status of the pet in the store. 144 | description: Request body for adding a new pet. 145 | ApiResponse: 146 | type: object 147 | required: 148 | - code 149 | - type 150 | - message 151 | properties: 152 | code: 153 | type: integer 154 | format: int32 155 | description: Status code of the response. 156 | type: 157 | type: string 158 | description: The type of response (e.g., success, error). 159 | message: 160 | type: string 161 | description: Detailed message about the response. 162 | description: Represents a standard API response for messages or errors. 163 | CreateOrderResponse: 164 | type: object 165 | required: 166 | - id 167 | properties: 168 | id: 169 | type: integer 170 | format: int32 171 | description: Unique identifier of the created order. 172 | description: Response for creating an order. 173 | CreatePetResponse: 174 | type: object 175 | required: 176 | - id 177 | properties: 178 | id: 179 | type: integer 180 | format: int32 181 | description: Unique identifier of the created pet. 182 | description: Response for creating a pet. 183 | CreateUserBody: 184 | type: object 185 | required: 186 | - username 187 | - firstName 188 | - lastName 189 | - email 190 | - password 191 | - phone 192 | properties: 193 | username: 194 | type: string 195 | description: Username of the user. 196 | firstName: 197 | type: string 198 | description: First name of the user. 199 | lastName: 200 | type: string 201 | description: Last name of the user. 202 | email: 203 | type: string 204 | description: Email address of the user. 205 | password: 206 | type: string 207 | description: Password for the user account. 208 | phone: 209 | type: string 210 | description: Phone number of the user. 211 | userStatus: 212 | type: integer 213 | format: int32 214 | description: User status (e.g., 1 for active, 0 for inactive). 215 | description: Create a new user. 216 | CreateUserResponse: 217 | type: object 218 | required: 219 | - id 220 | properties: 221 | id: 222 | type: integer 223 | format: int32 224 | description: Unique identifier of the created user. 225 | description: Response for creating a user. 226 | GetOrderResponse: 227 | type: object 228 | required: 229 | - id 230 | - petId 231 | - status 232 | - quantity 233 | properties: 234 | id: 235 | type: integer 236 | format: int32 237 | description: Unique identifier of the order. 238 | petId: 239 | type: integer 240 | format: int32 241 | description: Identifier of the pet associated with the order. 242 | status: 243 | type: string 244 | enum: 245 | - placed 246 | - approved 247 | - delivered 248 | description: The current status of the order. 249 | quantity: 250 | type: integer 251 | format: int32 252 | description: Quantity of pets ordered. 253 | description: Response for retrieving an order. 254 | GetPetResponse: 255 | type: object 256 | required: 257 | - id 258 | - name 259 | - status 260 | properties: 261 | id: 262 | type: integer 263 | format: int32 264 | description: Unique identifier of the pet. 265 | name: 266 | type: string 267 | description: Name of the pet. 268 | status: 269 | type: string 270 | enum: 271 | - available 272 | - pending 273 | - sold 274 | description: The current status of the pet in the store. 275 | description: Response for retrieving a pet. 276 | GetUserResponse: 277 | type: object 278 | required: 279 | - id 280 | - username 281 | - email 282 | - phone 283 | properties: 284 | id: 285 | type: integer 286 | format: int32 287 | description: Unique identifier of the user. 288 | username: 289 | type: string 290 | description: Username associated with the user. 291 | email: 292 | type: string 293 | description: Email address of the user. 294 | phone: 295 | type: string 296 | description: Phone number of the user. 297 | description: Response for retrieving a user. 298 | Order: 299 | type: object 300 | required: 301 | - id 302 | - petId 303 | - quantity 304 | - shipDate 305 | - status 306 | properties: 307 | id: 308 | type: integer 309 | format: int32 310 | description: Unique identifier for the order. 311 | petId: 312 | type: integer 313 | format: int32 314 | description: ID of the pet being ordered. 315 | quantity: 316 | type: integer 317 | format: int32 318 | description: Quantity of pets ordered. 319 | shipDate: 320 | type: string 321 | description: Shipping date for the order. 322 | status: 323 | type: string 324 | enum: 325 | - placed 326 | - approved 327 | - delivered 328 | description: The current status of the order. 329 | complete: 330 | type: boolean 331 | description: Indicates whether the order is complete. 332 | description: Represents an order for purchasing a pet. 333 | Pet: 334 | type: object 335 | required: 336 | - id 337 | - name 338 | properties: 339 | id: 340 | type: integer 341 | format: int32 342 | description: Unique identifier for the pet. 343 | name: 344 | type: string 345 | description: Name of the pet. 346 | category: 347 | type: string 348 | description: Category of the pet (e.g., dog, cat, bird). 349 | tags: 350 | type: array 351 | items: 352 | type: string 353 | description: List of tags associated with the pet. 354 | status: 355 | type: string 356 | enum: 357 | - available 358 | - pending 359 | - sold 360 | description: The current status of the pet in the store. 361 | description: Represents a pet in the store. 362 | PlaceOrderBody: 363 | type: object 364 | required: 365 | - petId 366 | - quantity 367 | - shipDate 368 | - status 369 | properties: 370 | petId: 371 | type: integer 372 | format: int32 373 | description: ID of the pet being ordered. 374 | quantity: 375 | type: integer 376 | format: int32 377 | description: Quantity of pets ordered. 378 | shipDate: 379 | type: string 380 | description: Shipping date for the order. 381 | status: 382 | type: string 383 | enum: 384 | - placed 385 | - approved 386 | - delivered 387 | description: The current status of the order. 388 | complete: 389 | type: boolean 390 | description: Indicates whether the order is complete. 391 | description: Place an order for a pet. 392 | User: 393 | type: object 394 | required: 395 | - id 396 | - username 397 | - firstName 398 | - lastName 399 | - email 400 | - password 401 | - phone 402 | properties: 403 | id: 404 | type: integer 405 | format: int32 406 | description: Unique identifier for the user. 407 | username: 408 | type: string 409 | description: Username of the user. 410 | firstName: 411 | type: string 412 | description: First name of the user. 413 | lastName: 414 | type: string 415 | description: Last name of the user. 416 | email: 417 | type: string 418 | description: Email address of the user. 419 | password: 420 | type: string 421 | description: Password for the user account. 422 | phone: 423 | type: string 424 | description: Phone number of the user. 425 | userStatus: 426 | type: integer 427 | format: int32 428 | description: User status (e.g., 1 for active, 0 for inactive). 429 | description: Represents a user of the pet store. 430 | `; 431 | 432 | const dummyOpenApiSpecMinor = ` 433 | openapi: 3.0.0 434 | info: 435 | title: Petstore API 436 | description: A simple API for managing a pet store. 437 | version: 0.0.0 438 | tags: [] 439 | paths: 440 | /pet: 441 | post: 442 | operationId: addPet 443 | description: Add a new pet to the store. 444 | parameters: [] 445 | responses: 446 | '200': 447 | description: The request has succeeded. 448 | content: 449 | application/json: 450 | schema: 451 | $ref: '#/components/schemas/CreatePetResponse' 452 | requestBody: 453 | required: true 454 | content: 455 | application/json: 456 | schema: 457 | $ref: '#/components/schemas/AddPetBody' 458 | /pet/{petId}: 459 | get: 460 | operationId: getPetById 461 | description: Retrieve a pet by ID. 462 | parameters: 463 | - name: petId 464 | in: path 465 | required: true 466 | schema: 467 | type: integer 468 | format: int32 469 | responses: 470 | '200': 471 | description: The request has succeeded. 472 | content: 473 | application/json: 474 | schema: 475 | $ref: '#/components/schemas/GetPetResponse' 476 | /store/order: 477 | post: 478 | operationId: placeOrder 479 | parameters: [] 480 | responses: 481 | '200': 482 | description: The request has succeeded. 483 | content: 484 | application/json: 485 | schema: 486 | $ref: '#/components/schemas/CreateOrderResponse' 487 | requestBody: 488 | required: true 489 | content: 490 | application/json: 491 | schema: 492 | $ref: '#/components/schemas/PlaceOrderBody' 493 | /store/order/{orderId}: 494 | get: 495 | operationId: getOrderById 496 | description: Retrieve an order by ID. 497 | parameters: 498 | - name: orderId 499 | in: path 500 | required: true 501 | schema: 502 | type: integer 503 | format: int32 504 | responses: 505 | '200': 506 | description: The request has succeeded. 507 | content: 508 | application/json: 509 | schema: 510 | $ref: '#/components/schemas/GetOrderResponse' 511 | /user: 512 | post: 513 | operationId: createUser 514 | parameters: [] 515 | responses: 516 | '200': 517 | description: The request has succeeded. 518 | content: 519 | application/json: 520 | schema: 521 | $ref: '#/components/schemas/CreateUserResponse' 522 | requestBody: 523 | required: true 524 | content: 525 | application/json: 526 | schema: 527 | $ref: '#/components/schemas/CreateUserBody' 528 | /user/{username}: 529 | get: 530 | operationId: getUserByUsername 531 | description: Retrieve a user by username. 532 | parameters: 533 | - name: username 534 | in: path 535 | required: true 536 | schema: 537 | type: string 538 | responses: 539 | '200': 540 | description: The request has succeeded. 541 | content: 542 | application/json: 543 | schema: 544 | $ref: '#/components/schemas/GetUserResponse' 545 | components: 546 | schemas: 547 | AddPetBody: 548 | type: object 549 | required: 550 | - name 551 | properties: 552 | name: 553 | type: string 554 | description: Name of the pet. 555 | category: 556 | type: string 557 | description: Category of the pet (e.g., dog, cat, bird). 558 | tags: 559 | type: array 560 | items: 561 | type: string 562 | description: List of tags associated with the pet. 563 | status: 564 | type: string 565 | enum: 566 | - available 567 | - pending 568 | - sold 569 | description: The current status of the pet in the store. 570 | description: Request body for adding a new pet. 571 | ApiResponse: 572 | type: object 573 | required: 574 | - code 575 | - type 576 | - message 577 | properties: 578 | code: 579 | type: integer 580 | format: int32 581 | description: Status code of the response. 582 | type: 583 | type: string 584 | description: The type of response (e.g., success, error). 585 | message: 586 | type: string 587 | description: Detailed message about the response. 588 | description: Represents a standard API response for messages or errors. 589 | CreateOrderResponse: 590 | type: object 591 | required: 592 | - id 593 | properties: 594 | id: 595 | type: integer 596 | format: int32 597 | description: Unique identifier of the created order. 598 | description: Response for creating an order. 599 | CreatePetResponse: 600 | type: object 601 | required: 602 | - id 603 | properties: 604 | id: 605 | type: integer 606 | format: int32 607 | description: Unique identifier of the created pet. 608 | description: Response for creating a pet. 609 | CreateUserBody: 610 | type: object 611 | required: 612 | - username 613 | - firstName 614 | - lastName 615 | - email 616 | - password 617 | - phone 618 | properties: 619 | username: 620 | type: string 621 | description: Username of the user. 622 | firstName: 623 | type: string 624 | description: First name of the user. 625 | lastName: 626 | type: string 627 | description: Last name of the user. 628 | email: 629 | type: string 630 | description: Email address of the user. 631 | password: 632 | type: string 633 | description: Password for the user account. 634 | phone: 635 | type: string 636 | description: Phone number of the user. 637 | userStatus: 638 | type: integer 639 | format: int32 640 | description: User status (e.g., 1 for active, 0 for inactive). 641 | description: Create a new user. 642 | CreateUserResponse: 643 | type: object 644 | required: 645 | - id 646 | properties: 647 | id: 648 | type: integer 649 | format: int32 650 | description: Unique identifier of the created user. 651 | description: Response for creating a user. 652 | GetOrderResponse: 653 | type: object 654 | required: 655 | - id 656 | - petId 657 | - status 658 | - quantity 659 | properties: 660 | id: 661 | type: integer 662 | format: int32 663 | description: Unique identifier of the order. 664 | petId: 665 | type: integer 666 | format: int32 667 | description: Identifier of the pet associated with the order. 668 | status: 669 | type: string 670 | enum: 671 | - placed 672 | - approved 673 | - delivered 674 | description: The current status of the order. 675 | quantity: 676 | type: integer 677 | format: int32 678 | description: Quantity of pets ordered. 679 | description: Response for retrieving an order. 680 | GetPetResponse: 681 | type: object 682 | required: 683 | - id 684 | - name 685 | - status 686 | properties: 687 | id: 688 | type: integer 689 | format: int32 690 | description: Unique identifier of the pet. 691 | name: 692 | type: string 693 | description: Name of the pet. 694 | status: 695 | type: string 696 | enum: 697 | - available 698 | - pending 699 | - sold 700 | description: The current status of the pet in the store. 701 | category: 702 | type: string 703 | description: Category of the pet (e.g., dog, cat, bird). 704 | nickname: 705 | type: string 706 | description: Category of the pet (e.g., dog, cat, bird). 707 | description: Response for retrieving a pet. 708 | GetUserResponse: 709 | type: object 710 | required: 711 | - id 712 | - username 713 | - email 714 | - phone 715 | properties: 716 | id: 717 | type: integer 718 | format: int32 719 | description: Unique identifier of the user. 720 | username: 721 | type: string 722 | description: Username associated with the user. 723 | email: 724 | type: string 725 | description: Email address of the user. 726 | phone: 727 | type: string 728 | description: Phone number of the user. 729 | description: Response for retrieving a user. 730 | Order: 731 | type: object 732 | required: 733 | - id 734 | - petId 735 | - quantity 736 | - shipDate 737 | - status 738 | properties: 739 | id: 740 | type: integer 741 | format: int32 742 | description: Unique identifier for the order. 743 | petId: 744 | type: integer 745 | format: int32 746 | description: ID of the pet being ordered. 747 | quantity: 748 | type: integer 749 | format: int32 750 | description: Quantity of pets ordered. 751 | shipDate: 752 | type: string 753 | description: Shipping date for the order. 754 | status: 755 | type: string 756 | enum: 757 | - placed 758 | - approved 759 | - delivered 760 | description: The current status of the order. 761 | complete: 762 | type: boolean 763 | description: Indicates whether the order is complete. 764 | description: Represents an order for purchasing a pet. 765 | Pet: 766 | type: object 767 | required: 768 | - id 769 | - name 770 | properties: 771 | id: 772 | type: integer 773 | format: int32 774 | description: Unique identifier for the pet. 775 | name: 776 | type: string 777 | description: Name of the pet. 778 | category: 779 | type: string 780 | description: Category of the pet (e.g., dog, cat, bird). 781 | tags: 782 | type: array 783 | items: 784 | type: string 785 | description: List of tags associated with the pet. 786 | status: 787 | type: string 788 | enum: 789 | - available 790 | - pending 791 | - sold 792 | description: The current status of the pet in the store. 793 | description: Represents a pet in the store. 794 | PlaceOrderBody: 795 | type: object 796 | required: 797 | - petId 798 | - quantity 799 | - shipDate 800 | - status 801 | properties: 802 | petId: 803 | type: integer 804 | format: int32 805 | description: ID of the pet being ordered. 806 | quantity: 807 | type: integer 808 | format: int32 809 | description: Quantity of pets ordered. 810 | shipDate: 811 | type: string 812 | description: Shipping date for the order. 813 | status: 814 | type: string 815 | enum: 816 | - placed 817 | - approved 818 | - delivered 819 | description: The current status of the order. 820 | complete: 821 | type: boolean 822 | description: Indicates whether the order is complete. 823 | description: Place an order for a pet. 824 | User: 825 | type: object 826 | required: 827 | - id 828 | - username 829 | - firstName 830 | - lastName 831 | - email 832 | - password 833 | - phone 834 | properties: 835 | id: 836 | type: integer 837 | format: int32 838 | description: Unique identifier for the user. 839 | username: 840 | type: string 841 | description: Username of the user. 842 | firstName: 843 | type: string 844 | description: First name of the user. 845 | lastName: 846 | type: string 847 | description: Last name of the user. 848 | email: 849 | type: string 850 | description: Email address of the user. 851 | password: 852 | type: string 853 | description: Password for the user account. 854 | phone: 855 | type: string 856 | description: Phone number of the user. 857 | userStatus: 858 | type: integer 859 | format: int32 860 | description: User status (e.g., 1 for active, 0 for inactive). 861 | description: Represents a user of the pet store. 862 | `; 863 | 864 | describe('diffSpecs', () => { 865 | const tempDir = path.resolve(new URL('.', import.meta.url).pathname, 'temp'); 866 | const spec1Path = path.join(tempDir, 'spec1.yaml'); 867 | const spec2Path = path.join(tempDir, 'spec2.yaml'); 868 | 869 | beforeAll(() => { 870 | if (!fs.existsSync(tempDir)) { 871 | fs.mkdirSync(tempDir); 872 | } 873 | }); 874 | 875 | afterAll(() => { 876 | fs.rmSync(tempDir, { force: true, recursive: true }); 877 | }); 878 | 879 | it.only('should detect no changes between identical specs', async () => { 880 | fs.writeFileSync(spec1Path, dummyOpenApiSpecBreaking); 881 | fs.writeFileSync(spec2Path, dummyOpenApiSpecMinor); 882 | const result = await diffSpecs(spec2Path, spec1Path); 883 | expect(result.version).toBe('minor'); 884 | expect(result.diff).toEqual([]); 885 | }); 886 | }); 887 | -------------------------------------------------------------------------------- /packages/generators/diff/src/generator.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs'; 2 | import openapiDiff, { type DiffOutcome } from 'openapi-diff'; 3 | 4 | interface DiffResult { 5 | version: 'minor' | 'major' | 'patch' | 'unchanged'; 6 | diff: DiffOutcome | null; 7 | } 8 | 9 | export async function diffSpecs( 10 | destinationSpecPath: string, 11 | sourceSpecPath: string 12 | ): Promise { 13 | const diff = await openapiDiff.diffSpecs({ 14 | destinationSpec: { 15 | content: fs.readFileSync(destinationSpecPath, 'utf-8'), 16 | location: destinationSpecPath, 17 | format: 'openapi3', 18 | }, 19 | sourceSpec: { 20 | content: fs.readFileSync(sourceSpecPath, 'utf-8'), 21 | location: sourceSpecPath, 22 | format: 'openapi3', 23 | }, 24 | }); 25 | 26 | if (diff.breakingDifferencesFound) { 27 | return { version: 'major', diff: diff }; 28 | } 29 | 30 | if (diff.unclassifiedDifferences.length === 0 && diff.nonBreakingDifferences.length === 0) { 31 | return { version: 'unchanged', diff: null }; 32 | } 33 | 34 | return { version: 'minor', diff: diff }; 35 | } 36 | -------------------------------------------------------------------------------- /packages/generators/diff/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './generator.js'; 2 | export * from './diff-highlighter.js'; 3 | -------------------------------------------------------------------------------- /packages/generators/diff/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.build.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "baseUrl": ".", 6 | "outDir": "dist", 7 | "skipLibCheck": true 8 | }, 9 | "exclude": [ 10 | "dist", 11 | "index.ts", 12 | "node_modules", 13 | "test", 14 | "**/*.spec.ts", 15 | "jest.config.ts" 16 | ] 17 | } -------------------------------------------------------------------------------- /packages/generators/diff/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json" 3 | } -------------------------------------------------------------------------------- /packages/generators/spec/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../.eslintrc" 3 | } -------------------------------------------------------------------------------- /packages/generators/spec/index.ts: -------------------------------------------------------------------------------- 1 | export * from './src'; 2 | -------------------------------------------------------------------------------- /packages/generators/spec/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@contractual/generators.spec", 3 | "private": false, 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "type": "module", 7 | "module": "dist/index.js", 8 | "main": "dist/index.js", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/contractual-dev/contractual.git", 12 | "directory": "packages/generators/spec" 13 | }, 14 | "exports": { 15 | ".": { 16 | "import": "./dist/index.js", 17 | "require": "./dist/index.js" 18 | } 19 | }, 20 | "homepage": "https://contractual.dev", 21 | "bugs": { 22 | "url": "https://github.com/contractual-dev/contractual/issues" 23 | }, 24 | "contributors": [ 25 | { 26 | "name": "Omer Morad", 27 | "email": "omer.moradd@gmail.com" 28 | } 29 | ], 30 | "funding": [ 31 | { 32 | "type": "github", 33 | "url": "https://github.com/sponsors/contractual-dev" 34 | }, 35 | { 36 | "type": "opencollective", 37 | "url": "https://opencollective.com/contractual-dev" 38 | } 39 | ], 40 | "engines": { 41 | "node": ">=18.12.0" 42 | }, 43 | "scripts": { 44 | "prebuild": "pnpm rimraf dist", 45 | "build": "tsc -p tsconfig.build.json", 46 | "build:watch": "tsc -p tsconfig.build.json --watch", 47 | "tester": "jest --coverage --verbose", 48 | "lint": "eslint '{src,test}/**/*.ts'" 49 | }, 50 | "files": [ 51 | "dist", 52 | "README.md" 53 | ], 54 | "publishConfig": { 55 | "access": "public", 56 | "provenance": true 57 | }, 58 | "dependencies": { 59 | "@contractual/generators.diff": "workspace:*", 60 | "@typespec/compiler": "^0.63.0", 61 | "@typespec/http": "^0.63.0", 62 | "@typespec/openapi": "^0.63.0", 63 | "@typespec/openapi3": "^0.63.0", 64 | "@typespec/rest": "^0.63.1", 65 | "@typespec/versioning": "^0.63.0", 66 | "semver": "^7.6.3", 67 | "yaml": "^2.7.0" 68 | }, 69 | "devDependencies": { 70 | "@types/semver": "^7.5.8", 71 | "chalk": "^5.4.1", 72 | "inquirer": "^12.3.2", 73 | "openapi-types": "^12.1.3", 74 | "ora": "^8.1.1", 75 | "typescript": "~5.7.2" 76 | }, 77 | "peerDependencies": { 78 | "typescript": ">=5.x" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/generators/spec/src/generator.ts: -------------------------------------------------------------------------------- 1 | import { compile, logDiagnostics, NodeHost } from '@typespec/compiler'; 2 | import * as fs from 'node:fs'; 3 | import * as path from 'node:path'; 4 | import * as process from 'node:process'; 5 | import { parse, stringify } from 'yaml'; 6 | import { inc } from 'semver'; 7 | import type inquirer from 'inquirer'; 8 | import ora from 'ora'; 9 | import chalk from 'chalk'; 10 | import { diffSpecs, printOpenApiDiff } from '@contractual/generators.diff'; 11 | 12 | export function initializePaths() { 13 | const rootPath = path.resolve(process.cwd(), 'contractual'); 14 | const configFilePath = path.resolve(rootPath, 'api-lock.yaml'); 15 | const snapshotsPath = path.resolve(rootPath, 'specs'); 16 | const currentPath = path.resolve(path.dirname(new URL(import.meta.url).pathname)); 17 | const specPath = path.resolve(rootPath, 'api.tsp'); 18 | const tempSpecPath = path.resolve(currentPath, '@typespec', 'openapi3', 'openapi.yaml'); 19 | 20 | return { rootPath, configFilePath, snapshotsPath, currentPath, specPath, tempSpecPath }; 21 | } 22 | 23 | function checkFileExists(filePath: string, errorMessage: string): boolean { 24 | if (!fs.existsSync(filePath)) { 25 | console.error(errorMessage, filePath); 26 | return false; 27 | } 28 | 29 | return true; 30 | } 31 | 32 | async function compileSpecification(specPath: string, outputPath: string) { 33 | const program = await compile(NodeHost, specPath, { 34 | emit: ['@typespec/openapi3'], 35 | additionalImports: ['@typespec/openapi', '@typespec/openapi3', '@typespec/http'], 36 | outputDir: outputPath, 37 | ignoreDeprecated: true, 38 | warningAsError: false, 39 | }); 40 | 41 | if (program.hasError()) { 42 | logDiagnostics( 43 | program.diagnostics.filter(({ severity }) => severity === 'error'), 44 | NodeHost.logSink 45 | ); 46 | 47 | return null; 48 | } 49 | 50 | return program; 51 | } 52 | 53 | async function checkSpecificationDifferences( 54 | tempSpecPath: string, 55 | snapshotsPath: string, 56 | version: string 57 | ) { 58 | return diffSpecs(tempSpecPath, path.resolve(snapshotsPath, `openapi-v${version}.yaml`)); 59 | } 60 | 61 | function updateVersionAndSnapshot( 62 | configPath: string, 63 | snapshotsPath: string, 64 | tempSpecPath: string, 65 | currentVersion: string 66 | ) { 67 | const newVersion = inc(currentVersion, 'minor'); 68 | const newConfigContent = stringify({ version: { latest: newVersion } }); 69 | 70 | fs.writeFileSync(configPath, newConfigContent); 71 | fs.copyFileSync(tempSpecPath, path.resolve(snapshotsPath, `openapi-v${newVersion}.yaml`)); 72 | } 73 | 74 | export function getLatestVersion(configPath: string) { 75 | const configContent = parse(fs.readFileSync(configPath, 'utf-8')); 76 | return configContent.version.latest; 77 | } 78 | 79 | export async function generateSpecification(inquirerDep: typeof inquirer) { 80 | const paths = initializePaths(); 81 | 82 | if (!checkFileExists(paths.rootPath, `'contractual' directory not found`)) { 83 | return; 84 | } 85 | 86 | if (!fs.existsSync(paths.snapshotsPath)) { 87 | fs.mkdirSync(paths.snapshotsPath); 88 | } 89 | 90 | if (!checkFileExists(paths.specPath, 'specification file not found')) { 91 | process.exit(1); 92 | } 93 | 94 | const latest = getLatestVersion(paths.configFilePath); 95 | 96 | console.log(chalk.gray(`Latest version is ${latest}`)); 97 | 98 | const spinner = ora('Compiling TypeSpec API specification..').start(); 99 | 100 | const program = await compileSpecification(paths.specPath, paths.currentPath); 101 | 102 | if (!program) { 103 | spinner.fail('Compilation failed due to compilation errors'); 104 | return; 105 | } 106 | 107 | if (!checkFileExists(paths.tempSpecPath, 'openapi.yaml not found')) { 108 | spinner.fail('Compilation failed due to missing temp openapi.yaml'); 109 | return; 110 | } 111 | 112 | spinner.succeed('TypeSpec API specification compiled successfully'); 113 | 114 | if (latest === 'unversioned') { 115 | const { initialVersion } = await inquirerDep.prompt([ 116 | { 117 | type: 'input', 118 | name: 'initialVersion', 119 | message: 'Please provide the initial version (e.g., 1.0.0):', 120 | default: '1.0.0', 121 | validate: (input) => 122 | /^\d+\.\d+\.\d+$/.test(input) || 123 | 'Invalid version format. Please use semantic versioning format (e.g., 1.0.0).', 124 | }, 125 | ]); 126 | 127 | const updateSpinner = ora('Creating new version..').start(); 128 | 129 | const destinationPath = path.resolve(paths.snapshotsPath, `openapi-v${initialVersion}.yaml`); 130 | 131 | fs.copyFileSync(paths.tempSpecPath, destinationPath); 132 | 133 | updateSpinner.info(`New version ${initialVersion} created successfully`); 134 | 135 | const newConfigContent = stringify({ version: { latest: initialVersion } }); 136 | 137 | fs.writeFileSync(paths.configFilePath, newConfigContent); 138 | 139 | updateSpinner.info(`Updated to new version: ${initialVersion}`); 140 | 141 | updateSpinner.succeed('Specification generated successfully'); 142 | 143 | return; 144 | } 145 | 146 | const diffSpinner = ora('Checking for API diff..').start(); 147 | 148 | const differences = await checkSpecificationDifferences( 149 | paths.tempSpecPath, 150 | paths.snapshotsPath, 151 | latest 152 | ); 153 | 154 | if (differences.version === 'unchanged') { 155 | diffSpinner.info('No differences found. Specifications are identical or compatible.'); 156 | return; 157 | } 158 | 159 | diffSpinner.info('Differences found'); 160 | 161 | await printOpenApiDiff(differences.diff!); 162 | 163 | const { confirmUpdate } = await inquirerDep.prompt([ 164 | { 165 | type: 'confirm', 166 | name: 'confirmUpdate', 167 | message: 'Do you want to update the API version?', 168 | default: true, 169 | }, 170 | ]); 171 | 172 | if (!confirmUpdate) { 173 | diffSpinner.info('API version not updated'); 174 | return; 175 | } 176 | 177 | diffSpinner.start('Updating API version..'); 178 | 179 | updateVersionAndSnapshot(paths.configFilePath, paths.snapshotsPath, paths.tempSpecPath, latest); 180 | 181 | diffSpinner.succeed('API version updated successfully'); 182 | } 183 | -------------------------------------------------------------------------------- /packages/generators/spec/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './generator.js'; 2 | -------------------------------------------------------------------------------- /packages/generators/spec/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.build.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "baseUrl": ".", 6 | "outDir": "dist", 7 | "skipLibCheck": true 8 | }, 9 | "exclude": [ 10 | "dist", 11 | "index.ts", 12 | "node_modules", 13 | "test", 14 | "**/*.spec.ts", 15 | "jest.config.ts" 16 | ] 17 | } -------------------------------------------------------------------------------- /packages/generators/spec/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json" 3 | } -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | - "packages/generators/*" 4 | - "packages/providers/*" 5 | - "packages/types/*" 6 | -------------------------------------------------------------------------------- /spec-graduate.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/contractual-dev/contractual/3878028f9b0446b6ba4613b83c2dd832fd52f07c/spec-graduate.gif -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "incremental": false, 8 | "sourceMap": false 9 | }, 10 | "exclude": [ 11 | "vitest.config.ts", 12 | "spec-assets.ts", 13 | "node_modules", 14 | "dist", 15 | "**/*test.ts", 16 | "**/*spec.ts", 17 | "index.ts", 18 | "index.js", 19 | "index.d.ts" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "esnext", 5 | "skipLibCheck": true, 6 | "declaration": true, 7 | "removeComments": false, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "sourceMap": true, 11 | "baseUrl": "./", 12 | "outDir": "./dist", 13 | "incremental": false, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "moduleResolution": "node", 17 | "esModuleInterop": true, 18 | "resolveJsonModule": true, 19 | "types": [ 20 | "node", 21 | "vitest" 22 | ], 23 | "allowSyntheticDefaultImports": true 24 | }, 25 | "exclude": [ 26 | "e2e", 27 | "index.ts", 28 | "node_modules", 29 | "dist", 30 | "__test__", 31 | "env", 32 | "coverage" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import process from 'process'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | workspace: ['packages/*', 'packages/generators/*', 'packages/providers/*'], 7 | reporters: ['default', 'junit'], 8 | outputFile: { 9 | junit: 10 | (process.env.JUNIT_OUTPUT_DIR || '.') + 11 | '/' + 12 | (process.env.JUNIT_OUTPUT_NAME || 'junit') + 13 | '.xml', 14 | }, 15 | coverage: { 16 | exclude: ['**/*.spec.ts', '**/node_modules/**', '**/index.ts'], 17 | reportsDirectory: process.env.COVERAGE_DIR || 'coverage', 18 | reporter: [ 19 | 'text', 20 | [ 21 | 'cobertura', 22 | { 23 | file: process.env.COVERAGE_FILE || 'coverage-report.xml', 24 | }, 25 | ], 26 | ], 27 | }, 28 | globals: true, 29 | exclude: ['node_modules', 'dist', '**/node_modules/**'], 30 | }, 31 | }); 32 | --------------------------------------------------------------------------------