├── .dockerignore ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── internal-issues-only.md ├── pull_request_template.md └── workflows │ ├── deno.yml │ └── docker.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── functions └── .gitkeep └── src ├── README.md ├── connector.ts ├── entrypoint.sh ├── infer.ts ├── mod.ts ├── schema.ts ├── sdk.ts ├── test ├── classes_test.ts ├── complex_dependency_test.ts ├── conflicting_names_test.ts ├── data │ ├── classes.ts │ ├── complex.ts │ ├── conflicting_names.ts │ ├── conflicting_names_dep.ts │ ├── external_dependencies.ts │ ├── infinite_loop.ts │ ├── inline_types.ts │ ├── nullable_types.ts │ ├── pg_dep.ts │ ├── program.ts │ ├── recursive.ts │ ├── type_parameters.ts │ ├── validation_algorithm_update.ts │ └── void_types.ts ├── external_dependencies_test.ts ├── infer_test.ts ├── inline_types_test.ts ├── ndc_schema_test.ts ├── nullable_types_test.ts ├── pg_dep_test.ts ├── prepare_arguments_test.ts ├── recursive_types_test.ts ├── type_parameters_test.ts ├── validation_algorithm_test.ts └── void_test.ts └── util.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | target 2 | Dockerfile 3 | .dockerignore 4 | .gitkeep 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Bugs, Feature Requests, Ideas 4 | url: https://github.com/hasura/graphql-engine/issues/new/choose 5 | about: Use hasura/graphql-engine for issues related to ndc-typescript-deno. 6 | - name: ☁️ Hasura Cloud Help & Support Center 7 | url: https://cloud.hasura.io/support 8 | about: For Issues related to Hasura Cloud and DDN ⚡️ 9 | - name: ❓ Get Support from Discord Community 10 | url: https://discord.com/invite/hasura 11 | about: Please ask and answer questions here. 🏥 12 | - name: 🙋‍♀️ Have a question on how to achieve something with Hasura? 13 | url: https://github.com/hasura/graphql-engine/discussions 14 | about: Start a GitHub discussion 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/internal-issues-only.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New issues are meant to be created by Hasura collaborators only 3 | about: DON'T USE THIS. We request you to use hasura/graphql-engine repo to open new 4 | issues related to ndc-typescript-deno (bugs, feature requests etc). 5 | title: '' 6 | labels: '' 7 | assignees: 8 | 9 | --- 10 | 11 | 12 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | # TODO: PR TITLE - Please be descriptive! 3 | 4 | TODO: Description 5 | 6 | TODO: Reference issues fixed, etc. 7 | 8 | ## Testing 9 | 10 | TODO: Document steps to test this change or new functionality. 11 | 12 | ## Changelog 13 | 14 | - Add a changelog entry (in the "Changelog entry" section below) if the changes in this PR have any user-facing impact. 15 | - If no changelog is required ignore/remove this section and add a `no-changelog-required` label to the PR. 16 | 17 | ### Type 18 | _(Select only one. In case of multiple, choose the most appropriate)_ 19 | - [ ] highlight 20 | - [ ] enhancement 21 | - [ ] bugfix 22 | - [ ] behaviour-change 23 | - [ ] performance-enhancement 24 | - [ ] security-fix 25 | 26 | 27 | ### Changelog entry 28 | 34 | 35 | _Replace with changelog entry_ 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /.github/workflows/deno.yml: -------------------------------------------------------------------------------- 1 | 2 | # This workflow will install Deno then run `deno lint` and `deno test`. 3 | # For more information see: https://github.com/denoland/setup-deno 4 | 5 | name: Deno 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | - test-ci/** 12 | tags: 13 | - v** 14 | pull_request: 15 | branches: ["main"] 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Setup repo 26 | uses: actions/checkout@v3 27 | 28 | - name: Setup Deno 29 | # uses: denoland/setup-deno@v1 30 | uses: denoland/setup-deno@0df5d9c641efdff149993c321fc27c11c5df8623 # v1.1.3 31 | with: 32 | deno-version: v1.x 33 | 34 | # Uncomment this step to verify the use of 'deno fmt' 35 | # - name: Verify formatting 36 | # run: deno fmt --check 37 | # 38 | # Uncomment this step to run the linter 39 | # - name: Run linter 40 | # run: deno lint 41 | 42 | - name: Run tests 43 | run: deno test -A ./src/test 44 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | 2 | # Example modified from https://docs.github.com/en/actions/publishing-packages/publishing-docker-images 3 | name: Create and publish a Docker image 4 | 5 | # Configures this workflow to run every time a change is pushed to selected tags and branches 6 | on: 7 | push: 8 | branches: 9 | - main 10 | - test-ci/** 11 | tags: 12 | - v** 13 | 14 | # Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. 15 | env: 16 | REGISTRY: ghcr.io 17 | IMAGE_NAME: ${{ github.repository }} 18 | 19 | # There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. 20 | jobs: 21 | build-and-push-image: 22 | runs-on: ubuntu-latest 23 | # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. 24 | permissions: 25 | contents: read 26 | packages: write 27 | # 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v3 31 | # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. 32 | - name: Log in to the Container registry 33 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 34 | with: 35 | registry: ${{ env.REGISTRY }} 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. 39 | - name: Extract metadata (tags, labels) for Docker 40 | id: meta 41 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 42 | with: 43 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 44 | # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. 45 | # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. 46 | # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. 47 | - name: Build and push Docker image 48 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 49 | with: 50 | context: . 51 | push: true 52 | tags: ${{ steps.meta.outputs.tags }} 53 | labels: ${{ steps.meta.outputs.labels }} 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /target 3 | .vscode/ 4 | vendor/ 5 | scratch/ 6 | src/test/vendor -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # TypeScript (Deno) Connector Changelog 2 | 3 | This changelog documents the changes between release versions. 4 | 5 | `main` serves as a place to stash upcoming changes before they have been released under a version tag. 6 | 7 | 8 | ## main 9 | 10 | Changes to be included in the next upcoming release. 11 | 12 | ## v0.24 13 | 14 | * Pinned the Fastify version used to avoid using the latest version which does not work with Deno 15 | 16 | ## v0.23 17 | 18 | Update TS SDK dependency to v1.2.8. 19 | 20 | * Fixed issue where query response format was incorrect when variables were used in the request 21 | 22 | ## v0.22 23 | 24 | Update TS SDK Dependency to v1.2.6. 25 | 26 | * Fixes issue with SERVICE_TOKEN check 27 | * Support for query variables AKA "ForEach" 28 | 29 | ## v0.21 30 | 31 | Support for "nullable" types, for example `string | null`, `string | undefined`, `string | null | undefined`, 32 | and optional object properties. 33 | 34 | PR: https://github.com/hasura/ndc-typescript-deno/pull/82 35 | 36 | ## v0.20 37 | 38 | Improved support for running the connector in Watch mode, where it will auto-restart when changes 39 | to the functions are made. 40 | 41 | * The Docker container is now compatible with watch mode and can be used for local development 42 | * README documentation about watch mode updated to indicate the need to explicitly watch the functions 43 | folder (ie `--watch=./functions`) 44 | * `functions` configuration property is now required 45 | * Type inference is now only done at connector startup, not every time the `/schema` endpoint is called 46 | * Updated TypeScript SDK to v1.2.5 47 | 48 | PR: https://github.com/hasura/ndc-typescript-deno/pull/79 49 | 50 | ## v0.19 51 | 52 | Updating the TS SDK to 1.2.4 and fixing incorrectly required insert_schema field in mutations. 53 | 54 | PR: https://github.com/hasura/ndc-typescript-deno/pull/78 55 | 56 | * Updates SDK 57 | 58 | ## v0.18 59 | 60 | Broadening dependency support, better error logging, and fixing regressions. 61 | 62 | PR: https://github.com/hasura/ndc-typescript-deno/pull/77 63 | 64 | * Ignores TypeScript diagnostic error codes that Deno itself ignores. 65 | * Fixes the target compilation language which got broken in PR #63 66 | * Fatal errors occurring on a particular file are prefixed with FATAL too. 67 | 68 | ## v0.17 69 | 70 | Fixing issue with preVendor default. 71 | 72 | PR: https://github.com/hasura/ndc-typescript-deno/pull/76 73 | 74 | * Was only applying the default during user-interactive config flows 75 | 76 | Fixing NPM inference. 77 | 78 | PR: https://github.com/hasura/ndc-typescript-deno/pull/75 79 | 80 | * Creates node_modules and uses it for inference when vendoring npm dependencies 81 | 82 | ## v0.16 83 | 84 | Prevendoring by default. 85 | 86 | PR: https://github.com/hasura/ndc-typescript-deno/pull/74 87 | 88 | * Using defaults of `{"preVendor": true}` to minimise setup required for development 89 | 90 | ## v0.15 91 | 92 | Updating TypeScript target version from ES2017 to ES2022. 93 | 94 | PR: https://github.com/hasura/ndc-typescript-deno/pull/73 95 | 96 | * Resolves issues with some dependencies such as deno.land/x/postgres@v0.17.0 97 | 98 | ## v0.14.1 99 | 100 | Diff: b1bdc55..17e85d5 101 | 102 | Hotfix for infer bug. 103 | 104 | * Fixes: Infer command logging informational output on stdout 105 | 106 | ## v0.14 107 | 108 | PR: https://github.com/hasura/ndc-typescript-deno/pull/70 109 | 110 | * Fixes: #61 - Use a better check for inline object type detection 111 | * Fixes: #33 - Detect Object and Array scenarios using TS library functions 112 | * Fixes: #45 - Infinite loop for certain function definitions 113 | * Fixes: #51 - Inferring result for non-annotated functions. 114 | * Fixes: #31 - Find a better implementation of is_struct that is type aware 115 | * Fixes: #58 - Generic type parameters are not handled correctly 116 | 117 | ## v0.13 118 | 119 | PR: https://github.com/hasura/ndc-typescript-deno/pull/62 120 | 121 | * Helpful listing of functions/procedures when performing inference 122 | 123 | ## v0.12 124 | 125 | PR: https://github.com/hasura/ndc-typescript-deno/pull/60 126 | 127 | * Use positional names for inline types 128 | 129 | ## v0.11 130 | 131 | PR: https://github.com/hasura/ndc-typescript-deno/pull/59 132 | 133 | * Bugfix: Issue with unused argument being parsed as a file - Prevented invoking on deno.land 134 | 135 | ## v0.10 136 | 137 | PR: https://github.com/hasura/ndc-typescript-deno/pull/57 138 | 139 | * Types names are now preserved when possible 140 | * Depends on the latest version of the TS SDK which no longer needs multiregion functions 141 | * Defines arraybuffer and blob scalars 142 | * Sets MAX_INFERENCE_RECURSION = 20 to break any potential infinite recursion loops 143 | * New tests have been created for refactored inference cases 144 | 145 | ## v0.9 146 | 147 | Full-Stack Typescript! 148 | 149 | * Ported main server from Rust to pure Deno 150 | * Support exposing optional function parameters 151 | 152 | ## v0.8 153 | 154 | Required entrypoint: /functions/index.ts; 155 | 156 | * Entrypoint: /functions/index.ts 157 | * Moving work from entrypoint.sh into Dockerfile to improve hibernation wakeup speed 158 | 159 | ## v0.7 160 | 161 | Startup optimisation. 162 | 163 | * Reusing vendor from inference when running server.ts to avoid downloading dependencies twice 164 | 165 | ## v0.6 166 | 167 | Logging bug fix. 168 | 169 | * Improved entrypoint.sh to have better logging behaviour 170 | 171 | ## v0.5 172 | 173 | Support selecting fields. 174 | 175 | * Query with field selections are now respected 176 | * Errors during function execution are logged from Deno server 177 | 178 | ## v0.4 179 | 180 | Integrated V2 Proxy. 181 | 182 | * Includes a V2 compatibility proxy that can be activated by setting: `ENABLE_V2_COMPATIBILITY=true` 183 | * Fixes bug in `infer.ts` that would throw an error function definitions had no imports: https://github.com/hasura/ndc-typescript-deno/pull/7 184 | 185 | ## v0.3 186 | 187 | Error improvements. 188 | 189 | * Updating SDK 190 | * Correct error reporting from exceptions thrown from user functions 191 | 192 | ## v0.2 193 | 194 | Update to SDK. 195 | 196 | * Starting use of a `CHANGELOG.md` file 197 | * `Deno` is now defined. Users no longer need to defined this value in their functions. 198 | * Inference errors now exit and print in `entrypoint.sh` 199 | * Supports `SERVICE_TOKEN_SECRET` 200 | 201 | 202 | ## v0.1 203 | 204 | Initial release of the connector. 205 | 206 | This connector allows for quick deployment of Typescript functions to Hasura V3 Projects. 207 | 208 | See `README.md` for details. 209 | 210 | Limitations: 211 | 212 | * Server Auth Token support (`SERVICE_TOKEN_SECRET`) is not yet available 213 | * User needs to define `Deno` value in their functions 214 | * Startup inference errors are hidden 215 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM denoland/deno:alpine-1.38.3 2 | 3 | COPY ./src /app 4 | RUN deno cache /app/mod.ts 5 | 6 | COPY ./functions /functions/src 7 | 8 | # Pre-cache inference results and dependencies 9 | RUN PRECACHE_ONLY=true /app/entrypoint.sh 10 | 11 | EXPOSE 8080 12 | 13 | ENTRYPOINT [ "/app/entrypoint.sh" ] 14 | 15 | CMD [ ] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: help 3 | help: 4 | @echo Targets: 5 | @cat Makefile | grep '^[a-zA-Z]' | sed 's/^/ * /; s/://' 6 | 7 | test: 8 | deno test -A src/test 9 | 10 | test-and-watch: 11 | deno test -A --watch src/test 12 | 13 | serve: 14 | @echo Note, this requires a config argument - Run the command directly. 15 | deno run -A --watch --check src/mod.ts serve -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ndc-typescript-deno 2 | 3 | ![image](https://github.com/hasura/ndc-typescript-deno/assets/92299/9f139964-d0ed-4c92-b01f-9fda255717d4) 4 | 5 | The TypeScript (Deno) Connector allows a running connector to be inferred from a TypeScript file (optionally with dependencies). 6 | 7 | ![image](https://github.com/hasura/ndc-typescript-deno/assets/92299/fb7f4afd-0302-432b-b7ce-3cc7d1f3546b) 8 | 9 | Useful Links: 10 | 11 | * [TypeScript Deno Connector on the NDC Hub](https://hasura.io/connectors/typescript-deno) 12 | * [TypeScript Deno Connector on deno.com](https://deno.land/x/hasura_typescript_connector) 13 | * [Hasura V3 Documentation](https://hasura.io/docs/3.0) 14 | * [Hasura CLI](https://hasura.io/docs/3.0/cli/installation/) 15 | * [CLI Connector Plugin](https://hasura.io/docs/latest/hasura-cli/connector-plugin/) 16 | * [Hasura VSCode Extension](https://marketplace.visualstudio.com/items?itemName=HasuraHQ.hasura) 17 | * [Deno](https://deno.com) 18 | * [Native Data Connector Specification](https://hasura.github.io/ndc-spec/) 19 | * [TypeScript NDC SDK](https://github.com/hasura/ndc-sdk-typescript/) 20 | 21 | 22 | ## Overview 23 | 24 | The connector runs in the following manner: 25 | 26 | * TypeScript sources are assembled (with `index.ts` acting as your interface definition) 27 | * Dependencies are fetched 28 | * Inference is performed and made available via the `/schema` endpoint 29 | * Functions are served via the connector protocol 30 | 31 | Note: The Deno runtime is used and this connector assumes that dependencies are specified in accordance with [Deno](https://deno.com) conventions. 32 | 33 | 34 | ## Before you get Started 35 | 36 | It is recommended that you: 37 | 38 | * Install the [Hasura3 CLI](https://hasura.io/docs/3.0/cli/installation/) 39 | * Log in via the CLI 40 | * Install the [connector plugin](https://hasura.io/docs/latest/hasura-cli/connector-plugin/) 41 | * Install [VSCode](https://code.visualstudio.com) 42 | * Install the [Hasura VSCode Extension](https://marketplace.visualstudio.com/items?itemName=HasuraHQ.hasura) 43 | * Optionally install [Deno](https://deno.com) 44 | * Optionally install the [VSCode Deno Extension](https://marketplace.visualstudio.com/items?itemName=denoland.vscode-deno) 45 | 46 | **The last item is currently required for access to the Hasura DDN in order to deploy and manage V3 projects.** 47 | 48 | Once the Hasura DDN is generally available this will no longer be required. 49 | 50 | ## Typescript Functions Format 51 | 52 | Your functions should be organised into a directory with one file acting as the entrypoint. 53 | 54 |
55 | An example TypeScript entrypoint: 56 | 57 | ```typescript 58 | 59 | // functions/index.ts 60 | 61 | import { Hash, encode } from "https://deno.land/x/checksum@1.2.0/mod.ts"; 62 | 63 | export function make_bad_password_hash(pw: string): string { 64 | return new Hash("md5").digest(encode(pw)).hex(); 65 | } 66 | 67 | /** 68 | * Returns the github bio for the userid provided 69 | * 70 | * @param username Username of the user who's bio will be fetched. 71 | * @returns The github bio for the requested user. 72 | * @pure This function should only query data without making modifications 73 | */ 74 | export async function get_github_profile_description(username: string): Promise { 75 | const foo = await fetch(`https://api.github.com/users/${username}`); 76 | const response = await foo.json(); 77 | return response.bio; 78 | } 79 | 80 | export function make_array(): Array { 81 | return ['this', 'is', 'an', 'array'] 82 | } 83 | 84 | type MyObjectType = {'foo': string, 'baz': Boolean} 85 | 86 | export function make_object(): MyObjectType { 87 | return { 'foo': 'bar', 'baz': true} 88 | } 89 | 90 | export function make_object_array(): Array { 91 | return [make_object(), make_object()] 92 | } 93 | 94 | /** 95 | * @pure 96 | */ 97 | export function has_optional_args(a: string, b?: string) { 98 | if(b) { 99 | return `Two args: ${a} ${b}`; 100 | } else { 101 | return `One arg: ${a}`; 102 | } 103 | } 104 | ``` 105 | 106 |
107 | 108 | Top level exported function definitions with `pure` tag will be made available as functions, 109 | and others as procedures, which will become queries and mutations respectively. 110 | 111 | * Return types are inferred 112 | * Parameters are inferred and named after their input parameter names. 113 | * Simple scalar, array, and object types should be supported 114 | * Exceptions can be thrown 115 | * Optional parameters will become optional arguments 116 | 117 | Limitations: 118 | 119 | * ~~The `deno vendor` step must be run by hand for local development~~ (vendoring is now automatic by default) 120 | * Functions can be sync, or async, but `Promise`'s can't be nested 121 | * All numbers are exported as `Float`s 122 | * Unrecognised types will become opaque scalars, for example: union types. 123 | * Optional object fields are not currently supported 124 | * Functions can be executed via both the `/query` and `/mutation` endpoints 125 | * Conflicting type names in dependencies will be namespaced with their relative path 126 | * Generic type parameters will be treated as scalars when referenced 127 | * Importing multiple versions of the same package using Deno `npm:` package specifiers does not work properly with type inference (only a single version of the package is imported) 128 | 129 | Please [file an issue](https://github.com/hasura/ndc-typescript-deno/issues/new) for any problems you encounter during usage of this connector. 130 | 131 | 132 | ## Local Development of your Functions 133 | 134 | While you can deploy your functions and have errors returned in the `hasura3 connector` logs, 135 | local development will reward you with much more rapid feedback. 136 | 137 | In order to develop your functions locally the following is the recommended practice: 138 | 139 | * Have a `./functions/` directory in your project 140 | * Create your functions in an `index.ts` file inside the `./functions/` directory 141 | * Create a development config for your connector in `./config.json`: 142 | ```json 143 | { 144 | "functions": "./functions/index.ts", 145 | "vendor": "./vendor", 146 | "schemaMode": "INFER" 147 | } 148 | ``` 149 | * Make sure to .gitignore your computed `vendor` files. 150 | * Start the connector 151 | ```sh 152 | deno run -A --watch=./functions --check https://deno.land/x/hasura_typescript_connector/mod.ts serve --configuration ./config.json 153 | ``` 154 | * (Optionally) Add a test-suite for your functions. See [Deno Testing Basics](https://docs.deno.com/runtime/manual/basics/testing). 155 | 156 | 157 | ## Local Development of your Functions (Docker) 158 | You can also perform local development with rapid feedback by using the Docker container instead 159 | of `deno run`. You don't need a `config.json` in this case. 160 | 161 | * Have a `./functions/` directory in your project 162 | * Create your functions in an `index.ts` file inside the `./functions/` directory 163 | * Start the connector using Docker: 164 | ```bash 165 | docker run -it --rm -v ./functions:/functions/src -p 8080:8080 -e WATCH=1 ghcr.io/hasura/ndc-typescript-deno:latest 166 | ``` 167 | 168 | 169 | ## Config Format 170 | 171 | The configuration object has the following properties: 172 | 173 | ``` 174 | functions (string): Location of your functions entrypoint 175 | vendor (string): Location of dependencies vendor folder (optional) 176 | preVendor (boolean): Perform vendoring prior to inference in a sub-process (default: true) 177 | schemaMode (string): INFER the schema from your functions, or READ it from a file. (default: INFER) 178 | schemaLocation (string): Location of your schema file. schemaMode=READ reads the file (required), schemaMode=INFER writes the file (optional) 179 | ``` 180 | 181 | NOTE: When deploying the connector with the `connector create` command your config is currently replaced with: 182 | 183 | ```json 184 | { 185 | "functions": "/functions/src/index.ts", 186 | "vendor": "/functions/vendor", 187 | "schemaMode": "READ", 188 | "schemaLocation": "/functions/schema.json" 189 | } 190 | ``` 191 | 192 | This means that your functions volume will have to be mounted to `/functions/src`. 193 | 194 | ## Deployment for Hasura Users 195 | 196 | You will need: 197 | 198 | * [V3 CLI](https://hasura.io/docs/3.0/cli/installation/) (With Logged in Session) 199 | * [Connector Plugin](https://hasura.io/docs/latest/hasura-cli/connector-plugin/) 200 | * (Optionally) A value to use with `SERVICE_TOKEN_SECRET` 201 | * a typescript sources directory. E.g. `--volume ./my_functions_directory:/functions` 202 | 203 | Create the connector: 204 | 205 | ```bash 206 | hasura3 connector create my-cool-connector:v1 \ 207 | --github-repo-url https://github.com/hasura/ndc-typescript-deno/tree/main \ 208 | --config-file <(echo '{}') \ 209 | --volume ./functions:/functions \ 210 | --env SERVICE_TOKEN_SECRET=MY-SERVICE-TOKEN # (optional) 211 | ``` 212 | 213 | *Note: Even though you can use the "main" branch to deploy the latest connector features, see the [Hasura Connector Hub](https://hasura.io/connectors/typescript-deno) for verified release tags* 214 | 215 | Monitor the deployment status by name - This will indicate in-progress, complete, or failed status: 216 | 217 | > hasura3 connector status my-cool-connector:v1 218 | 219 | List all your connectors with their deployed URLs: 220 | 221 | > hasura3 connector list 222 | 223 | View logs from your running connector: 224 | 225 | > hasura3 connector logs my-cool-connector:v1 226 | 227 | ## Usage 228 | 229 | This connector is intended to be used with Hasura v3 projects. 230 | 231 | Find the URL of your connector once deployed: 232 | 233 | > hasura3 connector list 234 | 235 | ``` 236 | my-cool-connector:v1 https://connector-9XXX7-hyc5v23h6a-ue.a.run.app active 237 | ``` 238 | 239 | In order to use the connector once deployed you will first want to reference the connector in your project metadata: 240 | 241 | ```yaml 242 | kind: "AuthConfig" 243 | allowRoleEmulationFor: "admin" 244 | webhook: 245 | mode: "POST" 246 | webhookUrl: "https://auth.pro.hasura.io/webhook/ddn?role=admin" 247 | --- 248 | kind: DataConnector 249 | version: v1 250 | definition: 251 | name: my_connector 252 | url: 253 | singleUrl: 'https://connector-9XXX7-hyc5v23h6a-ue.a.run.app' 254 | ``` 255 | 256 | If you have the [Hasura VSCode Extension](https://marketplace.visualstudio.com/items?itemName=HasuraHQ.hasura) installed 257 | you can run the following code actions: 258 | 259 | * `Hasura: Refresh data source` 260 | * `Hasura: Track all collections / functions ...` 261 | 262 | This will integrate your connector into your Hasura project which can then be deployed or updated using the Hasura3 CLI: 263 | 264 | ``` 265 | hasura3 cloud build create --project-id my-project-id --metadata-file metadata.hml 266 | ``` 267 | 268 | ## Service Authentication 269 | 270 | If you don't wish to have your connector publically accessible then you must set a service token by specifying the `SERVICE_TOKEN_SECRET` environment variable when creating your connector: 271 | 272 | * `--env SERVICE_TOKEN_SECRET=SUPER_SECRET_TOKEN_XXX123` 273 | 274 | Your Hasura project metadata must then set a matching bearer token: 275 | 276 | ```yaml 277 | kind: DataConnector 278 | version: v1 279 | definition: 280 | name: my_connector 281 | url: 282 | singleUrl: 'https://connector-9XXX7-hyc5v23h6a-ue.a.run.app' 283 | headers: 284 | Authorization: 285 | value: "Bearer SUPER_SECRET_TOKEN_XXX123" 286 | ``` 287 | 288 | While you can specify the token inline as above, it is recommended to use the Hasura secrets functionality for this purpose: 289 | 290 | ```yaml 291 | kind: DataConnector 292 | version: v1 293 | definition: 294 | name: my_connector 295 | url: 296 | singleUrl: 'https://connector-9XXX7-hyc5v23h6a-ue.a.run.app' 297 | headers: 298 | Authorization: 299 | valueFromSecret: BEARER_TOKEN_SECRET 300 | ``` 301 | 302 | NOTE: This secret should contain the `Bearer ` prefix. 303 | 304 | ## Debugging Issues 305 | 306 | Errors may arise from any of the following: 307 | 308 | * Dependency errors in your functions 309 | * Type errors in your functions 310 | * Implementation errors in your functions 311 | * Invalid connector configuration 312 | * Invalid project metadata 313 | * Connector Deployment Failure 314 | * Misconfigured project authentication 315 | * Misconfigured service authentication 316 | * Insufficient query permissions 317 | * Invalid queries 318 | * Unanticipated bug in connector implementation 319 | 320 | For a bottom-up debugging approach: 321 | 322 | * First check your functions: 323 | * Run `deno check` on your functions to determine if there are any obvious errors 324 | * Write a `deno test` harness to ensure that your functions are correctly implemented 325 | * Then check your connector: 326 | * Check that the connector deployed successfully with `hasura3 connector status my-cool-connector:v1` 327 | * Check the build/runtime logs of your connector with `hasura3 connector logs my-cool-connector:v1` 328 | * Then check your project: 329 | * Ensure that your metadata and project build were successful 330 | * Then check end-to-end integration: 331 | * Run test queries and view the connector logs to ensure that your queries are propagating correctly 332 | 333 | 334 | ## Development 335 | 336 | For contribution to this connector you will want to have the following dependencies: 337 | 338 | * [Deno](https://deno.com) 339 | * (Optionally) [Docker](https://www.docker.com) 340 | 341 | In order to perform local development on this codebase: 342 | 343 | * Check out the repository: `git clone https://github.com/hasura/ndc-typescript-deno.git` 344 | * This assumes that you will be testing against function in `./functions` 345 | * Serve your functions with `deno run -A --watch=./functions --check ./src/mod.ts serve --configuration <(echo '{"functions": "./functions/index.ts", "vendor": "./vendor", "schemaMode": "INFER"}')` 346 | * The connector should now be running on localhost:8100 and respond to any changes to the your functions and the connector source 347 | * Use the `hasura3` tunnel commands to reference this connector from a Hasura Cloud project 348 | 349 | If you are fixing a bug, then please consider adding a test case to `./src/test/data`. 350 | 351 | ## Support & Troubleshooting 352 | 353 | The documentation and community will help you troubleshoot most issues. 354 | If you have encountered a bug or need to get in touch with us, you can contact us using one of the following channels: 355 | 356 | - Support & feedback: [Discord](https://discord.gg/hasura) 357 | - Issue & bug tracking: [GitHub issues](https://github.com/hasura/graphql-engine/issues) 358 | - Follow product updates: [@HasuraHQ](https://twitter.com/hasurahq) 359 | - Talk to us on our [website chat](https://hasura.io) 360 | -------------------------------------------------------------------------------- /functions/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hasura/ndc-typescript-deno/97b218d1c8cb57e184f6d0f56a6f720da857aae6/functions/.gitkeep -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # Hasura NDC TypeScript (Deno) Connector 2 | 3 | ![image](https://github.com/hasura/ndc-typescript-deno/assets/92299/9f139964-d0ed-4c92-b01f-9fda255717d4) 4 | 5 | The TypeScript (Deno) Connector allows a running connector to be inferred from a TypeScript file (optionally with dependencies). 6 | 7 | ![image](https://github.com/hasura/ndc-typescript-deno/assets/92299/fb7f4afd-0302-432b-b7ce-3cc7d1f3546b) 8 | 9 | ## [Github Repository](https://github.com/hasura/ndc-typescript-deno) 10 | 11 | See the [Github Repo](https://github.com/hasura/ndc-typescript-deno) for additional details. 12 | 13 | ## Quickstart 14 | 15 | Once your project is set up, run locally with: 16 | 17 | ``` 18 | > cat config.json 19 | { 20 | "functions": "./functions/index.ts", 21 | "vendor": "./vendor", 22 | "schemaMode": "INFER" 23 | } 24 | 25 | > deno run -A --watch=./functions --check https://deno.land/x/hasura_typescript_connector/mod.ts serve --configuration ./config.json 26 | ``` 27 | 28 | ## TypeScript Functions Format 29 | 30 | Your functions should be organised into a directory with one file acting as the entrypoint. 31 | 32 | An example Typescript entrypoint: 33 | 34 | ```typescript 35 | 36 | // functions/index.ts 37 | 38 | import { Hash, encode } from "https://deno.land/x/checksum@1.2.0/mod.ts"; 39 | 40 | export function make_bad_password_hash(pw: string): string { 41 | return new Hash("md5").digest(encode(pw)).hex(); 42 | } 43 | 44 | /** 45 | * Returns the github bio for the userid provided 46 | * 47 | * @param username Username of the user who's bio will be fetched. 48 | * @returns The github bio for the requested user. 49 | * @pure This function should only query data without making modifications 50 | */ 51 | export async function get_github_profile_description(username: string): Promise { 52 | const foo = await fetch(`https://api.github.com/users/${username}`); 53 | const response = await foo.json(); 54 | return response.bio; 55 | } 56 | 57 | export function make_array(): Array { 58 | return ['this', 'is', 'an', 'array'] 59 | } 60 | 61 | type MyObjectType = {'foo': string, 'baz': Boolean} 62 | 63 | export function make_object(): MyObjectType { 64 | return { 'foo': 'bar', 'baz': true} 65 | } 66 | 67 | export function make_object_array(): Array { 68 | return [make_object(), make_object()] 69 | } 70 | 71 | /** 72 | * @pure 73 | */ 74 | export function has_optional_args(a: string, b?: string) { 75 | if(b) { 76 | return `Two args: ${a} ${b}`; 77 | } else { 78 | return `One arg: ${a}`; 79 | } 80 | } 81 | ``` 82 | -------------------------------------------------------------------------------- /src/connector.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Implementation of the Connector interface for Deno connector. 3 | * Using https://github.com/hasura/ndc-qdrant/blob/main/src/index.ts as an example. 4 | */ 5 | 6 | import { FunctionDefinitions, get_ndc_schema, NullOrUndefinability, ObjectTypeDefinitions, ProgramSchema, TypeDefinition } from "./schema.ts"; 7 | import { inferProgramSchema, Struct, } from "./infer.ts"; 8 | import { resolve } from "https://deno.land/std@0.208.0/path/mod.ts"; 9 | import { JSONSchemaObject } from "npm:@json-schema-tools/meta-schema"; 10 | import { isArray, unreachable } from "./util.ts"; 11 | 12 | import { sdk } from './sdk.ts'; 13 | 14 | export type State = { 15 | functions: RuntimeFunctions 16 | } 17 | 18 | export type RuntimeFunctions = { 19 | // deno-lint-ignore ban-types 20 | [function_name: string]: Function 21 | } 22 | 23 | export interface RawConfiguration { 24 | functions: string, 25 | port?: number, // Included only for punning Connector.start() 26 | hostname?: string, // Included only for punning Connector.start() 27 | schemaMode?: 'READ' | 'INFER', 28 | schemaLocation?: string, 29 | vendor?: string, 30 | preVendor?: boolean, 31 | } 32 | 33 | export const RAW_CONFIGURATION_SCHEMA: JSONSchemaObject = { 34 | description: 'Typescript (Deno) Connector Configuration', 35 | type: 'object', 36 | required: [ 'functions' ], 37 | properties: 38 | { 39 | functions: { 40 | description: 'Location of your functions entrypoint (default: ./functions/index.ts)', 41 | type: 'string' 42 | }, 43 | vendor: { 44 | description: 'Location of dependencies vendor folder (optional)', 45 | type: 'string' 46 | }, 47 | preVendor: { 48 | description: 'Perform vendoring prior to inference in a sub-process (default: true)', 49 | type: 'boolean' 50 | }, 51 | schemaMode: { 52 | description: 'INFER the schema from your functions, or READ it from a file.', 53 | type: "string", 54 | enum: ["READ", "INFER"] 55 | }, 56 | schemaLocation: { 57 | description: 'Location of your schema file. schemaMode=READ reads the file, schemaMode=INFER writes the file (optional)', 58 | type: 'string' 59 | }, 60 | } 61 | }; 62 | 63 | type Configuration = { 64 | inferenceConfig: InferenceConfig, 65 | programSchema: ProgramSchema, 66 | } 67 | 68 | type InferenceConfig = { 69 | functions: string, 70 | schemaMode: 'READ' | 'INFER', 71 | schemaLocation?: string, 72 | vendorDir: string, 73 | preVendor: boolean, 74 | } 75 | 76 | export const CAPABILITIES_RESPONSE: sdk.CapabilitiesResponse = { 77 | versions: "^0.1.0", 78 | capabilities: { 79 | query: { 80 | variables: {} 81 | }, 82 | }, 83 | }; 84 | 85 | type Payload = { 86 | function: string, 87 | args: Struct 88 | } 89 | 90 | 91 | ///////////////////// Helper Functions ///////////////////// 92 | 93 | 94 | /** 95 | * Performs analysis on the supplied program. 96 | * Expects that if there are dependencies then they will have been vendored. 97 | * 98 | * @param cmdObj 99 | * @returns Schema and argument position information 100 | */ 101 | export function getProgramSchema(cmdObj: InferenceConfig): ProgramSchema { 102 | switch(cmdObj.schemaMode) { 103 | /** 104 | * The READ option is available in case the user wants to pre-cache their schema during development. 105 | */ 106 | case 'READ': { 107 | if(!cmdObj.schemaLocation) { 108 | throw new Error('--schema-location is required if using --schema-mode READ'); 109 | } 110 | console.error(`Reading existing schema: ${cmdObj.schemaLocation}`); 111 | const bytes = Deno.readFileSync(cmdObj.schemaLocation); 112 | const decoder = new TextDecoder("utf-8"); 113 | const decoded = decoder.decode(bytes); 114 | return JSON.parse(decoded); 115 | } 116 | case 'INFER': { 117 | console.error(`Inferring schema with map location ${cmdObj.vendorDir}`); 118 | const programSchema = inferProgramSchema(cmdObj.functions, cmdObj.vendorDir, cmdObj.preVendor); 119 | const schemaLocation = cmdObj.schemaLocation; 120 | if(schemaLocation) { 121 | console.error(`Writing schema to ${cmdObj.schemaLocation}`); 122 | const infoString = JSON.stringify(programSchema); 123 | // NOTE: Using sync functions should be ok since they're run on startup. 124 | Deno.writeTextFileSync(schemaLocation, infoString); 125 | } 126 | return programSchema; 127 | } 128 | default: 129 | throw new Error('Invalid schema-mode. Use READ or INFER.'); 130 | } 131 | } 132 | 133 | /** 134 | * Performs invocation of the requested function. 135 | * Assembles the arguments into the correct order. 136 | * This doesn't catch any exceptions. 137 | * 138 | * @param functions 139 | * @param function_definitions 140 | * @param payload 141 | * @returns the result of invocation with no wrapper 142 | */ 143 | async function invoke(function_name: string, args: Record, functions: RuntimeFunctions, program_schema: ProgramSchema): Promise { 144 | const func = functions[function_name]; 145 | const prepared_args = prepare_arguments(function_name, args, program_schema.functions, program_schema.object_types); 146 | 147 | try { 148 | const result = func.apply(undefined, prepared_args); 149 | // Resolve the result if it is a promise 150 | if (typeof result === "object" && 'then' in result && typeof result.then === "function") { 151 | return await result; 152 | } 153 | return result; 154 | } catch (e) { 155 | throw new sdk.InternalServerError(`Error encountered when invoking function ${function_name}`, { message: e.message, stack: e.stack }); 156 | } 157 | } 158 | 159 | /** 160 | * This takes argument position information and a payload of function 161 | * and named arguments and returns the correctly ordered arguments ready to be applied. 162 | * 163 | * @param function_definitions 164 | * @param payload 165 | * @returns An array of the function's arguments in the definition order 166 | */ 167 | export function prepare_arguments(function_name: string, args: Record, function_definitions: FunctionDefinitions, object_type_definitions: ObjectTypeDefinitions): unknown[] { 168 | const function_definition = function_definitions[function_name]; 169 | 170 | if(!function_definition) { 171 | throw new sdk.InternalServerError(`Couldn't find function ${function_name} in schema.`); 172 | } 173 | 174 | return function_definition.arguments 175 | .map(argDef => coerce_argument_value(args[argDef.argument_name], argDef.type, [argDef.argument_name], object_type_definitions)); 176 | } 177 | 178 | function coerce_argument_value(value: unknown, type: TypeDefinition, value_path: string[], object_type_definitions: ObjectTypeDefinitions): unknown { 179 | switch (type.type) { 180 | case "array": 181 | if (!isArray(value)) 182 | throw new sdk.BadRequest(`Unexpected value in function arguments. Expected an array at '${value_path.join(".")}'.`); 183 | return value.map((element, index) => coerce_argument_value(element, type.element_type, [...value_path, `[${index}]`], object_type_definitions)) 184 | 185 | case "nullable": 186 | if (value === null) { 187 | return type.null_or_undefinability == NullOrUndefinability.AcceptsUndefinedOnly 188 | ? undefined 189 | : null; 190 | } else if (value === undefined) { 191 | return type.null_or_undefinability == NullOrUndefinability.AcceptsNullOnly 192 | ? null 193 | : undefined; 194 | } else { 195 | return coerce_argument_value(value, type.underlying_type, value_path, object_type_definitions) 196 | } 197 | case "named": 198 | if (type.kind === "scalar") { 199 | // Scalars are currently treated as opaque values, which is a bit dodgy 200 | return value; 201 | } else { 202 | const object_type_definition = object_type_definitions[type.name]; 203 | if (!object_type_definition) 204 | throw new sdk.InternalServerError(`Couldn't find object type '${type.name}' in the schema`); 205 | if (value === null || typeof value !== "object") { 206 | throw new sdk.BadRequest(`Unexpected value in function arguments. Expected an object at '${value_path.join(".")}'.`); 207 | } 208 | return Object.fromEntries(object_type_definition.properties.map(property_definition => { 209 | const prop_value = (value as Record)[property_definition.property_name]; 210 | return [property_definition.property_name, coerce_argument_value(prop_value, property_definition.type, [...value_path, property_definition.property_name], object_type_definitions)] 211 | })); 212 | } 213 | default: 214 | return unreachable(type["type"]); 215 | } 216 | } 217 | 218 | // TODO: https://github.com/hasura/ndc-typescript-deno/issues/26 Do deeper field recursion once that's available 219 | function pruneFields(func: string, fields: Struct | null | undefined, result: unknown): unknown { 220 | // This seems like a bug to request {} fields when expecting a scalar response... 221 | // File with engine? 222 | if(!fields || Object.keys(fields).length == 0) { 223 | // TODO: https://github.com/hasura/ndc-typescript-deno/issues/21 How to log with SDK? 224 | console.error(`Warning: No fields present in query for function ${func}.`); 225 | return result; 226 | } 227 | 228 | const response: Struct = {}; 229 | 230 | if (result === null || Array.isArray(result) || typeof result !== "object") { 231 | throw new sdk.InternalServerError(`Function '${func}' did not return an object when expected to`); 232 | } 233 | 234 | for(const [k,v] of Object.entries(fields)) { 235 | switch(v.type) { 236 | case 'column': 237 | response[k] = (result as Record)[v.column] ?? null; // Coalesce undefined into null to ensure we always have a value for a requested column 238 | break; 239 | default: 240 | console.error(`Function ${func} field of type ${v.type} is not supported.`); 241 | } 242 | } 243 | 244 | return response; 245 | } 246 | 247 | async function query( 248 | configuration: Configuration, 249 | state: State, 250 | functionName: string, 251 | requestArgs: Struct, 252 | requestFields: { [k: string]: sdk.Field; } | null, 253 | ): Promise { 254 | const result = await invoke(functionName, requestArgs, state.functions, configuration.programSchema); 255 | return pruneFields(functionName, requestFields, result); 256 | } 257 | 258 | function resolveArguments( 259 | func: string, 260 | requestArgs: Struct, 261 | requestVariables: { [k: string]: unknown }, 262 | ): Struct { 263 | const args = Object.fromEntries(Object.entries(requestArgs).map(([k,v], _i) => { 264 | const t = v.type; 265 | switch(t) { 266 | case 'literal': 267 | return [k, v.value]; 268 | case 'variable': { 269 | if(!(v.name in requestVariables)) { 270 | throw new Error(`Variable ${v.name} not found for function ${func}`); 271 | } 272 | const value = requestVariables[v.name]; 273 | return [k, value]; 274 | } 275 | default: 276 | return unreachable(t) 277 | } 278 | })); 279 | return args; 280 | } 281 | 282 | /** 283 | * See https://github.com/hasura/ndc-sdk-typescript for information on these interfaces. 284 | */ 285 | export const connector: sdk.Connector = { 286 | async try_init_state( 287 | config: Configuration, 288 | _metrics: unknown 289 | ): Promise { 290 | const functionsArg = config.inferenceConfig.functions; 291 | const functionsURL = `file://${functionsArg}`; // NOTE: This is required to run directly from deno.land. 292 | const functions = await import(functionsURL); 293 | return { 294 | functions 295 | } 296 | }, 297 | 298 | get_capabilities(_: Configuration): sdk.CapabilitiesResponse { 299 | return CAPABILITIES_RESPONSE; 300 | }, 301 | 302 | get_raw_configuration_schema(): JSONSchemaObject { 303 | return RAW_CONFIGURATION_SCHEMA; 304 | }, 305 | 306 | make_empty_configuration(): RawConfiguration { 307 | const conf: RawConfiguration = { 308 | functions: './functions/index.ts', 309 | vendor: './vendor' 310 | }; 311 | return conf; 312 | }, 313 | 314 | // TODO: https://github.com/hasura/ndc-typescript-deno/issues/27 Make this add in the defaults 315 | update_configuration(configuration: RawConfiguration): Promise { 316 | return Promise.resolve(configuration); 317 | }, 318 | 319 | validate_raw_configuration(configuration: RawConfiguration): Promise { 320 | if (configuration.functions.trim() === "") { 321 | throw new sdk.BadRequest("'functions' must be set to the location of the TypeScript file that contains your functions") 322 | } 323 | if (configuration.schemaMode === "READ" && !configuration.schemaLocation) { 324 | throw new sdk.BadRequest("'schemaLocation' must be set if 'schemaMode' is READ"); 325 | } 326 | const inferenceConfig: InferenceConfig = { 327 | functions: resolve(configuration.functions), 328 | schemaMode: configuration.schemaMode ?? "INFER", 329 | preVendor: configuration.preVendor ?? true, 330 | schemaLocation: configuration.schemaLocation, 331 | vendorDir: resolve(configuration.vendor || "./vendor"), 332 | }; 333 | const programSchema = getProgramSchema(inferenceConfig); 334 | return Promise.resolve({ 335 | inferenceConfig, 336 | programSchema 337 | }); 338 | }, 339 | 340 | get_schema(config: Configuration): Promise { 341 | return Promise.resolve(get_ndc_schema(config.programSchema)); 342 | }, 343 | 344 | // TODO: https://github.com/hasura/ndc-typescript-deno/issues/28 What do we want explain to do in this scenario? 345 | explain( 346 | _configuration: Configuration, 347 | _: State, 348 | _request: sdk.QueryRequest 349 | ): Promise { 350 | throw new Error('Implementation of `explain` pending.'); 351 | }, 352 | 353 | // NOTE: query and mutation both make all functions available and discrimination is performed by the schema 354 | async query( 355 | configuration: Configuration, 356 | state: State, 357 | request: sdk.QueryRequest 358 | ): Promise { 359 | 360 | const rowSets: sdk.RowSet[] = []; 361 | for(const variables of request.variables ?? [{}]) { 362 | const args = resolveArguments(request.collection, request.arguments, variables); 363 | const result = await query(configuration, state, request.collection, args, request.query.fields ?? null); 364 | rowSets.push({ 365 | aggregates: {}, 366 | rows: [ 367 | { '__value': result } 368 | ] 369 | }); 370 | } 371 | 372 | return rowSets; 373 | }, 374 | 375 | async mutation( 376 | configuration: Configuration, 377 | state: State, 378 | request: sdk.MutationRequest 379 | ): Promise { 380 | const results: Array = []; 381 | for(const op of request.operations) { 382 | switch(op.type) { 383 | case 'procedure': { 384 | const result = await query(configuration, state, op.name, op.arguments, op.fields ?? null); 385 | results.push({ 386 | affected_rows: 1, 387 | returning: [{ 388 | '__value': result 389 | }] 390 | }); 391 | break; 392 | } 393 | default: 394 | throw new Error(`Mutation type ${op.type} not supported.`); 395 | } 396 | } 397 | return { 398 | operation_results: results 399 | } 400 | }, 401 | 402 | // If the connector starts successfully it should be healthy 403 | health_check(_: Configuration, __: State): Promise { 404 | return Promise.resolve(undefined); 405 | }, 406 | 407 | // TODO: https://github.com/hasura/ndc-typescript-deno/issues/29 https://qdrant.github.io/qdrant/redoc/index.html#tag/service/operation/metrics 408 | fetch_metrics(_: Configuration, __: State): Promise { 409 | return Promise.resolve(undefined); 410 | }, 411 | }; 412 | -------------------------------------------------------------------------------- /src/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | #### 4 | # This script serves as the entrypoint for the Dockerfile 5 | # It is used both during the build phase (with PRECACHE_ONLY=true), and during the run phase. 6 | # This could be split into two scripts easily enough if that is required. 7 | #### 8 | 9 | set -e 10 | 11 | cd /functions 12 | 13 | if [ ! -f ./src/index.ts ] 14 | then 15 | echo "No /functions/src/index.ts found - Please place your functions in /functions/src" 16 | exit 0 17 | fi 18 | 19 | if [ -d vendor ] 20 | then 21 | echo "found existing vendor results" 22 | else 23 | deno vendor --node-modules-dir --vendor /functions/vendor -f ./src/index.ts 24 | fi 25 | 26 | if [ -f schema.json ] 27 | then 28 | echo "found existing inference results" 29 | else 30 | deno run \ 31 | --allow-env --allow-sys --allow-read --allow-net --allow-write \ 32 | /app/mod.ts infer \ 33 | --vendor /functions/vendor \ 34 | ./src/index.ts >schema.json 35 | fi 36 | 37 | if [ "$PRECACHE_ONLY" ] 38 | then 39 | echo "Thanks for running pre-caching - Please come again soon!" 40 | exit 0 41 | fi 42 | 43 | if [[ "$WATCH" == "1" || "$WATCH" == "true" ]] 44 | then 45 | DENO_PARAMS="--watch=/functions/src --no-clear-screen" 46 | echo '{"functions": "/functions/src/index.ts", "vendor": "/functions/vendor", "preVendor": true, "schemaMode": "INFER" }' \ 47 | > /etc/connector-config.json 48 | 49 | else 50 | DENO_PARAMS="" 51 | echo '{"functions": "/functions/src/index.ts", "vendor": "/functions/vendor", "preVendor": false, "schemaMode": "READ", "schemaLocation": "/functions/schema.json"}' \ 52 | > /etc/connector-config.json 53 | fi 54 | 55 | deno run \ 56 | --allow-run --allow-net --allow-read --allow-write --allow-env --allow-sys \ 57 | $DENO_PARAMS \ 58 | /app/mod.ts serve \ 59 | --port 8080 \ 60 | --configuration /etc/connector-config.json 61 | -------------------------------------------------------------------------------- /src/infer.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * This module provides the inference implementation for the connector. 4 | * It relies on the Typescript compiler to perform the heavy lifting. 5 | * 6 | * The exported function that is intended for use is `inferProgramSchema`. 7 | * 8 | * Dependencies are required to be vendored before invocation. 9 | */ 10 | 11 | import ts, { FunctionDeclaration, StringLiteralLike } from "npm:typescript@5.1.6"; 12 | import { resolve, dirname } from "https://deno.land/std@0.208.0/path/mod.ts"; 13 | import { existsSync } from "https://deno.land/std@0.208.0/fs/mod.ts"; 14 | import { mapObject } from "./util.ts"; 15 | import { FunctionDefinitions, FunctionNdcKind, NullOrUndefinability, ObjectTypeDefinitions, ProgramSchema, ScalarTypeDefinitions, TypeDefinition, listing } from "./schema.ts"; 16 | 17 | export type Struct = Record; 18 | 19 | const scalar_mappings: {[key: string]: string} = { 20 | "string": "String", 21 | "bool": "Boolean", 22 | "boolean": "Boolean", 23 | "number": "Float", 24 | "arraybuffer": "ArrayBuffer", // Treat ArrayBuffer as scalar since it shouldn't recur 25 | "blob": "Blob", // Treat ArrayBuffer as scalar since it shouldn't recur 26 | // "void": "Void", // Void type can be included to permit void types as scalars 27 | }; 28 | 29 | // TODO: https://github.com/hasura/ndc-typescript-deno/issues/21 Use standard logging from SDK 30 | const LOG_LEVEL = Deno.env.get("LOG_LEVEL") || "INFO"; 31 | const DEBUG = LOG_LEVEL == 'DEBUG'; 32 | const MAX_INFERENCE_RECURSION = 20; // Better to abort than get into an infinite loop, this could be increased if required. 33 | 34 | function gql_name(n: string): string { 35 | // Construct a GraphQL complient name: https://spec.graphql.org/draft/#sec-Type-Name-Introspection 36 | // Check if this is actually required. 37 | return n.replace(/^[^a-zA-Z]/, '').replace(/[^0-9a-zA-Z]/g,'_'); 38 | } 39 | 40 | function qualify_type_name(root_file: string, t: any, name: string): string { 41 | let symbol = t.getSymbol(); 42 | 43 | if (!symbol) { 44 | try { 45 | symbol = t.types[0].getSymbol(); 46 | } catch (e) { 47 | throw new Error(`Couldn't find symbol for type ${name}`, { cause: e }); 48 | } 49 | } 50 | 51 | const locations = symbol.declarations.map((d: ts.Declaration) => d.getSourceFile()); 52 | for(const f of locations) { 53 | const where = f.fileName; 54 | const short = where.replace(dirname(root_file) + '/','').replace(/\.ts$/, ''); 55 | 56 | // If the type is present in the entrypoint, don't qualify the name 57 | // If it is under the entrypoint's directory qualify with the subpath 58 | // Otherwise, use the minimum ancestor of the type's location to ensure non-conflict 59 | if(root_file == where) { 60 | return name; 61 | } else if (short.length < where.length) { 62 | return `${gql_name(short)}_${name}`; 63 | } else { 64 | throw new Error(`Unsupported location for type ${name} in ${where}`); 65 | } 66 | } 67 | 68 | throw new Error(`Couldn't find any declarations for type ${name}`); 69 | } 70 | 71 | function validate_type(root_file: string, checker: ts.TypeChecker, object_type_definitions: ObjectTypeDefinitions, scalar_type_definitions: ScalarTypeDefinitions, name: string, ty: any, depth: number): TypeDefinition { 72 | const type_str = checker.typeToString(ty); 73 | const type_name = ty.symbol?.escapedName || ty.intrinsicName || 'unknown_type'; 74 | const type_name_lower: string = type_name.toLowerCase(); 75 | 76 | if(depth > MAX_INFERENCE_RECURSION) { 77 | throw_error(`Schema inference validation exceeded depth ${MAX_INFERENCE_RECURSION} for type ${type_str}`); 78 | } 79 | 80 | // PROMISE 81 | // TODO: https://github.com/hasura/ndc-typescript-deno/issues/32 There is no recursion that resolves inner promises. 82 | // Nested promises should be resolved in the function definition. 83 | // TODO: promises should not be allowed in parameters 84 | if (type_name == "Promise") { 85 | const inner_type = ty.resolvedTypeArguments[0]; 86 | const inner_type_result = validate_type(root_file, checker, object_type_definitions, scalar_type_definitions, name, inner_type, depth + 1); 87 | return inner_type_result; 88 | } 89 | 90 | // ARRAY 91 | if (checker.isArrayType(ty)) { 92 | const inner_type = ty.resolvedTypeArguments[0]; 93 | const inner_type_result = validate_type(root_file, checker, object_type_definitions, scalar_type_definitions, `Array_of_${name}`, inner_type, depth + 1); 94 | return { type: 'array', element_type: inner_type_result }; 95 | } 96 | 97 | // NULL OR UNDEFINED TYPES (x | null, x | undefined, x | null | undefined) 98 | const not_nullable_result = unwrap_nullable_type(ty); 99 | if (not_nullable_result !== null) { 100 | const [not_nullable_type, null_or_undefinability] = not_nullable_result; 101 | const not_nullable_type_result = validate_type(root_file, checker, object_type_definitions, scalar_type_definitions, `Array_of_${name}`, not_nullable_type, depth + 1); 102 | return { type: "nullable", underlying_type: not_nullable_type_result, null_or_undefinability: null_or_undefinability } 103 | } 104 | 105 | // Named SCALAR 106 | if (scalar_mappings[type_name_lower]) { 107 | const type_name_gql = scalar_mappings[type_name_lower]; 108 | scalar_type_definitions[type_name_gql] = {}; 109 | return { type: 'named', name: type_name_gql, kind: "scalar" }; 110 | } 111 | 112 | // OBJECT 113 | // TODO: https://github.com/hasura/ndc-typescript-deno/issues/33 There should be a library function that allows us to check this case 114 | const info = get_object_type_info(root_file, checker, ty, name); 115 | if (info) { 116 | const type_str_qualified = info.type_name; 117 | 118 | // Shortcut recursion if the type has already been named 119 | if (object_type_definitions[type_str_qualified]) { 120 | return { type: 'named', name: type_str_qualified, kind: "object" }; 121 | } 122 | 123 | object_type_definitions[type_str_qualified] = { properties: [] }; // Break infinite recursion 124 | 125 | const properties = Array.from(info.members, ([property_name, property_type]) => { 126 | const property_type_validated = validate_type(root_file, checker, object_type_definitions, scalar_type_definitions, `${name}_field_${property_name}`, property_type, depth + 1); 127 | return { property_name, type: property_type_validated }; 128 | }); 129 | 130 | object_type_definitions[type_str_qualified] = { properties } 131 | 132 | return { type: 'named', name: type_str_qualified, kind: "object" } 133 | } 134 | 135 | // TODO: We could potentially support classes, but only as return types, not as function arguments 136 | if ((ty.objectFlags & ts.ObjectFlags.Class) !== 0) { 137 | console.error(`class types are not supported: ${name}`); 138 | throw_error('validate_type failed'); 139 | } 140 | 141 | if (ty === checker.getVoidType()) { 142 | console.error(`void functions are not supported: ${name}`); 143 | throw_error('validate_type failed'); 144 | } 145 | 146 | // UNHANDLED: Assume that the type is a scalar 147 | console.error(`Unable to validate type of ${name}: ${type_str} (${type_name}). Assuming that it is a scalar type.`); 148 | scalar_type_definitions[name] = {}; 149 | return { type: 'named', name, kind: "scalar" }; 150 | } 151 | 152 | /** 153 | * Executes `deno vendor` in a subprocess as a conveneience. 154 | * 155 | * @param vendorPath 156 | * @param filename 157 | */ 158 | function pre_vendor(vendorPath: string, filename: string) { 159 | // Exampe taken from: 160 | // https://docs.deno.com/runtime/tutorials/subprocess 161 | const deno_exec_path = Deno.execPath(); 162 | const vendor_args = [ "vendor", "--node-modules-dir", "--output", vendorPath, "--force", filename ]; 163 | 164 | console.error(`Vendoring dependencies: ${[deno_exec_path, ...vendor_args].join(" ")}`); 165 | 166 | const vendor_command = new Deno.Command(deno_exec_path, { args: vendor_args }); 167 | const { code, stdout, stderr } = vendor_command.outputSync(); 168 | 169 | if(code !== 0) { 170 | console.error(`Error: Got code ${code} during deno vendor operation.`) 171 | console.error(`stdout: ${new TextDecoder().decode(stdout)}`); 172 | console.error(`stderr: ${new TextDecoder().decode(stderr)}`); 173 | throw_error('pre_vendor failed'); 174 | } 175 | } 176 | 177 | function throw_error(message: string): never { 178 | throw new Error(message); 179 | } 180 | 181 | /** 182 | * Returns the flags associated with a type. 183 | */ 184 | function which_flags(flags_enum: Record, value: number): string[] { 185 | return Object 186 | .keys(flags_enum) 187 | .flatMap(k => { 188 | const k_int = parseInt(k); 189 | return isNaN(k_int) 190 | ? [] 191 | : (value & k_int) !== 0 192 | ? [flags_enum[k] as string] 193 | : [] 194 | }); 195 | } 196 | 197 | type ObjectTypeInfo = { 198 | // The qualified name of the type 199 | type_name: string, 200 | // Parameter types used with this type in positional order 201 | generic_parameter_types: readonly ts.Type[] 202 | // The member properties of the object type. The types are 203 | // concrete types after type parameter resolution 204 | members: Map 205 | } 206 | 207 | function get_members(checker: ts.TypeChecker, ty: ts.Type, member_names: string[]) { 208 | return new Map( 209 | member_names.map(name => [name, checker.getTypeOfSymbol(checker.getPropertyOfType(ty, name)!)]) 210 | ) 211 | } 212 | 213 | function get_object_type_info(root_file: string, checker: ts.TypeChecker, ty: any, contextual_name: string): ObjectTypeInfo | null { 214 | // Anonymous object type - this covers: 215 | // - {a: number, b: string} 216 | // - type Bar = { test: string } 217 | // - type GenericBar = { data: T } 218 | if ((ty.objectFlags & ts.ObjectFlags.Anonymous) !== 0) { 219 | const members = 220 | ty.aliasTypeArguments !== undefined 221 | ? ty.target.members 222 | : ty.members; 223 | return { 224 | type_name: qualify_type_name(root_file, ty, ty.aliasSymbol ? checker.typeToString(ty) : contextual_name), 225 | generic_parameter_types: ty.aliasTypeArguments ?? [], 226 | members: get_members(checker, ty, Array.from(members.keys())), 227 | } 228 | } 229 | // Interface type - this covers: 230 | // interface IThing { test: string } 231 | else if ((ty.objectFlags & ts.ObjectFlags.Interface) !== 0) { 232 | return { 233 | type_name: ty.symbol.escapedName, 234 | generic_parameter_types: [], 235 | members: get_members(checker, ty, Array.from(ty.members.keys())), 236 | } 237 | } 238 | // Generic interface type - this covers: 239 | // interface IGenericThing { data: T } 240 | else if ((ty.objectFlags & ts.ObjectFlags.Reference) !== 0 && (ty.target.objectFlags & ts.ObjectFlags.Interface) !== 0 && checker.isArrayType(ty) == false && ty.symbol.escapedName !== "Promise") { 241 | return { 242 | type_name: ty.symbol.escapedName, 243 | generic_parameter_types: ty.typeArguments, 244 | members: get_members(checker, ty, Array.from(ty.target.members.keys())), 245 | } 246 | } 247 | // Intersection type - this covers: 248 | // - { num: number } & Bar 249 | // - type IntersectionObject = { wow: string } & Bar 250 | // - type GenericIntersectionObject = { data: T } & Bar 251 | else if ((ty.flags & ts.TypeFlags.Intersection) !== 0) { 252 | return { 253 | type_name: qualify_type_name(root_file, ty, ty.aliasSymbol ? checker.typeToString(ty) : contextual_name), 254 | generic_parameter_types: ty.aliasTypeArguments ?? [], 255 | members: new Map(ty.resolvedProperties.map((symbol: ts.Symbol) => [symbol.name, checker.getTypeOfSymbol(symbol)])), 256 | } 257 | } 258 | 259 | return null; 260 | } 261 | 262 | function unwrap_nullable_type(ty: ts.Type): [ts.Type, NullOrUndefinability] | null { 263 | if (!ty.isUnion()) return null; 264 | 265 | const isNullable = ty.types.find(is_null_type) !== undefined; 266 | const isUndefined = ty.types.find(is_undefined_type) !== undefined; 267 | const nullOrUndefinability = 268 | isNullable 269 | ? ( isUndefined 270 | ? NullOrUndefinability.AcceptsEither 271 | : NullOrUndefinability.AcceptsNullOnly 272 | ) 273 | : ( isUndefined 274 | ? NullOrUndefinability.AcceptsUndefinedOnly 275 | : null 276 | ); 277 | 278 | const typesWithoutNullAndUndefined = ty.types 279 | .filter(t => !is_null_type(t) && !is_undefined_type(t)); 280 | 281 | return typesWithoutNullAndUndefined.length === 1 && nullOrUndefinability 282 | ? [typesWithoutNullAndUndefined[0], nullOrUndefinability] 283 | : null; 284 | } 285 | 286 | function is_null_type(ty: ts.Type): boolean { 287 | return (ty.flags & ts.TypeFlags.Null) !== 0; 288 | } 289 | function is_undefined_type(ty: ts.Type): boolean { 290 | return (ty.flags & ts.TypeFlags.Undefined) !== 0; 291 | } 292 | 293 | export function inferProgramSchema(filename: string, vendorPath: string, perform_vendor: boolean): ProgramSchema { 294 | // TODO: https://github.com/hasura/ndc-typescript-deno/issues/27 This should have already been established upstream 295 | const importMapPath = `${vendorPath}/import_map.json`; 296 | let pathsMap: {[key: string]: Array} = {}; 297 | 298 | // NOTE: We can't just move this inside the else branch of the exists importMap check 299 | // Since the dependencies may change when updating your functions. 300 | if(perform_vendor) { 301 | pre_vendor(vendorPath, filename); 302 | } 303 | 304 | if (existsSync(importMapPath)) { 305 | const importString = Deno.readTextFileSync(importMapPath); 306 | const vendorMap = JSON.parse(importString); 307 | pathsMap = mapObject(vendorMap.imports, (k: string, v: string) => { 308 | if(/\.ts$/.test(k)) { 309 | return [k, [ v.replace(/./, vendorPath) ]]; 310 | } else { 311 | return [k.replace(/$/,'*'), [ v.replace(/./, vendorPath).replace(/$/, '*') ]]; 312 | } 313 | }); 314 | } else { 315 | // NOTE: We allow the import map to be optional but dependency lookup will fail if it was required. 316 | console.error(`Couldn't find import map: ${importMapPath}`); 317 | } 318 | 319 | const deno_d_ts = Deno.makeTempFileSync({ suffix: ".d.ts" }); 320 | Deno.writeTextFileSync(deno_d_ts, ` 321 | /** 322 | * This module exists to be included as a library by the typescript compiler in infer.ts. 323 | * The reason for this is that the user is likely to use the Deno dev tools when developing their functions. 324 | * And they will have Deno in scope. 325 | * This ensures that these references will typecheck correctly in infer.ts. 326 | */ 327 | 328 | export {}; 329 | 330 | declare global { 331 | var Deno: any 332 | } 333 | `); 334 | 335 | const compilerOptions: ts.CompilerOptions = { 336 | // This should match the version targeted in the deno version that is being used. 337 | target: ts.ScriptTarget.ES2022, 338 | module: ts.ModuleKind.CommonJS, 339 | noImplicitAny: true, 340 | // NOTE: We just declare Deno globally as any in order to allow users to omit it's declaration in their function files 341 | // This should ideally use the real deno type definitions. 342 | lib: ['lib.d.ts', 'lib.es2022.d.ts', resolve(deno_d_ts)], 343 | allowJs: true, 344 | allowImportingTsExtensions: true, 345 | noEmit: true, 346 | baseUrl: '.', 347 | paths: pathsMap, 348 | strictNullChecks: true, 349 | }; 350 | 351 | const host = ts.createCompilerHost(compilerOptions); 352 | host.resolveModuleNameLiterals = (moduleLiterals: StringLiteralLike[], containingFile: string): ts.ResolvedModuleWithFailedLookupLocations[] => { 353 | return moduleLiterals.map(moduleName => { 354 | let moduleNameToResolve = moduleName.text; 355 | // If this looks like a Deno "npm:pkgName[@version][/path]" module import, extract the node module 356 | // name and resolve that instead. So long as we've done a deno vendor with --node-modules-dir 357 | // then we'll have a node_modules directory that the standard TypeScript module resolution 358 | // process can locate the npm package in by its name 359 | const npmDepMatch = /^npm:(?(?:@.+?\/)?[^/\n]+?)(?:@.+)?(?:\/.+)?$/.exec(moduleName.text); 360 | if (npmDepMatch) { 361 | moduleNameToResolve = npmDepMatch.groups?.pkgName!; 362 | } 363 | 364 | return ts.resolveModuleName(moduleNameToResolve, containingFile, compilerOptions, { fileExists: host.fileExists, readFile: host.readFile }); 365 | }) 366 | } 367 | 368 | const program = ts.createProgram([filename], compilerOptions, host); 369 | 370 | Deno.removeSync(deno_d_ts); 371 | 372 | // These diagnostic codes are ignored because Deno ignores them 373 | // See: https://github.com/denoland/deno/blob/bf42467e215b20b36ec6b4bf30212e4beb2dd01f/cli/tsc/99_main_compiler.js#L441 374 | const ignoredDiagnosticCodes = [1452, 2306, 2688, 2792, 5009, 5055, 5070, 7016]; 375 | const diagnostics = ts.getPreEmitDiagnostics(program); 376 | 377 | // https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API 378 | if (diagnostics.length) { 379 | let fatal = 0; 380 | console.error(`There were ${diagnostics.length} diagnostic errors.`); 381 | diagnostics.filter(d => !ignoredDiagnosticCodes.includes(d.code)).forEach(diagnostic => { 382 | if (diagnostic.file) { 383 | let errorPrefix = ""; 384 | const isFatal = !resolve(diagnostic.file.fileName).startsWith(vendorPath) 385 | if (isFatal) { 386 | fatal++; 387 | errorPrefix = "FATAL: " 388 | } 389 | const { line, character } = ts.getLineAndCharacterOfPosition(diagnostic.file, diagnostic.start!); 390 | const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); 391 | console.error(`${errorPrefix}${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`); 392 | } else { 393 | console.error(`FATAL: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`); 394 | fatal++; 395 | } 396 | }); 397 | 398 | if(fatal > 0) { 399 | throw_error(`Fatal errors: ${fatal}`); 400 | } 401 | } 402 | 403 | const checker = program.getTypeChecker(); 404 | 405 | const object_type_definitions: ObjectTypeDefinitions = {}; 406 | const function_definitions: FunctionDefinitions = {}; 407 | const scalar_type_definitions: ScalarTypeDefinitions = {}; 408 | 409 | function isExported(node: FunctionDeclaration): boolean { 410 | for(const mod of node.modifiers || []) { 411 | if(mod.kind == ts.SyntaxKind.ExportKeyword) { 412 | return true; 413 | } 414 | } 415 | return false; 416 | } 417 | 418 | for (const src of program.getSourceFiles()) { 419 | if (src.isDeclarationFile) { 420 | if(DEBUG) { 421 | console.error(`Skipping analysis of declaration source: ${src.fileName}`); 422 | } 423 | continue; 424 | } 425 | 426 | if (resolve(src.fileName) != resolve(filename)) { 427 | if(DEBUG) { 428 | console.error(`Skipping analysis of source with resolve inconsistency: ${src.fileName}`); 429 | } 430 | continue; 431 | } 432 | 433 | const root_file = resolve(filename); 434 | 435 | ts.forEachChild(src, (node: ts.Node) => { 436 | if (ts.isFunctionDeclaration(node)) { 437 | const fn_sym = checker.getSymbolAtLocation(node.name!)!; 438 | const fn_name = fn_sym.escapedName; 439 | 440 | if(!isExported(node)) { 441 | console.error(`Skipping non-exported function: ${fn_name}`); 442 | return; 443 | } 444 | 445 | const fn_type = checker.getTypeOfSymbolAtLocation(fn_sym, fn_sym.valueDeclaration!); 446 | const fn_desc = ts.displayPartsToString(fn_sym.getDocumentationComment(checker)).trim(); 447 | const fn_tags = fn_sym.getJsDocTags(); 448 | const fn_pure = !!(fn_tags.find((e) => e.name == 'pure')); 449 | 450 | const call = fn_type.getCallSignatures()[0]!; 451 | const result_type = call.getReturnType(); 452 | const result_type_name = `${fn_name}_output`; 453 | 454 | const result_type_validated = validate_type(root_file, checker, object_type_definitions, scalar_type_definitions, result_type_name, result_type, 0); 455 | 456 | const function_arguments = call.parameters.map(param => { 457 | const param_name = param.getName(); 458 | const param_desc = ts.displayPartsToString(param.getDocumentationComment(checker)).trim(); 459 | const param_type = checker.getTypeOfSymbolAtLocation(param, param.valueDeclaration!); 460 | // TODO: https://github.com/hasura/ndc-typescript-deno/issues/34 Use the user's given type name if one exists. 461 | const type_name = `${fn_name}_arguments_${param_name}`; 462 | const param_type_validated = validate_type(root_file, checker, object_type_definitions, scalar_type_definitions, type_name, param_type, 0); // E.g. `bio_arguments_username` 463 | const description = param_desc ? param_desc : null 464 | 465 | return { 466 | argument_name: param_name, 467 | description, 468 | type: param_type_validated, 469 | } 470 | }); 471 | 472 | function_definitions[node.name!.text] = { 473 | ndc_kind: fn_pure ? FunctionNdcKind.Function : FunctionNdcKind.Procedure, 474 | description: fn_desc ? fn_desc : null, 475 | arguments: function_arguments, 476 | result_type: result_type_validated 477 | }; 478 | } 479 | }); 480 | 481 | } 482 | 483 | const result = { 484 | functions: function_definitions, 485 | object_types: object_type_definitions, 486 | scalar_types: scalar_type_definitions, 487 | } 488 | 489 | listing(FunctionNdcKind.Function, result.functions) 490 | listing(FunctionNdcKind.Procedure, result.functions) 491 | 492 | return result; 493 | } 494 | -------------------------------------------------------------------------------- /src/mod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Typescript entrypoint for running the connector. 3 | */ 4 | 5 | // NOTE: Ensure that sdk matches version in connector.ts 6 | import * as commander from 'npm:commander@11.0.0'; 7 | import { inferProgramSchema } from './infer.ts' 8 | import { connector } from './connector.ts' 9 | import { sdk } from './sdk.ts'; 10 | 11 | const inferCommand = new commander.Command("infer") 12 | .argument('', 'TypeScript source entrypoint') 13 | .option('-v, --vendor ', 'Vendor location (optional)') 14 | .action((entrypoint, cmdObj, _command) => { 15 | const output = inferProgramSchema(entrypoint, cmdObj.vendor, cmdObj.preVendor); 16 | console.log(JSON.stringify(output)); 17 | }); 18 | 19 | const program = new commander.Command('typescript-connector'); 20 | 21 | program.addCommand(sdk.get_serve_command(connector)); 22 | program.addCommand(sdk.get_serve_configuration_command(connector)); 23 | program.addCommand(inferCommand); 24 | 25 | // The commander library expects node style arguments that have 26 | // 'node' and the entrypoint as the first two arguments. 27 | // The node_style_args array makes Deno.args compatible. 28 | // The import.meta.url is used instead of a file, since it may be invoked from deno.land 29 | const node_style_args = [Deno.execPath(), import.meta.url, ...Deno.args]; 30 | 31 | program.parseAsync(node_style_args).catch(console.error); 32 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | import { sdk } from './sdk.ts'; 2 | import { mapObjectValues, unreachable } from "./util.ts"; 3 | 4 | export type ProgramSchema = { 5 | functions: FunctionDefinitions 6 | object_types: ObjectTypeDefinitions 7 | scalar_types: ScalarTypeDefinitions 8 | } 9 | 10 | export type FunctionDefinitions = { 11 | [function_name: string]: FunctionDefinition 12 | } 13 | 14 | export type FunctionDefinition = { 15 | ndc_kind: FunctionNdcKind 16 | description: string | null, 17 | arguments: ArgumentDefinition[] // Function arguments are ordered 18 | result_type: TypeDefinition 19 | } 20 | 21 | export enum FunctionNdcKind { 22 | Function = "Function", 23 | Procedure = "Procedure" 24 | } 25 | 26 | export type ArgumentDefinition = { 27 | argument_name: string, 28 | description: string | null, 29 | type: TypeDefinition 30 | } 31 | 32 | export type ObjectTypeDefinitions = { 33 | [object_type_name: string]: ObjectTypeDefinition 34 | } 35 | 36 | export type ObjectTypeDefinition = { 37 | properties: ObjectPropertyDefinition[] 38 | } 39 | 40 | export type ObjectPropertyDefinition = { 41 | property_name: string, 42 | type: TypeDefinition, 43 | } 44 | 45 | export type ScalarTypeDefinitions = { 46 | [scalar_type_name: string]: ScalarTypeDefinition 47 | } 48 | 49 | export type ScalarTypeDefinition = Record // Empty object, for now 50 | 51 | export type TypeDefinition = ArrayTypeDefinition | NullableTypeDefinition | NamedTypeDefinition 52 | 53 | export type ArrayTypeDefinition = { 54 | type: "array" 55 | element_type: TypeDefinition 56 | } 57 | 58 | export type NullableTypeDefinition = { 59 | type: "nullable", 60 | null_or_undefinability: NullOrUndefinability 61 | underlying_type: TypeDefinition 62 | } 63 | 64 | export type NamedTypeDefinition = { 65 | type: "named" 66 | name: string 67 | kind: "scalar" | "object" 68 | } 69 | 70 | export enum NullOrUndefinability { 71 | AcceptsNullOnly = "AcceptsNullOnly", 72 | AcceptsUndefinedOnly = "AcceptsUndefinedOnly", 73 | AcceptsEither = "AcceptsEither", 74 | } 75 | 76 | export function get_ndc_schema(programInfo: ProgramSchema): sdk.SchemaResponse { 77 | const functions = Object.entries(programInfo.functions); 78 | 79 | const object_types = mapObjectValues(programInfo.object_types, obj_def => { 80 | return { 81 | fields: Object.fromEntries(obj_def.properties.map(prop_def => [prop_def.property_name, { type: convert_type_definition_to_sdk_type(prop_def.type)}])) 82 | } 83 | }); 84 | 85 | const scalar_types = mapObjectValues(programInfo.scalar_types, _scalar_def => { 86 | return { 87 | aggregate_functions: {}, 88 | comparison_operators: {}, 89 | } 90 | }) 91 | 92 | return { 93 | functions: functions 94 | .filter(([_, def]) => def.ndc_kind === FunctionNdcKind.Function) 95 | .map(([name, def]) => convert_function_definition_to_sdk_schema_type(name, def)), 96 | procedures: functions 97 | .filter(([_, def]) => def.ndc_kind === FunctionNdcKind.Procedure) 98 | .map(([name, def]) => convert_function_definition_to_sdk_schema_type(name, def)), 99 | collections: [], 100 | object_types, 101 | scalar_types, 102 | } 103 | } 104 | 105 | function convert_type_definition_to_sdk_type(typeDef: TypeDefinition): sdk.Type { 106 | switch (typeDef.type) { 107 | case "array": return { type: "array", element_type: convert_type_definition_to_sdk_type(typeDef.element_type) } 108 | case "nullable": return { type: "nullable", underlying_type: convert_type_definition_to_sdk_type(typeDef.underlying_type) } 109 | case "named": return { type: "named", name: typeDef.name } 110 | default: return unreachable(typeDef["type"]) 111 | } 112 | } 113 | 114 | function convert_function_definition_to_sdk_schema_type(function_name: string, definition: FunctionDefinition): sdk.FunctionInfo | sdk.ProcedureInfo { 115 | const args = 116 | definition.arguments 117 | .map(arg_def => 118 | [ arg_def.argument_name, 119 | { 120 | type: convert_type_definition_to_sdk_type(arg_def.type), 121 | ...(arg_def.description ? { description: arg_def.description } : {}), 122 | } 123 | ] 124 | ); 125 | 126 | return { 127 | name: function_name, 128 | arguments: Object.fromEntries(args), 129 | result_type: convert_type_definition_to_sdk_type(definition.result_type), 130 | ...(definition.description ? { description: definition.description } : {}), 131 | } 132 | } 133 | 134 | /** 135 | * Logs simple listing of functions/procedures on stderr. 136 | * 137 | * @param prompt 138 | * @param functionDefinitions 139 | * @param info 140 | */ 141 | export function listing(functionNdcKind: FunctionNdcKind, functionDefinitions: FunctionDefinitions) { 142 | const functions = Object.entries(functionDefinitions).filter(([_, def]) => def.ndc_kind === functionNdcKind); 143 | if (functions.length > 0) { 144 | console.error(``); 145 | console.error(`${functionNdcKind}s:`) 146 | for (const [function_name, function_definition] of functions) { 147 | const args = function_definition.arguments.join(', '); 148 | console.error(`* ${function_name}(${args})`); 149 | } 150 | console.error(``); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/sdk.ts: -------------------------------------------------------------------------------- 1 | // This pins the fastify version (transitively used by the ndc-sdk-typescript) 2 | // because 4.26.0 introduces a deno-incompatible change 3 | import fastify from "npm:fastify@4.25.2" 4 | // Have this dependency defined in one place 5 | export * as sdk from 'npm:@hasura/ndc-sdk-typescript@1.2.8'; 6 | -------------------------------------------------------------------------------- /src/test/classes_test.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as test from "https://deno.land/std@0.208.0/assert/mod.ts"; 3 | import * as path from "https://deno.land/std@0.208.0/path/mod.ts"; 4 | import * as infer from '../infer.ts'; 5 | 6 | // Classes are currently not supoported and should throw an error 7 | Deno.test("Classes", () => { 8 | const program_path = path.fromFileUrl(import.meta.resolve('./data/classes.ts')); 9 | const vendor_path = path.fromFileUrl(import.meta.resolve('./vendor')); 10 | test.assertThrows(() => { 11 | infer.inferProgramSchema(program_path, vendor_path, false); 12 | }) 13 | }); 14 | -------------------------------------------------------------------------------- /src/test/complex_dependency_test.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as test from "https://deno.land/std@0.208.0/assert/mod.ts"; 3 | import * as path from "https://deno.land/std@0.208.0/path/mod.ts"; 4 | import * as infer from '../infer.ts'; 5 | import { FunctionNdcKind } from "../schema.ts"; 6 | 7 | // This program omits its return type and it is inferred via the 'fetch' dependency. 8 | Deno.test("Inference on Dependency", () => { 9 | const program_path = path.fromFileUrl(import.meta.resolve('./data/infinite_loop.ts')); 10 | const vendor_path = path.fromFileUrl(import.meta.resolve('./vendor')); 11 | const program_schema = infer.inferProgramSchema(program_path, vendor_path, false); 12 | test.assertEquals(program_schema.functions, { 13 | infinite_loop: { 14 | ndc_kind: FunctionNdcKind.Procedure, 15 | description: null, 16 | arguments: [], 17 | result_type: { type: "named", kind: "object", name: "Response" } 18 | } 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/test/conflicting_names_test.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as test from "https://deno.land/std@0.208.0/assert/mod.ts"; 3 | import * as path from "https://deno.land/std@0.208.0/path/mod.ts"; 4 | import * as infer from '../infer.ts'; 5 | import { FunctionNdcKind } from "../schema.ts"; 6 | 7 | Deno.test("Conflicting Type Names in Imports", () => { 8 | const program_path = path.fromFileUrl(import.meta.resolve('./data/conflicting_names.ts')); 9 | const vendor_path = path.fromFileUrl(import.meta.resolve('./vendor')); 10 | 11 | const program_schema = infer.inferProgramSchema(program_path, vendor_path, false); 12 | 13 | test.assertEquals(program_schema, { 14 | functions: { 15 | "foo": { 16 | ndc_kind: FunctionNdcKind.Procedure, 17 | description: null, 18 | arguments: [], 19 | result_type: { 20 | name: "Foo", 21 | kind: "object", 22 | type: "named", 23 | }, 24 | } 25 | }, 26 | object_types: { 27 | Foo: { 28 | properties: [ 29 | { 30 | property_name: "x", 31 | type: { 32 | name: "Boolean", 33 | kind: "scalar", 34 | type: "named", 35 | }, 36 | }, 37 | { 38 | property_name: "y", 39 | type: { 40 | name: "conflicting_names_dep_Foo", 41 | kind: "object", 42 | type: "named", 43 | }, 44 | }, 45 | ] 46 | }, 47 | conflicting_names_dep_Foo: { 48 | properties: [ 49 | { 50 | property_name: "a", 51 | type: { 52 | name: "String", 53 | kind: "scalar", 54 | type: "named", 55 | }, 56 | }, 57 | { 58 | property_name: "b", 59 | type: { 60 | name: "Float", 61 | kind: "scalar", 62 | type: "named", 63 | }, 64 | }, 65 | ] 66 | }, 67 | }, 68 | scalar_types: { 69 | Boolean: {}, 70 | Float: {}, 71 | String: {}, 72 | } 73 | }) 74 | }); 75 | -------------------------------------------------------------------------------- /src/test/data/classes.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | class MyClass { 4 | } 5 | 6 | export function bar( 7 | clazz: MyClass 8 | ): string { 9 | return 'hello'; 10 | } 11 | -------------------------------------------------------------------------------- /src/test/data/complex.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Hash, encode } from "https://deno.land/x/checksum@1.2.0/mod.ts"; 3 | 4 | type Result = { 5 | num: number, 6 | str: string, 7 | bod: string 8 | } 9 | 10 | export async function complex(a: number, b: number, c?: string): Promise { 11 | const num = a + b; 12 | const msg = `${c || 'Addition'}: ${num}`; 13 | const hash = new Hash("md5").digest(encode(msg)).hex(); 14 | const str = `Yo: ${msg} - ${hash}`; 15 | const res = await fetch('https://httpbin.org/get'); 16 | const bod = await res.text(); 17 | 18 | return { 19 | num, 20 | str, 21 | bod 22 | } 23 | } -------------------------------------------------------------------------------- /src/test/data/conflicting_names.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as dep from './conflicting_names_dep.ts'; 3 | 4 | type Foo = { 5 | x: boolean, 6 | y: dep.Foo 7 | } 8 | 9 | export function foo(): Foo { 10 | return { 11 | x: true, 12 | y: { 13 | a: 'hello', 14 | b: 33 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/data/conflicting_names_dep.ts: -------------------------------------------------------------------------------- 1 | 2 | export type Foo = { 3 | a: string, 4 | b: number 5 | } 6 | -------------------------------------------------------------------------------- /src/test/data/external_dependencies.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as base64 from "https://denopkg.com/chiefbiiko/base64@v0.2.1/mod.ts"; 3 | import * as emojify from "npm:node-emoji@2.1"; 4 | 5 | export function test_deps(s: string): string { 6 | const b64 = base64.fromUint8Array(new TextEncoder().encode(s)); 7 | const emo = emojify.emojify(":t-rex: :heart: NPM"); 8 | return `${b64} ${emo}`; 9 | } -------------------------------------------------------------------------------- /src/test/data/infinite_loop.ts: -------------------------------------------------------------------------------- 1 | 2 | // Infinite loop void bug: https://github.com/hasura/ndc-typescript-deno/issues/45 3 | 4 | export async function infinite_loop() { 5 | const contents = await fetch('https://www.google.com') 6 | return contents; 7 | } 8 | -------------------------------------------------------------------------------- /src/test/data/inline_types.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export function bar(x: {a: number, b: string}): string { 4 | return 'hello'; 5 | } 6 | -------------------------------------------------------------------------------- /src/test/data/nullable_types.ts: -------------------------------------------------------------------------------- 1 | 2 | type MyObject = { 3 | string: string, 4 | nullableString: string | null, 5 | optionalString?: string 6 | undefinedString: string | undefined 7 | nullOrUndefinedString: string | undefined | null 8 | } 9 | 10 | export function test( 11 | myObject: MyObject, 12 | nullableParam: string | null, 13 | undefinedParam: string | undefined, 14 | nullOrUndefinedParam: string | undefined | null, 15 | unionWithNull: string | number | null, 16 | optionalParam?: string 17 | ): string | null { 18 | return "test" 19 | } 20 | -------------------------------------------------------------------------------- /src/test/data/pg_dep.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "https://deno.land/x/postgres@v0.17.0/mod.ts"; 2 | 3 | const dbConfig = { 4 | user: "aaysha", 5 | hostname: "asdfasdfasd.us-west-2.aws.neon.tech", 6 | port: 5432, 7 | password: "asdfasdasdf", 8 | database: "asdfasdfasdf", 9 | ssl: true, 10 | sslmode: "require", 11 | }; 12 | 13 | export async function insert_user( 14 | user_name: string, 15 | ): Promise< 16 | { id: string; name: string; created_at: string } | { message: string } | { 17 | error: string; 18 | } | {} 19 | > { 20 | const client = new Client(dbConfig); 21 | 22 | try { 23 | await client.connect(); 24 | 25 | const result = await client.queryObject({ 26 | text: `INSERT INTO users(name) VALUES ('${user_name}') RETURNING *`, 27 | }); 28 | 29 | if (result && result.rows.length > 0 && result.rows[0]) { 30 | return result.rows[0]; 31 | } else { 32 | return { message: "Insert Failed" }; 33 | } 34 | } catch (error) { 35 | console.error("Error:", error); 36 | return { error: "Error: " + error.message }; 37 | } finally { 38 | await client.end(); 39 | } 40 | } 41 | 42 | export async function insert_todos( 43 | user_id: string, 44 | todo: string 45 | ): Promise< 46 | { id: string; user_id: string; todo: string; created_at: string } | { message: string } | { 47 | error: string; 48 | } | {} 49 | > { 50 | const client = new Client(dbConfig); 51 | 52 | try { 53 | await client.connect(); 54 | 55 | 56 | // Check if the user exists in the users table 57 | 58 | const userExistsQuery = await client.queryObject({ 59 | text: `SELECT id FROM users where id =${user_id}` 60 | }) 61 | 62 | if (userExistsQuery.rows.length === 0) { 63 | return { message: "User not found. Insert Failed" }; 64 | } 65 | const result = await client.queryObject({ 66 | text: `INSERT INTO todos(user_id,todo) VALUES ('${user_id}','${todo}') RETURNING *`, 67 | }); 68 | 69 | if (result && result.rows.length > 0 && result.rows[0]) { 70 | return result.rows[0]; 71 | } else { 72 | return { message: "Insert Failed" }; 73 | } 74 | } catch (error) { 75 | console.error("Error:", error); 76 | return { error: "Error: " + error.message }; 77 | } finally { 78 | await client.end(); 79 | } 80 | } 81 | 82 | 83 | export async function delete_todos( 84 | todo_id: string 85 | ){ 86 | const client = new Client(dbConfig); 87 | try{ 88 | await client.connect(); 89 | 90 | const result = await client.queryObject({ text: `DELETE FROM todos WHERE id =${todo_id}`}) 91 | if(result.rowCount===1){ 92 | return `Deleted todo with id= ${todo_id} sucesssfully` 93 | }else{ 94 | return "Deletion unsucessfull" 95 | } 96 | }catch(error){ 97 | return "Error : "+ error.message 98 | }finally{ 99 | client.end(); 100 | } 101 | 102 | } -------------------------------------------------------------------------------- /src/test/data/program.ts: -------------------------------------------------------------------------------- 1 | 2 | function non_exported() { 3 | } 4 | 5 | export function hello(): string { 6 | return 'hello world'; 7 | } 8 | 9 | export function add(a: number, b: number): number { 10 | return a + b; 11 | } -------------------------------------------------------------------------------- /src/test/data/recursive.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | // Named types should prevent infinite recursion in schema inference 4 | 5 | type Foo = { 6 | a: number, 7 | b: Array 8 | } 9 | 10 | export function bar(): Foo { 11 | return { 12 | a: 1, 13 | b: [{ 14 | a: 2, 15 | b: [] 16 | }] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/test/data/type_parameters.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | // This tests that type parameters for object don't short circuit with a scalar 4 | 5 | type Foo = { 6 | a: number, 7 | b: string 8 | } 9 | 10 | type Bar = { 11 | x: number, 12 | y: X 13 | } 14 | 15 | // Foo and Bar should both have `object_types` defined. 16 | export function bar(): Bar { 17 | return { 18 | x: 1, 19 | y: { 20 | a: 2, 21 | b: 'hello' 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/test/data/validation_algorithm_update.ts: -------------------------------------------------------------------------------- 1 | 2 | // Placeholder index.ts to allow CI to run against functions volume 3 | 4 | type Bar = { 5 | test: string 6 | } 7 | 8 | type GenericBar = { 9 | data: T 10 | } 11 | 12 | interface IThing { 13 | prop: string 14 | } 15 | 16 | interface IGenericThing { 17 | data: T 18 | } 19 | 20 | type IntersectionObject = { wow: string } & Bar 21 | 22 | type GenericIntersectionObject = { data: T } & Bar 23 | 24 | type AliasedString = string; 25 | 26 | type GenericScalar = GenericScalar2 27 | type GenericScalar2 = T 28 | 29 | export function bar( 30 | string: string, 31 | aliasedString: AliasedString, 32 | genericScalar: GenericScalar, 33 | array: string[], 34 | promise: Promise, 35 | anonObj: {a: number, b: string}, 36 | aliasedObj: Bar, 37 | genericAliasedObj: GenericBar, 38 | interfce: IThing, 39 | genericInterface: IGenericThing, 40 | aliasedIntersectionObj: IntersectionObject, 41 | anonIntersectionObj: {num:number} & Bar, 42 | genericIntersectionObj: GenericIntersectionObject, 43 | ): string { 44 | return 'hello'; 45 | } 46 | -------------------------------------------------------------------------------- /src/test/data/void_types.ts: -------------------------------------------------------------------------------- 1 | 2 | // Infinite loop void bug: https://github.com/hasura/ndc-typescript-deno/issues/45 3 | 4 | export function void_function(): void { 5 | return 6 | } 7 | -------------------------------------------------------------------------------- /src/test/external_dependencies_test.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as test from "https://deno.land/std@0.208.0/assert/mod.ts"; 3 | import * as path from "https://deno.land/std@0.208.0/path/mod.ts"; 4 | import * as infer from '../infer.ts'; 5 | import { FunctionNdcKind } from "../schema.ts"; 6 | 7 | // Skipped due to NPM dependency resolution not currently being supported. 8 | Deno.test("External Dependencies", () => { 9 | const program_path = path.fromFileUrl(import.meta.resolve('./data/external_dependencies.ts')); 10 | const vendor_path = path.fromFileUrl(import.meta.resolve('./vendor')); 11 | const program_schema = infer.inferProgramSchema(program_path, vendor_path, true); 12 | 13 | test.assertEquals(program_schema, { 14 | scalar_types: { 15 | String: {}, 16 | }, 17 | object_types: {}, 18 | functions: { 19 | "test_deps": { 20 | ndc_kind: FunctionNdcKind.Procedure, 21 | description: null, 22 | arguments: [ 23 | { 24 | argument_name: "s", 25 | description: null, 26 | type: { 27 | name: "String", 28 | kind: "scalar", 29 | type: "named", 30 | } 31 | } 32 | ], 33 | result_type: { 34 | name: "String", 35 | kind: "scalar", 36 | type: "named", 37 | } 38 | } 39 | } 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/test/infer_test.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as test from "https://deno.land/std@0.208.0/assert/mod.ts"; 3 | import * as path from "https://deno.land/std@0.208.0/path/mod.ts"; 4 | import * as infer from '../infer.ts'; 5 | import { FunctionNdcKind, NullOrUndefinability } from "../schema.ts"; 6 | 7 | Deno.test("Inference", () => { 8 | const program_path = path.fromFileUrl(import.meta.resolve('./data/program.ts')); 9 | const vendor_path = path.fromFileUrl(import.meta.resolve('./vendor')); 10 | const program_schema = infer.inferProgramSchema(program_path, vendor_path, false); 11 | 12 | test.assertEquals(program_schema, { 13 | scalar_types: { 14 | Float: {}, 15 | String: {}, 16 | }, 17 | object_types: {}, 18 | functions: { 19 | "hello": { 20 | ndc_kind: FunctionNdcKind.Procedure, 21 | description: null, 22 | arguments: [], 23 | result_type: { 24 | name: "String", 25 | kind: "scalar", 26 | type: "named", 27 | } 28 | }, 29 | "add": { 30 | ndc_kind: FunctionNdcKind.Procedure, 31 | description: null, 32 | arguments: [ 33 | { 34 | argument_name: "a", 35 | description: null, 36 | type: { 37 | name: "Float", 38 | kind: "scalar", 39 | type: "named", 40 | } 41 | }, 42 | { 43 | argument_name: "b", 44 | description: null, 45 | type: { 46 | name: "Float", 47 | kind: "scalar", 48 | type: "named", 49 | } 50 | } 51 | ], 52 | result_type: { 53 | name: "Float", 54 | kind: "scalar", 55 | type: "named", 56 | } 57 | } 58 | } 59 | }); 60 | }); 61 | 62 | Deno.test("Complex Inference", () => { 63 | const program_path = path.fromFileUrl(import.meta.resolve('./data/complex.ts')); 64 | const vendor_path = path.fromFileUrl(import.meta.resolve('./vendor')); 65 | const program_schema = infer.inferProgramSchema(program_path, vendor_path, true); 66 | 67 | test.assertEquals(program_schema, { 68 | functions: { 69 | "complex": { 70 | ndc_kind: FunctionNdcKind.Procedure, 71 | description: null, 72 | arguments: [ 73 | { 74 | argument_name: "a", 75 | description: null, 76 | type: { 77 | name: "Float", 78 | kind: "scalar", 79 | type: "named", 80 | } 81 | }, 82 | { 83 | argument_name: "b", 84 | description: null, 85 | type: { 86 | name: "Float", 87 | kind: "scalar", 88 | type: "named", 89 | } 90 | }, 91 | { 92 | argument_name: "c", 93 | description: null, 94 | type: { 95 | type: "nullable", 96 | null_or_undefinability: NullOrUndefinability.AcceptsUndefinedOnly, 97 | underlying_type: { 98 | name: "String", 99 | kind: "scalar", 100 | type: "named", 101 | } 102 | } 103 | } 104 | ], 105 | result_type: { 106 | name: "Result", 107 | kind: "object", 108 | type: "named", 109 | }, 110 | } 111 | }, 112 | object_types: { 113 | Result: { 114 | properties: [ 115 | { 116 | property_name: "num", 117 | type: { 118 | name: "Float", 119 | kind: "scalar", 120 | type: "named", 121 | }, 122 | }, 123 | { 124 | property_name: "str", 125 | type: { 126 | name: "String", 127 | kind: "scalar", 128 | type: "named", 129 | }, 130 | }, 131 | { 132 | property_name: "bod", 133 | type: { 134 | name: "String", 135 | kind: "scalar", 136 | type: "named", 137 | }, 138 | }, 139 | ] 140 | }, 141 | }, 142 | scalar_types: { 143 | Float: {}, 144 | String: {}, 145 | } 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /src/test/inline_types_test.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as test from "https://deno.land/std@0.208.0/assert/mod.ts"; 3 | import * as path from "https://deno.land/std@0.208.0/path/mod.ts"; 4 | import * as infer from '../infer.ts'; 5 | import { FunctionNdcKind } from "../schema.ts"; 6 | 7 | Deno.test("Inline Types", () => { 8 | const program_path = path.fromFileUrl(import.meta.resolve('./data/inline_types.ts')); 9 | const vendor_path = path.fromFileUrl(import.meta.resolve('./vendor')); 10 | 11 | const program_schema = infer.inferProgramSchema(program_path, vendor_path, false); 12 | 13 | test.assertEquals(program_schema, 14 | { 15 | scalar_types: { 16 | String: {}, 17 | Float: {} 18 | }, 19 | functions: { 20 | "bar": { 21 | ndc_kind: FunctionNdcKind.Procedure, 22 | description: null, 23 | arguments: [ 24 | { 25 | argument_name: "x", 26 | description: null, 27 | type: { 28 | type: "named", 29 | kind: "object", 30 | name: "bar_arguments_x" 31 | } 32 | } 33 | ], 34 | result_type: { 35 | type: "named", 36 | kind: "scalar", 37 | name: "String" 38 | } 39 | } 40 | }, 41 | object_types: { 42 | "bar_arguments_x": { 43 | properties: [ 44 | { 45 | property_name: "a", 46 | type: { 47 | type: "named", 48 | kind: "scalar", 49 | name: "Float" 50 | } 51 | }, 52 | { 53 | property_name: "b", 54 | type: { 55 | type: "named", 56 | kind: "scalar", 57 | name: "String" 58 | } 59 | }, 60 | ] 61 | } 62 | } 63 | } 64 | ); 65 | 66 | }); 67 | -------------------------------------------------------------------------------- /src/test/ndc_schema_test.ts: -------------------------------------------------------------------------------- 1 | import * as test from "https://deno.land/std@0.208.0/assert/mod.ts"; 2 | import { FunctionNdcKind, NullOrUndefinability, ProgramSchema, get_ndc_schema } from "../schema.ts"; 3 | 4 | Deno.test("NDC Schema Generation", () => { 5 | const program_schema: ProgramSchema = { 6 | functions: { 7 | "test_proc": { 8 | arguments: [ 9 | { 10 | argument_name: "nullableParam", 11 | description: null, 12 | type: { 13 | type: "nullable", 14 | null_or_undefinability: NullOrUndefinability.AcceptsNullOnly, 15 | underlying_type: { 16 | kind: "scalar", 17 | name: "String", 18 | type: "named", 19 | }, 20 | }, 21 | }, 22 | ], 23 | description: null, 24 | ndc_kind: FunctionNdcKind.Procedure, 25 | result_type: { 26 | type: "nullable", 27 | null_or_undefinability: NullOrUndefinability.AcceptsNullOnly, 28 | underlying_type: { 29 | kind: "scalar", 30 | name: "String", 31 | type: "named", 32 | } 33 | }, 34 | }, 35 | "test_func": { 36 | arguments: [ 37 | { 38 | argument_name: "myObject", 39 | description: null, 40 | type: { 41 | kind: "object", 42 | name: "MyObject", 43 | type: "named", 44 | }, 45 | }, 46 | ], 47 | description: null, 48 | ndc_kind: FunctionNdcKind.Function, 49 | result_type: { 50 | type: "array", 51 | element_type: { 52 | kind: "scalar", 53 | name: "String", 54 | type: "named", 55 | } 56 | }, 57 | }, 58 | }, 59 | object_types: { 60 | "MyObject": { 61 | properties: [ 62 | { 63 | property_name: "string", 64 | type: { 65 | kind: "scalar", 66 | name: "String", 67 | type: "named", 68 | }, 69 | }, 70 | { 71 | property_name: "nullableString", 72 | type: { 73 | type: "nullable", 74 | null_or_undefinability: NullOrUndefinability.AcceptsNullOnly, 75 | underlying_type: { 76 | kind: "scalar", 77 | name: "String", 78 | type: "named", 79 | }, 80 | }, 81 | }, 82 | ], 83 | }, 84 | }, 85 | scalar_types: { 86 | String: {}, 87 | test_arguments_unionWithNull: {}, 88 | }, 89 | }; 90 | 91 | const schema_response = get_ndc_schema(program_schema) 92 | 93 | test.assertEquals(schema_response, { 94 | collections: [], 95 | functions: [ 96 | { 97 | name: "test_func", 98 | arguments: { 99 | "myObject": { 100 | type: { 101 | name: "MyObject", 102 | type: "named", 103 | }, 104 | }, 105 | }, 106 | result_type: { 107 | type: "array", 108 | element_type: { 109 | name: "String", 110 | type: "named", 111 | } 112 | }, 113 | }, 114 | ], 115 | procedures: [ 116 | { 117 | name: "test_proc", 118 | arguments: { 119 | "nullableParam": { 120 | type: { 121 | type: "nullable", 122 | underlying_type: { 123 | name: "String", 124 | type: "named", 125 | }, 126 | }, 127 | }, 128 | }, 129 | result_type: { 130 | type: "nullable", 131 | underlying_type: { 132 | name: "String", 133 | type: "named", 134 | } 135 | }, 136 | } 137 | ], 138 | object_types: { 139 | "MyObject": { 140 | fields: { 141 | "string": { 142 | type: { 143 | name: "String", 144 | type: "named", 145 | }, 146 | }, 147 | "nullableString": { 148 | type: { 149 | type: "nullable", 150 | underlying_type: { 151 | name: "String", 152 | type: "named", 153 | }, 154 | }, 155 | }, 156 | }, 157 | }, 158 | }, 159 | scalar_types: { 160 | String: { 161 | aggregate_functions: {}, 162 | comparison_operators: {} 163 | }, 164 | test_arguments_unionWithNull: { 165 | aggregate_functions: {}, 166 | comparison_operators: {} 167 | }, 168 | } 169 | }); 170 | 171 | }); 172 | -------------------------------------------------------------------------------- /src/test/nullable_types_test.ts: -------------------------------------------------------------------------------- 1 | import * as test from "https://deno.land/std@0.208.0/assert/mod.ts"; 2 | import * as path from "https://deno.land/std@0.208.0/path/mod.ts"; 3 | import * as infer from '../infer.ts'; 4 | import { FunctionNdcKind, NullOrUndefinability } from "../schema.ts"; 5 | 6 | Deno.test("Nullable Types", () => { 7 | const program_path = path.fromFileUrl(import.meta.resolve('./data/nullable_types.ts')); 8 | const vendor_path = path.fromFileUrl(import.meta.resolve('./vendor')); 9 | const program_schema = infer.inferProgramSchema(program_path, vendor_path, false); 10 | 11 | test.assertEquals(program_schema, { 12 | functions: { 13 | "test": { 14 | arguments: [ 15 | { 16 | argument_name: "myObject", 17 | description: null, 18 | type: { 19 | kind: "object", 20 | name: "MyObject", 21 | type: "named", 22 | }, 23 | }, 24 | { 25 | argument_name: "nullableParam", 26 | description: null, 27 | type: { 28 | type: "nullable", 29 | null_or_undefinability: NullOrUndefinability.AcceptsNullOnly, 30 | underlying_type: { 31 | kind: "scalar", 32 | name: "String", 33 | type: "named", 34 | }, 35 | }, 36 | }, 37 | { 38 | argument_name: "undefinedParam", 39 | description: null, 40 | type: { 41 | type: "nullable", 42 | null_or_undefinability: NullOrUndefinability.AcceptsUndefinedOnly, 43 | underlying_type: { 44 | kind: "scalar", 45 | name: "String", 46 | type: "named", 47 | }, 48 | }, 49 | }, 50 | { 51 | argument_name: "nullOrUndefinedParam", 52 | description: null, 53 | type: { 54 | type: "nullable", 55 | null_or_undefinability: NullOrUndefinability.AcceptsEither, 56 | underlying_type: { 57 | kind: "scalar", 58 | name: "String", 59 | type: "named", 60 | }, 61 | }, 62 | }, 63 | { 64 | argument_name: "unionWithNull", 65 | description: null, 66 | type: { 67 | kind: "scalar", 68 | name: "test_arguments_unionWithNull", 69 | type: "named", 70 | }, 71 | }, 72 | { 73 | argument_name: "optionalParam", 74 | description: null, 75 | type: { 76 | type: "nullable", 77 | null_or_undefinability: NullOrUndefinability.AcceptsUndefinedOnly, 78 | underlying_type: { 79 | kind: "scalar", 80 | name: "String", 81 | type: "named", 82 | }, 83 | }, 84 | }, 85 | ], 86 | description: null, 87 | ndc_kind: FunctionNdcKind.Procedure, 88 | result_type: { 89 | type: "nullable", 90 | null_or_undefinability: NullOrUndefinability.AcceptsNullOnly, 91 | underlying_type: { 92 | kind: "scalar", 93 | name: "String", 94 | type: "named", 95 | } 96 | }, 97 | }, 98 | }, 99 | object_types: { 100 | "MyObject": { 101 | properties: [ 102 | { 103 | property_name: "string", 104 | type: { 105 | kind: "scalar", 106 | name: "String", 107 | type: "named", 108 | }, 109 | }, 110 | { 111 | property_name: "nullableString", 112 | type: { 113 | type: "nullable", 114 | null_or_undefinability: NullOrUndefinability.AcceptsNullOnly, 115 | underlying_type: { 116 | kind: "scalar", 117 | name: "String", 118 | type: "named", 119 | }, 120 | }, 121 | }, 122 | { 123 | property_name: "optionalString", 124 | type: { 125 | type: "nullable", 126 | null_or_undefinability: NullOrUndefinability.AcceptsUndefinedOnly, 127 | underlying_type: { 128 | kind: "scalar", 129 | name: "String", 130 | type: "named", 131 | }, 132 | }, 133 | }, 134 | { 135 | property_name: "undefinedString", 136 | type: { 137 | type: "nullable", 138 | null_or_undefinability: NullOrUndefinability.AcceptsUndefinedOnly, 139 | underlying_type: { 140 | kind: "scalar", 141 | name: "String", 142 | type: "named", 143 | }, 144 | }, 145 | }, 146 | { 147 | property_name: "nullOrUndefinedString", 148 | type: { 149 | type: "nullable", 150 | null_or_undefinability: NullOrUndefinability.AcceptsEither, 151 | underlying_type: { 152 | kind: "scalar", 153 | name: "String", 154 | type: "named", 155 | }, 156 | }, 157 | }, 158 | ], 159 | }, 160 | }, 161 | scalar_types: { 162 | String: {}, 163 | test_arguments_unionWithNull: {}, 164 | }, 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /src/test/pg_dep_test.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as test from "https://deno.land/std@0.208.0/assert/mod.ts"; 3 | import * as path from "https://deno.land/std@0.208.0/path/mod.ts"; 4 | import * as infer from '../infer.ts'; 5 | import { FunctionNdcKind } from "../schema.ts"; 6 | 7 | // NOTE: It would be good to have explicit timeout for this 8 | // See: https://github.com/denoland/deno/issues/11133 9 | // Test bug: https://github.com/hasura/ndc-typescript-deno/issues/45 10 | Deno.test("Postgres NPM Dependency", () => { 11 | const program_path = path.fromFileUrl(import.meta.resolve('./data/pg_dep.ts')); 12 | const vendor_path = path.fromFileUrl(import.meta.resolve('./vendor')); 13 | const program_schema = infer.inferProgramSchema(program_path, vendor_path, true); 14 | 15 | test.assertEquals(program_schema, { 16 | functions: { 17 | "insert_user": { 18 | ndc_kind: FunctionNdcKind.Procedure, 19 | description: null, 20 | arguments: [ 21 | { 22 | argument_name: "user_name", 23 | description: null, 24 | type: { 25 | type: "named", 26 | kind: "scalar", 27 | name: "String" 28 | } 29 | } 30 | ], 31 | result_type: { 32 | type: "named", 33 | kind: "scalar", 34 | name: "insert_user_output" 35 | } 36 | }, 37 | "insert_todos": { 38 | ndc_kind: FunctionNdcKind.Procedure, 39 | description: null, 40 | arguments: [ 41 | { 42 | argument_name: "user_id", 43 | description: null, 44 | type: { 45 | type: "named", 46 | kind: "scalar", 47 | name: "String" 48 | } 49 | }, 50 | { 51 | argument_name: "todo", 52 | description: null, 53 | type: { 54 | type: "named", 55 | kind: "scalar", 56 | name: "String" 57 | } 58 | }, 59 | ], 60 | result_type: { 61 | type: "named", 62 | kind: "scalar", 63 | name: "insert_todos_output" 64 | } 65 | }, 66 | "delete_todos": { 67 | ndc_kind: FunctionNdcKind.Procedure, 68 | description: null, 69 | arguments: [ 70 | { 71 | argument_name: "todo_id", 72 | description: null, 73 | type: { 74 | type: "named", 75 | kind: "scalar", 76 | name: "String" 77 | } 78 | } 79 | ], 80 | result_type: { 81 | type: "named", 82 | kind: "scalar", 83 | name: "String" 84 | } 85 | }, 86 | }, 87 | scalar_types: { 88 | String: {}, 89 | insert_todos_output: {}, 90 | insert_user_output: {}, 91 | }, 92 | object_types: {}, 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/test/prepare_arguments_test.ts: -------------------------------------------------------------------------------- 1 | import * as test from "https://deno.land/std@0.208.0/assert/mod.ts"; 2 | import * as connector from '../connector.ts'; 3 | import { FunctionDefinitions, FunctionNdcKind, NullOrUndefinability, ObjectTypeDefinitions } from "../schema.ts"; 4 | 5 | Deno.test("argument ordering", () => { 6 | const function_name = "test_fn" 7 | const function_definitions: FunctionDefinitions = { 8 | [function_name]: { 9 | ndc_kind: FunctionNdcKind.Function, 10 | description: null, 11 | arguments: [ 12 | { 13 | argument_name: "c", 14 | description: null, 15 | type: { 16 | type: "named", 17 | kind: "scalar", 18 | name: "Float" 19 | } 20 | }, 21 | { 22 | argument_name: "a", 23 | description: null, 24 | type: { 25 | type: "named", 26 | kind: "scalar", 27 | name: "Float" 28 | } 29 | }, 30 | { 31 | argument_name: "b", 32 | description: null, 33 | type: { 34 | type: "named", 35 | kind: "scalar", 36 | name: "Float" 37 | } 38 | }, 39 | ], 40 | result_type: { 41 | type: "named", 42 | kind: "scalar", 43 | name: "String" 44 | } 45 | } 46 | } 47 | const object_types: ObjectTypeDefinitions = {} 48 | const args = { 49 | b: 1, 50 | a: 2, 51 | c: 3, 52 | } 53 | 54 | const prepared_args = connector.prepare_arguments(function_name, args, function_definitions, object_types); 55 | 56 | test.assertEquals(prepared_args, [ 3, 2, 1 ]); 57 | }) 58 | 59 | Deno.test("nullable type coercion", async t => { 60 | const function_name = "test_fn" 61 | const function_definitions: FunctionDefinitions = { 62 | [function_name]: { 63 | ndc_kind: FunctionNdcKind.Function, 64 | description: null, 65 | arguments: [ 66 | { 67 | argument_name: "nullOnlyArg", 68 | description: null, 69 | type: { 70 | type: "nullable", 71 | null_or_undefinability: NullOrUndefinability.AcceptsNullOnly, 72 | underlying_type: { 73 | type: "named", 74 | kind: "scalar", 75 | name: "String" 76 | } 77 | } 78 | }, 79 | { 80 | argument_name: "undefinedOnlyArg", 81 | description: null, 82 | type: { 83 | type: "nullable", 84 | null_or_undefinability: NullOrUndefinability.AcceptsUndefinedOnly, 85 | underlying_type: { 86 | type: "named", 87 | kind: "scalar", 88 | name: "String" 89 | } 90 | } 91 | }, 92 | { 93 | argument_name: "nullOrUndefinedArg", 94 | description: null, 95 | type: { 96 | type: "nullable", 97 | null_or_undefinability: NullOrUndefinability.AcceptsEither, 98 | underlying_type: { 99 | type: "named", 100 | kind: "scalar", 101 | name: "String" 102 | } 103 | } 104 | }, 105 | { 106 | argument_name: "objectArg", 107 | description: null, 108 | type: { 109 | type: "named", 110 | kind: "object", 111 | name: "MyObject", 112 | } 113 | }, 114 | { 115 | argument_name: "nullOnlyArrayArg", 116 | description: null, 117 | type: { 118 | type: "array", 119 | element_type: { 120 | type: "nullable", 121 | null_or_undefinability: NullOrUndefinability.AcceptsNullOnly, 122 | underlying_type: { 123 | type: "named", 124 | kind: "scalar", 125 | name: "String" 126 | } 127 | } 128 | } 129 | }, 130 | { 131 | argument_name: "undefinedOnlyArrayArg", 132 | description: null, 133 | type: { 134 | type: "array", 135 | element_type: { 136 | type: "nullable", 137 | null_or_undefinability: NullOrUndefinability.AcceptsUndefinedOnly, 138 | underlying_type: { 139 | type: "named", 140 | kind: "scalar", 141 | name: "String" 142 | } 143 | } 144 | } 145 | }, 146 | { 147 | argument_name: "nullOrUndefinedArrayArg", 148 | description: null, 149 | type: { 150 | type: "array", 151 | element_type: { 152 | type: "nullable", 153 | null_or_undefinability: NullOrUndefinability.AcceptsEither, 154 | underlying_type: { 155 | type: "named", 156 | kind: "scalar", 157 | name: "String" 158 | } 159 | } 160 | } 161 | }, 162 | ], 163 | result_type: { 164 | type: "named", 165 | kind: "scalar", 166 | name: "String" 167 | } 168 | } 169 | } 170 | const object_types: ObjectTypeDefinitions = { 171 | "MyObject": { 172 | properties: [ 173 | { 174 | property_name: "nullOnlyProp", 175 | type: { 176 | type: "nullable", 177 | null_or_undefinability: NullOrUndefinability.AcceptsNullOnly, 178 | underlying_type: { 179 | type: "named", 180 | kind: "scalar", 181 | name: "String" 182 | } 183 | } 184 | }, 185 | { 186 | property_name: "undefinedOnlyProp", 187 | type: { 188 | type: "nullable", 189 | null_or_undefinability: NullOrUndefinability.AcceptsUndefinedOnly, 190 | underlying_type: { 191 | type: "named", 192 | kind: "scalar", 193 | name: "String" 194 | } 195 | } 196 | }, 197 | { 198 | property_name: "nullOrUndefinedProp", 199 | type: { 200 | type: "nullable", 201 | null_or_undefinability: NullOrUndefinability.AcceptsEither, 202 | underlying_type: { 203 | type: "named", 204 | kind: "scalar", 205 | name: "String" 206 | } 207 | } 208 | } 209 | ] 210 | } 211 | } 212 | const test_cases = [ 213 | { 214 | name: "all nulls", 215 | args: { 216 | nullOnlyArg: null, 217 | undefinedOnlyArg: null, 218 | nullOrUndefinedArg: null, 219 | objectArg: { 220 | nullOnlyProp: null, 221 | undefinedOnlyProp: null, 222 | nullOrUndefinedProp: null, 223 | }, 224 | nullOnlyArrayArg: [null, null], 225 | undefinedOnlyArrayArg: [null, null], 226 | nullOrUndefinedArrayArg: [null, null], 227 | }, 228 | expected: [ 229 | null, 230 | undefined, 231 | null, 232 | { nullOnlyProp: null, undefinedOnlyProp: undefined, nullOrUndefinedProp: null }, 233 | [null, null], 234 | [undefined, undefined], 235 | [null, null], 236 | ] 237 | }, 238 | { 239 | name: "all undefineds", 240 | args: { 241 | nullOnlyArg: undefined, 242 | undefinedOnlyArg: undefined, 243 | nullOrUndefinedArg: undefined, 244 | objectArg: { 245 | nullOnlyProp: undefined, 246 | undefinedOnlyProp: undefined, 247 | nullOrUndefinedProp: undefined, 248 | }, 249 | nullOnlyArrayArg: [undefined, undefined], 250 | undefinedOnlyArrayArg: [undefined, undefined], 251 | nullOrUndefinedArrayArg: [undefined, undefined], 252 | }, 253 | expected: [ 254 | null, 255 | undefined, 256 | undefined, 257 | { nullOnlyProp: null, undefinedOnlyProp: undefined, nullOrUndefinedProp: undefined }, 258 | [null, null], 259 | [undefined, undefined], 260 | [undefined, undefined], 261 | ] 262 | }, 263 | { 264 | name: "all missing", 265 | args: { 266 | objectArg: {}, 267 | nullOnlyArrayArg: [], 268 | undefinedOnlyArrayArg: [], 269 | nullOrUndefinedArrayArg: [], 270 | }, 271 | expected: [ 272 | null, 273 | undefined, 274 | undefined, 275 | { nullOnlyProp: null, undefinedOnlyProp: undefined, nullOrUndefinedProp: undefined }, 276 | [], 277 | [], 278 | [], 279 | ] 280 | }, 281 | { 282 | name: "all valued", 283 | args: { 284 | nullOnlyArg: "a", 285 | undefinedOnlyArg: "b", 286 | nullOrUndefinedArg: "c", 287 | objectArg: { 288 | nullOnlyProp: "d", 289 | undefinedOnlyProp: "e", 290 | nullOrUndefinedProp: "f", 291 | }, 292 | nullOnlyArrayArg: ["g", "h"], 293 | undefinedOnlyArrayArg: ["i", "j"], 294 | nullOrUndefinedArrayArg: ["k", "l"], 295 | }, 296 | expected: [ 297 | "a", 298 | "b", 299 | "c", 300 | { nullOnlyProp: "d", undefinedOnlyProp: "e", nullOrUndefinedProp: "f" }, 301 | ["g", "h"], 302 | ["i", "j"], 303 | ["k", "l"], 304 | ] 305 | }, 306 | ]; 307 | 308 | await Promise.all(test_cases.map(test_case => t.step({ 309 | name: test_case.name, 310 | fn: () => { 311 | const prepared_args = connector.prepare_arguments(function_name, test_case.args, function_definitions, object_types); 312 | test.assertEquals(prepared_args, test_case.expected); 313 | }, 314 | sanitizeOps: false, 315 | sanitizeResources: false, 316 | sanitizeExit: false, 317 | }))); 318 | 319 | }) 320 | -------------------------------------------------------------------------------- /src/test/recursive_types_test.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as test from "https://deno.land/std@0.208.0/assert/mod.ts"; 3 | import * as path from "https://deno.land/std@0.208.0/path/mod.ts"; 4 | import * as infer from '../infer.ts'; 5 | import { FunctionNdcKind } from "../schema.ts"; 6 | 7 | Deno.test("Recursive Types", () => { 8 | const program_path = path.fromFileUrl(import.meta.resolve('./data/recursive.ts')); 9 | const vendor_path = path.fromFileUrl(import.meta.resolve('./vendor')); 10 | 11 | const program_schema = infer.inferProgramSchema(program_path, vendor_path, false); 12 | 13 | test.assertEquals(program_schema, { 14 | functions: { 15 | "bar": { 16 | ndc_kind: FunctionNdcKind.Procedure, 17 | description: null, 18 | arguments: [], 19 | result_type: { 20 | type: "named", 21 | kind: "object", 22 | name: "Foo" 23 | } 24 | } 25 | }, 26 | object_types: { 27 | Foo: { 28 | properties: [ 29 | { 30 | property_name: "a", 31 | type: { 32 | type: "named", 33 | kind: "scalar", 34 | name: "Float" 35 | } 36 | }, 37 | { 38 | property_name: "b", 39 | type: { 40 | type: "array", 41 | element_type: { 42 | type: "named", 43 | kind: "object", 44 | name: "Foo" 45 | } 46 | } 47 | } 48 | ] 49 | }, 50 | }, 51 | scalar_types: { 52 | Float: {}, 53 | }, 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/test/type_parameters_test.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as test from "https://deno.land/std@0.208.0/assert/mod.ts"; 3 | import * as path from "https://deno.land/std@0.208.0/path/mod.ts"; 4 | import * as infer from '../infer.ts'; 5 | import { FunctionNdcKind } from "../schema.ts"; 6 | 7 | Deno.test("Type Parameters", () => { 8 | const program_path = path.fromFileUrl(import.meta.resolve('./data/type_parameters.ts')); 9 | const vendor_path = path.fromFileUrl(import.meta.resolve('./vendor')); 10 | 11 | const program_schema = infer.inferProgramSchema(program_path, vendor_path, false); 12 | 13 | // TODO: Currently broken since parameters aren't normalised 14 | 15 | test.assertEquals(program_schema, { 16 | functions: { 17 | "bar": { 18 | ndc_kind: FunctionNdcKind.Procedure, 19 | description: null, 20 | arguments: [], 21 | result_type: { 22 | name: "Bar", 23 | kind: "object", 24 | type: "named", 25 | } 26 | } 27 | }, 28 | object_types: { 29 | "Bar": { 30 | properties: [ 31 | { 32 | property_name: "x", 33 | type: { 34 | type: "named", 35 | kind: "scalar", 36 | name: "Float" 37 | } 38 | }, 39 | { 40 | property_name: "y", 41 | type: { 42 | type: "named", 43 | kind: "object", 44 | name: "Foo" 45 | } 46 | }, 47 | ] 48 | }, 49 | "Foo": { 50 | properties: [ 51 | { 52 | property_name: "a", 53 | type: { 54 | type: "named", 55 | kind: "scalar", 56 | name: "Float" 57 | } 58 | }, 59 | { 60 | property_name: "b", 61 | type: { 62 | type: "named", 63 | kind: "scalar", 64 | name: "String" 65 | } 66 | }, 67 | ] 68 | }, 69 | }, 70 | scalar_types: { 71 | Float: {}, 72 | String: {}, 73 | }, 74 | }); 75 | 76 | }); 77 | -------------------------------------------------------------------------------- /src/test/validation_algorithm_test.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as test from "https://deno.land/std@0.208.0/assert/mod.ts"; 3 | import * as path from "https://deno.land/std@0.208.0/path/mod.ts"; 4 | import * as infer from '../infer.ts'; 5 | import { FunctionNdcKind } from "../schema.ts"; 6 | 7 | Deno.test("Validation Algorithm", () => { 8 | const program_path = path.fromFileUrl(import.meta.resolve('./data/validation_algorithm_update.ts')); 9 | const vendor_path = path.fromFileUrl(import.meta.resolve('./vendor')); 10 | 11 | const program_results = infer.inferProgramSchema(program_path, vendor_path, false); 12 | 13 | test.assertEquals(program_results, { 14 | functions: { 15 | "bar": { 16 | ndc_kind: FunctionNdcKind.Procedure, 17 | description: null, 18 | arguments: [ 19 | { 20 | argument_name: "string", 21 | description: null, 22 | type: { 23 | type: "named", 24 | kind: "scalar", 25 | name: "String" 26 | } 27 | }, 28 | { 29 | argument_name: "aliasedString", 30 | description: null, 31 | type: { 32 | type: "named", 33 | kind: "scalar", 34 | name: "String" 35 | } 36 | }, 37 | { 38 | argument_name: "genericScalar", 39 | description: null, 40 | type: { 41 | type: "named", 42 | kind: "scalar", 43 | name: "String" 44 | } 45 | }, 46 | { 47 | argument_name: "array", 48 | description: null, 49 | type: { 50 | type: "array", 51 | element_type: { 52 | type: "named", 53 | kind: "scalar", 54 | name: "String" 55 | } 56 | } 57 | }, 58 | { 59 | argument_name: "promise", 60 | description: null, 61 | type: { 62 | type: "named", 63 | kind: "scalar", 64 | name: "String" 65 | } 66 | }, 67 | { 68 | argument_name: "anonObj", 69 | description: null, 70 | type: { 71 | type: "named", 72 | kind: "object", 73 | name: "bar_arguments_anonObj" 74 | } 75 | }, 76 | { 77 | argument_name: "aliasedObj", 78 | description: null, 79 | type: { 80 | type: "named", 81 | kind: "object", 82 | name: "Bar" 83 | } 84 | }, 85 | { 86 | argument_name: "genericAliasedObj", 87 | description: null, 88 | type: { 89 | type: "named", 90 | kind: "object", 91 | name: "GenericBar" 92 | } 93 | }, 94 | { 95 | argument_name: "interfce", 96 | description: null, 97 | type: { 98 | type: "named", 99 | kind: "object", 100 | name: "IThing" 101 | } 102 | }, 103 | { 104 | argument_name: "genericInterface", 105 | description: null, 106 | type: { 107 | type: "named", 108 | kind: "object", 109 | name: "IGenericThing" 110 | } 111 | }, 112 | { 113 | argument_name: "aliasedIntersectionObj", 114 | description: null, 115 | type: { 116 | type: "named", 117 | kind: "object", 118 | name: "IntersectionObject" 119 | } 120 | }, 121 | { 122 | argument_name: "anonIntersectionObj", 123 | description: null, 124 | type: { 125 | type: "named", 126 | kind: "object", 127 | name: "bar_arguments_anonIntersectionObj" 128 | } 129 | }, 130 | { 131 | argument_name: "genericIntersectionObj", 132 | description: null, 133 | type: { 134 | type: "named", 135 | kind: "object", 136 | name: "GenericIntersectionObject" 137 | } 138 | }, 139 | ], 140 | result_type: { 141 | name: "String", 142 | kind: "scalar", 143 | type: "named", 144 | } 145 | } 146 | }, 147 | object_types: { 148 | "GenericBar": { 149 | properties: [ 150 | { 151 | property_name: "data", 152 | type: { 153 | name: "String", 154 | kind: "scalar", 155 | type: "named", 156 | }, 157 | }, 158 | ], 159 | }, 160 | "GenericIntersectionObject": { 161 | properties: [ 162 | { 163 | property_name: "data", 164 | type: { 165 | name: "String", 166 | kind: "scalar", 167 | type: "named", 168 | }, 169 | }, 170 | { 171 | property_name: "test", 172 | type: { 173 | name: "String", 174 | kind: "scalar", 175 | type: "named", 176 | }, 177 | }, 178 | ], 179 | }, 180 | Bar: { 181 | properties: [ 182 | { 183 | property_name: "test", 184 | type: { 185 | name: "String", 186 | kind: "scalar", 187 | type: "named", 188 | }, 189 | }, 190 | ], 191 | }, 192 | IGenericThing: { 193 | properties: [ 194 | { 195 | property_name: "data", 196 | type: { 197 | name: "String", 198 | kind: "scalar", 199 | type: "named", 200 | }, 201 | }, 202 | ], 203 | }, 204 | IThing: { 205 | properties: [ 206 | { 207 | property_name: "prop", 208 | type: { 209 | name: "String", 210 | kind: "scalar", 211 | type: "named", 212 | }, 213 | }, 214 | ], 215 | }, 216 | IntersectionObject: { 217 | properties: [ 218 | { 219 | property_name: "wow", 220 | type: { 221 | name: "String", 222 | kind: "scalar", 223 | type: "named", 224 | }, 225 | }, 226 | { 227 | property_name: "test", 228 | type: { 229 | name: "String", 230 | kind: "scalar", 231 | type: "named", 232 | }, 233 | }, 234 | ], 235 | }, 236 | bar_arguments_anonIntersectionObj: { 237 | properties: [ 238 | { 239 | property_name: "num", 240 | type: { 241 | name: "Float", 242 | kind: "scalar", 243 | type: "named", 244 | }, 245 | }, 246 | { 247 | property_name: "test", 248 | type: { 249 | name: "String", 250 | kind: "scalar", 251 | type: "named", 252 | }, 253 | }, 254 | ], 255 | }, 256 | bar_arguments_anonObj: { 257 | properties: [ 258 | { 259 | property_name: "a", 260 | type: { 261 | name: "Float", 262 | kind: "scalar", 263 | type: "named", 264 | }, 265 | }, 266 | { 267 | property_name: "b", 268 | type: { 269 | name: "String", 270 | kind: "scalar", 271 | type: "named", 272 | }, 273 | }, 274 | ], 275 | }, 276 | }, 277 | scalar_types: { 278 | Float: {}, 279 | String: {}, 280 | } 281 | }); 282 | }); 283 | -------------------------------------------------------------------------------- /src/test/void_test.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as test from "https://deno.land/std@0.208.0/assert/mod.ts"; 3 | import * as path from "https://deno.land/std@0.208.0/path/mod.ts"; 4 | import * as infer from '../infer.ts'; 5 | 6 | // NOTE: It would be good to have explicit timeout for this 7 | // See: https://github.com/denoland/deno/issues/11133 8 | // Test bug: https://github.com/hasura/ndc-typescript-deno/issues/45 9 | Deno.test("Void", () => { 10 | const program_path = path.fromFileUrl(import.meta.resolve('./data/void_types.ts')); 11 | const vendor_path = path.fromFileUrl(import.meta.resolve('./vendor')); 12 | test.assertThrows(() => { 13 | infer.inferProgramSchema(program_path, vendor_path, false); 14 | }) 15 | }); 16 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export const unreachable = (x: never): never => { throw new Error(`Unreachable code reached! The types lied! 😭 Unexpected value: ${x}`) }; 2 | 3 | export function isArray(x: unknown): x is unknown[] { 4 | return Array.isArray(x); 5 | } 6 | 7 | export function mapObjectValues(obj: { [k: string]: T }, fn: (value: T, propertyName: string) => U): Record { 8 | return Object.fromEntries(Object.entries(obj).map(([prop, val]) => [prop, fn(val, prop)])); 9 | } 10 | 11 | export function mapObject(obj: {[key: string]: I}, fn: ((key: string, value: I) => [string, O])): {[key: string]: O} { 12 | return Object.fromEntries(Object.entries(obj).map(([prop, val]) => fn(prop,val))); 13 | } 14 | --------------------------------------------------------------------------------