├── .github └── workflows │ ├── nodejs.yml │ └── publish_npm.yml ├── .gitignore ├── .nvmrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.mjs ├── package-lock.json ├── package.json ├── src ├── analysis │ └── GraphAnalyzer.ts ├── cli.ts ├── generation │ ├── effSchemGen │ │ ├── genUtil.test.ts │ │ ├── genUtil.ts │ │ ├── moduleGen.ts │ │ ├── moduleGenWithSpec.ts │ │ └── schemaGen.ts │ ├── formatting.ts │ └── generationSpec.ts ├── openapiToEffect.ts └── util │ └── openapi.ts ├── tests ├── fixtures │ ├── fixture0_api.json │ ├── fixture0_spec.ts │ ├── fixture1_api.json │ └── fixture1_spec.ts └── integration │ ├── fixture0.test.ts │ ├── fixture1.test.ts │ ├── generate_fixture.sh │ └── tsconfig.json ├── tsconfig.decl.json └── tsconfig.json /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: 12 | - 18.x 13 | - 20.x 14 | - 22.x 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: npm install, build, and test 23 | shell: bash 24 | run: | 25 | npm ci 26 | npm run build 27 | npm test 28 | env: 29 | CI: true 30 | -------------------------------------------------------------------------------- /.github/workflows/publish_npm.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | on: 3 | release: 4 | types: [published] # Run this whenever we create a new release through GitHub releases 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | id-token: write 11 | steps: 12 | - uses: actions/checkout@v4 13 | # Setup .npmrc file to publish to npm 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: '22.x' 17 | registry-url: 'https://registry.npmjs.org' 18 | - run: | 19 | npm ci 20 | npm run build 21 | npm test 22 | - run: npm publish --provenance --access public 23 | env: 24 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # OS artifact files 3 | .DS_Store 4 | Thumbs.db 5 | 6 | # Node artifact files 7 | node_modules/ 8 | 9 | # IDEs 10 | .idea/ 11 | .vscode/ 12 | 13 | # Project-specific 14 | /dist/ 15 | /tests/project_simulation/generated/ 16 | !/tests/project_simulation/generated/.gitkeep 17 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [speak-up@fortanix.com](mailto:speak-up@fortanix.com). All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributing 3 | 4 | ## Code of Conduct 5 | 6 | Please see `./CODE_OF_CONDUCT.md`. 7 | 8 | 9 | ## Release workflow 10 | 11 | To create a new release: 12 | 13 | - Submit a release PR targeting the `master` branch: 14 | - Bumps the version in `package.json`. 15 | - Run `npm install` to update the `package-lock.json`. 16 | - The commit message should be of the form "Release vx.y.z" 17 | - The title of the release PR should be of the form "Release vx.y.z" 18 | 19 | - Once the PR is merged, create a new release: 20 | - Go the GitHub repo, and navigate to ["Releases"](https://github.com/fortanix/openapi-to-effect/releases). 21 | - Click ["Draft a new release"](https://github.com/fortanix/openapi-to-effect/releases/new). 22 | - Under "Choose a new tag", create a new tag of the form `vx.y.z`. 23 | - The name of the release should be of the form `vx.y.z`. 24 | - Write the release notes. 25 | - If the version is a pre-release, mark it as such. 26 | - Hit "Publish the release". 27 | 28 | - Once the release has been created, a GitHub Actions workflow will automatically run to publish this release to npm. 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![npm](https://img.shields.io/npm/v/openapi-to-effect.svg?style=flat)](https://www.npmjs.com/package/openapi-to-effect) 3 | [![GitHub Actions](https://github.com/fortanix/openapi-to-effect/actions/workflows/nodejs.yml/badge.svg)](https://github.com/fortanix/openapi-to-effect/actions) 4 | 5 | # openapi-to-effect 6 | 7 | Generate [Effect Schema](https://effect.website/docs/schema/introduction) definitions from an [OpenAPI](https://www.openapis.org) document. 8 | 9 | **Features:** 10 | 11 | - All output is TypeScript code. 12 | - Fully configurable using a spec file, including hooks to customize the output (e.g. support more `format`s). 13 | - Automatic detection of recursive definitions using graph analysis. In the output, the recursive references are wrapped in `Schema.suspend()`. 14 | - Supports generating either one file per schema, or all schemas bundled into one file. When bundled, the schemas are sorted according to a topological sort algorithm so that schema dependencies are reflected in the output order. 15 | - Pretty printing using `prettier`. Descriptions in the schema (e.g. `title`, `description`) are output as comments in the generated code. `title` fields are assumed to be single line comments, which are output as `//` comments, whereas `description` results in a block comment. 16 | 17 | **Limitations:** 18 | 19 | - We currently only support [OpenAPI v3.1](https://spec.openapis.org/oas/latest.html) documents. 20 | - Only JSON is supported for the OpenAPI document format. For other formats like YAML, run it through a [converter](https://onlineyamltools.com/convert-yaml-to-json) first. 21 | - The input must be a single OpenAPI document. Cross-document [references](https://swagger.io/docs/specification/using-ref) are not currently supported. 22 | - The `$allOf` operator currently only supports schemas of type `object`. Generic intersections are not currently supported. 23 | 24 | ## Usage 25 | 26 | This package exposes an `openapi-to-effect` command: 27 | 28 | ```console 29 | npx openapi-to-effect 30 | ``` 31 | 32 | ### Generating Effect Schema code with the `gen` command 33 | 34 | The `gen` command takes the path to an OpenAPI v3.1 document (in JSON format), the path to the output directory, and optionally a spec file to configure the output: 35 | 36 | ```console 37 | npx openapi-to-effect gen ./api.json ./output --spec=./spec.ts 38 | ``` 39 | 40 | ### Example 41 | 42 | ```console 43 | npx openapi-to-effect gen ./example_api.json ./output --spec=./example_spec.ts 44 | ``` 45 | 46 | **example_api.json** 47 | 48 | ```json 49 | { 50 | "openapi": "3.1.0", 51 | "info": { 52 | "title": "Example API", 53 | "version": "0.1.0" 54 | }, 55 | "components": { 56 | "schemas": { 57 | "Category": { 58 | "type": "object", 59 | "properties": { 60 | "name": { "type": "string" }, 61 | "subcategories": { 62 | "type": "object", 63 | "additionalProperties": { 64 | "$ref": "#/components/schemas/Category" 65 | }, 66 | "default": {} 67 | } 68 | }, 69 | "required": ["name"] 70 | }, 71 | "User": { 72 | "type": "object", 73 | "properties": { 74 | "id": { 75 | "title": "Unique ID", 76 | "type": "string", 77 | "format": "uuid" 78 | }, 79 | "name": { 80 | "title": "The user's full name.", 81 | "type": "string" 82 | }, 83 | "last_logged_in": { 84 | "title": "When the user last logged in.", 85 | "type": "string", "format": "date-time" 86 | }, 87 | "role": { 88 | "title": "The user's role within the system.", 89 | "description": "Roles:\n- ADMIN: Administrative permissions\n- USER: Normal permissions\n- AUDITOR: Read only permissions", 90 | "type": "string", 91 | "enum": ["ADMIN", "USER", "AUDITOR"] 92 | }, 93 | "interests": { 94 | "type": "array", 95 | "items": { "$ref": "#/components/schemas/Category" }, 96 | "default": [] 97 | } 98 | }, 99 | "required": ["id", "name", "last_logged_in", "role"] 100 | } 101 | } 102 | } 103 | } 104 | ``` 105 | 106 | **example_spec.ts** 107 | 108 | ```ts 109 | import { type GenerationSpec } from '../../src/generation/generationSpec.ts'; 110 | 111 | 112 | export default { 113 | generationMethod: { method: 'bundled', bundleName: 'example' }, 114 | hooks: {}, 115 | runtime: {}, 116 | modules: { 117 | './Category.ts': { 118 | definitions: [ 119 | { 120 | action: 'generate-schema', 121 | schemaId: 'Category', 122 | typeDeclarationEncoded: `{ 123 | readonly name: string, 124 | readonly subcategories?: undefined | { readonly [key: string]: _CategoryEncoded } 125 | }`, 126 | typeDeclaration: `{ 127 | readonly name: string, 128 | readonly subcategories: { readonly [key: string]: _Category } 129 | }`, 130 | }, 131 | ], 132 | }, 133 | }, 134 | } satisfies GenerationSpec; 135 | ``` 136 | 137 | **output/example.ts** 138 | 139 | ```ts 140 | import { Schema as S } from 'effect'; 141 | 142 | /* Category */ 143 | 144 | type _Category = { 145 | readonly name: string; 146 | readonly subcategories: { readonly [key: string]: _Category }; 147 | }; 148 | type _CategoryEncoded = { 149 | readonly name: string; 150 | readonly subcategories?: undefined | { readonly [key: string]: _CategoryEncoded }; 151 | }; 152 | export const Category = S.Struct({ 153 | name: S.String, 154 | subcategories: S.optionalWith( 155 | S.Record({ 156 | key: S.String, 157 | value: S.suspend((): S.Schema<_Category, _CategoryEncoded> => Category), 158 | }), 159 | { 160 | default: () => ({}), 161 | }, 162 | ), 163 | }).annotations({ identifier: 'Category' }); 164 | export type Category = S.Schema.Type; 165 | export type CategoryEncoded = S.Schema.Encoded; 166 | 167 | /* User */ 168 | 169 | export const User = S.Struct({ 170 | id: S.UUID, // Unique ID 171 | name: S.String, // The user's full name. 172 | last_logged_in: S.Date, // When the user last logged in. 173 | /** 174 | * Roles: 175 | * 176 | * - ADMIN: Administrative permissions 177 | * - USER: Normal permissions 178 | * - AUDITOR: Read only permissions 179 | */ 180 | role: S.Literal('ADMIN', 'USER', 'AUDITOR'), // The user's role within the system. 181 | interests: S.optionalWith(S.Array(Category), { 182 | default: () => [], 183 | }), 184 | }).annotations({ identifier: 'User' }); 185 | export type User = S.Schema.Type; 186 | export type UserEncoded = S.Schema.Encoded; 187 | ``` 188 | 189 | 190 | ## Contributing 191 | 192 | We gratefully accept bug reports and contributions from the community. 193 | By participating in this community, you agree to abide by [Code of Conduct](./CODE_OF_CONDUCT.md). 194 | All contributions are covered under the Developer's Certificate of Origin (DCO). 195 | 196 | ### Developer's Certificate of Origin 1.1 197 | 198 | By making a contribution to this project, I certify that: 199 | 200 | (a) The contribution was created in whole or in part by me and I 201 | have the right to submit it under the open source license 202 | indicated in the file; or 203 | 204 | (b) The contribution is based upon previous work that, to the best 205 | of my knowledge, is covered under an appropriate open source 206 | license and I have the right under that license to submit that 207 | work with modifications, whether created in whole or in part 208 | by me, under the same open source license (unless I am 209 | permitted to submit under a different license), as indicated 210 | in the file; or 211 | 212 | (c) The contribution was provided directly to me by some other 213 | person who certified (a), (b) or (c) and I have not modified 214 | it. 215 | 216 | (d) I understand and agree that this project and the contribution 217 | are public and that a record of the contribution (including all 218 | personal information I submit with it, including my sign-off) is 219 | maintained indefinitely and may be redistributed consistent with 220 | this project or the open source license(s) involved. 221 | 222 | ## License 223 | 224 | This project is primarily distributed under the terms of the Mozilla Public License (MPL) 2.0, see [LICENSE](./LICENSE) for details. 225 | -------------------------------------------------------------------------------- /babel.config.mjs: -------------------------------------------------------------------------------- 1 | 2 | const target = process.env.BABEL_ENV; 3 | export default { 4 | targets: { 5 | node: '20', 6 | }, 7 | sourceMaps: true, 8 | presets: [ 9 | '@babel/typescript', 10 | ['@babel/env', { 11 | // Do not include polyfills automatically. Leave it up to the consumer to include the right polyfills 12 | // for their required environment. 13 | useBuiltIns: false, 14 | 15 | // Whether to transpile modules 16 | modules: target === 'cjs' ? 'commonjs' : false, 17 | }], 18 | ], 19 | plugins: [ 20 | ['replace-import-extension', { 'extMapping': { '.ts': target === 'cjs' ? '.cjs' : '.mjs' }}] 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openapi-to-effect", 3 | "version": "0.9.2", 4 | "license": "MPL-2.0", 5 | "homepage": "https://github.com/fortanix/openapi-to-effect", 6 | "description": "OpenAPI to Effect Schema code generator", 7 | "author": "Fortanix", 8 | "repository": { 9 | "type": "git", 10 | "url": "git@github.com:fortanix/openapi-to-effect.git" 11 | }, 12 | "files": [ 13 | "./dist" 14 | ], 15 | "type": "module", 16 | "bin": { 17 | "openapi-to-effect": "./dist/esm/cli.mjs" 18 | }, 19 | "exports": { 20 | "./generation/generationSpec.ts": { 21 | "types": "./dist/types/generation/generationSpec.d.ts", 22 | "require": "./dist/cjs/generation/generationSpec.cjs", 23 | "default": "./dist/esm/generation/generationSpec.mjs" 24 | }, 25 | "./generation/effSchemGen/genUtil.ts": { 26 | "types": "./dist/types/generation/effSchemGen/genUtil.d.ts", 27 | "require": "./dist/cjs/generation/effSchemGen/genUtil.cjs", 28 | "default": "./dist/esm/generation/effSchemGen/genUtil.mjs" 29 | }, 30 | ".": { 31 | "types": "./dist/types/openapiToEffect.d.ts", 32 | "require": "./dist/cjs/openapiToEffect.cjs", 33 | "default": "./dist/esm/openapiToEffect.mjs" 34 | } 35 | }, 36 | "scripts": { 37 | "repl": "tsx", 38 | "node": "node --import=tsx", 39 | "check:types": "tsc --noEmit && echo 'No type errors found'", 40 | "//test:unit": "node --import=tsx --test --test-reporter=spec \"**/*.test.ts\" # Note: glob requires Node v22", 41 | "test:unit": "find src -type f -iname '*.test.ts' -print0 | xargs -0 node --import=tsx --test --test-reporter=spec", 42 | "test:integration": "find tests/integration -type f -iname '*.test.ts' -print0 | xargs -0 node --import=tsx --test --test-reporter=spec", 43 | "test": "npm run test:integration && npm run test:unit", 44 | "test-watch": "node --import=tsx --test --test-reporter=spec --watch tests", 45 | "_build": "NODE_ENV=production babel src --extensions=.ts,.tsx --delete-dir-on-start", 46 | "build:cjs": "BABEL_ENV=cjs npm run _build -- --out-dir=dist/cjs --out-file-extension=.cjs", 47 | "build:esm": "BABEL_ENV=esm npm run _build -- --out-dir=dist/esm --out-file-extension=.mjs", 48 | "build:types": "tsc --project ./tsconfig.decl.json", 49 | "build": "npm run check:types && npm run build:cjs && npm run build:esm && npm run build:types && chmod +x ./dist/cjs/cli.cjs ./dist/esm/cli.mjs", 50 | "generate": "node --import=tsx ./src/openapiToEffect.ts gen", 51 | "prepare": "npm run build" 52 | }, 53 | "devDependenciesComments": { 54 | "babel": "// Still needed because tsc doesn't like to emit files with .ts extensions" 55 | }, 56 | "devDependencies": { 57 | "typescript": "^5.8.2", 58 | "tsx": "^4.19.3", 59 | "@babel/core": "^7.26.10", 60 | "@babel/cli": "^7.26.4", 61 | "@babel/preset-env": "^7.26.9", 62 | "@babel/preset-typescript": "^7.26.0", 63 | "babel-plugin-replace-import-extension": "^1.1.5", 64 | "@types/node": "^22.13.10", 65 | "openapi-types": "^12.1.3" 66 | }, 67 | "dependencies": { 68 | "ts-dedent": "^2.2.0", 69 | "immutable-json-patch": "^6.0.1", 70 | "prettier": "^3.5.3", 71 | "prettier-plugin-jsdoc": "^1.3.2", 72 | "effect": "^3.13.12" 73 | }, 74 | "peerDependencies": { 75 | "openapi-types": "^12.0.0" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/analysis/GraphAnalyzer.ts: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Fortanix, Inc. 2 | * 3 | * This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { type OpenAPIV3_1 as OpenApi } from 'openapi-types'; 9 | import { type OpenApiSchemaId, type OpenApiSchema, decodeJsonPointer, encodeJsonPointer } from '../util/openapi.ts'; 10 | 11 | 12 | // 13 | // Reference resolving 14 | // 15 | 16 | type Ref = string; // OpenAPI schema reference 17 | type Resolve = (ref: Ref) => OpenApiSchema; // Callback to take a ref and resolve it to the corresponding schema 18 | 19 | export const schemaIdFromRef = (ref: Ref): OpenApiSchemaId => { 20 | if (!/^#\/components\/schemas\/.+/.test(ref)) { 21 | throw new Error(`Reference format not supported: ${ref}`); 22 | } 23 | 24 | const pointer = ref.replace(/^#/, ''); 25 | const [refSchemaId, ...segments] = decodeJsonPointer(pointer).slice(2); 26 | if (typeof refSchemaId === 'undefined') { throw new Error('Should not happen'); } 27 | if (segments.length !== 0) { throw new Error(`Refs to nested paths not supported: ${ref}`); } 28 | 29 | return refSchemaId; 30 | }; 31 | export const refFromSchemaId = (schemaId: OpenApiSchemaId): Ref => { 32 | return '#' + encodeJsonPointer(['components', 'schemas', schemaId]); 33 | }; 34 | 35 | const resolver = (schemas: Record) => (ref: string): OpenApiSchema => { 36 | const refSchemaId = schemaIdFromRef(ref); 37 | const refSchema = schemas[refSchemaId]; 38 | if (typeof refSchema === 'undefined') { throw new Error('Should not happen'); } 39 | return refSchema; 40 | }; 41 | 42 | // Get all direct (shallow) dependencies 43 | const depsShallow = (schema: OpenApiSchema): Set => { 44 | if ('$ref' in schema) { // Case: OpenApi.ReferenceObject 45 | return new Set([schema.$ref]); 46 | } else { // Case: OpenApi.SchemaObject 47 | if (schema.type === 'array' || 'items' in schema) { // Case: OpenApi.ArraySchemaObject 48 | if ('items' in schema) { 49 | return depsShallow(schema.items); 50 | } else { // Array of unknown 51 | return new Set(); 52 | } 53 | } else { // Case: OpenApi.NonArraySchemaObject 54 | if ('allOf' in schema && typeof schema.allOf !== 'undefined') { 55 | return new Set(schema.allOf.flatMap(subschema => [...depsShallow(subschema)])); 56 | } else if ('oneOf' in schema && typeof schema.oneOf !== 'undefined') { 57 | return new Set(schema.oneOf.flatMap(subschema => [...depsShallow(subschema)])); 58 | } else if ('anyOf' in schema && typeof schema.anyOf !== 'undefined') { 59 | return new Set(schema.anyOf.flatMap(subschema => [...depsShallow(subschema)])); 60 | } 61 | 62 | if (typeof schema.type === 'undefined') { 63 | return new Set(); // Any type 64 | } else if (Array.isArray(schema.type)) { 65 | const schemaAnyOf = { anyOf: schema.type.map(type => ({ ...schema, type })) } as OpenApiSchema; 66 | return depsShallow(schemaAnyOf); 67 | } 68 | switch (schema.type) { 69 | case 'null': 70 | case 'string': 71 | case 'number': 72 | case 'integer': 73 | case 'boolean': 74 | return new Set(); 75 | case 'object': 76 | const props: Record = schema.properties ?? {}; 77 | const additionalProps = schema.additionalProperties; 78 | const additionalPropsSchema: null | OpenApiSchema = 79 | typeof additionalProps === 'object' && additionalProps !== null 80 | ? additionalProps 81 | : null; 82 | return new Set([ 83 | ...Object.values(props).flatMap(propSchema => [...depsShallow(propSchema)]), 84 | ...(additionalPropsSchema ? depsShallow(additionalPropsSchema) : []), 85 | ]); 86 | default: throw new TypeError(`Unsupported type ${JSON.stringify(schema.type)}`); 87 | } 88 | } 89 | } 90 | }; 91 | 92 | // Get all dependencies for the given schema (as a dependency tree rooted at `schema`) 93 | const depsDeep = (schema: OpenApiSchema, resolve: Resolve, visited: Set): DepTree => { 94 | if ('$ref' in schema) { // Case: OpenApi.ReferenceObject 95 | if (visited.has(schema.$ref)) { return { [schema.$ref]: 'recurse' }; } 96 | return { [schema.$ref]: depsDeep(resolve(schema.$ref), resolve, new Set([...visited, schema.$ref])) }; 97 | } else { // Case: OpenApi.SchemaObject 98 | if (schema.type === 'array' || 'items' in schema) { // Case: OpenApi.ArraySchemaObject 99 | if ('items' in schema) { 100 | return depsDeep(schema.items, resolve, visited); 101 | } else { // Array of unknown 102 | return {}; 103 | } 104 | } else { // Case: OpenApi.NonArraySchemaObject 105 | if ('allOf' in schema && typeof schema.allOf !== 'undefined') { 106 | return Object.assign({}, ...schema.allOf.flatMap(subschema => depsDeep(subschema, resolve, visited))); 107 | } else if ('oneOf' in schema && typeof schema.oneOf !== 'undefined') { 108 | return Object.assign({}, ...schema.oneOf.flatMap(subschema => depsDeep(subschema, resolve, visited))); 109 | } else if ('anyOf' in schema && typeof schema.anyOf !== 'undefined') { 110 | return Object.assign({}, ...schema.anyOf.flatMap(subschema => depsDeep(subschema, resolve, visited))); 111 | } 112 | 113 | if (typeof schema.type === 'undefined') { 114 | return {}; // Any type 115 | } else if (Array.isArray(schema.type)) { 116 | const schemaAnyOf = { anyOf: schema.type.map(type => ({ ...schema, type })) } as OpenApiSchema; 117 | return depsDeep(schemaAnyOf, resolve, visited); 118 | } 119 | switch (schema.type) { 120 | case 'null': 121 | case 'string': 122 | case 'number': 123 | case 'integer': 124 | case 'boolean': 125 | return {}; 126 | case 'object': 127 | const props: Record = schema.properties ?? {}; 128 | const additionalProps = schema.additionalProperties; 129 | const additionalPropsSchema: null | OpenApiSchema = 130 | typeof additionalProps === 'object' && additionalProps !== null 131 | ? additionalProps 132 | : null; 133 | return Object.assign( 134 | {}, 135 | ...Object.values(props).flatMap(propSchema => depsDeep(propSchema, resolve, visited)), 136 | additionalPropsSchema ? depsDeep(additionalPropsSchema, resolve, visited) : {}, 137 | ); 138 | default: throw new TypeError(`Unsupported type "${schema.type}"`); 139 | } 140 | } 141 | } 142 | }; 143 | 144 | 145 | // 146 | // Dependency trees 147 | // 148 | 149 | type DepTree = { [ref: Ref]: DepTree | 'recurse' }; 150 | 151 | // Create a dependency tree rooted at `rootRef`. If we find a cycle, inject a special "recurse" marker. 152 | export const dependencyTree = (document: OpenApi.Document, rootSchemaRef: Ref): DepTree => { 153 | const schemas: Record = document.components?.schemas ?? {}; 154 | const rootSchemaId = schemaIdFromRef(rootSchemaRef); 155 | 156 | const rootSchema: undefined | OpenApiSchema = schemas[rootSchemaId]; 157 | if (!rootSchema) { throw new Error(`Unable to find schema "${rootSchemaRef}"`); } 158 | 159 | return { [rootSchemaRef]: depsDeep(rootSchema, resolver(schemas), new Set([rootSchemaRef])) }; 160 | }; 161 | 162 | 163 | // 164 | // Adjacency maps 165 | // 166 | 167 | type AdjacencyMap = Map>; 168 | 169 | // Get a mapping from schemas to all of their direct dependencies 170 | export const dependenciesMapFromDocument = (document: OpenApi.Document): AdjacencyMap => { 171 | const schemas: Record = document.components?.schemas ?? {}; 172 | 173 | // Mapping from a particular schema to its dependencies (directed neighbors in the graph: schema -> dependency) 174 | const dependenciesMap: AdjacencyMap = new Map>(); 175 | 176 | for (const [schemaId, schema] of Object.entries(schemas)) { 177 | const schemaRef = refFromSchemaId(schemaId); 178 | const dependencies: Set = depsShallow(schema); 179 | 180 | dependenciesMap.set(schemaRef, dependencies); 181 | } 182 | 183 | return dependenciesMap; 184 | }; 185 | 186 | // Get a mapping from schemas to all of their direct dependents 187 | export const dependentsMapFromDocument = (document: OpenApi.Document): AdjacencyMap => { 188 | const schemas: Record = document.components?.schemas ?? {}; 189 | 190 | // Mapping from a particular schema to its dependents (directed neighbors in the graph: schema <- dependent) 191 | const dependentsMap: AdjacencyMap = new Map>(); 192 | 193 | for (const [schemaId, schema] of Object.entries(schemas)) { 194 | const schemaRef = refFromSchemaId(schemaId); 195 | const dependencies = ((): Set => { 196 | try { 197 | return depsShallow(schema); 198 | } catch (error: unknown) { 199 | throw new Error(`Unable to determine dependents for '${schemaRef}'`, { cause: error }); 200 | } 201 | })(); 202 | 203 | for (const dependency of dependencies) { 204 | dependentsMap.set(dependency, new Set([ 205 | ...(dependentsMap.get(dependency) ?? new Set()), 206 | schemaRef, 207 | ])); 208 | } 209 | } 210 | 211 | return dependentsMap; 212 | }; 213 | 214 | 215 | // 216 | // Topological sort 217 | // 218 | 219 | type TopologicalSort = Array<{ 220 | ref: Ref, 221 | circularDependency: boolean, // If true, then this has at least one dependency that breaks the topological ordering 222 | }>; 223 | const topologicalSorter = (dependentsMap: AdjacencyMap) => { 224 | const visited = new Set(); // Keep track of schemas we've already visited 225 | const refsWithCycles = new Set(); // Keep track of schemas that have at least one circular dependency 226 | const sorted: TopologicalSort = []; 227 | 228 | const visit = (schemaRef: Ref): void => { 229 | if (visited.has(schemaRef)) { 230 | refsWithCycles.add(schemaRef); 231 | return; 232 | } 233 | 234 | visited.add(schemaRef); 235 | 236 | const dependents = dependentsMap.get(schemaRef) ?? new Set(); 237 | for (const dependentSchemaRef of dependents) { 238 | visit(dependentSchemaRef); 239 | } 240 | 241 | sorted.unshift({ ref: schemaRef, circularDependency: refsWithCycles.has(schemaRef) }); 242 | }; 243 | 244 | return { visit, sorted }; 245 | }; 246 | 247 | export const topologicalSort = (document: OpenApi.Document): TopologicalSort => { 248 | const schemas: Record = document.components?.schemas ?? {}; 249 | 250 | const adjacency = dependentsMapFromDocument(document); 251 | const sorter = topologicalSorter(adjacency); 252 | 253 | for (const schemaId of Object.keys(schemas)) { 254 | const schemaRef = refFromSchemaId(schemaId); 255 | sorter.visit(schemaRef); 256 | } 257 | 258 | return sorter.sorted; 259 | }; 260 | 261 | 262 | // Util: check if a given schema is an object schema 263 | export class InfiniteRecursionError extends Error {} 264 | const _isObjectSchema = (schema: OpenApiSchema, resolve: Resolve, visited: Set): boolean => { 265 | if ('$ref' in schema) { // Case: OpenApi.ReferenceObject 266 | if (visited.has(schema.$ref)) { 267 | throw new InfiniteRecursionError(`Infinite recursion at ${schema.$ref}`); 268 | } 269 | return _isObjectSchema(resolve(schema.$ref), resolve, new Set([...visited, schema.$ref])); 270 | } else { // Case: OpenApi.SchemaObject 271 | if (schema.type === 'array' || 'items' in schema) { // Case: OpenApi.ArraySchemaObject 272 | return false; 273 | } else { // Case: OpenApi.NonArraySchemaObject 274 | if ('allOf' in schema && typeof schema.allOf !== 'undefined') { 275 | return schema.allOf.flatMap(subschema => _isObjectSchema(subschema, resolve, visited)).every(Boolean); 276 | } else if ('oneOf' in schema && typeof schema.oneOf !== 'undefined') { 277 | return false; // Union 278 | } else if ('anyOf' in schema && typeof schema.anyOf !== 'undefined') { 279 | return false; // Union 280 | } 281 | 282 | if (typeof schema.type === 'undefined') { 283 | return false; // Any type 284 | } else if (Array.isArray(schema.type)) { 285 | const schemaAnyOf = { anyOf: schema.type.map(type => ({ ...schema, type })) } as OpenApiSchema; 286 | return _isObjectSchema(schemaAnyOf, resolve, visited); 287 | } 288 | switch (schema.type) { 289 | case 'null': 290 | case 'string': 291 | case 'number': 292 | case 'integer': 293 | case 'boolean': 294 | return false; 295 | case 'object': 296 | return true; 297 | default: throw new TypeError(`Unsupported type "${schema.type}"`); 298 | } 299 | } 300 | } 301 | }; 302 | export const isObjectSchema = (schemas: Record, rootSchemaRef: Ref): boolean => { 303 | const rootSchemaId = schemaIdFromRef(rootSchemaRef); 304 | 305 | const rootSchema: undefined | OpenApiSchema = schemas[rootSchemaId]; 306 | if (!rootSchema) { throw new Error(`Unable to find schema "${rootSchemaRef}"`); } 307 | 308 | return _isObjectSchema(rootSchema, resolver(schemas), new Set([rootSchemaRef])); 309 | }; 310 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { run } from './openapiToEffect.ts'; 4 | 5 | 6 | const main = async () => { 7 | const [_argExec, _argScript, ...args] = process.argv; // First two arguments should be the executable + script 8 | try { 9 | await run(args); 10 | process.exit(0); 11 | } catch (error: unknown) { 12 | console.error(error); 13 | process.exit(1); 14 | } 15 | }; 16 | 17 | await main(); 18 | -------------------------------------------------------------------------------- /src/generation/effSchemGen/genUtil.test.ts: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Fortanix, Inc. 2 | * 3 | * This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import assert from 'node:assert/strict'; 9 | import { test } from 'node:test'; 10 | 11 | import { type GenResult, GenResultUtil } from './genUtil.ts'; 12 | 13 | 14 | test('genUtil', (t) => { 15 | t.test('combineRefs', (t) => { 16 | t.test('should return the unique combination of refs', (t) => { 17 | const refs1: GenResult['refs'] = ['#/components/schemas/A', '#/components/schemas/B']; 18 | const refs2: GenResult['refs'] = ['#/components/schemas/B', '#/components/schemas/C']; 19 | 20 | const refsCombined: GenResult['refs'] = GenResultUtil.combineRefs(refs1, refs2); 21 | 22 | assert.deepStrictEqual(refsCombined, [ 23 | '#/components/schemas/A', 24 | '#/components/schemas/B', 25 | '#/components/schemas/C', 26 | ]); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/generation/effSchemGen/genUtil.ts: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Fortanix, Inc. 2 | * 3 | * This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { dedent } from 'ts-dedent'; 9 | import { type OpenAPIV3_1 as OpenAPIV3 } from 'openapi-types'; 10 | import { type OpenApiRef } from '../../util/openapi.ts'; 11 | 12 | 13 | export type GenComments = { 14 | commentShort: string, 15 | commentLong: string, 16 | deprecated: boolean, 17 | format?: undefined | string, 18 | }; 19 | export type GenResult = { 20 | code: string, 21 | refs: Array, 22 | comments: GenComments, 23 | }; 24 | export const GenResultUtil = { 25 | combineRefs(...refs: Array): GenResult['refs'] { 26 | return [...new Set(refs.flat())]; 27 | }, 28 | importNameFromRef(ref: string) { 29 | const fileName = ref.split('/').at(-1); 30 | if (!fileName) { throw new Error(`Invalid ref: ${ref}`); } 31 | const importName = fileName.replace(/\.ts$/, ''); 32 | return importName; 33 | }, 34 | 35 | initComments(): GenComments { 36 | return { 37 | commentShort: '', 38 | commentLong: '', 39 | deprecated: false, 40 | //format: undefined, 41 | }; 42 | }, 43 | initGenResult(): GenResult { 44 | return { 45 | code: '', 46 | refs: [], 47 | comments: GenResultUtil.initComments(), 48 | }; 49 | }, 50 | 51 | commentsFromSchemaObject(schema: OpenAPIV3.BaseSchemaObject): GenComments { 52 | return { 53 | commentShort: schema.title ?? '', 54 | commentLong: schema.description ?? '', 55 | deprecated: schema.deprecated ?? false, 56 | format: schema.format, 57 | }; 58 | }, 59 | commentsToCode(comments: GenComments): { commentBlock: string, commentInline: string } { 60 | const commentBlock = dedent` 61 | ${comments.commentLong} 62 | ${comments.deprecated ? `@deprecated` : ''} 63 | `.trim(); 64 | const commentInline = comments.commentShort.split('\n').join(' ').trim(); 65 | return { 66 | commentBlock: commentBlock === '' ? '' : `/** ${commentBlock} */`, 67 | commentInline: commentInline === '' ? '' : `// ${commentInline}`, 68 | }; 69 | }, 70 | 71 | generatePipe(pipe: Array): string { 72 | const head: undefined | string = pipe[0]; 73 | if (head === undefined) { throw new TypeError(`generatePipe needs at least one argument`); } 74 | if (pipe.length === 1) { return head; } 75 | return `pipe(${pipe.join(', ')})`; 76 | }, 77 | 78 | // Convert an arbitrary name to a JS identifier 79 | encodeIdentifier(name: string): string { 80 | if (/^[a-zA-Z$_][a-zA-Z0-9$_]*$/g.test(name)) { 81 | return name; 82 | } else { 83 | const nameSpecialCharsRemoved = name.replace(/[^a-zA-Z0-9$_]/g, ''); 84 | if (/^[0-9]/.test(nameSpecialCharsRemoved)) { 85 | return '_' + nameSpecialCharsRemoved; 86 | } else { 87 | return nameSpecialCharsRemoved; 88 | } 89 | } 90 | }, 91 | }; 92 | -------------------------------------------------------------------------------- /src/generation/effSchemGen/moduleGen.ts: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Fortanix, Inc. 2 | * 3 | * This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { dedent } from 'ts-dedent'; 9 | import { type OpenApiSchemaId, type OpenApiSchema } from '../../util/openapi.ts'; 10 | 11 | import { GenResultUtil } from './genUtil.ts'; 12 | import { type Context as SchemaGenContext, generateForSchema } from './schemaGen.ts'; 13 | 14 | 15 | const id = GenResultUtil.encodeIdentifier; 16 | 17 | // Take an OpenAPI schema and generate a top-level module, as a string. 18 | // @throws Error When we cannot generate a module from the given schema. 19 | export const generateModule = (schemaId: string, schema: OpenApiSchema): string => { 20 | const generationContext: SchemaGenContext = { 21 | schemas: {}, // FIXME 22 | hooks: {}, 23 | isSchemaIdBefore(_schemaId: OpenApiSchemaId) { return true; }, 24 | }; 25 | const { code, refs, comments } = generateForSchema(generationContext, schema); 26 | const commentsGenerated = GenResultUtil.commentsToCode(comments); 27 | 28 | const fileNameForRef = (ref: string): undefined | string => ref.split('/').at(-1); 29 | const schemaIdForRef = (ref: string): undefined | string => fileNameForRef(ref)?.replace(/\.ts$/, ''); 30 | 31 | const refsSorted = [...refs] 32 | .filter(ref => schemaIdForRef(ref) !== schemaId) 33 | .sort((ref1, ref2) => { 34 | if (ref1.startsWith('../util/')) { 35 | return -1; 36 | } else if (ref2.startsWith('../util/')) { 37 | return +1; 38 | } 39 | return 0; 40 | }); 41 | return dedent` 42 | import { pipe, Option, Schema as S } from 'effect'; 43 | 44 | ${refsSorted.map(ref => { 45 | const refId = schemaIdForRef(ref); 46 | if (!refId) { throw new Error(`Invalid ref: ${ref}`); } 47 | return `import { ${id(refId)} } from '${ref}';`; 48 | }).join('\n')} 49 | 50 | ${commentsGenerated.commentBlock} 51 | export const ${id(schemaId)} = ${code}; ${commentsGenerated.commentInline} 52 | export type ${id(schemaId)} = S.Schema.Type; 53 | export const ${id(schemaId)}Encoded = S.encodedSchema(${id(schemaId)}); 54 | export type ${id(schemaId)}Encoded = S.Schema.Encoded; 55 | `; 56 | }; 57 | -------------------------------------------------------------------------------- /src/generation/effSchemGen/moduleGenWithSpec.ts: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Fortanix, Inc. 2 | * 3 | * This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { dedent } from 'ts-dedent'; 9 | import * as path from 'node:path'; 10 | import { type OpenApiRef, type OpenApiSchemaId, type OpenApiSchema } from '../../util/openapi.ts'; 11 | 12 | import { refFromSchemaId, isObjectSchema } from '../../analysis/GraphAnalyzer.ts'; 13 | import * as GenSpec from '../generationSpec.ts'; 14 | import { type GenResult, GenResultUtil } from './genUtil.ts'; 15 | import { type Context as SchemaGenContext, generateForSchema } from './schemaGen.ts'; 16 | 17 | 18 | const assertUnreachable = (x: never): never => { throw new Error(`Should not happen`); }; 19 | const id = GenResultUtil.encodeIdentifier; 20 | 21 | 22 | // Transform the given `schema` by reordering object fields according the given `ordering` spec. 23 | // @throws TypeError When `schema` is not an object schema. 24 | const reorderFields = (fieldGroups: GenSpec.FieldGroups, schema: OpenApiSchema): OpenApiSchema => { 25 | // We assume the `schema` is an object schema (or close enough to one). If `schema` is an allOf/oneOf/anyOf then 26 | // we reorder all of the possibilities. 27 | 28 | if ('$ref' in schema) { // Case: OpenAPIV3.ReferenceObject 29 | throw new TypeError(`Not an object schema`); 30 | } else { // Case: OpenAPIV3.SchemaObject 31 | if ('items' in schema) { // Case: OpenAPIV3.ArraySchemaObject 32 | throw new TypeError(`Not an object schema`); 33 | } else { // Case: OpenAPIV3.NonArraySchemaObject 34 | if ('allOf' in schema && typeof schema.allOf !== 'undefined') { 35 | return { ...schema, allOf: schema.allOf.map(schema => reorderFields(fieldGroups, schema)) }; 36 | } else if ('oneOf' in schema && typeof schema.oneOf !== 'undefined') { 37 | return { ...schema, oneOf: schema.oneOf.map(schema => reorderFields(fieldGroups, schema)) }; 38 | } else if ('anyOf' in schema && typeof schema.anyOf !== 'undefined') { 39 | return { ...schema, anyOf: schema.anyOf.map(schema => reorderFields(fieldGroups, schema)) }; 40 | } 41 | 42 | if (schema.type !== 'object') { 43 | throw new TypeError(`Not an object schema`); 44 | } 45 | 46 | // Reorder `properties` (if any) 47 | // Note: we don't care about `required` or `additionalProperties` here 48 | 49 | const properties: undefined | Record = schema.properties; 50 | if (typeof properties === 'undefined') { return schema; } 51 | 52 | // Prep: get the list of all keys across all groups, for quick lookup purposes 53 | const orderingKeys: Array = [...new Set(Object.values(fieldGroups).flatMap(fieldGroup => { 54 | // Each key may be a comma-separated string of field names 55 | return Object.keys(fieldGroup).flatMap(fieldNames => fieldNames.split(/,\s*/)); 56 | }))]; 57 | 58 | // FIXME 59 | // const groupHeadingComment: null | string = groupKey.startsWith('//') 60 | // ? groupKey.replace('//', '').trim() 61 | // : null; 62 | 63 | // Get a mapping of the initial fields in the group to the group name (for later annotation purposes) 64 | const orderingHeadings = Object.entries(fieldGroups).reduce( 65 | (acc, [fieldGroupKey, fieldGroup]) => { 66 | const fieldGroupNames = Object.keys(fieldGroup).flatMap(fieldNames => fieldNames.split(/,\s*/)); 67 | 68 | // Get the first field in the list (this field will be annotated to identify where the group starts) 69 | const fieldInitial: undefined | string = fieldGroupNames[0]; 70 | 71 | if (typeof fieldInitial === 'string') { 72 | acc[fieldInitial] = fieldGroupKey; 73 | } 74 | return acc; 75 | }, 76 | {} as Record, 77 | ); 78 | 79 | return { 80 | ...schema, 81 | properties: Object.fromEntries(Object.entries(properties) 82 | .filter(([propKey]) => orderingKeys.includes(propKey)) 83 | .sort(([prop1Key], [prop2Key]) => { 84 | return orderingKeys.indexOf(prop1Key) - orderingKeys.indexOf(prop2Key); 85 | }) 86 | .map(([propKey, prop]) => { 87 | if (Object.hasOwn(orderingHeadings, propKey)) { 88 | return [propKey, { ...prop, 'x-heading': orderingHeadings[propKey] }]; 89 | } else { 90 | return [propKey, prop]; 91 | } 92 | }), 93 | ), 94 | }; 95 | } 96 | } 97 | }; 98 | const processSchemaWithSpec = (spec: GenSpec.GenerationDefinitionSpec, schema: OpenApiSchema): OpenApiSchema => { 99 | if (spec.action !== 'generate-schema') { return schema; } 100 | 101 | const fields: undefined | GenSpec.FieldGroups = spec.fields; 102 | if (typeof fields !== 'undefined') { 103 | return reorderFields(fields, schema); 104 | } 105 | 106 | return schema; 107 | }; 108 | 109 | // Generate an Effect Schema module for the given module as per the given module spec 110 | type GenerateModuleWithSpecOptions = { 111 | // Whether `schema1` is before `schema2` in the generation order 112 | isSchemaBefore: (schema1: OpenApiSchemaId, schema2: OpenApiSchemaId) => boolean, 113 | }; 114 | export const generateModuleWithSpec = ( 115 | schemas: Record, // All available source schemas (may contain ones we don't need) 116 | spec: GenSpec.GenerationSpec, // The (full) generation spec 117 | modulePath: GenSpec.ModulePath, // The specific module we want to generate 118 | options: GenerateModuleWithSpecOptions, 119 | ): string => { 120 | const generationContext: SchemaGenContext = { 121 | schemas, 122 | hooks: spec.hooks, 123 | isSchemaIdBefore: (schemaId: OpenApiSchemaId): boolean => { 124 | return true; 125 | }, 126 | }; 127 | 128 | const specModules = { ...spec.runtime, ...spec.modules }; 129 | 130 | const moduleSpec: undefined | GenSpec.GenerationModuleSpec = specModules[modulePath]; 131 | if (typeof moduleSpec === 'undefined') { throw new Error(`Cannot find definition for module ${modulePath}`); } 132 | 133 | // Derive the list of all schema IDs from `schemas` 134 | const schemaIds: Set = new Set(moduleSpec.definitions.map((def): OpenApiSchemaId => { 135 | return GenSpec.DefUtil.sourceSchemaIdFromDef(def); 136 | })); 137 | 138 | // Reverse mapping of schema IDs to the first definition that contains it 139 | type DefinitionLocator = { modulePath: GenSpec.ModulePath, definition: GenSpec.GenerationDefinitionSpec }; 140 | const schemasToDefinitionsMap: Map = new Map(); 141 | for (const [modulePath, moduleSpec] of Object.entries(specModules)) { 142 | for (const definition of moduleSpec.definitions) { 143 | const schemaId = GenSpec.DefUtil.sourceSchemaIdFromDef(definition); 144 | if (!schemasToDefinitionsMap.has(schemaId)) { 145 | schemasToDefinitionsMap.set(schemaId, { modulePath, definition }); 146 | } 147 | } 148 | } 149 | 150 | let codeExports = ''; 151 | let refsModule: GenResult['refs'] = []; 152 | for (const definitionSpec of moduleSpec.definitions) { 153 | const targetSchemaId = GenSpec.DefUtil.targetSchemaIdFromDef(definitionSpec); 154 | 155 | const generationContextForSchema: SchemaGenContext = { 156 | ...generationContext, 157 | isSchemaIdBefore: (referencedSchemaId: OpenApiSchemaId): boolean => { 158 | return options.isSchemaBefore(referencedSchemaId, targetSchemaId); 159 | 160 | // const referencedDef = schemasToDefinitionsMap.get(referencedSchemaId); 161 | // if (!referencedDef) { return false; } 162 | // const defs = moduleSpec.definitions; 163 | // 164 | // console.log('YYY', referencedSchemaId, '<-', targetSchemaId, referencedDef.definition); 165 | // if (defs.indexOf(referencedDef.definition) >= defs.indexOf(definitionSpec)) { 166 | // console.log('XXX', referencedSchemaId, '>=', targetSchemaId); 167 | // } 168 | // 169 | // return defs.indexOf(referencedDef.definition) < defs.indexOf(definitionSpec); 170 | }, 171 | }; 172 | 173 | switch (definitionSpec.action) { 174 | case 'custom-code': { 175 | codeExports = definitionSpec.code; 176 | break; 177 | }; 178 | case 'custom-schema': { 179 | const schema: OpenApiSchema = definitionSpec.schema; 180 | const sourceSchemaId: OpenApiSchemaId = GenSpec.DefUtil.sourceSchemaIdFromDef(definitionSpec); 181 | 182 | const { code, refs, comments } = generateForSchema(generationContextForSchema, schema); 183 | const commentsGenerated = GenResultUtil.commentsToCode(comments); 184 | 185 | refsModule = GenResultUtil.combineRefs(refsModule, refs); 186 | 187 | const schemaExportComment: string = dedent` 188 | ${commentsGenerated.commentBlock} 189 | ${sourceSchemaId === targetSchemaId ? '' : `// Generated from OpenAPI \`${sourceSchemaId}\` schema`} 190 | `.trim(); 191 | 192 | codeExports += '\n\n' + dedent` 193 | ${schemaExportComment ? `${schemaExportComment}\n` : ''}${ 194 | typeof definitionSpec.typeDeclaration === 'string' 195 | //? `export const ${id(targetSchemaId)}: S.Schema<${id(targetSchemaId)}> = ` // Replaced with suspend annotations 196 | ? `export const ${id(targetSchemaId)} = ` 197 | : `export const ${id(targetSchemaId)} = ` 198 | }${ 199 | //definitionSpec.lazy ? `S.suspend(() => ${code});` : code // Replaced with suspend annotations 200 | code 201 | }; ${commentsGenerated.commentInline} 202 | `.trim(); 203 | if (typeof definitionSpec.typeDeclaration === 'string') { 204 | codeExports += dedent` 205 | export type ${id(targetSchemaId)} = ${definitionSpec.typeDeclaration}; 206 | `; 207 | } else { 208 | codeExports += dedent` 209 | export type ${id(targetSchemaId)} = S.Schema.Type; 210 | `; 211 | } 212 | break; 213 | }; 214 | case 'generate-schema': { 215 | const sourceSchemaId: OpenApiSchemaId = GenSpec.DefUtil.sourceSchemaIdFromDef(definitionSpec); 216 | const schemaRaw: undefined | OpenApiSchema = schemas[sourceSchemaId]; 217 | if (!schemaRaw) { throw new Error(`Could not find a schema named ${sourceSchemaId}`) } 218 | const schema: OpenApiSchema = processSchemaWithSpec(definitionSpec, schemaRaw); 219 | 220 | const { code, refs, comments } = generateForSchema(generationContextForSchema, schema); 221 | const commentsGenerated = GenResultUtil.commentsToCode(comments); 222 | 223 | refsModule = GenResultUtil.combineRefs(refsModule, refs); 224 | 225 | 226 | // Start generating the schema exports 227 | codeExports += '\n\n'; 228 | 229 | // Comments 230 | codeExports += dedent` 231 | /* ${id(targetSchemaId)} */ 232 | ${commentsGenerated.commentBlock} 233 | ${sourceSchemaId === targetSchemaId ? '' : `// Generated from OpenAPI \`${sourceSchemaId}\` schema`} 234 | `.replace(/[\n]+/, '\n') + '\n'; 235 | 236 | // Manual type declarations (for recursive references) 237 | if (typeof definitionSpec.typeDeclaration === 'string') { 238 | codeExports += `type _${id(targetSchemaId)} = ${definitionSpec.typeDeclaration};`; 239 | if (typeof definitionSpec.typeDeclarationEncoded === 'string') { 240 | codeExports += `type _${id(targetSchemaId)}Encoded = ${definitionSpec.typeDeclarationEncoded};`; 241 | } 242 | } 243 | /* 244 | const typeAnnotation = typeof definitionSpec.typeDeclaration === 'string' 245 | ? ( 246 | typeof definitionSpec.typeDeclarationEncoded === 'string' 247 | ? `: S.Schema<_${id(targetSchemaId)}, _${id(targetSchemaId)}Encoded>` 248 | : `: S.Schema<_${id(targetSchemaId)}>` 249 | ) 250 | : ''; 251 | */ 252 | const typeAnnotation = ''; // Replaced with suspend annotations 253 | 254 | const schemaCode = dedent` 255 | ${ 256 | //definitionSpec.lazy ? `S.suspend(() => ${code})` : code // Replaced with suspend annotations 257 | code 258 | } 259 | .annotations({ identifier: ${JSON.stringify(targetSchemaId)} }) 260 | `.trim(); 261 | 262 | // See: https://github.com/Effect-TS/effect/tree/main/packages/schema#understanding-opaque-names 263 | const shouldGenerateOpaqueStruct = false;//isObjectSchema(schemas, refFromSchemaId(sourceSchemaId)); 264 | const shouldGenerateOpaqueClass = false; //isObjectSchema(schemas, refFromSchemaId(sourceSchemaId)) && /^S.Struct/.test(code); 265 | if (shouldGenerateOpaqueStruct) { 266 | codeExports += dedent` 267 | const _${id(targetSchemaId)}${typeAnnotation} = ${schemaCode}; ${commentsGenerated.commentInline} 268 | export interface ${id(targetSchemaId)} extends S.Schema.Type {} 269 | export interface ${id(targetSchemaId)}Encoded extends S.Schema.Encoded {} 270 | export const ${id(targetSchemaId)}: S.Schema<${id(targetSchemaId)}, ${id(targetSchemaId)}Encoded> = _${id(targetSchemaId)}; 271 | `.trim(); 272 | /* To generate the runtime encoded schema: 273 | export const ${id(targetSchemaId)}Encoded: S.Schema<${id(targetSchemaId)}Encoded, ${id(targetSchemaId)}Encoded> = 274 | S.encodedSchema(_${id(targetSchemaId)}); 275 | */ 276 | } else if (shouldGenerateOpaqueClass) { 277 | // Experimental: generate a top level Struct as a class (for better opaque types) 278 | codeExports += dedent` 279 | export class ${id(targetSchemaId)} extends S.Class<${id(targetSchemaId)}>("${id(targetSchemaId)}")( 280 | ${code.replace(/^S\.Struct\(/, '').replace(/\),?$/, '')} 281 | ) {} 282 | `.trim(); 283 | } else { 284 | codeExports += dedent` 285 | export const ${id(targetSchemaId)}${typeAnnotation} = ${schemaCode}; ${commentsGenerated.commentInline} 286 | export type ${id(targetSchemaId)} = S.Schema.Type; 287 | export type ${id(targetSchemaId)}Encoded = S.Schema.Encoded; 288 | `.trim(); 289 | /* To generate the runtime encoded schema: 290 | export const ${id(targetSchemaId)}Encoded = S.encodedSchema(${id(targetSchemaId)}); 291 | */ 292 | } 293 | break; 294 | }; 295 | default: assertUnreachable(definitionSpec); 296 | } 297 | } 298 | 299 | const refsSorted = [...refsModule] 300 | .filter(ref => { 301 | // Filter out imports that are already part of this file 302 | const importName = GenResultUtil.importNameFromRef(ref); 303 | return !schemaIds.has(importName); 304 | }) 305 | .sort((ref1, ref2) => { 306 | // Sort certain imports at the top 307 | // FIXME: unhardcode this logic somehow 308 | if (ref1.includes('/util/')) { 309 | return -1; 310 | } else if (ref2.includes('/util/')) { 311 | return +1; 312 | } 313 | return 0; 314 | }); 315 | 316 | const codeImports = dedent` 317 | ${refsSorted.map(ref => { 318 | const sourceSchemaId: OpenApiSchemaId = GenResultUtil.importNameFromRef(ref); // Get the source schema ID 319 | 320 | // Try to reverse-map this schema ID to one of the spec's definitions 321 | const definitionLocator: undefined | DefinitionLocator = schemasToDefinitionsMap.get(sourceSchemaId); 322 | 323 | if (typeof definitionLocator !== 'undefined') { 324 | const targetSchemaId = GenSpec.DefUtil.targetSchemaIdFromDef(definitionLocator.definition); 325 | 326 | // Calculate the relative path to the module 327 | let modulePathRelative = path.relative(path.dirname(modulePath), definitionLocator.modulePath); 328 | if (!modulePathRelative.startsWith('.')) { // Ensure the path starts with a `.` (ESM import paths require it) 329 | modulePathRelative = './' + modulePathRelative; 330 | } 331 | 332 | return `import { ${id(targetSchemaId)} } from '${modulePathRelative}';`; 333 | } else { 334 | return `// MISSING ${sourceSchemaId}`; 335 | //return `import { ${sourceSchemaId} } from '${ref}';`; 336 | } 337 | }).join('\n')} 338 | `; 339 | 340 | // TODO: may want to add a comment at the top like: 341 | // /* Generated on ${new Date().toISOString()} from ${apiName} version ${apiVersion} */ 342 | // Currently leaving this out because it means even simple changes cause a git diff upon regeneration. 343 | return dedent` 344 | import { pipe, Option, Schema as S } from 'effect'; 345 | 346 | ${codeImports} 347 | 348 | ${codeExports} 349 | `; 350 | }; 351 | -------------------------------------------------------------------------------- /src/generation/effSchemGen/schemaGen.ts: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Fortanix, Inc. 2 | * 3 | * This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { dedent } from 'ts-dedent'; 9 | import { type OpenAPIV3_1 as OpenApi } from 'openapi-types'; 10 | import { type OpenApiSchemaId, type OpenApiSchema } from '../../util/openapi.ts'; 11 | 12 | import * as GenSpec from '../generationSpec.ts'; 13 | import { isObjectSchema, schemaIdFromRef } from '../../analysis/GraphAnalyzer.ts'; 14 | import { type GenResult, GenResultUtil } from './genUtil.ts'; 15 | 16 | 17 | const id = GenResultUtil.encodeIdentifier; 18 | 19 | export type Context = { 20 | schemas: Record, 21 | hooks: GenSpec.GenerationHooks, 22 | isSchemaIdBefore: (schemaId: OpenApiSchemaId) => boolean, 23 | }; 24 | 25 | export const generateForUnknownSchema = (ctx: Context, schema: OpenApi.NonArraySchemaObject): GenResult => { 26 | return { 27 | code: `S.Unknown`, 28 | refs: [], 29 | comments: GenResultUtil.commentsFromSchemaObject(schema), 30 | }; 31 | }; 32 | 33 | export const generateForNullSchema = (ctx: Context, schema: OpenApi.NonArraySchemaObject): GenResult => { 34 | return { 35 | code: `S.Null`, 36 | refs: [], 37 | comments: GenResultUtil.commentsFromSchemaObject(schema), 38 | }; 39 | }; 40 | 41 | export const generateForStringSchema = (ctx: Context, schema: OpenApi.NonArraySchemaObject): GenResult => { 42 | let refs: GenResult['refs'] = []; 43 | const code = ((): string => { 44 | if (Array.isArray(schema.enum)) { 45 | if (!schema.enum.every(value => typeof value === 'string' || typeof value === 'number')) { 46 | throw new TypeError(`Unknown enum value, expected string array: ${JSON.stringify(schema.enum)}`); 47 | } 48 | return dedent`S.Literal( 49 | ${schema.enum.map((value: string | number) => JSON.stringify(String(value)) + ',').join('\n')} 50 | )`; 51 | } 52 | 53 | let baseSchema = `S.String`; 54 | switch (schema.format) { 55 | case 'uuid': baseSchema = `S.UUID`; break; 56 | //case 'date': baseSchema = `S.Date`; // FIXME: validate lack of time component 57 | case 'date-time': baseSchema = `S.Date`; break; 58 | // FIXME: using `S.Base64` will result in `Uint8Array` rather than strings, which will break some downstream 59 | // consumers. 60 | //case 'byte': baseSchema = `S.Base64`; break; 61 | } 62 | 63 | let pipe: Array = [baseSchema]; 64 | 65 | // Note: no built-in validator for emails, see https://github.com/effect-ts/effect/tree/main/packages/schema#email 66 | if (schema.format === 'email') { pipe.push(`S.pattern(/.+@.+/)`); } 67 | 68 | if (typeof schema.pattern === 'string') { 69 | pipe.push(`S.pattern(new RegExp(${JSON.stringify(schema.pattern)}))`); 70 | } 71 | 72 | if (typeof schema.minLength === 'number') { pipe.push(`S.minLength(${schema.minLength})`); } 73 | if (typeof schema.maxLength === 'number') { pipe.push(`S.maxLength(${schema.maxLength})`); } 74 | 75 | // Run hook 76 | const hookResult = ctx.hooks.generateStringType?.(schema) ?? null; 77 | if (hookResult !== null) { 78 | refs = GenResultUtil.combineRefs(refs, hookResult.refs); 79 | pipe = [hookResult.code]; 80 | } 81 | 82 | //if (schema.nullable) { pipe.push(`S.NullOr`); } 83 | 84 | return GenResultUtil.generatePipe(pipe); 85 | })(); 86 | return { 87 | code, 88 | refs, 89 | comments: GenResultUtil.commentsFromSchemaObject(schema), 90 | }; 91 | }; 92 | 93 | export const generateForNumberSchema = ( 94 | ctx: Context, 95 | schema: OpenApi.NonArraySchemaObject, 96 | ): GenResult => { 97 | let refs: GenResult['refs'] = []; 98 | const code = ((): string => { 99 | if (Array.isArray(schema.enum)) { 100 | if (!schema.enum.every(value => typeof value === 'number')) { 101 | throw new TypeError(`Unknown enum value, expected number array: ${JSON.stringify(schema.enum)}`); 102 | } 103 | return dedent`S.Literal( 104 | ${schema.enum.map((value: number) => JSON.stringify(value) + ',').join('\n')} 105 | )`; 106 | } 107 | 108 | let baseSchema = `S.Number`; 109 | switch (schema.format) { 110 | //case 'date': baseSchema = `S.Date`; break; // FIXME: validate lack of time component 111 | case 'date-time': baseSchema = `S.DateFromNumber.pipe(S.validDate())`; break; 112 | } 113 | 114 | let pipe: Array = [baseSchema]; 115 | if (schema.type === 'integer') { pipe.push(`S.int()`); } 116 | 117 | // Run hook 118 | const hookResult = ctx.hooks.generateNumberType?.(schema) ?? null; 119 | if (hookResult !== null) { 120 | refs = GenResultUtil.combineRefs(refs, hookResult.refs); 121 | pipe = [hookResult.code]; 122 | } 123 | 124 | if (typeof schema.minimum === 'number') { pipe.push(`S.greaterThanOrEqualTo(${schema.minimum})`); } 125 | if (typeof schema.maximum === 'number') { pipe.push(`S.lessThanOrEqualTo(${schema.maximum})`); } 126 | if (typeof schema.exclusiveMinimum === 'number') { pipe.push(`S.greaterThan(${schema.exclusiveMinimum})`); } 127 | if (typeof schema.exclusiveMaximum === 'number') { pipe.push(`S.lessThan(${schema.exclusiveMaximum})`); } 128 | //if (schema.nullable) { pipe.push(`S.NullOr`); } 129 | 130 | return GenResultUtil.generatePipe(pipe); 131 | })(); 132 | return { 133 | code, 134 | refs, 135 | comments: GenResultUtil.commentsFromSchemaObject(schema), 136 | }; 137 | }; 138 | 139 | export const generateForBooleanSchema = (ctx: Context, schema: OpenApi.NonArraySchemaObject): GenResult => { 140 | const code = ((): string => { 141 | if (Array.isArray(schema.enum)) { throw new Error(`Boolean enum currently not supported`); } 142 | 143 | let code = `S.Boolean`; 144 | //if (schema.nullable) { code = `S.NullOr(${code})`; } 145 | return code; 146 | })(); 147 | return { 148 | code, 149 | refs: [], 150 | comments: GenResultUtil.commentsFromSchemaObject(schema), 151 | }; 152 | }; 153 | 154 | // Format a property name as valid JS identifier, to be used in a JS object literal 155 | const propertyNameAsIdentifier = (propertyName: string): string => { 156 | if (!(/^[a-zA-Z$_][a-zA-Z0-9$_]*$/g.test(propertyName))) { 157 | return `'${propertyName.replaceAll(/'/g, '\\\'')}'`; 158 | } 159 | 160 | return propertyName; 161 | }; 162 | 163 | export const generateFieldsForObjectSchema = (ctx: Context, schema: OpenApi.NonArraySchemaObject): GenResult => { 164 | const propSchemas: Record = schema.properties ?? {}; 165 | const propsRequired: Set = new Set(schema.required ?? []); 166 | 167 | let refs: GenResult['refs'] = []; 168 | 169 | const code = Object.entries(propSchemas).map(([propName, propSchema], propIndex) => { 170 | const propResult = generateForSchema(ctx, propSchema); 171 | 172 | // Merge refs 173 | refs = GenResultUtil.combineRefs(refs, propResult.refs); 174 | 175 | const commentsGenerated = GenResultUtil.commentsToCode(propResult.comments); 176 | let propCode = propResult.code; 177 | 178 | // If the prop is optional, mark it as `S.optional()` 179 | if (!propsRequired.has(propName)) { 180 | type OptionalParams = { exact?: boolean, default?: string }; 181 | const optionalParams: OptionalParams = {}; 182 | const printOptional = (code: string, optionalParams: OptionalParams): string => { 183 | if (Object.keys(optionalParams).length === 0) { return `S.optional(${code})`; } 184 | const paramsFormatted = dedent`{ 185 | ${Object.hasOwn(optionalParams, 'exact') ? `exact: ${optionalParams.exact},` : ''} 186 | ${Object.hasOwn(optionalParams, 'default') ? `default: ${optionalParams.default}` : ''} 187 | }`; 188 | return `S.optionalWith(${code}, ${paramsFormatted})`; 189 | }; 190 | 191 | const hasDefault = 'default' in propSchema && typeof propSchema.default !== 'undefined'; 192 | if (hasDefault) { 193 | if (typeof propSchema.default === 'object' && propSchema.default !== null) { 194 | // Add parens for object literals when used as arrow function return value 195 | optionalParams.default = `() => (${JSON.stringify(propSchema.default)})`; 196 | } else { 197 | optionalParams.default = `() => ${JSON.stringify(propSchema.default)}`; 198 | } 199 | } 200 | 201 | if (ctx.hooks.optionalFieldRepresentation === 'nullish') { 202 | propCode = printOptional(`S.NullOr(${propCode})`, optionalParams); 203 | } else { 204 | propCode = printOptional(propCode, optionalParams); 205 | } 206 | } 207 | 208 | // Handle (custom/hacked in) `x-heading` fields to group certain fields with a heading comment 209 | const propNeedsSeparator = propIndex > 0 && 'x-heading' in propSchema; 210 | const propHeading: null | string = 'x-heading' in propSchema ? propSchema['x-heading'] as string : null; 211 | const propHeadingComment: null | string = propHeading !== null && propHeading.startsWith('//') 212 | ? propHeading 213 | : null; 214 | 215 | return (propNeedsSeparator ? '\n\n' : '') + dedent` 216 | ${propHeadingComment ?? ''}${commentsGenerated.commentBlock ? `\n${commentsGenerated.commentBlock}` : ''} 217 | ${propertyNameAsIdentifier(propName)}: ${propCode}, ${commentsGenerated.commentInline} 218 | `.trim(); 219 | }).join('\n'); 220 | 221 | return { 222 | code, 223 | refs, 224 | comments: GenResultUtil.commentsFromSchemaObject(schema), 225 | }; 226 | }; 227 | export const generateForObjectSchema = (ctx: Context, schema: OpenApi.NonArraySchemaObject): GenResult => { 228 | const propSchemas: Record = schema.properties ?? {}; 229 | 230 | let refs: GenResult['refs'] = []; 231 | 232 | const code = ((): string => { 233 | let code = ''; 234 | if (Object.keys(propSchemas).length === 0) { 235 | const additionalPropSchema = schema.additionalProperties; 236 | if (typeof additionalPropSchema === 'undefined') { 237 | code = `S.Struct({})`; 238 | } else if (additionalPropSchema === true) { 239 | code = `S.Record({ key: S.String, value: S.Unknown() })`; 240 | } else if (additionalPropSchema === false) { 241 | code = `S.Record({ key: S.Never, value: S.Never })`; 242 | } else { 243 | const additionalPropsResult = generateForSchema(ctx, additionalPropSchema); 244 | refs = GenResultUtil.combineRefs(refs, additionalPropsResult.refs); 245 | // TODO: also include the `comments` from `additionalPropsResult`? 246 | code = `S.Record({ key: S.String, value: ${additionalPropsResult.code} })`; 247 | } 248 | } else { 249 | let indexSignature = ''; 250 | if (typeof schema.additionalProperties !== 'undefined') { 251 | if (schema.additionalProperties === false) { 252 | // No equivalent for this on a schema level, requires the consumer to use `onExcessProperty: "error"` 253 | // https://github.com/Effect-TS/effect/tree/main/packages/schema#strict 254 | } else if (schema.additionalProperties === true) { 255 | // No equivalent for this on a schema level, requires the consumer to use `onExcessProperty: "preserve"` 256 | // https://github.com/Effect-TS/effect/tree/main/packages/schema#passthrough 257 | } else { 258 | const additionalPropsResult = generateForSchema(ctx, schema.additionalProperties); 259 | refs = GenResultUtil.combineRefs(refs, additionalPropsResult.refs); 260 | 261 | // https://github.com/Effect-TS/effect/tree/main/packages/schema#index-signatures 262 | // TODO: also include the `comments` from `additionalPropsResult`? 263 | indexSignature = `{ key: S.String, value: ${additionalPropsResult.code} }`; 264 | } 265 | } 266 | 267 | const fieldsGen = generateFieldsForObjectSchema(ctx, schema); 268 | refs = GenResultUtil.combineRefs(refs, fieldsGen.refs); 269 | code = dedent` 270 | S.Struct({\n 271 | ${fieldsGen.code} 272 | }${indexSignature ? `, ${indexSignature}` : ''}) 273 | `; 274 | } 275 | return code; 276 | })(); 277 | 278 | return { 279 | code, 280 | refs, 281 | comments: GenResultUtil.commentsFromSchemaObject(schema), 282 | }; 283 | }; 284 | 285 | export const generateForArraySchema = (ctx: Context, schema: OpenApi.ArraySchemaObject): GenResult => { 286 | const itemSchema: OpenApiSchema = schema.items; 287 | const itemResult = generateForSchema(ctx, itemSchema); 288 | 289 | const code = ((): string => { 290 | let code = `S.Array(${itemResult.code})`; 291 | if (typeof schema.minLength === 'number') { code = `${code}.min(${schema.minLength})`; } 292 | if (typeof schema.maxLength === 'number') { code = `${code}.max(${schema.maxLength})`; } 293 | //if (schema.nullable) { code = `S.NullOr(${code})`; } 294 | return code; 295 | })(); 296 | 297 | // FIXME: include the comments for `itemSchema` as well? 298 | return { 299 | code, 300 | refs: itemResult.refs, 301 | comments: GenResultUtil.commentsFromSchemaObject(schema), 302 | }; 303 | }; 304 | 305 | export const generateForReferenceObject = (ctx: Context, schema: OpenApi.ReferenceObject): GenResult => { 306 | // FIXME: make this logic customizable (allow a callback to resolve a `$ref` string to a `Ref` instance?) 307 | const schemaId = schemaIdFromRef(schema.$ref); 308 | 309 | // If the referenced schema ID is topologically after the current one, wrap it in `S.suspend` for lazy eval 310 | const shouldSuspend = !ctx.isSchemaIdBefore(schemaId); 311 | const code = shouldSuspend 312 | ? `S.suspend((): S.Schema<_${id(schemaId)}, _${id(schemaId)}Encoded> => ${id(schemaId)})` 313 | : id(schemaId); 314 | 315 | return { code, refs: [`./${schemaId}.ts`], comments: GenResultUtil.initComments() }; 316 | }; 317 | 318 | // Generate the Effect Schema code for the given OpenAPI schema 319 | export const generateForSchema = (ctx: Context, schema: OpenApiSchema): GenResult => { 320 | const isNonArraySchemaType = (schema: OpenApiSchema): schema is OpenApi.NonArraySchemaObject => { 321 | return !('$ref' in schema) && (!('type' in schema) || !Array.isArray(schema.type)); 322 | }; 323 | 324 | if ('$ref' in schema) { // Case: OpenApi.ReferenceObject 325 | return generateForReferenceObject(ctx, schema); 326 | } else { // Case: OpenApi.SchemaObject 327 | if (schema.type === 'array' || 'items' in schema) { // Case: OpenApi.ArraySchemaObject 328 | return generateForArraySchema(ctx, { items: {}, ...schema } as OpenApi.ArraySchemaObject); 329 | } else { // Case: OpenApi.NonArraySchemaObject | OpenApi.MixedSchemaObject 330 | if ('allOf' in schema && typeof schema.allOf !== 'undefined') { 331 | const schemasHead: undefined | OpenApiSchema = schema.allOf[0]; 332 | if (schemasHead && schema.allOf.length === 1) { // If only one schema, simply generate that schema 333 | return generateForSchema(ctx, schemasHead); 334 | } 335 | 336 | // `allOf` supports any type, but Effect Schema does not currently support generic intersections. Thus, 337 | // currently we only support `allOf` if it consists only of object schemas. 338 | // Idea: merge `allOf` schema first, e.g. using https://github.com/mokkabonna/json-schema-merge-allof 339 | const areAllObjects: boolean = schema.allOf.reduce( 340 | (acc, schema) => { 341 | if ('$ref' in schema) { 342 | return acc && isObjectSchema(ctx.schemas, schema.$ref); 343 | } 344 | 345 | const isObject = isNonArraySchemaType(schema) && schema.type === 'object'; 346 | return acc && isObject; 347 | }, 348 | true, 349 | ); 350 | 351 | /* 352 | if (!areAllObjects) { 353 | throw new Error(dedent` 354 | Found \`allOf\` with a non-object schema. Currently only object schemas or unions of object schemas are 355 | supported. 356 | `); 357 | } 358 | */ 359 | 360 | const schemas: Array = schema.allOf 361 | // Filter out empty schemas 362 | // XXX this only makes sense when all schemas are objects, would not work if we supported generic `allOf` 363 | .filter((schema: OpenApiSchema) => { 364 | if (!isNonArraySchemaType(schema) || schema.type !== 'object') { return true; } 365 | 366 | const props = schema.properties ?? {}; 367 | if (Object.keys(props).length === 0 && !schema.additionalProperties) { 368 | return false; 369 | } else { 370 | return true; 371 | } 372 | }); 373 | 374 | if (typeof schemasHead === 'undefined') { 375 | // XXX this only works for object schemas, as in `S.extend()`, would not work if we support generic `allOf` 376 | return generateForObjectSchema(ctx, { type: 'object' }); // Empty `allOf` 377 | } else if (schemas.length === 1) { 378 | // Trivial case: only one schema 379 | return generateForSchema(ctx, schemasHead); 380 | } else { 381 | let code = ''; 382 | let schemasResults: Array; 383 | if (areAllObjects) { 384 | // Experimental: using `...MySchema.fields` to combine struct schemas. 385 | // Note: doesn't work if `MySchema` is a union of structs 386 | 387 | schemasResults = schemas.map(schema => { 388 | const genResult = generateForSchema(ctx, schema); 389 | if ('$ref' in schema) { 390 | genResult.code = `...${genResult.code}.fields,`; 391 | } else if (isNonArraySchemaType(schema)) { 392 | genResult.code = generateFieldsForObjectSchema(ctx, schema).code; 393 | } 394 | return genResult; 395 | }); 396 | 397 | code = dedent` 398 | S.Struct({ 399 | ${schemasResults 400 | .map(({ code, comments }, index) => { 401 | const commentsGenerated = GenResultUtil.commentsToCode(comments); 402 | return dedent` 403 | ${commentsGenerated.commentBlock} 404 | ${commentsGenerated.commentInline} 405 | ${code} 406 | `.trim(); 407 | }) 408 | .join('\n') 409 | } 410 | }) 411 | `; 412 | } else { 413 | schemasResults = schemas.map(schema => generateForSchema(ctx, schema)); 414 | 415 | // Note: `extend` doesn't quite cover the semantics of `allOf`, since it only accepts objects and 416 | // assumes distinct types. However, Effect Schema has no generic built-in mechanism for this. 417 | code = dedent` 418 | pipe( 419 | ${schemasResults 420 | .map(({ code, comments }, index) => { 421 | const commentsGenerated = GenResultUtil.commentsToCode(comments); 422 | return dedent` 423 | ${commentsGenerated.commentBlock} 424 | ${index > 0 ? `S.extend(${code})` : code}, ${commentsGenerated.commentInline} 425 | `.trim(); 426 | }) 427 | .join('\n') 428 | } 429 | ) 430 | `; 431 | } 432 | 433 | return { 434 | code, 435 | refs: GenResultUtil.combineRefs(schemasResults.flatMap(({ refs }) => refs)), 436 | comments: GenResultUtil.initComments(), 437 | }; 438 | } 439 | } else if ('oneOf' in schema && typeof schema.oneOf !== 'undefined') { 440 | const schemas: Array = schema.oneOf; 441 | const schemasHead: undefined | OpenApiSchema = schemas[0]; 442 | if (typeof schemasHead === 'undefined') { 443 | throw new TypeError(`oneOf must have at least one schema`); 444 | } else if (schemas.length === 1) { 445 | // Trivial case: only one schema 446 | return generateForSchema(ctx, schemasHead); 447 | } else { 448 | const schemasResults: Array = schemas.map(schema => generateForSchema(ctx, schema)); 449 | // Note: `union` doesn't quite cover the semantics of `oneOf`, since `oneOf` must guarantee that exactly 450 | // one schema matches. However, Effect Schema has no easy built-in mechanism for this. 451 | const code = dedent` 452 | S.Union( 453 | ${schemasResults 454 | .map(({ code, comments }) => { 455 | const commentsGenerated = GenResultUtil.commentsToCode(comments); 456 | return dedent` 457 | ${commentsGenerated.commentBlock} 458 | ${code}, ${commentsGenerated.commentInline} 459 | `.trim(); 460 | }) 461 | .join('\n') 462 | } 463 | ) 464 | `; 465 | return { 466 | code, 467 | refs: GenResultUtil.combineRefs(schemasResults.map(({ refs }) => refs).flat()), 468 | comments: GenResultUtil.commentsFromSchemaObject(schema), 469 | }; 470 | } 471 | } else if ('anyOf' in schema && typeof schema.anyOf !== 'undefined') { 472 | const schemas: Array = schema.anyOf; 473 | const schemasHead: undefined | OpenApiSchema = schemas[0]; 474 | if (typeof schemasHead === 'undefined') { 475 | throw new TypeError(`anyOf must have at least one schema`); 476 | } else if (schemas.length === 1) { 477 | // Trivial case: only one schema 478 | return generateForSchema(ctx, schemasHead); 479 | } else { 480 | const schemasResults: Array = schemas.map(schema => generateForSchema(ctx, schema)); 481 | const code = dedent` 482 | S.Union( 483 | ${schemasResults 484 | .map(({ code, comments }) => { 485 | const commentsGenerated = GenResultUtil.commentsToCode(comments); 486 | return dedent` 487 | ${commentsGenerated.commentBlock} 488 | ${code}, ${commentsGenerated.commentInline} 489 | `.trim(); 490 | }) 491 | .join('\n') 492 | } 493 | ) 494 | `; 495 | return { 496 | code, 497 | refs: GenResultUtil.combineRefs(schemasResults.map(({ refs }) => refs).flat()), 498 | comments: GenResultUtil.commentsFromSchemaObject(schema), 499 | }; 500 | } 501 | } 502 | 503 | type SchemaType = 'array' | OpenApi.NonArraySchemaObjectType; 504 | const type: undefined | OpenApi.NonArraySchemaObjectType | Array = schema.type; 505 | 506 | const hookResult: null | GenResult = ctx.hooks.generateSchema?.(schema) ?? null; 507 | 508 | let result: GenResult; 509 | if (hookResult !== null) { 510 | result = hookResult; 511 | } else { 512 | if (typeof type === 'undefined') { 513 | result = generateForUnknownSchema(ctx, schema as OpenApi.NonArraySchemaObject); // Any type 514 | } else if (Array.isArray(type)) { 515 | // `type` as an array is equivalent to `anyOf` with `type` set to the individual type string 516 | const schemaAnyOf = { anyOf: type.map(type => ({ ...schema, type })) } as OpenApiSchema; 517 | result = generateForSchema(ctx, schemaAnyOf); 518 | } else { 519 | const schemaNonMixed = schema as OpenApi.NonArraySchemaObject; 520 | switch (type) { 521 | case 'null': result = generateForNullSchema(ctx, schemaNonMixed); break; 522 | case 'string': result = generateForStringSchema(ctx, schemaNonMixed); break; 523 | case 'number': result = generateForNumberSchema(ctx, schemaNonMixed); break; 524 | case 'integer': result = generateForNumberSchema(ctx, schemaNonMixed); break; 525 | case 'boolean': result = generateForBooleanSchema(ctx, schemaNonMixed); break; 526 | case 'object': result = generateForObjectSchema(ctx, schemaNonMixed); break; 527 | default: throw new TypeError(`Unsupported type "${type}"`); 528 | } 529 | } 530 | } 531 | return { 532 | ...result, 533 | code: `${result.code}`, 534 | }; 535 | } 536 | } 537 | }; 538 | -------------------------------------------------------------------------------- /src/generation/formatting.ts: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Fortanix, Inc. 2 | * 3 | * This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { format as prettierFormat } from 'prettier'; 9 | 10 | 11 | /** 12 | * @throws SyntaxError When the `generatedCode` is not syntactically valid. 13 | */ 14 | export const formatGeneratedCode = async (generatedCode: string): Promise => { 15 | return '\n' + await prettierFormat(generatedCode, { 16 | parser: 'babel-ts', 17 | plugins: ['prettier-plugin-jsdoc'], 18 | semi: true, 19 | singleQuote: true, 20 | trailingComma: 'all', 21 | printWidth: 100, 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /src/generation/generationSpec.ts: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Fortanix, Inc. 2 | * 3 | * This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { type JSONPatchDocument } from 'immutable-json-patch'; 9 | 10 | import { type OpenAPIV3_1 as OpenAPIV3 } from 'openapi-types'; 11 | import { type OpenApiSchemaId, type OpenApiSchema } from '../util/openapi.ts'; 12 | import { type GenResult } from './effSchemGen/genUtil.ts'; 13 | 14 | 15 | const assertUnreachable = (x: never): never => { throw new Error(`Should not happen`); }; 16 | 17 | 18 | export type GenerationHooks = { 19 | generateSchema?: (schema: OpenApiSchema) => null | GenResult, 20 | generateStringType?: (schema: OpenAPIV3.NonArraySchemaObject) => null | GenResult, 21 | generateNumberType?: (schema: OpenAPIV3.NonArraySchemaObject) => null | GenResult, 22 | optionalFieldRepresentation?: undefined | 'nullish', // TEMP 23 | }; 24 | 25 | 26 | // Effect Schema 27 | export type EffectSchemaId = string; 28 | 29 | export type FieldNames = string; // Comma-separated string of field names (identifiers) 30 | export type FieldSpec = { 31 | default?: undefined | unknown, 32 | }; 33 | export type FieldGroup = Record; 34 | export type FieldGroups = Record; 35 | 36 | export type ModulePath = string; 37 | export type GenerationDefinitionSpec = ( 38 | | { 39 | action: 'custom-code', 40 | schemaId: EffectSchemaId, 41 | code: string, 42 | } 43 | | { 44 | action: 'custom-schema', 45 | schemaId: EffectSchemaId, 46 | schema: OpenApiSchema, 47 | lazy?: undefined | boolean, // Whether we should wrap this in a `suspend()` call (for recursive references) 48 | typeDeclaration?: undefined | string, // Explicit type annotation (overrides inferred), usually used with `suspend` 49 | typeDeclarationEncoded?: undefined | string, // If specified, uses this as the "Encoded" type 50 | } 51 | | { 52 | action: 'generate-schema', 53 | sourceSchemaId?: undefined | OpenApiSchemaId, 54 | schemaId: EffectSchemaId, 55 | lazy?: undefined | boolean, // Whether we should wrap this in a `suspend()` call (for recursive references) 56 | typeDeclaration?: undefined | string, // Explicit type annotation (overrides inferred), usually used with `suspend` 57 | typeDeclarationEncoded?: undefined | string, // If specified, uses this as the "Encoded" type 58 | fields?: undefined | FieldGroups, 59 | } 60 | ); 61 | export type GenerationModuleSpec = { 62 | definitions: Array, 63 | }; 64 | export type GenerationSpec = { 65 | patch?: undefined | JSONPatchDocument, // A JSON Patch to apply on the OpenAPI document on initialization 66 | generationMethod: ( 67 | | { method: 'one-to-one', generateBarrelFile?: undefined | boolean /* Default: false */ } 68 | | { method: 'bundled', bundleName: string } 69 | | { method: 'custom' } 70 | ), 71 | hooks: GenerationHooks, 72 | runtime: Record, 73 | modules: Record, 74 | }; 75 | 76 | 77 | export const DefUtil = { 78 | sourceSchemaIdFromDef(def: GenerationDefinitionSpec): OpenApiSchemaId { 79 | switch (def.action) { 80 | case 'custom-code': return def.schemaId; 81 | case 'custom-schema': return def.schemaId; 82 | case 'generate-schema': return def.sourceSchemaId ?? def.schemaId; 83 | default: return assertUnreachable(def); 84 | } 85 | }, 86 | targetSchemaIdFromDef(def: GenerationDefinitionSpec): EffectSchemaId { 87 | switch (def.action) { 88 | case 'custom-code': return def.schemaId; 89 | case 'custom-schema': return def.schemaId; 90 | case 'generate-schema': return def.schemaId; 91 | default: return assertUnreachable(def); 92 | } 93 | }, 94 | }; 95 | -------------------------------------------------------------------------------- /src/openapiToEffect.ts: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Fortanix, Inc. 2 | * 3 | * This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { dedent } from 'ts-dedent'; 9 | import { parseArgs } from 'node:util'; 10 | import * as path from 'node:path'; 11 | import { fileURLToPath, pathToFileURL } from 'node:url'; 12 | import { type Stats } from 'node:fs'; 13 | import * as fs from 'node:fs/promises'; 14 | import { AsyncLocalStorage } from 'node:async_hooks'; 15 | 16 | import { type OpenAPIV3_1 as OpenApi } from 'openapi-types'; 17 | import { type OpenApiSchemaId, type OpenApiSchema } from './util/openapi.ts'; 18 | 19 | import * as GenSpec from './generation/generationSpec.ts'; 20 | import { generateModule } from './generation/effSchemGen/moduleGen.ts'; 21 | import { generateModuleWithSpec } from './generation/effSchemGen/moduleGenWithSpec.ts'; 22 | import { formatGeneratedCode } from './generation/formatting.ts'; 23 | import { type JSONPatchDocument, immutableJSONPatch } from 'immutable-json-patch'; 24 | import { schemaIdFromRef, topologicalSort, dependencyTree } from './analysis/GraphAnalyzer.ts'; 25 | 26 | 27 | // To use a directory path as a base URL in `new URL(, )` we need to ensure it has a trailing slash, 28 | // otherwise the path will be relative to the parent directory. 29 | const ensureTrailingSlash = (path: string) => path.replace(/[/]+$/, '') + '/'; 30 | 31 | const cwd: URL = pathToFileURL(ensureTrailingSlash(process.cwd())); // Trailing slash so we can use it as a base URL 32 | 33 | 34 | type Logger = Pick; 35 | type Services = { logger: Logger }; 36 | const servicesStorage = new AsyncLocalStorage(); 37 | const getServices = () => { 38 | const services = servicesStorage.getStore(); 39 | if (typeof services === 'undefined') { throw new Error(`Missing services`); } 40 | return services; 41 | }; 42 | 43 | // Parse an OpenAPI v3.1 document (in JSON format). 44 | // @throws Error[code=ENOENT] When the document cannot be read. 45 | // @throws SyntaxError When the document is not a valid OpenAPI v3.1 JSON document. 46 | const parseDocument = async (documentUrl: URL): Promise => { 47 | const content: string = await fs.readFile(documentUrl, 'utf8'); 48 | 49 | const contentJson = ((): unknown => { 50 | try { 51 | return JSON.parse(content); 52 | } catch (error: unknown) { 53 | if (error instanceof SyntaxError) { 54 | throw error; 55 | } else { 56 | throw new Error(`Unable to parse JSON`, { cause: error }); // Unexpected error 57 | } 58 | } 59 | })(); 60 | 61 | // Some quick sanity checks 62 | const isDocument = typeof contentJson === 'object' && contentJson !== null && 'openapi' in contentJson; 63 | if (!isDocument) { throw new SyntaxError('Not a valid OpenAPI document'); } 64 | if (typeof contentJson.openapi !== 'string' || !contentJson.openapi.trim().startsWith('3.1')) { 65 | const documentVersion = typeof contentJson.openapi !== 'string' ? contentJson.openapi : '(unknown version)'; 66 | throw new SyntaxError(`Expected OpenAPI version 3.1, but got version '${documentVersion}'`); 67 | } 68 | 69 | // Note: we assume that this is a valid instance of `OpenApi.Document` 70 | // We may want to do some proper runtime validation here (but this can be expensive since the document may be large) 71 | return contentJson as any as OpenApi.Document; // (!) Unsafe cast 72 | }; 73 | 74 | 75 | export type GenerateRequest = { 76 | document: OpenApi.Document, 77 | spec: null | GenSpec.GenerationSpec, 78 | outputDirectory: URL, 79 | }; 80 | type GenerateRequestWithSpec = GenerateRequest & { spec: GenSpec.GenerationSpec }; 81 | 82 | // Generate one module per schema (does not support a spec file) 83 | export const generateSchemas = async (request: GenerateRequest): Promise => { 84 | const { logger } = getServices(); 85 | const { document, outputDirectory } = request; 86 | 87 | logger.info(`Generating schemas from document: ${document.info.title} ${document.info.version}`); 88 | 89 | const schemas: Record = document.components?.schemas ?? {}; 90 | 91 | for (const [schemaId, schema] of Object.entries(schemas)) { 92 | const outputUrl = new URL(`${schemaId}.ts`, outputDirectory); 93 | 94 | logger.info(`Generating schema: '${path.relative(process.cwd(), fileURLToPath(outputUrl))}'`); 95 | 96 | try { 97 | // Generate the effect-schema module code 98 | const generatedCode = ((): string => { 99 | try { 100 | return generateModule(schemaId, schema); 101 | } catch (error: unknown) { 102 | logger.error(`Unable to convert to effect-schema`); 103 | throw error; 104 | } 105 | })(); 106 | 107 | // Format the generated code 108 | const generatedCodeFormatted = await (async (): Promise => { 109 | try { 110 | return await formatGeneratedCode(generatedCode); 111 | } catch (error: unknown) { 112 | // Note: if we made a mistake in our code generation logic (and generate invalid syntax), it will usually 113 | // appear as an error here 114 | logger.info(generatedCode); 115 | logger.error(`Unable to format code`); 116 | throw error; 117 | } 118 | })(); 119 | 120 | // Write to the output file 121 | try { 122 | await fs.writeFile(outputUrl, generatedCodeFormatted, 'utf-8'); 123 | } catch (error: unknown) { 124 | logger.error(`Failed to write to file: '${fileURLToPath(outputUrl)}'`); 125 | throw error; 126 | } 127 | } catch (error: unknown) { 128 | // If anything fails, just print the error and continue with the next schema 129 | logger.error(error); 130 | } 131 | } 132 | }; 133 | 134 | // Generate schemas based on the given spec file 135 | export const generateSchemasWithSpec = async (request: GenerateRequestWithSpec): Promise => { 136 | const { logger } = getServices(); 137 | const { document, spec, outputDirectory } = request; 138 | 139 | logger.info(`Generating schemas from document: ${document.info.title} ${document.info.version}`); 140 | 141 | const schemas: Record = document.components?.schemas ?? {}; 142 | const schemasSorted = topologicalSort(document); 143 | 144 | const specParsed = ((): GenSpec.GenerationSpec => { 145 | if (spec.generationMethod.method === 'one-to-one') { // Autogenerate modules one-to-one (one file per schema) 146 | return { 147 | ...spec, 148 | modules: { 149 | // Add each schema in the OpenAPI document to the spec modules list, so as to be autogenerated 150 | ...Object.fromEntries(Object.keys(schemas) 151 | .map((schemaId: OpenApiSchemaId): [GenSpec.ModulePath, GenSpec.GenerationModuleSpec] => { 152 | return [`./${schemaId}.ts`, { definitions: [{ action: 'generate-schema', schemaId }] }]; 153 | }), 154 | ), 155 | // Modules already in the spec can still override the default autogenerate directives. 156 | ...spec.modules, 157 | }, 158 | }; 159 | } else if (spec.generationMethod.method === 'bundled') { // Bundle everything into one file 160 | return { 161 | ...spec, 162 | modules: { 163 | ...Object.fromEntries(schemasSorted 164 | .map(({ ref, circularDependency }): [GenSpec.ModulePath, GenSpec.GenerationModuleSpec] => { 165 | const schemaId = schemaIdFromRef(ref); 166 | const def: GenSpec.GenerationDefinitionSpec = { 167 | ...(spec.modules[`./${schemaId}.ts`]?.definitions?.find(def => def.schemaId === schemaId) ?? {}), 168 | action: 'generate-schema', 169 | schemaId, 170 | lazy: circularDependency, 171 | }; 172 | return [`./${schemaId}.ts`, { definitions: [def] }]; 173 | }), 174 | ), 175 | }, 176 | }; 177 | } else { 178 | return spec; 179 | } 180 | })(); 181 | 182 | let bundle = ''; 183 | for (const [modulePath, _moduleSpec] of Object.entries({ ...specParsed.runtime, ...specParsed.modules })) { 184 | logger.info(`Generating module: ${modulePath}`); 185 | 186 | try { 187 | // Generate the effect-schema module code 188 | const generatedCode = ((): string => { 189 | try { 190 | return generateModuleWithSpec(schemas, specParsed, modulePath, { 191 | isSchemaBefore(schema1: OpenApiSchemaId, schema2: OpenApiSchemaId): boolean { 192 | if (spec.generationMethod.method !== 'bundled') { return true; } 193 | 194 | const schema1Index = schemasSorted.findIndex(({ ref }) => schemaIdFromRef(ref) === schema1); 195 | const schema2Index = schemasSorted.findIndex(({ ref }) => schemaIdFromRef(ref) === schema2); 196 | 197 | if (schema1Index === -1 || schema2Index === -1) { 198 | return true; 199 | } 200 | 201 | return schema1Index < schema2Index; 202 | }, 203 | }); 204 | } catch (error: unknown) { 205 | logger.error(`Unable to convert to effect-schema`); 206 | throw error; 207 | } 208 | })(); 209 | 210 | // Format the generated code 211 | const generatedCodeFormatted = await (async (): Promise => { 212 | try { 213 | return await formatGeneratedCode(generatedCode); 214 | } catch (error: unknown) { 215 | // Note: if we made a mistake in our code generation logic (and generate invalid syntax), it will usually 216 | // appear as an error here 217 | logger.info(generatedCode); 218 | logger.error(`Unable to format code`); 219 | throw error; 220 | } 221 | })(); 222 | 223 | // Write to an output file 224 | const outputUrl = new URL(modulePath, outputDirectory); 225 | try { 226 | if (specParsed.generationMethod.method === 'bundled') { 227 | bundle += '\n\n' + generatedCodeFormatted; 228 | } else { 229 | await fs.mkdir(path.dirname(fileURLToPath(outputUrl)), { recursive: true }); 230 | await fs.writeFile(outputUrl, generatedCodeFormatted, 'utf-8'); 231 | } 232 | } catch (error: unknown) { 233 | logger.error(`Failed to write to file: ${outputUrl}`); 234 | throw error; 235 | } 236 | } catch (error: unknown) { 237 | if (specParsed.generationMethod.method === 'bundled') { 238 | throw error; // Stop bundling on first error 239 | } else { 240 | // If anything fails, just print the error and continue with the next schema 241 | logger.error('Error:', error); 242 | } 243 | } 244 | } 245 | 246 | if (specParsed.generationMethod.method === 'bundled') { 247 | const outputUrl = new URL(`${specParsed.generationMethod.bundleName}.ts`, outputDirectory); 248 | logger.info(`Writing to bundle: ${fileURLToPath(outputUrl)}`); 249 | 250 | try { 251 | const bundleFormatted = await formatGeneratedCode( 252 | dedent` 253 | /* Generated on ${new Date().toISOString()} from ${document.info.title} version ${document.info.version} */ 254 | 255 | import { pipe, Option, Schema as S } from 'effect'; 256 | 257 | ${bundle.replace(/(^|\n)(\s*)import [^;]+;(?! \/\/ )/g, '')} 258 | `, 259 | ); 260 | 261 | await fs.mkdir(path.dirname(fileURLToPath(outputUrl)), { recursive: true }); 262 | await fs.writeFile(outputUrl, bundleFormatted, 'utf-8'); 263 | } catch (error: unknown) { 264 | logger.error(`Failed to write to file: ${outputUrl}`); 265 | throw error; 266 | } 267 | } 268 | 269 | if (spec.generationMethod.method === 'one-to-one' && spec.generationMethod.generateBarrelFile) { 270 | // Generate a barrel file containing all exports 271 | let barrelContent = ''; 272 | for (const [modulePath, moduleSpec] of Object.entries({ ...specParsed.runtime, ...specParsed.modules })) { 273 | barrelContent += `export * from '${modulePath}';\n`; 274 | } 275 | const barrelUrl = new URL('./index.ts', outputDirectory); 276 | logger.log(`Generating barrel file: ./index.ts`); 277 | await fs.writeFile(barrelUrl, barrelContent, 'utf-8'); 278 | } 279 | }; 280 | 281 | 282 | export type RequestInput = { 283 | documentPath: string, 284 | outputPath: string, 285 | specPath?: undefined | string, 286 | }; 287 | 288 | // Parse the given input and return a valid `GenerateRequest` (or throw an error). 289 | // @throws TypeError[ERR_INVALID_URL] If any of the given file paths are syntactically invalid. 290 | export const parseRequest = async ({ documentPath, outputPath, specPath }: RequestInput): Promise => { 291 | const { logger } = getServices(); 292 | 293 | // Note: the following could throw a `TypeError` exception if the paths are (syntactically) invalid, e.g. `//` 294 | const documentUrl = new URL(documentPath, cwd); 295 | const outputUrl = new URL(ensureTrailingSlash(outputPath), cwd); // Trailing slash for use as base URL 296 | const specUrl: null | URL = typeof specPath === 'undefined' ? null : new URL(specPath, cwd); 297 | 298 | // Check if the OpenAPI document exists 299 | try { 300 | await fs.stat(documentUrl); 301 | } catch (error: unknown) { 302 | throw new Error(`Provided OpenAPI file does not exist: '${documentPath}'`, { cause: error }); 303 | } 304 | 305 | // Check if the output path exists and is a directory 306 | let outputUrlStats: Stats; 307 | try { 308 | outputUrlStats = await fs.stat(outputUrl); 309 | } catch (error: unknown) { 310 | throw new Error(`Provided output path does not exist: '${outputPath}'`, { cause: error }); 311 | } 312 | if (!outputUrlStats.isDirectory()) { 313 | throw new Error(`Provided output path '${outputPath}' is not a directory`); 314 | } 315 | 316 | // Check if the generation spec file exists 317 | if (specUrl !== null) { 318 | try { 319 | await fs.stat(specUrl); 320 | } catch (error: unknown) { 321 | throw new Error(`Provided generation spec file does not exist: '${specPath}'`, { cause: error }); 322 | } 323 | } 324 | 325 | // Load the generation spec 326 | const spec: null | GenSpec.GenerationSpec = specUrl === null ? null : (await import(specUrl.href)).default; 327 | 328 | // Load the OpenAPI document 329 | const document = await (async (): Promise => { 330 | try { 331 | return await parseDocument(documentUrl); 332 | } catch (error: unknown) { 333 | logger.error(`Unable to parse OpenAPI document: '${documentPath}'`); 334 | throw error; 335 | } 336 | })(); 337 | 338 | // Apply JSON Patch (if any) 339 | const patch: null | JSONPatchDocument = spec !== null && Array.isArray(spec?.patch) ? spec.patch : null; 340 | const documentPatched = patch === null 341 | ? document 342 | : immutableJSONPatch(document, patch); 343 | 344 | return { document: documentPatched, outputDirectory: outputUrl, spec }; 345 | }; 346 | 347 | 348 | const printUsage = () => { 349 | const { logger } = getServices(); 350 | 351 | logger.info(dedent` 352 | Usage: openapi-to-effect [-h | --help] [--silent] [] 353 | 354 | Commands: 355 | gen [--spec=] 356 | analyze:dependency-tree 357 | `); 358 | }; 359 | 360 | type ScriptArgs = { 361 | values: { 362 | help?: undefined | boolean, 363 | spec?: undefined | string, 364 | }, 365 | positionals: Array, 366 | }; 367 | 368 | export const runGenerator = async (args: ScriptArgs): Promise => { 369 | const { logger } = getServices(); 370 | 371 | const documentPath: undefined | string = args.positionals[0]; 372 | const outputPath: undefined | string = args.positionals[1]; 373 | if (!documentPath || !outputPath) { 374 | printUsage(); 375 | return; 376 | } 377 | 378 | const requestInput: RequestInput = { 379 | documentPath, 380 | outputPath, 381 | specPath: args.values.spec, 382 | }; 383 | const request: GenerateRequest = await parseRequest(requestInput); 384 | 385 | if (request.spec === null) { 386 | await generateSchemas(request); 387 | } else { 388 | await generateSchemasWithSpec(request as GenerateRequestWithSpec); 389 | } 390 | 391 | logger.info('Done!'); 392 | }; 393 | 394 | // Example: 395 | // `npm --silent run node src/openapiToEffect.ts analyze:dependency-tree tests/fixtures/fixture1_api.json\ 396 | // '#/components/schemas/BatchRequest'` 397 | export const runAnalyzeDependencyTree = async (args: ScriptArgs): Promise => { 398 | const { logger } = getServices(); 399 | 400 | const documentPath: undefined | string = args.positionals[0]; 401 | const rootSchemaRef: undefined | string = args.positionals[1]; 402 | if (!documentPath || !rootSchemaRef) { 403 | printUsage(); 404 | return; 405 | } 406 | 407 | const documentUrl = new URL(documentPath, cwd); 408 | const document = await parseDocument(documentUrl); 409 | const tree = await dependencyTree(document, rootSchemaRef); 410 | 411 | logger.log(tree); 412 | 413 | logger.info('Done!'); 414 | }; 415 | 416 | // Run the script with the given CLI arguments 417 | export const run = async (argsRaw: Array): Promise => { 418 | // Ref: https://exploringjs.com/nodejs-shell-scripting/ch_node-util-parseargs.html 419 | const args = parseArgs({ 420 | args: argsRaw, 421 | allowPositionals: true, 422 | options: { 423 | help: { type: 'boolean', short: 'h' }, 424 | silent: { type: 'boolean' }, 425 | spec: { type: 'string' }, // Path to a spec file (optional) 426 | //runtime: { type: 'string' } // TODO: allow specifying a path to a "runtime" dir that we include in the output? 427 | }, 428 | }); 429 | 430 | // Services 431 | const logger: Logger = { 432 | info: args.values.silent ? () => {} : console.info, 433 | error: console.error, 434 | log: console.log, 435 | }; 436 | 437 | await servicesStorage.run({ logger }, async () => { 438 | const command: null | string = args.positionals[0] ?? null; 439 | if (command === null || args.values.help) { 440 | printUsage(); 441 | return; 442 | } 443 | 444 | const argsForCommand: ScriptArgs = { ...args, positionals: args.positionals.slice(1) }; 445 | switch (command) { 446 | case 'gen': 447 | await runGenerator(argsForCommand); 448 | break; 449 | case 'analyze:dependency-tree': 450 | await runAnalyzeDependencyTree(argsForCommand); 451 | break; 452 | default: 453 | logger.error(`Unknown command '${command}'\n`); 454 | printUsage(); 455 | break; 456 | } 457 | }); 458 | }; 459 | 460 | // Detect if this module is being run directly from the command line 461 | const [_argExec, argScript, ...args] = process.argv; // First two arguments should be the executable + script 462 | if (argScript && await fs.realpath(argScript) === fileURLToPath(import.meta.url)) { 463 | try { 464 | await run(args); 465 | process.exit(0); 466 | } catch (error: unknown) { 467 | console.error(error); 468 | process.exit(1); 469 | } 470 | } 471 | -------------------------------------------------------------------------------- /src/util/openapi.ts: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Fortanix, Inc. 2 | * 3 | * This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { type OpenAPIV3_1 as OpenApi } from 'openapi-types'; 9 | 10 | 11 | export type OpenApiRef = string; 12 | 13 | export type OpenApiSchema = OpenApi.ReferenceObject | OpenApi.SchemaObject; 14 | export type OpenApiSchemaId = string; 15 | 16 | 17 | const unescapeJsonPointerSegment = (segment: string): string => 18 | segment.replace(/~1/g, '/').replace(/~0/g, '~'); 19 | export const decodeJsonPointer = (jsonPointer: string): Array => { 20 | const jsonPointerTrimmed = jsonPointer.trim(); 21 | if (jsonPointerTrimmed === '') { return []; } 22 | if (jsonPointerTrimmed.charAt(0) !== '/') { throw new Error(`Invalid JSON Pointer: ${jsonPointer}`); } 23 | return jsonPointer.substring(1).split('/').map(segment => unescapeJsonPointerSegment(segment)); 24 | }; 25 | 26 | const escapeJsonPointerSegment = (segment: string): string => 27 | segment.replace(/~/g, '~0').replace(/\//g, '~1'); 28 | export const encodeJsonPointer = (segments: Array): string => { 29 | return '/' + segments.map(segment => escapeJsonPointerSegment(segment)).join('/'); 30 | }; 31 | -------------------------------------------------------------------------------- /tests/fixtures/fixture0_api.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.1.0", 3 | "info": { 4 | "title": "Example API", 5 | "version": "0.1.0" 6 | }, 7 | "components": { 8 | "schemas": { 9 | "Category": { 10 | "type": "object", 11 | "properties": { 12 | "name": { "type": "string" }, 13 | "description": { "type": ["null", "string"] }, 14 | "status": { "type": ["null", "string"], "enum": ["ACTIVE", "DEPRIORITIZED"] }, 15 | "subcategories": { 16 | "type": "object", 17 | "additionalProperties": { 18 | "$ref": "#/components/schemas/Category" 19 | }, 20 | "default": {} 21 | } 22 | }, 23 | "required": ["name", "description"] 24 | }, 25 | "User": { 26 | "type": "object", 27 | "properties": { 28 | "id": { 29 | "title": "Unique ID", 30 | "type": "string", 31 | "format": "uuid" 32 | }, 33 | "name": { 34 | "title": "The user's full name.", 35 | "type": "string" 36 | }, 37 | "last_logged_in": { 38 | "title": "When the user last logged in.", 39 | "type": "string", 40 | "format": "date-time" 41 | }, 42 | "role": { 43 | "title": "The user's role within the system.", 44 | "description": "Roles:\n- ADMIN: Administrative permissions\n- USER: Normal permissions\n- AUDITOR: Read only permissions", 45 | "type": "string", 46 | "enum": ["ADMIN", "USER", "AUDITOR"] 47 | }, 48 | "interests": { 49 | "type": "array", 50 | "items": { "$ref": "#/components/schemas/Category" }, 51 | "default": [] 52 | } 53 | }, 54 | "required": ["id", "name", "last_logged_in", "role"] 55 | }, 56 | "Invalid Identifier #": { 57 | "type": "string" 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/fixtures/fixture0_spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import { type GenerationSpec } from '../../src/generation/generationSpec.ts'; 3 | 4 | 5 | export default { 6 | generationMethod: { method: 'bundled', bundleName: 'fixture0' }, 7 | hooks: {}, 8 | runtime: {}, 9 | modules: { 10 | './Category.ts': { 11 | definitions: [ 12 | { 13 | action: 'generate-schema', 14 | schemaId: 'Category', 15 | typeDeclarationEncoded: `{ 16 | readonly name: string, 17 | readonly description: null | string, 18 | readonly status?: undefined | null | 'ACTIVE' | 'DEPRIORITIZED', 19 | readonly subcategories?: undefined | { readonly [key: string]: _CategoryEncoded } 20 | }`, 21 | typeDeclaration: `{ 22 | readonly name: string, 23 | readonly description: null | string, 24 | readonly status?: undefined | null | 'ACTIVE' | 'DEPRIORITIZED', 25 | readonly subcategories: { readonly [key: string]: _Category } 26 | }`, 27 | }, 28 | ], 29 | }, 30 | }, 31 | } satisfies GenerationSpec; 32 | -------------------------------------------------------------------------------- /tests/fixtures/fixture1_spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import { dedent } from 'ts-dedent'; 3 | import { SchemaAST } from 'effect'; 4 | 5 | import { type GenerationSpec } from '../../src/generation/generationSpec.ts'; 6 | import { GenResultUtil, type GenResult } from '../../src/generation/effSchemGen/genUtil.ts'; 7 | import { type OpenAPIV3_1 as OpenAPIV3 } from 'openapi-types'; 8 | 9 | 10 | const parseOptions: SchemaAST.ParseOptions = { 11 | errors: 'all', 12 | onExcessProperty: 'ignore', 13 | }; 14 | 15 | export default { 16 | patch: [ 17 | // `Sobject.kid` field should be required (PROD-6903) 18 | { op: 'add', path: '/components/schemas/Sobject/allOf/0/required/-', value: 'kid' }, 19 | // `Sobject.never_exportable` field should be required (TODO: create ticket) 20 | { op: 'add', path: '/components/schemas/Sobject/allOf/0/required/-', value: 'never_exportable' }, 21 | // https://fortanix.atlassian.net/browse/PROD-8584 22 | { op: 'remove', path: '/components/schemas/WrappingKeyName/oneOf/0' }, 23 | // https://fortanix.atlassian.net/browse/PROD-8585 24 | { 25 | op: 'add', 26 | path: '/components/schemas/RemovablePluginCodeSigningPolicy', 27 | value: { 28 | oneOf: [ 29 | { 30 | type: 'string', 31 | enum: ['remove'], 32 | }, 33 | { 34 | $ref: '#/components/schemas/PluginCodeSigningPolicy', 35 | }, 36 | ], 37 | }, 38 | }, 39 | ], 40 | 41 | generationMethod: { method: 'bundled', bundleName: 'fixture1' }, 42 | 43 | hooks: { 44 | optionalFieldRepresentation: 'nullish', 45 | generateNumberType(schema: OpenAPIV3.NonArraySchemaObject): null | GenResult { 46 | // Special handling for certain integer types generated in Roche 47 | if (schema.type === 'integer' && schema.minimum === 0 && schema.maximum === 2**32 - 1) { 48 | // Clear the fields we're handling as part of this hook 49 | delete schema.minimum; // FIXME: do this without mutation 50 | delete schema.maximum; 51 | return { 52 | ...GenResultUtil.initGenResult(), 53 | code: `RocheUInt32`, 54 | refs: GenResultUtil.combineRefs(['./util/RocheUInt32.ts']), // FIXME: better refs/runtime system 55 | }; 56 | } 57 | return null; 58 | }, 59 | generateStringType(schema: OpenAPIV3.NonArraySchemaObject): null | GenResult { 60 | if (schema.pattern === '^[^\\n]*[^\\s\\n][^\\n]*$' && schema.maxLength === 4096) { // Roche names 61 | // Clear the fields we're handling as part of this hook 62 | delete schema.maxLength; // FIXME: do this without mutation 63 | return { 64 | ...GenResultUtil.initGenResult(), 65 | code: `RocheName`, 66 | refs: GenResultUtil.combineRefs(['./util/RocheName.ts']), // FIXME: better refs/runtime system 67 | }; 68 | } else if (schema.pattern === '^\\d{4}\\d{2}\\d{2}T\\d{2}\\d{2}\\d{2}Z$') { // ISO 8601 basic format 69 | return { 70 | ...GenResultUtil.initGenResult(), 71 | code: `RocheDate`, 72 | refs: GenResultUtil.combineRefs(['./util/RocheDate.ts']), // FIXME: better refs/runtime system 73 | }; 74 | } else if (schema.pattern === '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$') { // ISO 8601 extended format 75 | return { 76 | ...GenResultUtil.initGenResult(), 77 | code: `RocheDate`, 78 | refs: GenResultUtil.combineRefs(['./util/RocheDate.ts']), // FIXME: better refs/runtime system 79 | }; 80 | } 81 | return null; 82 | }, 83 | }, 84 | runtime: { 85 | /* 86 | './util/RocheOption.ts': { 87 | definitions: [ 88 | { 89 | action: 'custom-code', 90 | schemaId: 'RocheOption', 91 | code: dedent` 92 | const RocheOption = (FieldSchema: S.Schema) => { 93 | const FieldSchemaNullish = S.NullishOr(FieldSchema); // The "from" schema 94 | const FieldSchemaNullable = S.NullOr(FieldSchema); // The "to" schema 95 | 96 | type FA = undefined | null | A; 97 | type FI = undefined | null | I; 98 | type TA = null | A; 99 | type TI = null | I; 100 | 101 | // Transformation for a field property which is optional (\`?:\`) and nullish (undefined | null | A), and 102 | // convert it to a field that is required (\`:\`) and nullable (null | A). 103 | return S.optionalToRequired(FieldSchemaNullish, FieldSchemaNullable, { 104 | decode: (fieldNullish: Option.Option): TI => { 105 | const fieldNullable: TA = Option.getOrElse(fieldNullish, () => null) ?? null; 106 | return pipe(fieldNullable, S.encodeSync(FieldSchemaNullable, ${JSON.stringify(parseOptions)})); 107 | }, 108 | encode: (fieldNullable: TI): Option.Option => { 109 | return pipe( 110 | fieldNullable, 111 | S.decodeSync(FieldSchemaNullish, ${JSON.stringify(parseOptions)}), 112 | Option.some, 113 | ); 114 | }, 115 | }); 116 | }; 117 | `, 118 | }, 119 | ], 120 | }, 121 | */ 122 | './util/RocheUInt32.ts': { 123 | definitions: [ 124 | { 125 | action: 'custom-code', 126 | schemaId: 'RocheUInt32', 127 | code: dedent` 128 | export const RocheUInt32 = pipe( 129 | S.Number, 130 | S.int(), 131 | S.greaterThanOrEqualTo(0), 132 | S.lessThanOrEqualTo(4294967295), 133 | ); 134 | export const RocheUInt32Encoded = S.encodedSchema(RocheUInt32); 135 | export type RocheUInt32 = typeof RocheUInt32.Type; 136 | export type RocheUInt32Encoded = typeof RocheUInt32.Encoded; 137 | `, 138 | }, 139 | ], 140 | }, 141 | './util/RocheName.ts': { 142 | definitions: [ 143 | { 144 | action: 'custom-code', 145 | schemaId: 'RocheName', 146 | code: dedent` 147 | export const RocheName = pipe( 148 | S.String, 149 | S.pattern(new RegExp('^[^\\\\n]*[^\\\\s\\\\n][^\\\\n]*$')), 150 | S.maxLength(4096), 151 | ); 152 | export const RocheNameEncoded = S.encodedSchema(RocheName); 153 | export type RocheName = typeof RocheName.Type; 154 | export type RocheNameEncoded = typeof RocheName.Encoded; 155 | `, 156 | }, 157 | ], 158 | }, 159 | './util/RocheDate.ts': { 160 | definitions: [ 161 | { 162 | action: 'custom-code', 163 | schemaId: 'RocheDate', 164 | code: dedent` 165 | // FIXME: better refs/runtime system 166 | //import { IsoBasicDateTime } from '../../../common/codecs/IsoBasicDateTime.ts'; // 167 | const IsoBasicDateTime = S.Date; 168 | 169 | // Roche will return all date-time values as an ISO 8601 "basic format" string, in UTC timezone. This basic 170 | // format is not supported by the native JS \`Date\` ISO parsing, so we need to transform it. 171 | export const RocheDate = IsoBasicDateTime; 172 | export const RocheDateEncoded = S.encodedSchema(RocheDate); 173 | export type RocheDate = typeof RocheDate.Type; 174 | export type RocheDateEncoded = typeof RocheDate.Encoded; 175 | `, 176 | }, 177 | ], 178 | }, 179 | }, 180 | modules: { 181 | // Cycle: BatchRequest -> BatchRequestList -> BatchRequest 182 | './BatchRequest.ts': { 183 | definitions: [ 184 | { 185 | action: 'generate-schema', 186 | schemaId: 'BatchRequest', 187 | typeDeclarationEncoded: dedent`( 188 | | { readonly Batch: typeof BatchRequestList.Encoded } 189 | | { readonly SingleItem: typeof BatchRequestItem.Encoded } 190 | )`, 191 | typeDeclaration: dedent`( 192 | | { readonly Batch: typeof BatchRequestList.Type } 193 | | { readonly SingleItem: typeof BatchRequestItem.Type } 194 | )`, 195 | }, 196 | ], 197 | }, 198 | // Cycle: BatchResponse -> BatchResponseList -> BatchResponse 199 | // './BatchResponse.ts': { 200 | // definitions: [ 201 | // { 202 | // action: 'generate-schema', 203 | // schemaId: 'BatchResponse', 204 | // typeDeclarationEncoded: dedent` 205 | // ( 206 | // | { readonly Batch: typeof BatchResponseList.Encoded } 207 | // | { readonly SingleItem: typeof BatchResponseObject.Encoded } 208 | // ) 209 | // `, 210 | // typeDeclaration: dedent` 211 | // ( 212 | // | { readonly Batch: typeof BatchResponseList.Type } 213 | // | { readonly SingleItem: typeof BatchResponseObject.Type } 214 | // ) 215 | // `, 216 | // }, 217 | // ], 218 | // }, 219 | './BatchResponseList.ts': { 220 | definitions: [ 221 | { 222 | action: 'generate-schema', 223 | schemaId: 'BatchResponseList', 224 | typeDeclarationEncoded: dedent` 225 | { 226 | items: readonly (typeof BatchResponse.Encoded)[], 227 | } 228 | `, 229 | typeDeclaration: dedent` 230 | { 231 | items: readonly (typeof BatchResponse.Type)[], 232 | } 233 | `, 234 | }, 235 | ], 236 | }, 237 | 238 | // Cycle: QuorumPolicy -> Quorum -> QuorumPolicy 239 | './QuorumPolicy.ts': { 240 | definitions: [ 241 | { 242 | action: 'generate-schema', 243 | schemaId: 'QuorumPolicy', 244 | typeDeclarationEncoded: dedent` 245 | { 246 | quorum?: undefined | null | typeof Quorum.Encoded, 247 | user?: undefined | null | typeof S.UUID.Encoded, 248 | app?: undefined | null | typeof S.UUID.Encoded, 249 | } 250 | `, 251 | typeDeclaration: dedent` 252 | { 253 | quorum?: undefined | null | typeof Quorum.Type, 254 | user?: undefined | null | typeof S.UUID.Type, 255 | app?: undefined | null | typeof S.UUID.Type, 256 | } 257 | `, 258 | }, 259 | ], 260 | }, 261 | 262 | // Cycle: FpeConstraintsApplicability (self-reference) 263 | './FpeConstraintsApplicability.ts': { 264 | definitions: [ 265 | { 266 | action: 'generate-schema', 267 | schemaId: 'FpeConstraintsApplicability', 268 | typeDeclarationEncoded: dedent` 269 | ( 270 | | typeof All.Encoded 271 | // Note: this will not work if we use \`Record<>\` syntax, has to use mapped type syntax directly 272 | // https://stackoverflow.com/questions/42352858/type-alias-circularly-references-itself 273 | | { readonly [key: string]: _FpeConstraintsApplicabilityEncoded } 274 | ) 275 | `, 276 | typeDeclaration: dedent` 277 | ( 278 | | typeof All.Type 279 | // Note: this will not work if we use \`Record<>\` syntax, has to use mapped type syntax directly 280 | // https://stackoverflow.com/questions/42352858/type-alias-circularly-references-itself 281 | | { [key: string]: _FpeConstraintsApplicability } 282 | ) 283 | `, 284 | }, 285 | ], 286 | }, 287 | 288 | // 289 | // Cycle: FpeDataPart -> FpeCompoundPart -> FpeCompoundPart{Or/Concat/Multiple} -> FpeDataPart 290 | // Note: the explicit type annotations must be on the FpeCompoundPart{Or/Concat/Multiple} branches, adding them 291 | // to `FpeDataPart` is not enough to resolve the circular dependency. 292 | // 293 | './FpeDataPart.ts': { 294 | definitions: [ 295 | { 296 | action: 'generate-schema', 297 | schemaId: 'FpeDataPart', 298 | typeDeclarationEncoded: dedent` 299 | | typeof FpeEncryptedPart.Encoded 300 | | typeof FpeDataPartLiteral.Encoded 301 | | _FpeCompoundPartEncoded 302 | `, 303 | typeDeclaration: dedent` 304 | | typeof FpeEncryptedPart.Type 305 | | typeof FpeDataPartLiteral.Type 306 | | _FpeCompoundPart 307 | `, 308 | }, 309 | ], 310 | }, 311 | './FpeCompoundPart.ts': { 312 | definitions: [ 313 | { 314 | action: 'generate-schema', 315 | schemaId: 'FpeCompoundPart', 316 | typeDeclarationEncoded: dedent` 317 | | _FpeCompoundPartOrEncoded 318 | | _FpeCompoundPartConcatEncoded 319 | | _FpeCompoundPartMultipleEncoded 320 | `, 321 | typeDeclaration: dedent` 322 | | _FpeCompoundPartOr 323 | | _FpeCompoundPartConcat 324 | | _FpeCompoundPartMultiple 325 | `, 326 | }, 327 | ], 328 | }, 329 | './FpeCompoundPartOr.ts': { 330 | definitions: [ 331 | { 332 | action: 'generate-schema', 333 | schemaId: 'FpeCompoundPartOr', 334 | typeDeclarationEncoded: dedent` 335 | { 336 | readonly or: readonly typeof FpeDataPart.Encoded[], 337 | readonly constraints?: undefined | null | typeof FpeConstraints.Encoded, 338 | readonly preserve?: undefined | null | boolean, 339 | readonly mask?: undefined | null | boolean, 340 | readonly min_length?: undefined | null | typeof RocheUInt32.Encoded, 341 | readonly max_length?: undefined | null | typeof RocheUInt32.Encoded, 342 | } 343 | `, 344 | typeDeclaration: dedent` 345 | { 346 | readonly or: readonly typeof FpeDataPart.Type[], 347 | readonly constraints?: undefined | null | typeof FpeConstraints.Type, 348 | readonly preserve?: undefined | null | boolean, 349 | readonly mask?: undefined | null | boolean, 350 | readonly min_length?: undefined | null | typeof RocheUInt32.Type, 351 | readonly max_length?: undefined | null | typeof RocheUInt32.Type, 352 | } 353 | `, 354 | }, 355 | ], 356 | }, 357 | './FpeCompoundPartConcat.ts': { 358 | definitions: [ 359 | { 360 | action: 'generate-schema', 361 | schemaId: 'FpeCompoundPartConcat', 362 | typeDeclarationEncoded: dedent` 363 | { 364 | readonly concat: readonly typeof FpeDataPart.Encoded[], 365 | readonly constraints?: undefined | null | typeof FpeConstraints.Encoded, 366 | readonly preserve?: undefined | null | boolean, 367 | readonly mask?: undefined | null | boolean, 368 | readonly min_length?: undefined | null | typeof RocheUInt32.Encoded, 369 | readonly max_length?: undefined | null | typeof RocheUInt32.Encoded, 370 | } 371 | `, 372 | typeDeclaration: dedent` 373 | { 374 | readonly concat: readonly typeof FpeDataPart.Type[], 375 | readonly constraints?: undefined | null | typeof FpeConstraints.Type, 376 | readonly preserve?: undefined | null | boolean, 377 | readonly mask?: undefined | null | boolean, 378 | readonly min_length?: undefined | null | typeof RocheUInt32.Type, 379 | readonly max_length?: undefined | null | typeof RocheUInt32.Type, 380 | } 381 | `, 382 | }, 383 | ], 384 | }, 385 | './FpeCompoundPartMultiple.ts': { 386 | definitions: [ 387 | { 388 | action: 'generate-schema', 389 | schemaId: 'FpeCompoundPartMultiple', 390 | typeDeclarationEncoded: dedent` 391 | { 392 | readonly multiple: typeof FpeDataPart.Encoded, 393 | readonly min_repetitions?: undefined | null | number, 394 | readonly max_repetitions?: undefined | null | number, 395 | readonly constraints?: undefined | null | typeof FpeConstraints.Encoded, 396 | readonly preserve?: undefined | null | boolean, 397 | readonly mask?: undefined | null | boolean, 398 | readonly min_length?: undefined | null | typeof RocheUInt32.Encoded, 399 | readonly max_length?: undefined | null | typeof RocheUInt32.Encoded, 400 | } 401 | `, 402 | typeDeclaration: dedent` 403 | { 404 | readonly multiple: typeof FpeDataPart.Type, 405 | readonly min_repetitions?: undefined | null | number, 406 | readonly max_repetitions?: undefined | null | number, 407 | readonly constraints?: undefined | null | typeof FpeConstraints.Type, 408 | readonly preserve?: undefined | null | boolean, 409 | readonly mask?: undefined | null | boolean, 410 | readonly min_length?: undefined | null | typeof RocheUInt32.Type, 411 | readonly max_length?: undefined | null | typeof RocheUInt32.Type, 412 | } 413 | `, 414 | }, 415 | ], 416 | }, 417 | }, 418 | } satisfies GenerationSpec; 419 | -------------------------------------------------------------------------------- /tests/integration/fixture0.test.ts: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Fortanix, Inc. 2 | * 3 | * This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { promisify } from 'node:util'; 9 | import path from 'node:path'; 10 | import { fileURLToPath } from 'node:url'; 11 | import { exec as execCallback } from 'node:child_process'; 12 | 13 | import { Schema as S } from 'effect'; 14 | 15 | import assert from 'node:assert/strict'; 16 | import { test } from 'node:test'; 17 | 18 | 19 | const exec = promisify(execCallback); 20 | 21 | test('fixture0', { timeout: 30_000/*ms*/ }, async (t) => { 22 | const before = async () => { 23 | const cwd = path.dirname(fileURLToPath(import.meta.url)); 24 | console.log('Preparing fixture0...'); 25 | 26 | try { 27 | const { stdout, stderr } = await exec(`./generate_fixture.sh fixture0`, { cwd }); 28 | } catch (error: unknown) { 29 | if (error instanceof Error && 'stderr' in error) { 30 | console.error(error.stderr); 31 | } 32 | throw error; 33 | } 34 | }; 35 | await before(); 36 | 37 | // @ts-ignore Will not type check until the generation is complete. 38 | const fixture = await import('../project_simulation/generated/fixture0/fixture0.ts'); 39 | 40 | await t.test('User', async (t) => { 41 | const user1 = { 42 | id: '5141C532-90CA-4F12-B3EC-22776F9DDD80', 43 | name: 'Alice', 44 | last_logged_in: '2024-05-25T19:20:39.482Z', 45 | role: 'USER', 46 | interests: [ 47 | { name: 'Music', description: null }, 48 | ], 49 | }; 50 | assert.deepStrictEqual(S.decodeUnknownSync(fixture.User)(user1), { 51 | ...user1, 52 | last_logged_in: new Date('2024-05-25T19:20:39.482Z'), // Transformed to Date 53 | interests: [ 54 | { name: 'Music', description: null, subcategories: {} }, // Added default value 55 | ], 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /tests/integration/fixture1.test.ts: -------------------------------------------------------------------------------- 1 | /* Copyright (c) Fortanix, Inc. 2 | * 3 | * This Source Code Form is subject to the terms of the Mozilla Public 4 | * License, v. 2.0. If a copy of the MPL was not distributed with this 5 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { promisify } from 'node:util'; 9 | import path from 'node:path'; 10 | import { fileURLToPath } from 'node:url'; 11 | import { exec as execCallback } from 'node:child_process'; 12 | 13 | import { Schema as S } from 'effect'; 14 | 15 | import assert from 'node:assert/strict'; 16 | import { test } from 'node:test'; 17 | 18 | 19 | const exec = promisify(execCallback); 20 | 21 | test('fixture1', { timeout: 30_000/*ms*/ }, async (t) => { 22 | const before = async () => { 23 | const cwd = path.dirname(fileURLToPath(import.meta.url)); 24 | console.log('Preparing fixture1...'); 25 | 26 | try { 27 | const { stdout, stderr } = await exec(`./generate_fixture.sh fixture1`, { cwd }); 28 | } catch (error: unknown) { 29 | if (error instanceof Error && 'stderr' in error) { 30 | console.error(error.stderr); 31 | } 32 | throw error; 33 | } 34 | }; 35 | await before(); 36 | 37 | // @ts-ignore Will not type check until the generation is complete. 38 | const fixture = await import('../project_simulation/generated/fixture1/fixture1.ts'); 39 | 40 | await t.test('RocheName', async (t) => { 41 | assert.strictEqual(S.decodeSync(fixture.RocheName)('test'), 'test'); 42 | assert.throws(() => S.decodeSync(fixture.RocheName)(''), /Expected a string matching/); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/integration/generate_fixture.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # Get the path to the current directory (works in both bash and zsh) 5 | # https://stackoverflow.com/a/54755784 6 | PATH_CURRENT="$(dirname ${BASH_SOURCE[0]:-${(%):-%x}})" 7 | cd $PATH_CURRENT 8 | 9 | FIXTURE_NAME="${@:-1}" 10 | 11 | # Note: do not use `npm run` here, because `npm run` always changes the working directory to that of the project root 12 | generate() { 13 | node --import=tsx -- "../../src/openapiToEffect.ts" gen "$@" 14 | } 15 | 16 | TEST_SPEC_PATH="../fixtures/${FIXTURE_NAME}_spec.ts"; 17 | TEST_API_PATH="../fixtures/${FIXTURE_NAME}_api.json"; 18 | TEST_OUTPUT_PATH="../project_simulation/generated/${FIXTURE_NAME}"; 19 | mkdir -p "${TEST_OUTPUT_PATH}" 20 | generate --spec="${TEST_SPEC_PATH}" "${TEST_API_PATH}" "${TEST_OUTPUT_PATH}" 21 | 22 | echo 23 | echo 'Generating sample file...' 24 | cat <<"EOT" | FIXTURE_NAME="${FIXTURE_NAME}" node --import=tsx | npx --silent prettier --parser=babel-ts --single-quote > "${TEST_OUTPUT_PATH}/${FIXTURE_NAME}_sample.ts" 25 | (async () => { 26 | const fixtureName = process.env.FIXTURE_NAME; 27 | const { dedent } = await import('ts-dedent'); 28 | const S = await import('effect/Schema'); 29 | const FastCheck = await import('effect/FastCheck'); 30 | const Arbitrary = await import('effect/Arbitrary'); 31 | const Fx = await import(`../project_simulation/generated/${fixtureName}/${fixtureName}.ts`); 32 | 33 | console.log(dedent` 34 | import { pipe, Schema as S, SchemaAST, FastCheck, Arbitrary } from 'effect'; 35 | import * as Api from './${fixtureName}.ts'; 36 | ` + '\n\n'); 37 | 38 | const opts = { errors: 'all', onExcessProperty: 'ignore' }; 39 | console.log(dedent` 40 | const opts: SchemaAST.ParseOptions = { errors: 'all', onExcessProperty: 'ignore' }; 41 | ` + '\n\n'); 42 | 43 | Object.entries(Fx) 44 | .filter(([name]) => !name.endsWith('Encoded')) 45 | .forEach(([name, Schema]) => { 46 | // Note: using `encodedSchema` here will not produce an input that can be decoded successfully. See: 47 | // https://discord.com/channels/795981131316985866/847382157861060618/threads/1237521922011431014 48 | //const sample = FastCheck.sample(Arbitrary.make(S.encodedSchema(Schema)), 1)[0]; 49 | 50 | // Instead, we will sample an instance of the decoded type and then encode that 51 | const sample = FastCheck.sample(Arbitrary.make(Schema), 1)[0]; 52 | const sampleEncoded = S.encodeSync(Schema, opts)(sample); 53 | 54 | console.log(dedent` 55 | const sample${name}: Api.${name} = pipe( 56 | ${JSON.stringify(sampleEncoded, null, 2)} as const satisfies S.Schema.Encoded, 57 | S.decodeSync(Api.${name}, opts), 58 | ); 59 | ` + '\n\n'); 60 | }); 61 | })(); 62 | EOT 63 | echo 'Done!' 64 | 65 | node --import=tsx "${TEST_OUTPUT_PATH}/${FIXTURE_NAME}_sample.ts" 66 | 67 | 68 | # Type check the output 69 | # Note: no way to call `tsc` with a `tsconfig.json` while overriding the input file path dynamically, need to use 70 | # a specialized `tsconfig.json` file that extends the base config. 71 | npx --silent tsc --project tsconfig.json 72 | -------------------------------------------------------------------------------- /tests/integration/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["../project_simulation/generated"], 4 | "compilerOptions": { 5 | "noEmit": true, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.decl.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["./src"], 4 | "compilerOptions": { 5 | "noEmit": false, 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "declarationMap": true, 9 | "declarationDir": "./dist/types", 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | 4 | // Ref: https://github.com/tsconfig/bases/blob/main/bases/node20.json 5 | "compilerOptions": { 6 | // Emission 7 | "noEmit": true, // Do not emit by default (only type check) 8 | //"emitDeclarationOnly": true, 9 | "target": "es2022", // JavaScript language version to emit 10 | "module": "es2022", // The type of file to emit (CommonJS/ESM/etc.) 11 | //"esModuleInterop": true, 12 | //"allowSyntheticDefaultImports": true, 13 | 14 | // Imports 15 | "moduleResolution": "bundler", // Resolve import specifiers like bundlers (allows explicit file extensions) 16 | "allowImportingTsExtensions": true, // Allow importing `.ts` extensions 17 | "allowJs": false, // If `true` allows to import `.js` files 18 | //"resolveJsonModule": true, // Allow importing `.json` files 19 | "forceConsistentCasingInFileNames": true, // Do not allow case-insensitive import file name matching 20 | 21 | // Type checking 22 | "lib": ["es2022", "DOM"], // Library declaration files to include (globally) 23 | "skipLibCheck": true, // Do not type check declaration files (for performance) 24 | "noErrorTruncation": true, 25 | 26 | // Language 27 | "isolatedModules": true, // Restrict language features not compatible with tools like babel 28 | "strict": true, 29 | "exactOptionalPropertyTypes": true, 30 | "noUncheckedIndexedAccess": true 31 | }, 32 | "include": [ 33 | "./src", 34 | "./tests" 35 | ] 36 | } 37 | --------------------------------------------------------------------------------