├── .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 |
--------------------------------------------------------------------------------