├── .github └── workflows │ ├── jest.yaml │ ├── knip.yaml │ └── lint.yaml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.mjs ├── jest.config.ts ├── knip.ts ├── package-lock.json ├── package.json ├── src ├── app │ └── api.service.ts ├── container.ts ├── graphql │ ├── apollo-config-factory.service.ts │ ├── field-resolver │ │ ├── entry-reference-resolver.ts │ │ ├── field-default-value-resolver.ts │ │ ├── fieldResolver.ts │ │ └── union-value-resolver.ts │ ├── input-type-generator.service.ts │ ├── queries-mutations-generator.service.ts │ ├── resolver-generators │ │ ├── mutation-create-resolver-generator.ts │ │ ├── mutation-delete-resolver-generator.ts │ │ ├── mutation-update-resolver-generator.ts │ │ ├── object-type-field-default-value-resolver-generator.ts │ │ ├── query-all-resolver-generator.ts │ │ ├── query-by-id-resolver-generator.ts │ │ ├── query-count-all-resolver-generator.ts │ │ ├── query-type-by-id-resolver-generator.ts │ │ └── union-type-resolver-generator.ts │ ├── schema-analyzer.service.ts │ ├── schema-generator.service.ts │ ├── schema-root-type-generator.service.ts │ ├── schema-utils │ │ ├── entry-reference-util.ts │ │ ├── entry-type-util.ts │ │ └── union-type-util.ts │ └── schema-validator.ts ├── index.ts └── persistence │ └── persistence.service.ts ├── tests └── integration │ ├── mutation │ ├── create.test.ts │ ├── delete.test.ts │ └── update.test.ts │ ├── query │ └── query.test.ts │ └── schema │ └── schema-generator.test.ts ├── tsconfig.build.cjs.json ├── tsconfig.build.esm.json ├── tsconfig.build.json ├── tsconfig.build.types.json └── tsconfig.json /.github/workflows/jest.yaml: -------------------------------------------------------------------------------- 1 | name: Jest 2 | on: push 3 | jobs: 4 | test: 5 | runs-on: ubuntu-22.04 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Setup Node.js 9 | uses: actions/setup-node@v1 10 | with: 11 | node-version: "18" 12 | 13 | - name: Install dependencies 14 | run: npm install 15 | 16 | - name: Run tests 17 | run: npm test 18 | -------------------------------------------------------------------------------- /.github/workflows/knip.yaml: -------------------------------------------------------------------------------- 1 | name: Lint dependencies with knip 2 | 3 | on: push 4 | 5 | jobs: 6 | knip: 7 | runs-on: ubuntu-22.04 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | - name: Install dependencies 12 | run: npm install --ignore-scripts 13 | - name: Run knip 14 | run: npm run knip 15 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: push 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-22.04 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Setup Node.js 9 | uses: actions/setup-node@v1 10 | with: 11 | node-version: "18" 12 | 13 | - name: Install dependencies 14 | run: npm install 15 | 16 | - name: Run linter 17 | run: npm run lint 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # JetBrains 2 | .idea/ 3 | 4 | # Visual Studio Code 5 | .vscode/ 6 | 7 | ########################## 8 | 9 | **/.env.yaml 10 | 11 | dist/ 12 | node_modules/ 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.81.2] - 2025-04-16 9 | 10 | ### Changed 11 | 12 | - Improve library exports 13 | 14 | ## [0.81.1] - 2025-04-16 15 | 16 | ### Fixed 17 | 18 | - Fix incorrect types export 19 | 20 | ### Changed 21 | 22 | - Improve library exports 23 | 24 | ## [0.81.0] - 2025-04-13 25 | 26 | ### Changed 27 | 28 | - Refactor library packaging to support ESM and CJS 29 | - Clean up dependencies and relax version constraints 30 | 31 | ## [0.80.0] - 2025-03-19 32 | 33 | ### Changed 34 | 35 | - Rewrite README for clarity 36 | - Remove superfluous `DeletionResult` type and replace with `ID` scalar in order to simplify API 37 | - Rename mutation `message` argument to `commitMessage` for more intuitive API use 38 | - Make mutation `data` argument non-null for more intuitive API use 39 | 40 | ## [0.12.0] - 2025-03-17 41 | 42 | ### Changed 43 | 44 | - Stop transforming field name case for concrete union types so that relationship to concrete type is more clear in 45 | serialized data 46 | - Upgrade dependencies 47 | 48 | ## [0.11.1] - 2024-12-29 49 | 50 | ### Added 51 | 52 | - Export API response types 53 | 54 | ### Changed 55 | 56 | - Upgrade dependencies 57 | 58 | ## [0.11.0] - 2024-08-17 59 | 60 | ### Changed 61 | 62 | - Upgrade to `@commitspark/git-adapter` 0.13.0 63 | 64 | ## [0.10.0] - 2024-06-15 65 | 66 | ### Added 67 | 68 | - Add referential integrity by tracking and validating references between entries 69 | - Add support of partial updates 70 | 71 | ### Fixed 72 | 73 | - [#18](https://github.com/commitspark/graphql-api/issues/18), [#25](https://github.com/commitspark/graphql-api/issues/25) 74 | Fix handling of entries that only have an `id` field 75 | 76 | ### Changed 77 | 78 | - Return default data where possible when entry data is incomplete 79 | - Improve content retrieval performance 80 | 81 | ## [0.9.4] - 2023-11-14 82 | 83 | ### Fixed 84 | 85 | - Fix invalid schema built when running under Next.js 14 86 | 87 | ## [0.9.3] - 2023-09-06 88 | 89 | ### Fixed 90 | 91 | - Fix failure to resolve array of non-@Entry union types that is null 92 | 93 | ### Changed 94 | 95 | - Update dependencies 96 | 97 | ## [0.9.2] - 2023-07-22 98 | 99 | ### Fixed 100 | 101 | - Fix documentation to match implementation 102 | - Fix missing numeric character permission in entry ID validation regex 103 | 104 | ## [0.9.1] - 2023-07-09 105 | 106 | ### Fixed 107 | 108 | - Fix broken NPM package build 109 | 110 | ## [0.9.0] - 2023-07-09 111 | 112 | ### Changed 113 | 114 | - Improve schema formatting 115 | - Expose entry ID as argument of "create" mutation instead of automatic ID generation 116 | - Check ID of entry before executing an update, delete mutation 117 | - Update dependencies 118 | 119 | ### Fixed 120 | 121 | - [#32](https://github.com/commitspark/graphql-api/issues/32) Fix memory leak triggered by API calls 122 | 123 | ## [0.8.0] - 2023-06-17 124 | 125 | ### Fixed 126 | 127 | - [#26](https://github.com/commitspark/graphql-api/issues/26) Querying optional reference field with null value causes 128 | exception 129 | 130 | ## [0.7.0] - 2023-05-12 131 | 132 | ### Changed 133 | 134 | - Rename organization 135 | - Remove deprecated code 136 | 137 | ## [0.6.1] - 2023-05-07 138 | 139 | ### Changed 140 | 141 | - Improve documentation 142 | 143 | ## [0.6.0] - 2023-04-27 144 | 145 | ### Changed 146 | 147 | - Enable strict TypeScript for improved type safety 148 | - Update packages to address NPM security audit 149 | 150 | ### Fixed 151 | 152 | - [#23](https://github.com/commitspark/graphql-api/issues/23) Data of unions with non-Entry members is 153 | not discernible after serialization 154 | 155 | ## [0.5.4] - 2023-03-15 156 | 157 | ### Changed 158 | 159 | - Remove dependency injection package to support bundling with webpack & co. 160 | - Upgrade to Apollo Server 4.5 161 | 162 | ## [0.5.3] - 2023-03-12 163 | 164 | ### Changed 165 | 166 | - Improve GraphQL endpoint type definition 167 | 168 | ## [0.5.2] - 2022-11-14 169 | 170 | ### Changed 171 | 172 | - Update dependencies 173 | - Upgrade to Apollo Server 4 174 | 175 | ## [0.5.1] - 2022-11-04 176 | 177 | ### Changed 178 | 179 | - Clean up unused dependencies 180 | - Extensive update of README 181 | 182 | ### Fixed 183 | 184 | - Fix omission of providing preceding commit hash when requesting a new commit from Git adapter 185 | 186 | ## [0.5.0] - 2022-11-01 187 | 188 | ### Changed 189 | 190 | - Move NPM package to organization namespace 191 | - Update to organization-based `git-adapter` 192 | 193 | ## [0.4.1] - 2022-10-25 194 | 195 | ### Changed 196 | 197 | - Update to Git Adapter interface 0.4.0 198 | 199 | ## [0.4.0] - 2022-10-25 200 | 201 | ### Changed 202 | 203 | - Move responsibility for Git adapter lifecycle out of library 204 | - Make type query use commit hash for better performance 205 | 206 | ## [0.3.0] - 2022-10-24 207 | 208 | ### Changed 209 | 210 | - Drop NestJS in favor of awilix due to https://github.com/nestjs/nest/issues/9622 211 | 212 | ## [0.2.1] - 2022-10-11 213 | 214 | ### Fixed 215 | 216 | - Export signature of `ApiService` 217 | 218 | ### Changed 219 | 220 | - Move example application into [separate repository](https://github.com/commitspark/example-code-serverless) 221 | 222 | ## [0.2.0] - 2022-10-07 223 | 224 | ### Changed 225 | 226 | - Move GitLab (SaaS) implementation into [separate repository](https://github.com/commitspark/git-adapter-gitlab) 227 | - Refactor code to be used as library 228 | - Move application-specific code to example directory 229 | - Upgrade to NestJS 9 230 | - Refactor code to be truly stateless 231 | 232 | ## [0.1.0] - 2022-07-15 233 | 234 | ### Added 235 | 236 | - Initial release 237 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2023 Markus Weiland 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | [Commitspark](https://commitspark.com) is a set of tools to manage structured data with Git through a GraphQL API. 4 | 5 | The library found in this repository provides the GraphQL API that allows reading and writing structured data (entries) 6 | from and to a Git repository. 7 | 8 | Queries and mutations offered by the API are determined by a standard GraphQL type definition file (schema) inside the 9 | Git repository. 10 | 11 | Entries (data) are stored using plain YAML text files in the same Git repository. No other data store is needed. 12 | 13 | # Installation 14 | 15 | There are two common ways to use this library: 16 | 17 | 1. By making GraphQL calls directly to the library as a code dependency in your own JavaScript / TypeScript / 18 | Node.js application. 19 | 20 | To do this, simply install the library with 21 | 22 | ```shell 23 | npm i @commitspark/graphql-api 24 | ``` 25 | 2. By making GraphQL calls over HTTP to this library wrapped in a webserver or Lambda function of choice. 26 | 27 | Please see the [Node.js Express server example](https://github.com/commitspark/example-http-express) 28 | or [Lambda function example](https://github.com/commitspark/example-code-serverless) for details. 29 | 30 | ## Installing Git provider support 31 | 32 | This library is agnostic to where a Git repository is stored and relies on separate adapters for repository access. To 33 | access a Git repository, use one of the pre-built adapters listed below or build your own using the interfaces 34 | in [this repository](https://github.com/commitspark/git-adapter). 35 | 36 | | Adapter | Description | Install with | 37 | |---------------------------------------------------------------------|------------------------------------------------------------|---------------------------------------------| 38 | | [GitHub](https://github.com/commitspark/git-adapter-github) | Provides support for Git repositories hosted on github.com | `npm i @commitspark/git-adapter-github` | 39 | | [GitLab (SaaS)](https://github.com/commitspark/git-adapter-gitlab) | Provides support for Git repositories hosted on gitlab.com | `npm i @commitspark/git-adapter-gitlab` | 40 | | [Filesystem](https://github.com/commitspark/git-adapter-filesystem) | Provides read-only access to files on the filesystem level | `npm i @commitspark/git-adapter-filesystem` | 41 | 42 | # Building your GraphQL API 43 | 44 | Commitspark builds a GraphQL data management API with create, read, update, and delete (CRUD) functionality that is 45 | solely driven by data types you define in a standard GraphQL schema file in your Git repository. 46 | 47 | Commitspark achieves this by extending the types in your schema file at runtime with queries, mutations, and additional 48 | helper types. 49 | 50 | Let's assume you want to manage information about rocket flights and have already defined the following simple GraphQL 51 | schema in your Git repository: 52 | 53 | ```graphql 54 | # commitspark/schema/schema.graphql 55 | 56 | directive @Entry on OBJECT 57 | 58 | type RocketFlight @Entry { 59 | id: ID! 60 | vehicleName: String! 61 | payloads: [Payload!] 62 | } 63 | 64 | type Payload { 65 | weight: Int! 66 | } 67 | ``` 68 | 69 | At runtime, when sending a GraphQL request to Commitspark, these are the queries, mutations and helper types that are 70 | added by Commitspark to your schema for the duration of request execution: 71 | 72 | ```graphql 73 | schema { 74 | query: Query 75 | mutation: Mutation 76 | } 77 | 78 | type Query { 79 | allRocketFlights: [RocketFlight!] 80 | RocketFlight(id: ID!): RocketFlight 81 | _typeName(id: ID!): String 82 | } 83 | 84 | type Mutation { 85 | createRocketFlight(id: ID!, data: RocketFlightInput!, commitMessage: String): RocketFlight 86 | updateRocketFlight(id: ID!, data: RocketFlightInput!, commitMessage: String): RocketFlight 87 | deleteRocketFlight(id: ID!, commitMessage: String): ID 88 | } 89 | 90 | input RocketFlightInput { 91 | vehicleName: String! 92 | payloads: [PayloadInput!] 93 | } 94 | 95 | input PayloadInput { 96 | weight: Int! 97 | } 98 | ``` 99 | 100 | # Making GraphQL calls 101 | 102 | Let's now assume your repository is located on GitHub and you want to query for a single rocket flight. 103 | 104 | The code to do so could look like this: 105 | 106 | ```typescript 107 | import { 108 | createAdapter, 109 | GitHubRepositoryOptions, 110 | } from '@commitspark/git-adapter-github' 111 | import {getApiService} from '@commitspark/graphql-api' 112 | 113 | const gitHubAdapter = createAdapter() 114 | await gitHubAdapter.setRepositoryOptions({ 115 | repositoryOwner: process.env.GITHUB_REPOSITORY_OWNER, 116 | repositoryName: process.env.GITHUB_REPOSITORY_NAME, 117 | accessToken: process.env.GITHUB_ACCESS_TOKEN, 118 | } as GitHubRepositoryOptions) 119 | 120 | const apiService = await getApiService() 121 | 122 | const response = await apiService.postGraphQL( 123 | gitHubAdapter, 124 | process.env.GIT_BRANCH ?? 'main', 125 | { 126 | query: `query ($rocketFlightId: ID!) { 127 | rocketFlight: RocketFlight(id: $rocketFlightId) { 128 | vehicleName 129 | payloads { 130 | weight 131 | } 132 | } 133 | }`, 134 | variables: { 135 | rocketFlightId: 'VA256', 136 | } 137 | }, 138 | ) 139 | 140 | const rocketFlight = response.data.rocketFlight 141 | // ... 142 | ``` 143 | 144 | # Technical documentation 145 | 146 | ## API 147 | 148 | ### ApiService 149 | 150 | #### postGraphQL() 151 | 152 | This function is used to make GraphQL requests. 153 | 154 | Request execution is handled by ApolloServer behind the scenes. 155 | 156 | Argument `request` expects a conventional GraphQL query and supports query variables as well as introspection. 157 | 158 | #### getSchema() 159 | 160 | This function allows retrieving the GraphQL schema extended by Commitspark as a string. 161 | 162 | Compared to schema data obtained through GraphQL introspection, the schema returned by this function also includes 163 | directive declarations and annotations, allowing for development of additional tools that require this information. 164 | 165 | ## Picking from the Git tree 166 | 167 | As Commitspark is Git-based, all GraphQL requests support traversing the Git commit tree by setting the `ref` argument 168 | in library calls to a 169 | 170 | * ref (i.e. commit hash), 171 | * branch name, or 172 | * tag name (light or regular) 173 | 174 | This enables great flexibility, e.g. to use branches in order to enable data (entry) development workflows, to retrieve 175 | a specific (historic) commit where it is guaranteed that entries are immutable, or to retrieve entries by tag such as 176 | one that marks the latest reviewed and approved version in a repository. 177 | 178 | ### Writing data 179 | 180 | Mutation operations work on branch names only and (when successful) each append a new commit on 181 | HEAD in the given branch. 182 | 183 | To guarantee deterministic results, mutations in calls with multiple mutations are processed sequentially (see 184 | the [official GraphQL documentation](https://graphql.org/learn/queries/#multiple-fields-in-mutations) for details). 185 | 186 | ## Data model 187 | 188 | The data model (i.e. schema) is defined in a single GraphQL type definition text file using the 189 | [GraphQL type system](https://graphql.org/learn/schema/). 190 | 191 | The schema file must be located at `commitspark/schema/schema.graphql` inside the Git repository (unless otherwise 192 | configured in your Git adapter). 193 | 194 | Commitspark currently supports the following GraphQL types: 195 | 196 | * `type` 197 | * `union` 198 | * `enum` 199 | 200 | ### Data entries 201 | 202 | To denote which data is to be given a unique identity for referencing, Commitspark expects type annotation with 203 | directive `@Entry`: 204 | 205 | ```graphql 206 | directive @Entry on OBJECT # Important: You must declare this for your schema to be valid 207 | 208 | type MyType @Entry { 209 | id: ID! # Important: Any type annotated with `@Entry` must have such a field 210 | # ... 211 | } 212 | ``` 213 | 214 | **Note:** As a general guideline, you should only apply `@Entry` to data types that meet one of the following 215 | conditions: 216 | 217 | * You want to independently create and query instances of this type 218 | * You want to reference or link to an instance of such a type from multiple other entries 219 | 220 | This keeps the number of entries low and performance up. 221 | 222 | ## Entry storage 223 | 224 | Entries, i.e. instances of data types annotated with `@Entry`, are stored as `.yaml` YAML text files inside 225 | folder `commitspark/entries/` in the given Git repository (unless otherwise configured in your Git adapter). 226 | 227 | The filename (excluding file extension) constitutes the entry ID. 228 | 229 | Entry files have the following structure: 230 | 231 | ```yaml 232 | metadata: 233 | type: MyType # name of type as defined in your schema 234 | referencedBy: [ ] # array of entry IDs that hold a reference to this entry 235 | data: 236 | # ... fields of the type as defined in your schema 237 | ``` 238 | 239 | ### Serialization / Deserialization 240 | 241 | #### References 242 | 243 | References to types annotated with `@Entry` are serialized using a sub-field `id`. 244 | 245 | For example, consider this variation of our rocket flight schema above: 246 | 247 | ```graphql 248 | type RocketFlight @Entry { 249 | id: ID! 250 | operator: Operator 251 | } 252 | 253 | type Operator @Entry { 254 | id: ID! 255 | fullName: String! 256 | } 257 | ``` 258 | 259 | An entry YAML file for a `RocketFlight` with ID `VA256` referencing an `Operator` with ID `Arianespace` will look 260 | like this: 261 | 262 | ```yaml 263 | # commitspark/entries/VA256.yaml 264 | metadata: 265 | type: RocketFlight 266 | referencedBy: [ ] 267 | data: 268 | operator: 269 | id: Arianespace 270 | ``` 271 | 272 | The YAML file of referenced `Operator` with ID `Arianespace` will then look like this: 273 | 274 | ```yaml 275 | # commitspark/entries/Arianespace.yaml 276 | metadata: 277 | type: Operator 278 | referencedBy: 279 | - VA256 280 | data: 281 | fullName: Arianespace SA 282 | ``` 283 | 284 | When this data is deserialized, Commitspark transparently resolves references to other `@Entry` instances, allowing for 285 | retrieval of complex, linked data in a single query such as this one: 286 | 287 | ```graphql 288 | query { 289 | RocketFlight(id: "VA256") { 290 | id 291 | operator { 292 | fullName 293 | } 294 | } 295 | } 296 | ``` 297 | 298 | This returns the following data: 299 | 300 | ```json 301 | { 302 | "id": "VA256", 303 | "operator": { 304 | "fullName": "Arianespace SA" 305 | } 306 | } 307 | ``` 308 | 309 | #### Unions 310 | 311 | Consider this example of a schema for storing content for a marketing website built out of modular content elements, 312 | where field `contentElements` is an array of Union type `ContentElement`, allowing different concrete types `Hero` or 313 | `Text` to be applied: 314 | 315 | ```graphql 316 | type Page @Entry { 317 | id: ID! 318 | contentElements: [ContentElement!] 319 | } 320 | 321 | union ContentElement = 322 | | Hero 323 | | Text 324 | 325 | type Hero { 326 | heroText: String! 327 | } 328 | 329 | type Text { 330 | bodyText: String! 331 | } 332 | ``` 333 | 334 | During serialization, concrete type instances are represented through an additional nested level of data, using the 335 | concrete instance's type name as field name: 336 | 337 | ```yaml 338 | metadata: 339 | type: Page 340 | referencedBy: [ ] 341 | data: 342 | contentElements: 343 | - Hero: 344 | heroText: "..." 345 | - Text: 346 | bodyText: "..." 347 | ``` 348 | 349 | When querying data through the API, this additional level of nesting is transparently removed and not visible. 350 | 351 | # License 352 | 353 | The code in this repository is licensed under the permissive ISC license (see [LICENSE](LICENSE)). 354 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'eslint/config' 2 | import globals from 'globals' 3 | import tseslint from 'typescript-eslint' 4 | 5 | export default defineConfig([ 6 | { files: ['src/**/*.{ts}', 'tests/**/*.{ts}'] }, 7 | { 8 | files: ['src/**/*.{ts}'], 9 | languageOptions: { globals: { ...globals.browser, ...globals.node } }, 10 | }, 11 | tseslint.configs.recommended, 12 | { 13 | rules: { 14 | '@typescript-eslint/no-explicit-any': 'warn', 15 | '@typescript-eslint/no-unused-vars': 'warn', 16 | }, 17 | }, 18 | ]) 19 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | import type { Config } from 'jest' 6 | 7 | const jestConfig: Config = { 8 | moduleFileExtensions: ['js', 'ts'], 9 | transform: { 10 | '^.+\\.tsx?$': 'ts-jest', 11 | }, 12 | } 13 | 14 | export default jestConfig 15 | -------------------------------------------------------------------------------- /knip.ts: -------------------------------------------------------------------------------- 1 | import type { KnipConfig } from 'knip' 2 | 3 | const config: KnipConfig = { 4 | ignoreDependencies: [ 5 | 'ts-node', // required for Jest to read `jest.config.ts` 6 | ], 7 | } 8 | 9 | export default config 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@commitspark/graphql-api", 3 | "description": "GraphQL API for Commitspark", 4 | "version": "0.81.2", 5 | "license": "ISC", 6 | "private": false, 7 | "files": [ 8 | "dist/**", 9 | "src/**", 10 | "README.md", 11 | "LICENSE", 12 | "package.json", 13 | "CHANGELOG.md" 14 | ], 15 | "main": "./dist/cjs/index.js", 16 | "module": "./dist/esm/index.js", 17 | "types": "./dist/types/index.d.ts", 18 | "exports": { 19 | ".": { 20 | "types": "./dist/types/index.d.ts", 21 | "require": "./dist/cjs/index.js", 22 | "import": "./dist/esm/index.js" 23 | } 24 | }, 25 | "scripts": { 26 | "build": "npm run build:cjs && npm run build:esm && npm run build:types", 27 | "build:cjs": "tsc --project tsconfig.build.cjs.json", 28 | "build:esm": "tsc --project tsconfig.build.esm.json", 29 | "build:types": "tsc --project tsconfig.build.types.json", 30 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 31 | "lint": "eslint \"{src,tests}/**/*.ts\"", 32 | "test": "jest", 33 | "knip": "knip", 34 | "prepublishOnly": "npm run build" 35 | }, 36 | "dependencies": { 37 | "@apollo/server": "^4.0.0", 38 | "@commitspark/git-adapter": "^0.13.0", 39 | "@graphql-tools/schema": "^9.0.0", 40 | "@graphql-tools/utils": "^9.0.0" 41 | }, 42 | "peerDependencies": { 43 | "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" 44 | }, 45 | "devDependencies": { 46 | "@types/jest": "^29.5.2", 47 | "@types/node": "^22.14.0", 48 | "eslint": "^9.24.0", 49 | "globals": "^16.0.0", 50 | "jest": "^29.5.0", 51 | "jest-mock-extended": "^3.0.4", 52 | "knip": "^5.46.5", 53 | "prettier": "^2.4.1", 54 | "ts-jest": "^29.1.0", 55 | "ts-node": "^10.9.2", 56 | "typescript": "^5.0.0", 57 | "typescript-eslint": "^8.29.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/app/api.service.ts: -------------------------------------------------------------------------------- 1 | import { ApolloConfigFactoryService } from '../graphql/apollo-config-factory.service' 2 | import { SchemaGeneratorService } from '../graphql/schema-generator.service' 3 | import { 4 | DocumentNode, 5 | GraphQLFormattedError, 6 | TypedQueryDocumentNode, 7 | } from 'graphql' 8 | import { GitAdapter } from '@commitspark/git-adapter' 9 | import { 10 | ApolloServer, 11 | ApolloServerOptions, 12 | GraphQLRequest, 13 | } from '@apollo/server' 14 | 15 | type VariableValues = { [name: string]: any } 16 | 17 | export class ApiService { 18 | constructor( 19 | private readonly apolloConfigFactory: ApolloConfigFactoryService, 20 | private readonly schemaGenerator: SchemaGeneratorService, 21 | ) {} 22 | 23 | async postGraphQL< 24 | TData = Record, 25 | TVariables extends VariableValues = VariableValues, 26 | >( 27 | gitAdapter: GitAdapter, 28 | ref: string, 29 | request: Omit, 'query'> & { 30 | query?: string | DocumentNode | TypedQueryDocumentNode 31 | }, 32 | ): Promise> { 33 | let currentRef = await gitAdapter.getLatestCommitHash(ref) 34 | const context: ApolloContext = { 35 | branch: ref, 36 | gitAdapter: gitAdapter, 37 | getCurrentRef(): string { 38 | return currentRef 39 | }, 40 | setCurrentRef(refArg: string) { 41 | currentRef = refArg 42 | }, 43 | } 44 | 45 | const apolloDriverConfig: ApolloServerOptions = 46 | await this.apolloConfigFactory.createGqlOptions(context) 47 | 48 | const apolloServer = new ApolloServer({ 49 | ...apolloDriverConfig, 50 | }) 51 | 52 | const result = await apolloServer.executeOperation( 53 | request, 54 | { 55 | contextValue: context, 56 | }, 57 | ) 58 | 59 | await apolloServer.stop() 60 | 61 | return { 62 | ref: context.getCurrentRef(), 63 | data: 64 | result.body.kind === 'single' 65 | ? result.body.singleResult.data 66 | : undefined, 67 | errors: 68 | result.body.kind === 'single' 69 | ? result.body.singleResult.errors 70 | : undefined, 71 | } 72 | } 73 | 74 | async getSchema( 75 | gitAdapter: GitAdapter, 76 | ref: string, 77 | ): Promise { 78 | let currentRef = await gitAdapter.getLatestCommitHash(ref) 79 | const contextGenerator = () => 80 | ({ 81 | branch: ref, 82 | gitAdapter: gitAdapter, 83 | getCurrentRef(): string { 84 | return currentRef 85 | }, 86 | setCurrentRef(refArg: string) { 87 | currentRef = refArg 88 | }, 89 | } as ApolloContext) 90 | 91 | const typeDefinitionStrings = ( 92 | await this.schemaGenerator.generateSchema(contextGenerator()) 93 | ).typeDefs 94 | if (!Array.isArray(typeDefinitionStrings)) { 95 | throw new Error('Unknown element') 96 | } 97 | 98 | return { 99 | ref: contextGenerator().getCurrentRef(), 100 | data: typeDefinitionStrings.join('\n'), 101 | } 102 | } 103 | } 104 | 105 | export interface ApolloContext { 106 | branch: string 107 | gitAdapter: GitAdapter 108 | getCurrentRef(): string 109 | setCurrentRef(sha: string): void 110 | } 111 | 112 | export interface GraphQLResponse { 113 | ref: string 114 | data?: TData 115 | errors: ReadonlyArray | undefined 116 | } 117 | 118 | export interface SchemaResponse { 119 | ref: string 120 | data: string 121 | } 122 | -------------------------------------------------------------------------------- /src/container.ts: -------------------------------------------------------------------------------- 1 | import { ApiService } from './app/api.service' 2 | import { ApolloConfigFactoryService } from './graphql/apollo-config-factory.service' 3 | import { EntryReferenceResolver } from './graphql/field-resolver/entry-reference-resolver' 4 | import { EntryReferenceUtil } from './graphql/schema-utils/entry-reference-util' 5 | import { EntryTypeUtil } from './graphql/schema-utils/entry-type-util' 6 | import { FieldDefaultValueResolver } from './graphql/field-resolver/field-default-value-resolver' 7 | import { InputTypeGeneratorService } from './graphql/input-type-generator.service' 8 | import { MutationCreateResolverGenerator } from './graphql/resolver-generators/mutation-create-resolver-generator' 9 | import { MutationDeleteResolverGenerator } from './graphql/resolver-generators/mutation-delete-resolver-generator' 10 | import { MutationUpdateResolverGenerator } from './graphql/resolver-generators/mutation-update-resolver-generator' 11 | import { ObjectTypeFieldDefaultValueResolverGenerator } from './graphql/resolver-generators/object-type-field-default-value-resolver-generator' 12 | import { PersistenceService } from './persistence/persistence.service' 13 | import { QueriesMutationsGeneratorService } from './graphql/queries-mutations-generator.service' 14 | import { QueryAllResolverGenerator } from './graphql/resolver-generators/query-all-resolver-generator' 15 | import { QueryByIdResolverGenerator } from './graphql/resolver-generators/query-by-id-resolver-generator' 16 | import { QueryCountAllResolverGenerator } from './graphql/resolver-generators/query-count-all-resolver-generator' 17 | import { QueryTypeByIdResolverGenerator } from './graphql/resolver-generators/query-type-by-id-resolver-generator' 18 | import { SchemaAnalyzerService } from './graphql/schema-analyzer.service' 19 | import { SchemaGeneratorService } from './graphql/schema-generator.service' 20 | import { SchemaRootTypeGeneratorService } from './graphql/schema-root-type-generator.service' 21 | import { SchemaValidator } from './graphql/schema-validator' 22 | import { UnionTypeResolverGenerator } from './graphql/resolver-generators/union-type-resolver-generator' 23 | import { UnionTypeUtil } from './graphql/schema-utils/union-type-util' 24 | import { UnionValueResolver } from './graphql/field-resolver/union-value-resolver' 25 | 26 | // we used to have a DI container here, however that doesn't work well with webpack & co, so doing it by hand for now 27 | 28 | const schemaValidator = new SchemaValidator() 29 | const persistenceService = new PersistenceService() 30 | const entryTypeUtil = new EntryTypeUtil() 31 | const unionTypeUtil = new UnionTypeUtil() 32 | const entryReferenceUtil = new EntryReferenceUtil( 33 | persistenceService, 34 | entryTypeUtil, 35 | unionTypeUtil, 36 | ) 37 | const schemaRootTypeGeneratorService = new SchemaRootTypeGeneratorService() 38 | 39 | const inputTypeGeneratorService = new InputTypeGeneratorService(entryTypeUtil) 40 | const schemaAnalyzerService = new SchemaAnalyzerService() 41 | const mutationCreateResolverGenerator = new MutationCreateResolverGenerator( 42 | persistenceService, 43 | entryReferenceUtil, 44 | ) 45 | const mutationDeleteResolverGenerator = new MutationDeleteResolverGenerator( 46 | persistenceService, 47 | entryReferenceUtil, 48 | ) 49 | const mutationUpdateResolverGenerator = new MutationUpdateResolverGenerator( 50 | persistenceService, 51 | entryReferenceUtil, 52 | ) 53 | const queryAllResolverGenerator = new QueryAllResolverGenerator( 54 | persistenceService, 55 | ) 56 | const queryByIdResolverGenerator = new QueryByIdResolverGenerator( 57 | persistenceService, 58 | ) 59 | const queryCountAllResolverGenerator = new QueryCountAllResolverGenerator( 60 | persistenceService, 61 | ) 62 | const queryTypeByIdResolverGenerator = new QueryTypeByIdResolverGenerator( 63 | persistenceService, 64 | ) 65 | const unionTypeResolverGenerator = new UnionTypeResolverGenerator( 66 | persistenceService, 67 | entryTypeUtil, 68 | ) 69 | const unionValueResolver = new UnionValueResolver(unionTypeUtil) 70 | const entryReferenceResolver = new EntryReferenceResolver(persistenceService) 71 | const fieldDefaultValueResolver = new FieldDefaultValueResolver( 72 | entryTypeUtil, 73 | unionValueResolver, 74 | entryReferenceResolver, 75 | ) 76 | const objectTypeFieldDefaultValueResolverGenerator = 77 | new ObjectTypeFieldDefaultValueResolverGenerator(fieldDefaultValueResolver) 78 | 79 | const queriesMutationsGeneratorService = new QueriesMutationsGeneratorService( 80 | queryAllResolverGenerator, 81 | queryByIdResolverGenerator, 82 | queryCountAllResolverGenerator, 83 | queryTypeByIdResolverGenerator, 84 | mutationCreateResolverGenerator, 85 | mutationUpdateResolverGenerator, 86 | mutationDeleteResolverGenerator, 87 | ) 88 | 89 | const schemaGeneratorService = new SchemaGeneratorService( 90 | queriesMutationsGeneratorService, 91 | schemaAnalyzerService, 92 | inputTypeGeneratorService, 93 | schemaRootTypeGeneratorService, 94 | unionTypeResolverGenerator, 95 | objectTypeFieldDefaultValueResolverGenerator, 96 | schemaValidator, 97 | ) 98 | 99 | const apolloConfigFactoryService = new ApolloConfigFactoryService( 100 | schemaGeneratorService, 101 | ) 102 | 103 | export const apiService = new ApiService( 104 | apolloConfigFactoryService, 105 | schemaGeneratorService, 106 | ) 107 | -------------------------------------------------------------------------------- /src/graphql/apollo-config-factory.service.ts: -------------------------------------------------------------------------------- 1 | import { makeExecutableSchema } from '@graphql-tools/schema' 2 | import { SchemaGeneratorService } from './schema-generator.service' 3 | import { ApolloContext } from '../app/api.service' 4 | import { ApolloServerOptions } from '@apollo/server' 5 | import { ApolloServerPluginUsageReportingDisabled } from '@apollo/server/plugin/disabled' 6 | 7 | export class ApolloConfigFactoryService { 8 | constructor(private readonly schemaGenerator: SchemaGeneratorService) {} 9 | 10 | async createGqlOptions( 11 | context: ApolloContext, 12 | ): Promise> { 13 | const schemaDefinition = await this.schemaGenerator.generateSchema(context) 14 | const schema = makeExecutableSchema(schemaDefinition) 15 | 16 | return { 17 | schema: schema, 18 | plugins: [ApolloServerPluginUsageReportingDisabled()], 19 | } as ApolloServerOptions 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/graphql/field-resolver/entry-reference-resolver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FieldResolver, 3 | FieldResolverContext, 4 | ResolvedEntryData, 5 | } from './fieldResolver' 6 | import { 7 | GraphQLFieldResolver, 8 | GraphQLOutputType, 9 | GraphQLResolveInfo, 10 | } from 'graphql' 11 | import { PersistenceService } from '../../persistence/persistence.service' 12 | import { EntryData } from '@commitspark/git-adapter' 13 | 14 | export class EntryReferenceResolver implements FieldResolver { 15 | resolve: GraphQLFieldResolver< 16 | any, 17 | FieldResolverContext, 18 | any, 19 | Promise> 20 | > 21 | 22 | constructor(private readonly persistence: PersistenceService) { 23 | this.resolve = ( 24 | fieldValue, 25 | args: any, 26 | context: FieldResolverContext, 27 | info, 28 | ): Promise> => 29 | this.resolveFieldValue(fieldValue, args, context, info, info.returnType) 30 | } 31 | 32 | private async resolveFieldValue( 33 | fieldValue: any, 34 | args: any, 35 | context: FieldResolverContext, 36 | info: GraphQLResolveInfo, 37 | currentType: GraphQLOutputType, 38 | ): Promise> { 39 | const entry = await this.persistence.findById( 40 | context.gitAdapter, 41 | context.getCurrentRef(), 42 | fieldValue.id, 43 | ) 44 | 45 | return { ...entry.data, id: entry.id } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/graphql/field-resolver/field-default-value-resolver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FieldResolver, 3 | FieldResolverContext, 4 | ResolvedEntryData, 5 | } from './fieldResolver' 6 | import { EntryData } from '@commitspark/git-adapter' 7 | import { 8 | GraphQLFieldResolver, 9 | GraphQLNonNull, 10 | GraphQLOutputType, 11 | GraphQLResolveInfo, 12 | GraphQLUnionType, 13 | isListType, 14 | isNonNullType, 15 | isObjectType, 16 | isUnionType, 17 | } from 'graphql' 18 | import { EntryTypeUtil } from '../schema-utils/entry-type-util' 19 | import { UnionValueResolver } from './union-value-resolver' 20 | import { EntryReferenceResolver } from './entry-reference-resolver' 21 | 22 | export class FieldDefaultValueResolver implements FieldResolver { 23 | resolve: GraphQLFieldResolver< 24 | any, 25 | FieldResolverContext, 26 | any, 27 | Promise> 28 | > 29 | 30 | constructor( 31 | private readonly entryTypeUtil: EntryTypeUtil, 32 | private readonly unionValueResolver: UnionValueResolver, 33 | private readonly entryReferenceResolver: EntryReferenceResolver, 34 | ) { 35 | this.resolve = ( 36 | fieldValue, 37 | args, 38 | context, 39 | info, 40 | ): Promise> => 41 | this.resolveFieldValue( 42 | fieldValue, 43 | args, 44 | context, 45 | info, 46 | info.returnType, 47 | false, 48 | ) 49 | } 50 | 51 | private resolveFieldValue( 52 | fieldValue: any, 53 | args: any, 54 | context: FieldResolverContext, 55 | info: GraphQLResolveInfo, 56 | currentType: GraphQLOutputType, 57 | hasNonNullParent: boolean, 58 | ): Promise> { 59 | if (isNonNullType(currentType)) { 60 | return this.resolveFieldValue( 61 | fieldValue, 62 | args, 63 | { 64 | gitAdapter: context.gitAdapter, 65 | getCurrentRef: context.getCurrentRef, 66 | }, 67 | info, 68 | currentType.ofType, 69 | true, 70 | ) 71 | } else if (isListType(currentType)) { 72 | if (fieldValue === undefined || fieldValue === null) { 73 | if (hasNonNullParent) { 74 | return new Promise((resolve) => resolve([])) 75 | } else { 76 | return new Promise((resolve) => resolve(null)) 77 | } 78 | } 79 | 80 | if (!Array.isArray(fieldValue)) { 81 | throw new Error( 82 | `Expected array while resolving value for field "${info.fieldName}".`, 83 | ) 84 | } 85 | 86 | const resultPromises = [] 87 | for (const item of fieldValue) { 88 | const resultPromise = this.resolveFieldValue( 89 | item, 90 | args, 91 | { 92 | gitAdapter: context.gitAdapter, 93 | getCurrentRef: context.getCurrentRef, 94 | }, 95 | info, 96 | currentType.ofType, 97 | hasNonNullParent, 98 | ) 99 | resultPromises.push(resultPromise) 100 | } 101 | return Promise.all(resultPromises) 102 | } else if (isUnionType(currentType)) { 103 | if (fieldValue === undefined || fieldValue === null) { 104 | if (hasNonNullParent) { 105 | throw new Error( 106 | `Cannot generate a default value for NonNull field "${ 107 | info.parentType.name 108 | }.${info.fieldName}" because it is a union field of type "${ 109 | (info.returnType as GraphQLNonNull).ofType.name 110 | }".`, 111 | ) 112 | } else { 113 | return new Promise((resolve) => resolve(null)) 114 | } 115 | } 116 | 117 | if (this.entryTypeUtil.buildsOnTypeWithEntryDirective(currentType)) { 118 | return this.entryReferenceResolver.resolve( 119 | fieldValue, 120 | args, 121 | context, 122 | info, 123 | ) 124 | } 125 | 126 | return this.unionValueResolver.resolve(fieldValue, args, context, info) 127 | } else if (isObjectType(currentType)) { 128 | if (fieldValue === undefined || fieldValue === null) { 129 | if (hasNonNullParent) { 130 | throw new Error( 131 | `Cannot generate a default value for NonNull field "${info.fieldName}" of type "${info.parentType.name}".`, 132 | ) 133 | } else { 134 | return new Promise((resolve) => resolve(null)) 135 | } 136 | } 137 | 138 | if (this.entryTypeUtil.hasEntryDirective(currentType)) { 139 | return this.entryReferenceResolver.resolve( 140 | fieldValue, 141 | args, 142 | context, 143 | info, 144 | ) 145 | } 146 | 147 | return fieldValue 148 | } 149 | 150 | return fieldValue 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/graphql/field-resolver/fieldResolver.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldResolver } from 'graphql/type/definition' 2 | import { EntryData, GitAdapter } from '@commitspark/git-adapter' 3 | 4 | export interface FieldResolverContext { 5 | gitAdapter: GitAdapter 6 | getCurrentRef(): string 7 | } 8 | 9 | export interface FieldResolver< 10 | TSource, 11 | TContext = FieldResolverContext, 12 | TArgs = any, 13 | TResult = Promise>, 14 | > { 15 | resolve: GraphQLFieldResolver 16 | } 17 | 18 | export type ResolvedEntryData = T | Array> 19 | -------------------------------------------------------------------------------- /src/graphql/field-resolver/union-value-resolver.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldResolver } from 'graphql/type/definition' 2 | import { EntryData } from '@commitspark/git-adapter' 3 | import { 4 | FieldResolver, 5 | FieldResolverContext, 6 | ResolvedEntryData, 7 | } from './fieldResolver' 8 | import { UnionTypeUtil } from '../schema-utils/union-type-util' 9 | 10 | export class UnionValueResolver implements FieldResolver { 11 | resolve: GraphQLFieldResolver< 12 | any, 13 | FieldResolverContext, 14 | any, 15 | Promise> 16 | > 17 | 18 | constructor(private readonly unionTypeUtil: UnionTypeUtil) { 19 | this.resolve = ( 20 | fieldValue, 21 | args, 22 | context, 23 | info, 24 | ): Promise> => 25 | this.resolveFieldValue(fieldValue) 26 | } 27 | 28 | private resolveFieldValue( 29 | fieldValue: any, 30 | ): Promise> { 31 | const typeName = 32 | this.unionTypeUtil.getUnionTypeNameFromFieldValue(fieldValue) 33 | const unionValue = this.unionTypeUtil.getUnionValue(fieldValue) 34 | 35 | // We replace the helper type name field that holds our field's actual data 36 | // with this actual data and add a `__typename` field, so that our output data 37 | // corresponds to the output schema provided by the user (i.e. there is 38 | // no additional nesting level there). 39 | const res: EntryData = { 40 | ...unionValue, 41 | __typename: typeName, 42 | } 43 | 44 | return new Promise((resolve) => resolve(res)) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/graphql/input-type-generator.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLEnumType, 3 | GraphQLInputObjectType, 4 | GraphQLInterfaceType, 5 | GraphQLNullableType, 6 | GraphQLObjectType, 7 | GraphQLScalarType, 8 | GraphQLUnionType, 9 | isInterfaceType, 10 | isListType, 11 | isNonNullType, 12 | isObjectType, 13 | isScalarType, 14 | isUnionType, 15 | } from 'graphql' 16 | import { ISchemaAnalyzerResult } from './schema-analyzer.service' 17 | import { EntryTypeUtil } from './schema-utils/entry-type-util' 18 | 19 | export class InputTypeGeneratorService { 20 | constructor(private readonly entryTypeUtil: EntryTypeUtil) {} 21 | 22 | public generateFieldInputTypeString(type: GraphQLNullableType): string { 23 | if (isListType(type)) { 24 | return `[${this.generateFieldInputTypeString(type.ofType)}]` 25 | } else if (isNonNullType(type)) { 26 | return `${this.generateFieldInputTypeString(type.ofType)}!` 27 | } else if (isObjectType(type)) { 28 | if (this.entryTypeUtil.hasEntryDirective(type)) { 29 | return `${type.name}IdInput` 30 | } else { 31 | return `${type.name}Input` 32 | } 33 | } else if (isInterfaceType(type)) { 34 | // TODO 35 | return '' 36 | } else if (isUnionType(type)) { 37 | if (this.entryTypeUtil.buildsOnTypeWithEntryDirective(type)) { 38 | return `${type.name}IdInput` 39 | } else { 40 | return `${type.name}Input` 41 | } 42 | } else { 43 | return ( 44 | type as GraphQLScalarType | GraphQLEnumType | GraphQLInputObjectType 45 | ).name 46 | } 47 | } 48 | 49 | public generateIdInputTypeStrings( 50 | schemaAnalyzerResult: ISchemaAnalyzerResult, 51 | ): string[] { 52 | let typesWithIdField: ( 53 | | GraphQLObjectType 54 | | GraphQLInterfaceType 55 | | GraphQLUnionType 56 | )[] = [] 57 | typesWithIdField = typesWithIdField.concat( 58 | schemaAnalyzerResult.entryDirectiveTypes, 59 | ) 60 | typesWithIdField = typesWithIdField.concat( 61 | schemaAnalyzerResult.interfaceTypes, 62 | ) 63 | typesWithIdField = typesWithIdField.concat(schemaAnalyzerResult.unionTypes) 64 | 65 | return typesWithIdField.map((type): string => { 66 | return `input ${type.name}IdInput {\n` + ' id: ID!\n' + '}\n' 67 | }) 68 | } 69 | 70 | public generateObjectInputTypeStrings( 71 | objectTypes: GraphQLObjectType[], 72 | ): string[] { 73 | return objectTypes.map((objectType): string => { 74 | const name = objectType.name 75 | const inputTypeName = `${name}Input` 76 | let inputType = `input ${inputTypeName} {\n` 77 | let inputTypeFieldStrings = '' 78 | for (const fieldsKey in objectType.getFields()) { 79 | const field = objectType.getFields()[fieldsKey] 80 | if ( 81 | (isNonNullType(field.type) && 82 | isScalarType(field.type.ofType) && 83 | field.type.ofType.name === 'ID') || 84 | (isScalarType(field.type) && field.type.name === 'ID') 85 | ) { 86 | continue 87 | } 88 | const inputTypeString = this.generateFieldInputTypeString(field.type) 89 | inputTypeFieldStrings += ` ${field.name}: ${inputTypeString}\n` 90 | } 91 | 92 | if (inputTypeFieldStrings.length > 0) { 93 | inputType += `${inputTypeFieldStrings}` 94 | } else { 95 | // generate a dummy field as empty input types are not permitted 96 | inputType += ' _: Boolean\n' 97 | } 98 | inputType += '}\n' 99 | 100 | return inputType 101 | }) 102 | } 103 | 104 | public generateUnionInputTypeStrings( 105 | unionTypes: GraphQLUnionType[], 106 | ): string[] { 107 | const typeStrings = unionTypes.map((unionType): string => { 108 | if (this.entryTypeUtil.buildsOnTypeWithEntryDirective(unionType)) { 109 | return '' 110 | } 111 | 112 | // see https://github.com/graphql/graphql-spec/pull/825 113 | let unionInputType = `input ${unionType.name}Input @oneOf {\n` 114 | for (const innerType of unionType.getTypes()) { 115 | unionInputType += ` ${innerType.name}: ${innerType.name}Input\n` 116 | } 117 | unionInputType += '}\n' 118 | return unionInputType 119 | }) 120 | 121 | if (typeStrings.join('').length > 0) { 122 | typeStrings.push('directive @oneOf on INPUT_OBJECT\n') 123 | } 124 | 125 | return typeStrings 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/graphql/queries-mutations-generator.service.ts: -------------------------------------------------------------------------------- 1 | import { EntryData } from '@commitspark/git-adapter' 2 | import { ApolloContext } from '../app/api.service' 3 | import { GraphQLFieldResolver, GraphQLObjectType } from 'graphql' 4 | import { QueryAllResolverGenerator } from './resolver-generators/query-all-resolver-generator' 5 | import { QueryByIdResolverGenerator } from './resolver-generators/query-by-id-resolver-generator' 6 | import { MutationCreateResolverGenerator } from './resolver-generators/mutation-create-resolver-generator' 7 | import { MutationUpdateResolverGenerator } from './resolver-generators/mutation-update-resolver-generator' 8 | import { MutationDeleteResolverGenerator } from './resolver-generators/mutation-delete-resolver-generator' 9 | import { QueryCountAllResolverGenerator } from './resolver-generators/query-count-all-resolver-generator' 10 | import { QueryTypeByIdResolverGenerator } from './resolver-generators/query-type-by-id-resolver-generator' 11 | 12 | export class QueriesMutationsGeneratorService { 13 | constructor( 14 | private readonly queryAllResolverGenerator: QueryAllResolverGenerator, 15 | private readonly queryByIdResolverGenerator: QueryByIdResolverGenerator, 16 | private readonly queryCountAllResolverGenerator: QueryCountAllResolverGenerator, 17 | private readonly queryTypeByIdResolverGenerator: QueryTypeByIdResolverGenerator, 18 | private readonly mutationCreateResolverGenerator: MutationCreateResolverGenerator, 19 | private readonly mutationUpdateResolverGenerator: MutationUpdateResolverGenerator, 20 | private readonly mutationDeleteResolverGenerator: MutationDeleteResolverGenerator, 21 | ) {} 22 | 23 | public generateFromAnalyzedSchema( 24 | entryDirectiveTypes: GraphQLObjectType[], 25 | ): IGeneratedSchema[] { 26 | return entryDirectiveTypes.map((objectType): IGeneratedSchema => { 27 | const typeName = objectType.name 28 | 29 | const queryAllName = `all${typeName}s` 30 | const queryAllString = `${queryAllName}: [${objectType.name}!]` 31 | const queryAllResolver: GraphQLFieldResolver< 32 | any, 33 | ApolloContext, 34 | any, 35 | Promise 36 | > = this.queryAllResolverGenerator.createResolver(typeName) 37 | 38 | const queryAllMetaName = `_${queryAllName}Meta` 39 | const queryAllMetaString = `${queryAllMetaName}: ListMetadata` 40 | const queryAllMetaResolver: GraphQLFieldResolver< 41 | any, 42 | ApolloContext, 43 | any, 44 | Promise 45 | > = this.queryCountAllResolverGenerator.createResolver(typeName) 46 | 47 | const queryByIdName = typeName 48 | const queryByIdString = `${queryByIdName}(id: ID!): ${objectType.name}` 49 | const queryByIdResolver: GraphQLFieldResolver< 50 | any, 51 | ApolloContext, 52 | any, 53 | Promise 54 | > = this.queryByIdResolverGenerator.createResolver(typeName) 55 | 56 | const inputTypeName = `${typeName}Input` 57 | const createMutationName = `create${typeName}` 58 | const createMutationString = `${createMutationName}(id: ID!, data: ${inputTypeName}!, commitMessage: String): ${typeName}` 59 | const createMutationResolver: GraphQLFieldResolver< 60 | any, 61 | ApolloContext, 62 | any, 63 | Promise 64 | > = this.mutationCreateResolverGenerator.createResolver(typeName) 65 | 66 | const updateMutationName = `update${typeName}` 67 | const updateMutationString = `${updateMutationName}(id: ID!, data: ${inputTypeName}!, commitMessage: String): ${typeName}` 68 | const updateMutationResolver: GraphQLFieldResolver< 69 | any, 70 | ApolloContext, 71 | any, 72 | Promise 73 | > = this.mutationUpdateResolverGenerator.createResolver(typeName) 74 | 75 | const deleteMutationName = `delete${typeName}` 76 | const deleteMutationString = `${deleteMutationName}(id: ID!, commitMessage: String): ID` 77 | const deleteMutationResolver: GraphQLFieldResolver< 78 | any, 79 | ApolloContext, 80 | any, 81 | Promise 82 | > = this.mutationDeleteResolverGenerator.createResolver(typeName) 83 | 84 | return { 85 | queryAll: { 86 | name: queryAllName, 87 | schemaString: queryAllString, 88 | resolver: queryAllResolver, 89 | }, 90 | queryAllMeta: { 91 | name: queryAllMetaName, 92 | schemaString: queryAllMetaString, 93 | resolver: queryAllMetaResolver, 94 | }, 95 | queryById: { 96 | name: queryByIdName, 97 | schemaString: queryByIdString, 98 | resolver: queryByIdResolver, 99 | }, 100 | createMutation: { 101 | name: createMutationName, 102 | schemaString: createMutationString, 103 | resolver: createMutationResolver, 104 | }, 105 | updateMutation: { 106 | name: updateMutationName, 107 | schemaString: updateMutationString, 108 | resolver: updateMutationResolver, 109 | }, 110 | deleteMutation: { 111 | name: deleteMutationName, 112 | schemaString: deleteMutationString, 113 | resolver: deleteMutationResolver, 114 | }, 115 | } 116 | }) 117 | } 118 | 119 | public generateTypeNameQuery(): IGeneratedQuery> { 120 | const entryTypeQueryName = '_typeName' 121 | const entryTypeQueryString = `${entryTypeQueryName}(id: ID!): String!` 122 | const entryTypeQueryResolver: GraphQLFieldResolver< 123 | any, 124 | ApolloContext, 125 | any, 126 | Promise 127 | > = this.queryTypeByIdResolverGenerator.createResolver() 128 | 129 | return { 130 | name: entryTypeQueryName, 131 | schemaString: entryTypeQueryString, 132 | resolver: entryTypeQueryResolver, 133 | } 134 | } 135 | } 136 | 137 | export interface IGeneratedSchema { 138 | queryAll: IGeneratedQuery> 139 | queryAllMeta: IGeneratedQuery> 140 | queryById: IGeneratedQuery> 141 | createMutation: IGeneratedQuery> 142 | updateMutation: IGeneratedQuery> 143 | deleteMutation: IGeneratedQuery> 144 | } 145 | 146 | export interface IGeneratedQuery { 147 | name: string 148 | schemaString: string 149 | resolver: GraphQLFieldResolver 150 | } 151 | 152 | export type TypeCount = { 153 | count: number 154 | } 155 | -------------------------------------------------------------------------------- /src/graphql/resolver-generators/mutation-create-resolver-generator.ts: -------------------------------------------------------------------------------- 1 | import { ApolloContext } from '../../app/api.service' 2 | import { PersistenceService } from '../../persistence/persistence.service' 3 | import { GraphQLError, GraphQLFieldResolver, isObjectType } from 'graphql' 4 | import { EntryReferenceUtil } from '../schema-utils/entry-reference-util' 5 | import { 6 | ENTRY_ID_INVALID_CHARACTERS, 7 | EntryData, 8 | EntryDraft, 9 | } from '@commitspark/git-adapter' 10 | 11 | export class MutationCreateResolverGenerator { 12 | constructor( 13 | private readonly persistence: PersistenceService, 14 | private readonly entryReferenceUtil: EntryReferenceUtil, 15 | ) {} 16 | 17 | public createResolver( 18 | typeName: string, 19 | ): GraphQLFieldResolver> { 20 | return async ( 21 | source, 22 | args, 23 | context: ApolloContext, 24 | info, 25 | ): Promise => { 26 | if (!isObjectType(info.returnType)) { 27 | throw new Error('Expected to create an ObjectType') 28 | } 29 | 30 | const idValidationResult = args.id.match(ENTRY_ID_INVALID_CHARACTERS) 31 | if (idValidationResult) { 32 | throw new GraphQLError( 33 | `"id" contains invalid characters "${idValidationResult.join(', ')}"`, 34 | { 35 | extensions: { 36 | code: 'BAD_USER_INPUT', 37 | argumentName: 'id', 38 | }, 39 | }, 40 | ) 41 | } 42 | 43 | let existingEntry 44 | try { 45 | existingEntry = await this.persistence.findById( 46 | context.gitAdapter, 47 | context.getCurrentRef(), 48 | args.id, 49 | ) 50 | } catch (_) { 51 | /* empty */ 52 | } 53 | if (existingEntry) { 54 | throw new GraphQLError(`An entry with id "${args.id}" already exists`, { 55 | extensions: { 56 | code: 'BAD_USER_INPUT', 57 | argumentName: 'id', 58 | }, 59 | }) 60 | } 61 | 62 | const referencedEntryIds = 63 | await this.entryReferenceUtil.getReferencedEntryIds( 64 | info.returnType, 65 | context, 66 | null, 67 | info.returnType, 68 | args.data, 69 | ) 70 | 71 | const referencedEntryUpdates: EntryDraft[] = [] 72 | for (const referencedEntryId of referencedEntryIds) { 73 | const referencedEntry = await this.persistence.findById( 74 | context.gitAdapter, 75 | context.getCurrentRef(), 76 | referencedEntryId, 77 | ) 78 | const newReferencedEntryIds: string[] = [ 79 | ...(referencedEntry.metadata.referencedBy ?? []), 80 | args.id, 81 | ].sort() 82 | const newReferencedEntryDraft: EntryDraft = { 83 | ...referencedEntry, 84 | metadata: { 85 | ...referencedEntry.metadata, 86 | referencedBy: newReferencedEntryIds, 87 | }, 88 | deletion: false, 89 | } 90 | referencedEntryUpdates.push(newReferencedEntryDraft) 91 | } 92 | 93 | const newEntryDraft: EntryDraft = { 94 | id: args.id, 95 | metadata: { 96 | type: typeName, 97 | referencedBy: [], 98 | }, 99 | data: args.data, 100 | deletion: false, 101 | } 102 | 103 | const commit = await context.gitAdapter.createCommit({ 104 | ref: context.branch, 105 | parentSha: context.getCurrentRef(), 106 | entries: [newEntryDraft, ...referencedEntryUpdates], 107 | message: args.commitMessage, 108 | }) 109 | context.setCurrentRef(commit.ref) 110 | 111 | const newEntry = await this.persistence.findByTypeId( 112 | context.gitAdapter, 113 | context.getCurrentRef(), 114 | typeName, 115 | args.id, 116 | ) 117 | return { ...newEntry.data, id: newEntry.id } 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/graphql/resolver-generators/mutation-delete-resolver-generator.ts: -------------------------------------------------------------------------------- 1 | import { PersistenceService } from '../../persistence/persistence.service' 2 | import { ApolloContext } from '../../app/api.service' 3 | import { Entry, EntryData, EntryDraft } from '@commitspark/git-adapter' 4 | import { EntryReferenceUtil } from '../schema-utils/entry-reference-util' 5 | import { GraphQLError, GraphQLFieldResolver, isObjectType } from 'graphql' 6 | 7 | export class MutationDeleteResolverGenerator { 8 | constructor( 9 | private readonly persistence: PersistenceService, 10 | private readonly entryReferenceUtil: EntryReferenceUtil, 11 | ) {} 12 | 13 | public createResolver( 14 | typeName: string, 15 | ): GraphQLFieldResolver> { 16 | return async ( 17 | source, 18 | args, 19 | context: ApolloContext, 20 | info, 21 | ): Promise => { 22 | const entry: Entry = await this.persistence.findByTypeId( 23 | context.gitAdapter, 24 | context.getCurrentRef(), 25 | typeName, 26 | args.id, 27 | ) 28 | 29 | if ( 30 | entry.metadata.referencedBy && 31 | entry.metadata.referencedBy.length > 0 32 | ) { 33 | const otherIds = entry.metadata.referencedBy 34 | .map((referenceId) => `"${referenceId}"`) 35 | .join(', ') 36 | throw new GraphQLError( 37 | `Entry with id "${args.id}" is referenced by other entries: [${otherIds}]`, 38 | { 39 | extensions: { 40 | code: 'IN_USE', 41 | argumentName: 'id', 42 | }, 43 | }, 44 | ) 45 | } 46 | 47 | const entryType = info.schema.getType(typeName) 48 | if (!isObjectType(entryType)) { 49 | throw new Error('Expected to delete an ObjectType') 50 | } 51 | 52 | const referencedEntryIds = 53 | await this.entryReferenceUtil.getReferencedEntryIds( 54 | entryType, 55 | context, 56 | null, 57 | entryType, 58 | entry.data, 59 | ) 60 | const referencedEntryUpdates: EntryDraft[] = [] 61 | for (const referencedEntryId of referencedEntryIds) { 62 | const noLongerReferencedEntry = await this.persistence.findById( 63 | context.gitAdapter, 64 | context.getCurrentRef(), 65 | referencedEntryId, 66 | ) 67 | referencedEntryUpdates.push({ 68 | ...noLongerReferencedEntry, 69 | metadata: { 70 | ...noLongerReferencedEntry.metadata, 71 | referencedBy: noLongerReferencedEntry.metadata.referencedBy?.filter( 72 | (entryId) => entryId !== args.id, 73 | ), 74 | }, 75 | deletion: false, 76 | }) 77 | } 78 | 79 | const commit = await context.gitAdapter.createCommit({ 80 | ref: context.branch, 81 | parentSha: context.getCurrentRef(), 82 | entries: [ 83 | { 84 | ...entry, 85 | deletion: true, 86 | }, 87 | ...referencedEntryUpdates, 88 | ], 89 | message: args.commitMessage, 90 | }) 91 | context.setCurrentRef(commit.ref) 92 | 93 | return args.id 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/graphql/resolver-generators/mutation-update-resolver-generator.ts: -------------------------------------------------------------------------------- 1 | import { PersistenceService } from '../../persistence/persistence.service' 2 | import { ApolloContext } from '../../app/api.service' 3 | import { EntryReferenceUtil } from '../schema-utils/entry-reference-util' 4 | import { GraphQLFieldResolver, isObjectType } from 'graphql' 5 | import { EntryData, EntryDraft } from '@commitspark/git-adapter' 6 | 7 | export class MutationUpdateResolverGenerator { 8 | constructor( 9 | private readonly persistence: PersistenceService, 10 | private readonly entryReferenceUtil: EntryReferenceUtil, 11 | ) {} 12 | 13 | public createResolver( 14 | typeName: string, 15 | ): GraphQLFieldResolver> { 16 | return async ( 17 | source, 18 | args, 19 | context: ApolloContext, 20 | info, 21 | ): Promise => { 22 | if (!isObjectType(info.returnType)) { 23 | throw new Error('Expected to update an ObjectType') 24 | } 25 | 26 | const existingEntry = await this.persistence.findByTypeId( 27 | context.gitAdapter, 28 | context.getCurrentRef(), 29 | typeName, 30 | args.id, 31 | ) 32 | 33 | const existingReferencedEntryIds = 34 | await this.entryReferenceUtil.getReferencedEntryIds( 35 | info.returnType, 36 | context, 37 | null, 38 | info.returnType, 39 | existingEntry.data, 40 | ) 41 | 42 | const mergedData = this.mergeData(existingEntry.data ?? null, args.data) 43 | const updatedReferencedEntryIds = 44 | await this.entryReferenceUtil.getReferencedEntryIds( 45 | info.returnType, 46 | context, 47 | null, 48 | info.returnType, 49 | mergedData, 50 | ) 51 | 52 | const noLongerReferencedIds = existingReferencedEntryIds.filter( 53 | (entryId) => !updatedReferencedEntryIds.includes(entryId), 54 | ) 55 | const newlyReferencedIds = updatedReferencedEntryIds.filter( 56 | (entryId) => !existingReferencedEntryIds.includes(entryId), 57 | ) 58 | 59 | const referencedEntryUpdates: EntryDraft[] = [] 60 | for (const noLongerReferencedEntryId of noLongerReferencedIds) { 61 | const noLongerReferencedEntry = await this.persistence.findById( 62 | context.gitAdapter, 63 | context.getCurrentRef(), 64 | noLongerReferencedEntryId, 65 | ) 66 | referencedEntryUpdates.push({ 67 | ...noLongerReferencedEntry, 68 | metadata: { 69 | ...noLongerReferencedEntry.metadata, 70 | referencedBy: noLongerReferencedEntry.metadata.referencedBy?.filter( 71 | (entryId) => entryId !== args.id, 72 | ), 73 | }, 74 | deletion: false, 75 | }) 76 | } 77 | for (const newlyReferencedEntryId of newlyReferencedIds) { 78 | const newlyReferencedEntry = await this.persistence.findById( 79 | context.gitAdapter, 80 | context.getCurrentRef(), 81 | newlyReferencedEntryId, 82 | ) 83 | const updatedReferenceList: string[] = 84 | newlyReferencedEntry.metadata.referencedBy ?? [] 85 | updatedReferenceList.push(args.id) 86 | updatedReferenceList.sort() 87 | referencedEntryUpdates.push({ 88 | ...newlyReferencedEntry, 89 | metadata: { 90 | ...newlyReferencedEntry.metadata, 91 | referencedBy: updatedReferenceList, 92 | }, 93 | deletion: false, 94 | }) 95 | } 96 | 97 | const commit = await context.gitAdapter.createCommit({ 98 | ref: context.branch, 99 | parentSha: context.getCurrentRef(), 100 | entries: [ 101 | { ...existingEntry, data: mergedData, deletion: false }, 102 | ...referencedEntryUpdates, 103 | ], 104 | message: args.commitMessage, 105 | }) 106 | context.setCurrentRef(commit.ref) 107 | 108 | const updatedEntry = await this.persistence.findByTypeId( 109 | context.gitAdapter, 110 | context.getCurrentRef(), 111 | typeName, 112 | args.id, 113 | ) 114 | return { ...updatedEntry.data, id: updatedEntry.id } 115 | } 116 | } 117 | 118 | private mergeData( 119 | existingEntryData: EntryData, 120 | updateData: EntryData, 121 | ): EntryData { 122 | return { 123 | ...existingEntryData, 124 | ...updateData, 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/graphql/resolver-generators/object-type-field-default-value-resolver-generator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLField, 3 | GraphQLFieldResolver, 4 | GraphQLObjectType, 5 | GraphQLOutputType, 6 | GraphQLSchema, 7 | isListType, 8 | isNonNullType, 9 | isObjectType, 10 | isUnionType, 11 | } from 'graphql' 12 | import { ApolloContext } from '../../app/api.service' 13 | import { EntryData } from '@commitspark/git-adapter' 14 | import { ResolvedEntryData } from '../field-resolver/fieldResolver' 15 | import { FieldDefaultValueResolver } from '../field-resolver/field-default-value-resolver' 16 | 17 | export class ObjectTypeFieldDefaultValueResolverGenerator { 18 | constructor( 19 | private readonly fieldDefaultValueResolver: FieldDefaultValueResolver, 20 | ) {} 21 | 22 | public createResolver( 23 | schema: GraphQLSchema, 24 | ): Record< 25 | string, 26 | Record< 27 | string, 28 | GraphQLFieldResolver< 29 | any, 30 | ApolloContext, 31 | any, 32 | Promise> 33 | > 34 | > 35 | > { 36 | const resolvers: Record< 37 | string, 38 | Record< 39 | string, 40 | GraphQLFieldResolver< 41 | any, 42 | ApolloContext, 43 | any, 44 | Promise> 45 | > 46 | > 47 | > = {} 48 | for (const typeName of Object.keys(schema.getTypeMap())) { 49 | const type = schema.getType(typeName) 50 | if (!isObjectType(type) || type.name.startsWith('__')) { 51 | continue 52 | } 53 | const objectType = type as GraphQLObjectType 54 | const fieldsForCustomDefaultValueResolver = 55 | this.getFieldsForCustomDefaultValueResolver(objectType) 56 | 57 | const fieldResolvers: Record< 58 | string, 59 | GraphQLFieldResolver< 60 | any, 61 | ApolloContext, 62 | any, 63 | Promise> 64 | > 65 | > = {} 66 | for (const field of fieldsForCustomDefaultValueResolver) { 67 | fieldResolvers[field.name] = ( 68 | obj, 69 | args, 70 | context, 71 | info, 72 | ): Promise> => 73 | this.fieldDefaultValueResolver.resolve( 74 | field.name in obj ? obj[field.name] : undefined, 75 | args, 76 | { 77 | gitAdapter: context.gitAdapter, 78 | getCurrentRef: context.getCurrentRef, 79 | }, 80 | info, 81 | ) 82 | } 83 | 84 | if (Object.keys(fieldResolvers).length > 0) { 85 | resolvers[typeName] = fieldResolvers 86 | } 87 | } 88 | 89 | return resolvers 90 | } 91 | 92 | private getFieldsForCustomDefaultValueResolver( 93 | objectType: GraphQLObjectType, 94 | ): GraphQLField[] { 95 | const fields = [] 96 | for (const fieldsKey in objectType.getFields()) { 97 | const field: GraphQLField = objectType.getFields()[fieldsKey] 98 | if (this.requiresCustomDefaultValueResolver(field.type)) { 99 | fields.push(field) 100 | } 101 | } 102 | return fields 103 | } 104 | 105 | private requiresCustomDefaultValueResolver(type: GraphQLOutputType): boolean { 106 | if (isNonNullType(type)) { 107 | return this.requiresCustomDefaultValueResolver(type.ofType) 108 | } else if (isListType(type)) { 109 | return true 110 | } else if (isUnionType(type)) { 111 | return true 112 | } else if (isObjectType(type)) { 113 | return true 114 | } 115 | return false 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/graphql/resolver-generators/query-all-resolver-generator.ts: -------------------------------------------------------------------------------- 1 | import { ApolloContext } from '../../app/api.service' 2 | import { EntryData } from '@commitspark/git-adapter' 3 | import { PersistenceService } from '../../persistence/persistence.service' 4 | import { GraphQLFieldResolver } from 'graphql' 5 | 6 | export class QueryAllResolverGenerator { 7 | constructor(private readonly persistence: PersistenceService) {} 8 | 9 | public createResolver( 10 | typeName: string, 11 | ): GraphQLFieldResolver> { 12 | return async ( 13 | obj, 14 | args, 15 | context: ApolloContext, 16 | info, 17 | ): Promise => { 18 | const entries = await this.persistence.findByType( 19 | context.gitAdapter, 20 | context.getCurrentRef(), 21 | typeName, 22 | ) 23 | 24 | return entries.map((entry) => { 25 | return { ...entry.data, id: entry.id } 26 | }) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/graphql/resolver-generators/query-by-id-resolver-generator.ts: -------------------------------------------------------------------------------- 1 | import { EntryData } from '@commitspark/git-adapter' 2 | import { PersistenceService } from '../../persistence/persistence.service' 3 | import { GraphQLFieldResolver } from 'graphql' 4 | import { ApolloContext } from '../../app/api.service' 5 | 6 | export class QueryByIdResolverGenerator { 7 | constructor(private readonly persistence: PersistenceService) {} 8 | 9 | public createResolver( 10 | typeName: string, 11 | ): GraphQLFieldResolver> { 12 | return async (obj, args, context, info): Promise => { 13 | const entry = await this.persistence.findByTypeId( 14 | context.gitAdapter, 15 | context.getCurrentRef(), 16 | typeName, 17 | args.id, 18 | ) 19 | 20 | return { ...entry.data, id: entry.id } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/graphql/resolver-generators/query-count-all-resolver-generator.ts: -------------------------------------------------------------------------------- 1 | import { PersistenceService } from '../../persistence/persistence.service' 2 | import { GraphQLFieldResolver } from 'graphql' 3 | import { ApolloContext } from '../../app/api.service' 4 | import { TypeCount } from '../queries-mutations-generator.service' 5 | 6 | export class QueryCountAllResolverGenerator { 7 | constructor(private readonly persistence: PersistenceService) {} 8 | 9 | public createResolver( 10 | typeName: string, 11 | ): GraphQLFieldResolver> { 12 | return async ( 13 | obj, 14 | args, 15 | context: ApolloContext, 16 | info, 17 | ): Promise => { 18 | return { 19 | count: ( 20 | await this.persistence.findByType( 21 | context.gitAdapter, 22 | context.getCurrentRef(), 23 | typeName, 24 | ) 25 | ).length, 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/graphql/resolver-generators/query-type-by-id-resolver-generator.ts: -------------------------------------------------------------------------------- 1 | import { PersistenceService } from '../../persistence/persistence.service' 2 | import { GraphQLFieldResolver } from 'graphql' 3 | import { ApolloContext } from '../../app/api.service' 4 | 5 | export class QueryTypeByIdResolverGenerator { 6 | constructor(private readonly persistence: PersistenceService) {} 7 | 8 | public createResolver(): GraphQLFieldResolver< 9 | any, 10 | ApolloContext, 11 | any, 12 | Promise 13 | > { 14 | return async ( 15 | source, 16 | args, 17 | context: ApolloContext, 18 | info, 19 | ): Promise => { 20 | return this.persistence.getTypeById( 21 | context.gitAdapter, 22 | context.getCurrentRef(), 23 | args.id, 24 | ) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/graphql/resolver-generators/union-type-resolver-generator.ts: -------------------------------------------------------------------------------- 1 | import { ApolloContext } from '../../app/api.service' 2 | import { PersistenceService } from '../../persistence/persistence.service' 3 | import { 4 | GraphQLAbstractType, 5 | GraphQLResolveInfo, 6 | GraphQLTypeResolver, 7 | } from 'graphql' 8 | import { EntryTypeUtil } from '../schema-utils/entry-type-util' 9 | 10 | export class UnionTypeResolverGenerator { 11 | constructor( 12 | private readonly persistence: PersistenceService, 13 | private readonly entryTypeUtil: EntryTypeUtil, 14 | ) {} 15 | 16 | public createResolver(): GraphQLTypeResolver { 17 | return async ( 18 | obj: any, 19 | context: ApolloContext, 20 | info: GraphQLResolveInfo, 21 | abstractType: GraphQLAbstractType, 22 | ): Promise => { 23 | if (this.entryTypeUtil.buildsOnTypeWithEntryDirective(abstractType)) { 24 | return this.persistence.getTypeById( 25 | context.gitAdapter, 26 | context.getCurrentRef(), 27 | obj.id, 28 | ) 29 | } else { 30 | // We have injected an internal `__typename` field into the data of fields pointing to a non-entry union 31 | // in UnionValueResolver. This artificial field holds the type information we need here. 32 | return obj.__typename 33 | } 34 | 35 | // TODO same for interface type: https://www.apollographql.com/docs/apollo-server/data/resolvers/#resolving-unions-and-interfaces 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/graphql/schema-analyzer.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLInterfaceType, 3 | GraphQLObjectType, 4 | GraphQLSchema, 5 | GraphQLUnionType, 6 | isInterfaceType, 7 | isObjectType, 8 | isUnionType, 9 | } from 'graphql' 10 | import { getDirective } from '@graphql-tools/utils' 11 | 12 | export class SchemaAnalyzerService { 13 | public analyzeSchema(schema: GraphQLSchema): ISchemaAnalyzerResult { 14 | const result: ISchemaAnalyzerResult = { 15 | entryDirectiveTypes: [], 16 | objectTypes: [], 17 | interfaceTypes: [], 18 | unionTypes: [], 19 | } 20 | 21 | const typeMap = schema.getTypeMap() 22 | 23 | for (const [key, type] of Object.entries(typeMap)) { 24 | if (key.startsWith('__')) { 25 | continue 26 | } 27 | 28 | if (isObjectType(type)) { 29 | const objectType = type as GraphQLObjectType 30 | result.objectTypes.push(objectType) 31 | const entityDirective = getDirective(schema, objectType, 'Entry')?.[0] 32 | if (entityDirective) { 33 | result.entryDirectiveTypes.push(objectType) 34 | } 35 | } 36 | 37 | if (isInterfaceType(type)) { 38 | const interfaceType = type as GraphQLInterfaceType 39 | result.interfaceTypes.push(interfaceType) 40 | } 41 | 42 | if (isUnionType(type)) { 43 | const unionType = type as GraphQLUnionType 44 | result.unionTypes.push(unionType) 45 | } 46 | } 47 | 48 | return result 49 | } 50 | } 51 | 52 | export interface ISchemaAnalyzerResult { 53 | entryDirectiveTypes: GraphQLObjectType[] 54 | objectTypes: GraphQLObjectType[] 55 | interfaceTypes: GraphQLInterfaceType[] 56 | unionTypes: GraphQLUnionType[] 57 | } 58 | -------------------------------------------------------------------------------- /src/graphql/schema-generator.service.ts: -------------------------------------------------------------------------------- 1 | import { QueriesMutationsGeneratorService } from './queries-mutations-generator.service' 2 | import { 3 | IExecutableSchemaDefinition, 4 | makeExecutableSchema, 5 | } from '@graphql-tools/schema' 6 | import { SchemaAnalyzerService } from './schema-analyzer.service' 7 | import { InputTypeGeneratorService } from './input-type-generator.service' 8 | import { SchemaRootTypeGeneratorService } from './schema-root-type-generator.service' 9 | import { printSchemaWithDirectives } from '@graphql-tools/utils' 10 | import { UnionTypeResolverGenerator } from './resolver-generators/union-type-resolver-generator' 11 | import { ApolloContext } from '../app/api.service' 12 | import { GraphQLFieldResolver, GraphQLTypeResolver } from 'graphql' 13 | import { SchemaValidator } from './schema-validator' 14 | import { EntryData } from '@commitspark/git-adapter' 15 | import { ObjectTypeFieldDefaultValueResolverGenerator } from './resolver-generators/object-type-field-default-value-resolver-generator' 16 | 17 | export class SchemaGeneratorService { 18 | constructor( 19 | private readonly queriesMutationsGenerator: QueriesMutationsGeneratorService, 20 | private readonly schemaAnalyzer: SchemaAnalyzerService, 21 | private readonly inputTypeGenerator: InputTypeGeneratorService, 22 | private readonly schemaRootTypeGenerator: SchemaRootTypeGeneratorService, 23 | private readonly unionTypeResolverGenerator: UnionTypeResolverGenerator, 24 | private readonly objectTypeFieldDefaultValueResolverGenerator: ObjectTypeFieldDefaultValueResolverGenerator, 25 | private readonly schemaValidator: SchemaValidator, 26 | ) {} 27 | 28 | public async generateSchema( 29 | context: ApolloContext, 30 | ): Promise { 31 | const originalSchemaString = await context.gitAdapter.getSchema( 32 | context.getCurrentRef(), 33 | ) 34 | const schema = makeExecutableSchema({ 35 | typeDefs: originalSchemaString, 36 | }) 37 | 38 | const validationResult = this.schemaValidator.getValidationResult(schema) 39 | if (validationResult.length > 0) { 40 | throw new Error(validationResult.join('\n')) 41 | } 42 | const schemaAnalyzerResult = this.schemaAnalyzer.analyzeSchema(schema) 43 | 44 | const filteredOriginalSchemaString = 45 | printSchemaWithDirectives(schema) + '\n' 46 | 47 | const generatedIdInputTypeStrings = 48 | this.inputTypeGenerator.generateIdInputTypeStrings(schemaAnalyzerResult) 49 | 50 | const generatedQueriesMutations = 51 | this.queriesMutationsGenerator.generateFromAnalyzedSchema( 52 | schemaAnalyzerResult.entryDirectiveTypes, 53 | ) 54 | const generatedTypeNameQuery = 55 | this.queriesMutationsGenerator.generateTypeNameQuery() 56 | 57 | const generatedEntryReferenceResolvers: Record< 58 | string, 59 | Record< 60 | string, 61 | GraphQLFieldResolver< 62 | Record, 63 | ApolloContext, 64 | any, 65 | Promise 66 | > 67 | > 68 | > = {} 69 | 70 | const generatedObjectInputTypeStrings = 71 | this.inputTypeGenerator.generateObjectInputTypeStrings( 72 | schemaAnalyzerResult.objectTypes, 73 | ) 74 | 75 | const generatedUnionInputTypeStrings = 76 | this.inputTypeGenerator.generateUnionInputTypeStrings( 77 | schemaAnalyzerResult.unionTypes, 78 | ) 79 | 80 | const generatedSchemaRootTypeStrings = 81 | this.schemaRootTypeGenerator.generateSchemaRootTypeStrings( 82 | generatedQueriesMutations, 83 | generatedTypeNameQuery, 84 | ) 85 | 86 | const generatedSchemaString = 87 | 'schema {\n query: Query\n mutation: Mutation\n}\n\n' + 88 | generatedSchemaRootTypeStrings + 89 | '\n' + 90 | 'type ListMetadata {\n' + 91 | ' count: Int!\n' + 92 | '}\n' 93 | 94 | const generatedUnionTypeResolvers: Record = {} 95 | for (const unionType of schemaAnalyzerResult.unionTypes) { 96 | generatedUnionTypeResolvers[unionType.name] = { 97 | __resolveType: this.unionTypeResolverGenerator.createResolver(), 98 | } 99 | } 100 | const generatedObjectTypeFieldDefaultValueResolvers = 101 | this.objectTypeFieldDefaultValueResolverGenerator.createResolver(schema) 102 | 103 | const generatedQueryResolvers: Record< 104 | string, 105 | GraphQLFieldResolver 106 | > = {} 107 | const generatedMutationResolvers: Record< 108 | string, 109 | GraphQLFieldResolver 110 | > = {} 111 | 112 | for (const element of generatedQueriesMutations) { 113 | generatedQueryResolvers[element.queryAll.name] = element.queryAll.resolver 114 | generatedQueryResolvers[element.queryAllMeta.name] = 115 | element.queryAllMeta.resolver 116 | generatedQueryResolvers[element.queryById.name] = 117 | element.queryById.resolver 118 | generatedMutationResolvers[element.createMutation.name] = 119 | element.createMutation.resolver 120 | generatedMutationResolvers[element.updateMutation.name] = 121 | element.updateMutation.resolver 122 | generatedMutationResolvers[element.deleteMutation.name] = 123 | element.deleteMutation.resolver 124 | } 125 | generatedQueryResolvers[generatedTypeNameQuery.name] = 126 | generatedTypeNameQuery.resolver 127 | 128 | const allGeneratedResolvers: Record< 129 | string, 130 | Record< 131 | string, 132 | | GraphQLFieldResolver 133 | | GraphQLTypeResolver 134 | > 135 | > = { 136 | Query: generatedQueryResolvers, 137 | Mutation: generatedMutationResolvers, 138 | } 139 | 140 | for (const typeName of Object.keys(generatedUnionTypeResolvers)) { 141 | allGeneratedResolvers[typeName] = { 142 | ...(allGeneratedResolvers[typeName] ?? {}), 143 | ...generatedUnionTypeResolvers[typeName], 144 | } 145 | } 146 | for (const typeName of Object.keys( 147 | generatedObjectTypeFieldDefaultValueResolvers, 148 | )) { 149 | allGeneratedResolvers[typeName] = { 150 | ...(allGeneratedResolvers[typeName] ?? {}), 151 | ...generatedObjectTypeFieldDefaultValueResolvers[typeName], 152 | } 153 | } 154 | for (const typeName of Object.keys(generatedEntryReferenceResolvers)) { 155 | allGeneratedResolvers[typeName] = { 156 | ...(allGeneratedResolvers[typeName] ?? {}), 157 | ...generatedEntryReferenceResolvers[typeName], 158 | } 159 | } 160 | 161 | const typeDefs = [ 162 | filteredOriginalSchemaString, 163 | generatedSchemaString, 164 | generatedIdInputTypeStrings.join('\n'), 165 | generatedObjectInputTypeStrings.join('\n'), 166 | generatedUnionInputTypeStrings.join('\n'), 167 | ].filter((typeDef) => typeDef.length > 0) 168 | 169 | return { 170 | typeDefs: typeDefs, 171 | resolvers: allGeneratedResolvers, 172 | } 173 | } 174 | } 175 | 176 | interface UnionTypeResolver { 177 | __resolveType: GraphQLTypeResolver 178 | } 179 | -------------------------------------------------------------------------------- /src/graphql/schema-root-type-generator.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IGeneratedQuery, 3 | IGeneratedSchema, 4 | } from './queries-mutations-generator.service' 5 | 6 | export class SchemaRootTypeGeneratorService { 7 | public generateSchemaRootTypeStrings( 8 | generatedSchemas: IGeneratedSchema[], 9 | typeQuery: IGeneratedQuery>, 10 | ): string { 11 | return ( 12 | `type Query {\n` + 13 | generatedSchemas 14 | .map((generated) => ' ' + generated.queryAll.schemaString) 15 | .join('\n') + 16 | '\n' + 17 | generatedSchemas 18 | .map((generated) => ' ' + generated.queryAllMeta.schemaString) 19 | .join('\n') + 20 | '\n' + 21 | generatedSchemas 22 | .map((generated) => ' ' + generated.queryById.schemaString) 23 | .join('\n') + 24 | '\n' + 25 | ` ${typeQuery.schemaString}` + 26 | '\n' + 27 | '}\n\n' + 28 | 'type Mutation {\n' + 29 | generatedSchemas 30 | .map((generated) => ' ' + generated.createMutation.schemaString) 31 | .join('\n') + 32 | '\n' + 33 | generatedSchemas 34 | .map((generated) => ' ' + generated.updateMutation.schemaString) 35 | .join('\n') + 36 | '\n' + 37 | generatedSchemas 38 | .map((generated) => ' ' + generated.deleteMutation.schemaString) 39 | .join('\n') + 40 | '\n' + 41 | '}\n' 42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/graphql/schema-utils/entry-reference-util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLError, 3 | GraphQLNullableType, 4 | GraphQLObjectType, 5 | isListType, 6 | isNonNullType, 7 | isObjectType, 8 | isUnionType, 9 | } from 'graphql' 10 | import { ApolloContext } from '../../app/api.service' 11 | import { PersistenceService } from '../../persistence/persistence.service' 12 | import { UnionTypeUtil } from './union-type-util' 13 | import { EntryTypeUtil } from './entry-type-util' 14 | 15 | export class EntryReferenceUtil { 16 | constructor( 17 | private readonly persistence: PersistenceService, 18 | private readonly entryTypeUtil: EntryTypeUtil, 19 | private readonly unionTypeUtil: UnionTypeUtil, 20 | ) {} 21 | 22 | public async getReferencedEntryIds( 23 | rootType: GraphQLObjectType, 24 | context: ApolloContext, 25 | fieldName: string | null, 26 | type: GraphQLNullableType, 27 | data: any, 28 | ): Promise { 29 | if (isNonNullType(type)) { 30 | return this.getReferencedEntryIds( 31 | rootType, 32 | context, 33 | fieldName, 34 | type.ofType, 35 | data, 36 | ) 37 | } else if (isListType(type)) { 38 | let referencedEntryIds: string[] = [] 39 | for (const element of data) { 40 | referencedEntryIds = [ 41 | ...referencedEntryIds, 42 | ...(await this.getReferencedEntryIds( 43 | rootType, 44 | context, 45 | fieldName, 46 | type.ofType, 47 | element, 48 | )), 49 | ] 50 | } 51 | // deduplicate 52 | referencedEntryIds = [...new Set(referencedEntryIds)] 53 | return referencedEntryIds 54 | } else if (isUnionType(type)) { 55 | if (this.entryTypeUtil.isUnionOfEntryTypes(type)) { 56 | await this.validateReference(context, '', type, data) 57 | return [data.id] 58 | } 59 | 60 | const unionTypeName = 61 | this.unionTypeUtil.getUnionTypeNameFromFieldValue(data) 62 | const unionType = type 63 | .getTypes() 64 | .find((type) => type.name === unionTypeName) 65 | if (!unionType) { 66 | throw new Error( 67 | `Type "${unionTypeName}" found in field data is not a valid type for union type "${type.name}".`, 68 | ) 69 | } 70 | const unionValue = this.unionTypeUtil.getUnionValue(data) 71 | return this.getReferencedEntryIds( 72 | rootType, 73 | context, 74 | fieldName, 75 | unionType, 76 | unionValue, 77 | ) 78 | } else if (isObjectType(type)) { 79 | if ( 80 | type.name !== rootType.name && 81 | this.entryTypeUtil.hasEntryDirective(type) 82 | ) { 83 | await this.validateReference(context, fieldName ?? '', type, data) 84 | return [data.id] 85 | } else { 86 | let referencedEntryIds: string[] = [] 87 | for (const [fieldsKey, field] of Object.entries(type.getFields())) { 88 | const fieldValue = data[fieldsKey] ?? undefined 89 | if (fieldValue !== undefined) { 90 | const nestedResult = await this.getReferencedEntryIds( 91 | rootType, 92 | context, 93 | fieldsKey, 94 | field.type, 95 | fieldValue, 96 | ) 97 | referencedEntryIds = [...referencedEntryIds, ...nestedResult] 98 | } 99 | } 100 | // deduplicate 101 | referencedEntryIds = [...new Set(referencedEntryIds)] 102 | return referencedEntryIds 103 | } 104 | } 105 | 106 | return [] 107 | } 108 | 109 | private async validateReference( 110 | context: ApolloContext, 111 | fieldName: string, 112 | fieldType: GraphQLNullableType, 113 | fieldValue: any, 114 | ): Promise { 115 | if (isNonNullType(fieldType)) { 116 | await this.validateReference( 117 | context, 118 | fieldName, 119 | fieldType.ofType, 120 | fieldValue, 121 | ) 122 | return 123 | } else if (isListType(fieldType)) { 124 | if (!Array.isArray(fieldValue)) { 125 | throw new Error(`Expected array value in field "${fieldName}"`) 126 | } 127 | for (const fieldListElement of fieldValue) { 128 | await this.validateReference( 129 | context, 130 | fieldName, 131 | fieldType.ofType, 132 | fieldListElement, 133 | ) 134 | } 135 | return 136 | } else if (isUnionType(fieldType) || isObjectType(fieldType)) { 137 | if (!('id' in fieldValue)) { 138 | throw new Error('Expected key "id"') 139 | } 140 | const referencedId = fieldValue.id 141 | let referencedTypeName 142 | try { 143 | referencedTypeName = await this.persistence.getTypeById( 144 | context.gitAdapter, 145 | context.getCurrentRef(), 146 | referencedId, 147 | ) 148 | } catch (error) { 149 | throw new GraphQLError( 150 | `Reference with id "${referencedId}" points to non-existing entry`, 151 | { 152 | extensions: { 153 | code: 'BAD_USER_INPUT', 154 | fieldName: fieldName, 155 | }, 156 | }, 157 | ) 158 | } 159 | if (!this.isPermittedReferenceType(referencedTypeName, fieldType)) { 160 | throw new GraphQLError( 161 | `Reference with id "${referencedId}" points to entry of incompatible type`, 162 | { 163 | extensions: { 164 | code: 'BAD_USER_INPUT', 165 | fieldName: fieldName, 166 | }, 167 | }, 168 | ) 169 | } 170 | } 171 | } 172 | 173 | private isPermittedReferenceType( 174 | referencedTypeName: string, 175 | fieldType: GraphQLNullableType, 176 | ): boolean { 177 | if (isNonNullType(fieldType)) { 178 | return this.isPermittedReferenceType(referencedTypeName, fieldType.ofType) 179 | } else if (isListType(fieldType)) { 180 | return this.isPermittedReferenceType(referencedTypeName, fieldType.ofType) 181 | } else if (isUnionType(fieldType)) { 182 | return fieldType 183 | .getTypes() 184 | .map((concreteType) => concreteType.name) 185 | .includes(referencedTypeName) 186 | } else if (isObjectType(fieldType)) { 187 | return fieldType.name === referencedTypeName 188 | } 189 | return false 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/graphql/schema-utils/entry-type-util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLNullableType, 3 | GraphQLObjectType, 4 | GraphQLUnionType, 5 | isListType, 6 | isNonNullType, 7 | isObjectType, 8 | isUnionType, 9 | } from 'graphql' 10 | 11 | export class EntryTypeUtil { 12 | public buildsOnTypeWithEntryDirective(type: GraphQLNullableType): boolean { 13 | if (isNonNullType(type)) { 14 | return this.buildsOnTypeWithEntryDirective(type.ofType) 15 | } else if (isListType(type)) { 16 | return this.buildsOnTypeWithEntryDirective(type.ofType) 17 | } else if (isUnionType(type)) { 18 | return this.isUnionOfEntryTypes(type) 19 | } else if (isObjectType(type)) { 20 | return this.hasEntryDirective(type) 21 | } 22 | return false 23 | } 24 | 25 | public hasEntryDirective(type: GraphQLObjectType): boolean { 26 | return ( 27 | !!type.astNode && 28 | type.astNode.directives?.find( 29 | (directive) => directive.name.value === 'Entry', 30 | ) !== undefined 31 | ) 32 | } 33 | 34 | public isUnionOfEntryTypes(type: GraphQLUnionType): boolean { 35 | return ( 36 | type 37 | .getTypes() 38 | .filter((unionType) => this.buildsOnTypeWithEntryDirective(unionType)) 39 | .length > 0 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/graphql/schema-utils/union-type-util.ts: -------------------------------------------------------------------------------- 1 | import { EntryData } from '@commitspark/git-adapter' 2 | 3 | export class UnionTypeUtil { 4 | public getUnionTypeNameFromFieldValue(fieldValue: any): string { 5 | if (typeof fieldValue !== 'object') { 6 | throw new Error('Expected object as union value') 7 | } 8 | 9 | // Based on our @oneOf directive, we expect only one field whose name 10 | // corresponds to the concrete type's name. 11 | return Object.keys(fieldValue)[0] 12 | } 13 | 14 | public getUnionValue(fieldValue: any): EntryData { 15 | if (typeof fieldValue !== 'object') { 16 | throw new Error('Expected object as union value') 17 | } 18 | 19 | const firstKey = Object.keys(fieldValue)[0] 20 | return fieldValue[firstKey] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/graphql/schema-validator.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLSchema, GraphQLUnionType, Kind } from 'graphql' 2 | 3 | export class SchemaValidator { 4 | public getValidationResult(schema: GraphQLSchema): string[] { 5 | const results = [] 6 | results.push(this.checkUnionMembersConsistentUseOfEntryDirective(schema)) 7 | return results.filter((result) => result !== '') 8 | } 9 | 10 | private checkUnionMembersConsistentUseOfEntryDirective( 11 | schema: GraphQLSchema, 12 | ): string { 13 | const typeMap = schema.getTypeMap() 14 | 15 | for (const [key, type] of Object.entries(typeMap)) { 16 | if (type.astNode?.kind !== Kind.UNION_TYPE_DEFINITION) { 17 | continue 18 | } 19 | const innerTypes = (type as GraphQLUnionType).getTypes() 20 | 21 | const numberUnionMembersWithEntryDirective = innerTypes.filter( 22 | (innerType) => 23 | !!innerType.astNode && 24 | innerType.astNode.directives?.find( 25 | (directive) => directive.name.value === 'Entry', 26 | ) !== undefined, 27 | ).length 28 | 29 | if ( 30 | numberUnionMembersWithEntryDirective !== 0 && 31 | numberUnionMembersWithEntryDirective !== innerTypes.length 32 | ) { 33 | return `Either all union members of "${type.name}" must have "@Entry" directive or none.` 34 | } 35 | } 36 | 37 | return '' 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { ApiService, GraphQLResponse, SchemaResponse } from './app/api.service' 2 | import { apiService } from './container' 3 | 4 | export { ApiService, GraphQLResponse, SchemaResponse } 5 | 6 | export async function getApiService(): Promise { 7 | return apiService 8 | } 9 | -------------------------------------------------------------------------------- /src/persistence/persistence.service.ts: -------------------------------------------------------------------------------- 1 | import { Entry, GitAdapter } from '@commitspark/git-adapter' 2 | import { GraphQLError } from 'graphql' 3 | 4 | export class PersistenceService { 5 | public async getTypeById( 6 | gitAdapter: GitAdapter, 7 | commitHash: string, 8 | id: string, 9 | ): Promise { 10 | const allEntries = await gitAdapter.getEntries(commitHash) 11 | const requestedEntry = allEntries.find((entry: Entry) => entry.id === id) 12 | if (requestedEntry === undefined) { 13 | throw new GraphQLError(`Not found: ${id}`, { 14 | extensions: { 15 | code: 'NOT_FOUND', 16 | }, 17 | }) 18 | } 19 | 20 | return requestedEntry.metadata.type 21 | } 22 | 23 | public async findById( 24 | gitAdapter: GitAdapter, 25 | commitHash: string, 26 | id: string, 27 | ): Promise { 28 | const allEntries = await gitAdapter.getEntries(commitHash) 29 | const requestedEntry = allEntries.find((entry: Entry) => entry.id === id) 30 | if (requestedEntry === undefined) { 31 | throw new GraphQLError(`Not found: ${id}`, { 32 | extensions: { 33 | code: 'NOT_FOUND', 34 | }, 35 | }) 36 | } 37 | 38 | return requestedEntry 39 | } 40 | 41 | public async findByType( 42 | gitAdapter: GitAdapter, 43 | commitHash: string, 44 | type: string, 45 | ): Promise { 46 | const allEntries = await gitAdapter.getEntries(commitHash) 47 | return allEntries.filter((entry: Entry) => entry.metadata.type === type) 48 | } 49 | 50 | public async findByTypeId( 51 | gitAdapter: GitAdapter, 52 | commitHash: string, 53 | type: string, 54 | id: string, 55 | ): Promise { 56 | const allEntries = await gitAdapter.getEntries(commitHash) 57 | const requestedEntry = allEntries.find( 58 | (entry: Entry) => entry.id === id && entry.metadata.type === type, 59 | ) 60 | if (requestedEntry === undefined) { 61 | throw new GraphQLError( 62 | `No entry of type "${type}" with id "${id}" exists`, 63 | { 64 | extensions: { 65 | code: 'BAD_USER_INPUT', 66 | argumentName: 'id', 67 | }, 68 | }, 69 | ) 70 | } 71 | 72 | return requestedEntry 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/integration/mutation/create.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Commit, 3 | CommitDraft, 4 | Entry, 5 | GitAdapter, 6 | } from '@commitspark/git-adapter' 7 | import { Matcher, mock } from 'jest-mock-extended' 8 | import { getApiService } from '../../../src' 9 | 10 | describe('"Create" mutation resolvers', () => { 11 | it('should create an entry', async () => { 12 | const gitAdapter = mock() 13 | const gitRef = 'myRef' 14 | const commitHash = 'abcd' 15 | const schema = `directive @Entry on OBJECT 16 | 17 | type EntryA @Entry { 18 | id: ID! 19 | name: String 20 | }` 21 | 22 | const commitMessage = 'My message' 23 | const entryAId = 'd92f77d2-be9b-429f-877d-5c400ea9ce78' 24 | const postCommitHash = 'ef01' 25 | 26 | const mutationData = { 27 | name: 'My name', 28 | } 29 | const commitResult: Commit = { 30 | ref: postCommitHash, 31 | } 32 | const newEntry: Entry = { 33 | id: entryAId, 34 | metadata: { 35 | type: 'EntryA', 36 | referencedBy: [], 37 | }, 38 | data: { 39 | name: mutationData.name, 40 | }, 41 | } 42 | 43 | const commitDraft: CommitDraft = { 44 | ref: gitRef, 45 | parentSha: commitHash, 46 | entries: [{ ...newEntry, deletion: false }], 47 | message: commitMessage, 48 | } 49 | 50 | const commitDraftMatcher = new Matcher((actualValue) => { 51 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 52 | }, '') 53 | 54 | gitAdapter.getLatestCommitHash 55 | .calledWith(gitRef) 56 | .mockResolvedValue(commitHash) 57 | gitAdapter.getSchema.calledWith(commitHash).mockResolvedValue(schema) 58 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue([]) 59 | gitAdapter.createCommit 60 | .calledWith(commitDraftMatcher) 61 | .mockResolvedValue(commitResult) 62 | gitAdapter.getEntries 63 | .calledWith(postCommitHash) 64 | .mockResolvedValue([newEntry]) 65 | 66 | const apiService = await getApiService() 67 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 68 | query: `mutation ($id: ID!, $mutationData: EntryAInput!, $commitMessage: String!) { 69 | data: createEntryA(id: $id, data: $mutationData, commitMessage: $commitMessage) { 70 | id 71 | } 72 | }`, 73 | variables: { 74 | id: entryAId, 75 | mutationData: mutationData, 76 | commitMessage: commitMessage, 77 | }, 78 | }) 79 | 80 | expect(result.errors).toBeUndefined() 81 | expect(result.data).toEqual({ 82 | data: { 83 | id: entryAId, 84 | }, 85 | }) 86 | expect(result.ref).toBe(postCommitHash) 87 | }) 88 | 89 | it('should create an entry that references other entries', async () => { 90 | const gitAdapter = mock() 91 | const gitRef = 'myRef' 92 | const commitHash = 'abcd' 93 | const schema = `directive @Entry on OBJECT 94 | 95 | type EntryA @Entry { 96 | id: ID! 97 | optionalReference: OptionalReference1 98 | nonNullReference: NonNullReference! 99 | arrayReference: [ArrayReference!]! 100 | unionReference: UnionReference! 101 | unionNestedReference: UnionNestedReference! 102 | circularReferenceEntryReference: CircularReferenceEntry 103 | } 104 | 105 | type OptionalReference1 { 106 | nestedReference: OptionalReference2! 107 | } 108 | 109 | type OptionalReference2 @Entry { 110 | id: ID! 111 | } 112 | 113 | type NonNullReference @Entry { 114 | id: ID! 115 | } 116 | 117 | type ArrayReference @Entry { 118 | id: ID! 119 | } 120 | 121 | union UnionReference = 122 | | UnionEntryType1 123 | | UnionEntryType2 124 | 125 | type UnionEntryType1 @Entry { 126 | id: ID! 127 | } 128 | 129 | type UnionEntryType2 @Entry { 130 | id: ID! 131 | } 132 | 133 | union UnionNestedReference = 134 | | UnionType1 135 | | UnionType2 136 | 137 | type UnionType1 { 138 | otherField: String 139 | } 140 | 141 | type UnionType2 { 142 | nestedReference: UnionNestedEntry 143 | } 144 | 145 | type UnionNestedEntry @Entry { 146 | id: ID! 147 | } 148 | 149 | type CircularReferenceEntry @Entry { 150 | id: ID! 151 | next: CircularReferenceEntry 152 | }` 153 | 154 | const commitMessage = 'My message' 155 | const entryAId = 'A' 156 | const optionalReference2EntryId = 'optionalReference2EntryId' 157 | const nonNullReferenceEntryId = 'nonNullReferenceEntryId' 158 | const arrayReferenceEntry1Id = 'arrayReferenceEntry1Id' 159 | const arrayReferenceEntry2Id = 'arrayReferenceEntry2Id' 160 | const unionEntryType1Id = 'unionEntryType1Id' 161 | const unionEntryType2Id = 'unionEntryType2Id' 162 | const unionNestedEntryId = 'unionNestedEntryId' 163 | const circularReferenceEntry1Id = 'circularReferenceEntry1Id' 164 | const circularReferenceEntry2Id = 'circularReferenceEntry2Id' 165 | const postCommitHash = 'ef01' 166 | 167 | const mutationData = { 168 | optionalReference: { 169 | nestedReference: { id: optionalReference2EntryId }, 170 | }, 171 | nonNullReference: { id: nonNullReferenceEntryId }, 172 | arrayReference: [ 173 | { id: arrayReferenceEntry1Id }, 174 | { id: arrayReferenceEntry2Id }, 175 | ], 176 | unionReference: { 177 | id: unionEntryType2Id, 178 | }, 179 | unionNestedReference: { 180 | UnionType2: { 181 | nestedReference: { 182 | id: unionNestedEntryId, 183 | }, 184 | }, 185 | }, 186 | circularReferenceEntryReference: { 187 | id: circularReferenceEntry1Id, 188 | }, 189 | } 190 | 191 | const commitResult: Commit = { 192 | ref: postCommitHash, 193 | } 194 | 195 | const existingCircularReference2Entry = { 196 | id: circularReferenceEntry2Id, 197 | metadata: { 198 | type: 'CircularReferenceEntry', 199 | referencedBy: [circularReferenceEntry1Id], 200 | }, 201 | } 202 | 203 | const existingEntries: Entry[] = [ 204 | { 205 | id: optionalReference2EntryId, 206 | metadata: { type: 'OptionalReference2' }, 207 | }, 208 | { id: nonNullReferenceEntryId, metadata: { type: 'NonNullReference' } }, 209 | { id: arrayReferenceEntry1Id, metadata: { type: 'ArrayReference' } }, 210 | { id: arrayReferenceEntry2Id, metadata: { type: 'ArrayReference' } }, 211 | { id: unionEntryType1Id, metadata: { type: 'UnionEntryType1' } }, 212 | { id: unionEntryType2Id, metadata: { type: 'UnionEntryType2' } }, 213 | { id: unionNestedEntryId, metadata: { type: 'UnionNestedEntry' } }, 214 | { 215 | id: circularReferenceEntry1Id, 216 | metadata: { 217 | type: 'CircularReferenceEntry', 218 | referencedBy: [circularReferenceEntry2Id], 219 | }, 220 | }, 221 | existingCircularReference2Entry, 222 | ] 223 | const newEntryA: Entry = { 224 | id: entryAId, 225 | metadata: { 226 | type: 'EntryA', 227 | referencedBy: [], 228 | }, 229 | data: mutationData, 230 | } 231 | const updatedOptionalReference2: Entry = { 232 | id: optionalReference2EntryId, 233 | metadata: { 234 | type: 'OptionalReference2', 235 | referencedBy: [entryAId], 236 | }, 237 | } 238 | const updatedNonNullReference: Entry = { 239 | id: nonNullReferenceEntryId, 240 | metadata: { 241 | type: 'NonNullReference', 242 | referencedBy: [entryAId], 243 | }, 244 | } 245 | const updatedArrayReference1: Entry = { 246 | id: arrayReferenceEntry1Id, 247 | metadata: { 248 | type: 'ArrayReference', 249 | referencedBy: [entryAId], 250 | }, 251 | } 252 | const updatedArrayReference2: Entry = { 253 | id: arrayReferenceEntry2Id, 254 | metadata: { 255 | type: 'ArrayReference', 256 | referencedBy: [entryAId], 257 | }, 258 | } 259 | const updatedUnionEntryType2: Entry = { 260 | id: unionEntryType2Id, 261 | metadata: { 262 | type: 'UnionEntryType2', 263 | referencedBy: [entryAId], 264 | }, 265 | } 266 | const updatedUnionNestedEntry: Entry = { 267 | id: unionNestedEntryId, 268 | metadata: { 269 | type: 'UnionNestedEntry', 270 | referencedBy: [entryAId], 271 | }, 272 | } 273 | const updatedCircularReference1Entry: Entry = { 274 | id: circularReferenceEntry1Id, 275 | metadata: { 276 | type: 'CircularReferenceEntry', 277 | referencedBy: [entryAId, circularReferenceEntry2Id], 278 | }, 279 | } 280 | 281 | const commitDraft: CommitDraft = { 282 | ref: gitRef, 283 | parentSha: commitHash, 284 | entries: [ 285 | { ...newEntryA, deletion: false }, 286 | { ...updatedOptionalReference2, deletion: false }, 287 | { ...updatedNonNullReference, deletion: false }, 288 | { ...updatedArrayReference1, deletion: false }, 289 | { ...updatedArrayReference2, deletion: false }, 290 | { ...updatedUnionEntryType2, deletion: false }, 291 | { ...updatedUnionNestedEntry, deletion: false }, 292 | { ...updatedCircularReference1Entry, deletion: false }, 293 | ], 294 | message: commitMessage, 295 | } 296 | 297 | const commitDraftMatcher = new Matcher((actualValue) => { 298 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 299 | }, '') 300 | 301 | gitAdapter.getLatestCommitHash 302 | .calledWith(gitRef) 303 | .mockResolvedValue(commitHash) 304 | gitAdapter.getSchema.calledWith(commitHash).mockResolvedValue(schema) 305 | gitAdapter.getEntries 306 | .calledWith(commitHash) 307 | .mockResolvedValue(existingEntries) 308 | gitAdapter.createCommit 309 | .calledWith(commitDraftMatcher) 310 | .mockResolvedValue(commitResult) 311 | gitAdapter.getEntries 312 | .calledWith(postCommitHash) 313 | .mockResolvedValue([ 314 | newEntryA, 315 | updatedOptionalReference2, 316 | updatedNonNullReference, 317 | updatedArrayReference1, 318 | updatedArrayReference2, 319 | updatedUnionEntryType2, 320 | updatedUnionNestedEntry, 321 | updatedCircularReference1Entry, 322 | existingCircularReference2Entry, 323 | ]) 324 | 325 | const apiService = await getApiService() 326 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 327 | query: `mutation ($id: ID!, $mutationData: EntryAInput!, $commitMessage: String!) { 328 | data: createEntryA(id: $id, data: $mutationData, commitMessage: $commitMessage) { 329 | id 330 | } 331 | }`, 332 | variables: { 333 | id: entryAId, 334 | mutationData: mutationData, 335 | commitMessage: commitMessage, 336 | }, 337 | }) 338 | 339 | expect(result.errors).toBeUndefined() 340 | expect(result.data).toEqual({ 341 | data: { 342 | id: entryAId, 343 | }, 344 | }) 345 | expect(result.ref).toBe(postCommitHash) 346 | }) 347 | 348 | it('should not create an entry that references a non-existent entry', async () => { 349 | const gitAdapter = mock() 350 | const gitRef = 'myRef' 351 | const commitHash = 'abcd' 352 | const schema = `directive @Entry on OBJECT 353 | 354 | type EntryA @Entry { 355 | id: ID! 356 | reference: EntryB! 357 | } 358 | 359 | type EntryB @Entry { 360 | id: ID! 361 | }` 362 | 363 | const commitMessage = 'My message' 364 | const entryAId = 'A' 365 | const entryBId = 'B' 366 | 367 | const existingEntries: Entry[] = [ 368 | { id: entryBId, metadata: { type: 'EntryB' } }, 369 | ] 370 | 371 | gitAdapter.getLatestCommitHash 372 | .calledWith(gitRef) 373 | .mockResolvedValue(commitHash) 374 | gitAdapter.getSchema.calledWith(commitHash).mockResolvedValue(schema) 375 | gitAdapter.getEntries 376 | .calledWith(commitHash) 377 | .mockResolvedValue(existingEntries) 378 | 379 | const apiService = await getApiService() 380 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 381 | query: `mutation ($id: ID!, $mutationData: EntryAInput!, $commitMessage: String!) { 382 | data: createEntryA(id: $id, data: $mutationData, commitMessage: $commitMessage) { 383 | id 384 | } 385 | }`, 386 | variables: { 387 | id: entryAId, 388 | mutationData: { reference: { id: 'someUnknownId' } }, 389 | commitMessage: commitMessage, 390 | }, 391 | }) 392 | 393 | expect(result.errors).toMatchObject([ 394 | { extensions: { fieldName: 'reference', code: 'BAD_USER_INPUT' } }, 395 | ]) 396 | expect(result.data).toEqual({ data: null }) 397 | expect(result.ref).toBe(commitHash) 398 | }) 399 | 400 | it('should not create an entry that references an entry of incorrect type', async () => { 401 | const gitAdapter = mock() 402 | const gitRef = 'myRef' 403 | const commitHash = 'abcd' 404 | const schema = `directive @Entry on OBJECT 405 | 406 | type EntryA @Entry { 407 | id: ID! 408 | reference: EntryB! 409 | } 410 | 411 | type EntryB @Entry { 412 | id: ID! 413 | } 414 | 415 | type OtherEntry @Entry { 416 | id: ID! 417 | }` 418 | 419 | const commitMessage = 'My message' 420 | const entryAId = 'A' 421 | const entryBId = 'B' 422 | const otherEntryId = 'otherEntryId' 423 | 424 | const existingEntries: Entry[] = [ 425 | { id: entryBId, metadata: { type: 'EntryB' } }, 426 | { id: otherEntryId, metadata: { type: 'OtherEntry' } }, 427 | ] 428 | 429 | gitAdapter.getLatestCommitHash 430 | .calledWith(gitRef) 431 | .mockResolvedValue(commitHash) 432 | gitAdapter.getSchema.calledWith(commitHash).mockResolvedValue(schema) 433 | gitAdapter.getEntries 434 | .calledWith(commitHash) 435 | .mockResolvedValue(existingEntries) 436 | 437 | const apiService = await getApiService() 438 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 439 | query: `mutation ($id: ID!, $mutationData: EntryAInput!, $commitMessage: String!) { 440 | data: createEntryA(id: $id, data: $mutationData, commitMessage: $commitMessage) { 441 | id 442 | } 443 | }`, 444 | variables: { 445 | id: entryAId, 446 | mutationData: { reference: { id: otherEntryId } }, 447 | commitMessage: commitMessage, 448 | }, 449 | }) 450 | 451 | expect(result.errors).toMatchObject([ 452 | { extensions: { fieldName: 'reference', code: 'BAD_USER_INPUT' } }, 453 | ]) 454 | expect(result.data).toEqual({ data: null }) 455 | expect(result.ref).toBe(commitHash) 456 | }) 457 | }) 458 | -------------------------------------------------------------------------------- /tests/integration/mutation/delete.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Commit, 3 | CommitDraft, 4 | Entry, 5 | GitAdapter, 6 | } from '@commitspark/git-adapter' 7 | import { Matcher, mock } from 'jest-mock-extended' 8 | import { getApiService } from '../../../src' 9 | 10 | describe('"Delete" mutation resolvers', () => { 11 | it('should delete an entry', async () => { 12 | const gitAdapter = mock() 13 | const gitRef = 'myRef' 14 | const commitHash = 'abcd' 15 | const originalSchema = `directive @Entry on OBJECT 16 | 17 | type EntryA @Entry { 18 | id: ID! 19 | name: String 20 | }` 21 | 22 | const commitMessage = 'My message' 23 | const entryAId = 'A' 24 | const postCommitHash = 'ef01' 25 | 26 | const commitResult: Commit = { 27 | ref: postCommitHash, 28 | } 29 | const entry: Entry = { 30 | id: entryAId, 31 | metadata: { 32 | type: 'EntryA', 33 | }, 34 | data: { 35 | name: 'My name', 36 | }, 37 | } 38 | 39 | const commitDraft: CommitDraft = { 40 | ref: gitRef, 41 | parentSha: commitHash, 42 | entries: [ 43 | { 44 | ...entry, 45 | deletion: true, 46 | }, 47 | ], 48 | message: commitMessage, 49 | } 50 | 51 | const commitDraftMatcher = new Matcher((actualValue) => { 52 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 53 | }, '') 54 | 55 | gitAdapter.getLatestCommitHash 56 | .calledWith(gitRef) 57 | .mockResolvedValue(commitHash) 58 | gitAdapter.getSchema 59 | .calledWith(commitHash) 60 | .mockResolvedValue(originalSchema) 61 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue([entry]) 62 | gitAdapter.createCommit 63 | .calledWith(commitDraftMatcher) 64 | .mockResolvedValue(commitResult) 65 | 66 | const apiService = await getApiService() 67 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 68 | query: `mutation ($id: ID!, $commitMessage: String!) { 69 | data: deleteEntryA(id: $id, commitMessage: $commitMessage) 70 | }`, 71 | variables: { 72 | id: entryAId, 73 | commitMessage: commitMessage, 74 | }, 75 | }) 76 | 77 | expect(result.errors).toBeUndefined() 78 | expect(result.data).toEqual({ 79 | data: entryAId, 80 | }) 81 | expect(result.ref).toBe(postCommitHash) 82 | }) 83 | 84 | it('should return an error when trying to delete a non-existent entry', async () => { 85 | const gitAdapter = mock() 86 | const gitRef = 'myRef' 87 | const commitHash = 'abcd' 88 | const originalSchema = `directive @Entry on OBJECT 89 | 90 | type EntryA @Entry { 91 | id: ID! 92 | name: String 93 | }` 94 | 95 | const commitMessage = 'My message' 96 | const entryAId = 'A' 97 | 98 | gitAdapter.getLatestCommitHash 99 | .calledWith(gitRef) 100 | .mockResolvedValue(commitHash) 101 | gitAdapter.getSchema 102 | .calledWith(commitHash) 103 | .mockResolvedValue(originalSchema) 104 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue([]) 105 | 106 | const apiService = await getApiService() 107 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 108 | query: `mutation ($id: ID!, $commitMessage: String!) { 109 | data: deleteEntryA(id: $id, commitMessage: $commitMessage) 110 | }`, 111 | variables: { 112 | id: entryAId, 113 | commitMessage: commitMessage, 114 | }, 115 | }) 116 | 117 | expect(result.errors).toMatchObject([ 118 | { extensions: { argumentName: 'id', code: 'BAD_USER_INPUT' } }, 119 | ]) 120 | expect(result.data).toEqual({ data: null }) 121 | expect(result.ref).toBe(commitHash) 122 | }) 123 | 124 | it('should return an error when trying to delete an entry that is referenced elsewhere', async () => { 125 | const gitAdapter = mock() 126 | const gitRef = 'myRef' 127 | const commitHash = 'abcd' 128 | const originalSchema = `directive @Entry on OBJECT 129 | 130 | type EntryA @Entry { 131 | id: ID! 132 | reference: EntryB! 133 | } 134 | 135 | type EntryB @Entry { 136 | id: ID! 137 | }` 138 | 139 | const commitMessage = 'My message' 140 | const entryAId = 'A' 141 | const entryBId = 'B' 142 | 143 | const entries: Entry[] = [ 144 | { 145 | id: entryAId, 146 | metadata: { 147 | type: 'EntryA', 148 | }, 149 | data: { 150 | reference: { 151 | id: entryBId, 152 | }, 153 | }, 154 | }, 155 | { 156 | id: entryBId, 157 | metadata: { 158 | type: 'EntryB', 159 | referencedBy: [entryAId], 160 | }, 161 | }, 162 | ] 163 | 164 | gitAdapter.getLatestCommitHash 165 | .calledWith(gitRef) 166 | .mockResolvedValue(commitHash) 167 | gitAdapter.getSchema 168 | .calledWith(commitHash) 169 | .mockResolvedValue(originalSchema) 170 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 171 | 172 | const apiService = await getApiService() 173 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 174 | query: `mutation ($id: ID!, $commitMessage: String!) { 175 | data: deleteEntryB(id: $id, commitMessage: $commitMessage) 176 | }`, 177 | variables: { 178 | id: entryBId, 179 | commitMessage: commitMessage, 180 | }, 181 | }) 182 | 183 | expect(result.errors).toMatchObject([ 184 | { extensions: { argumentName: 'id', code: 'IN_USE' } }, 185 | ]) 186 | expect(result.data).toEqual({ data: null }) 187 | expect(result.ref).toBe(commitHash) 188 | }) 189 | 190 | it('should remove references from metadata of other entries when deleting an entry', async () => { 191 | const gitAdapter = mock() 192 | const gitRef = 'myRef' 193 | const commitHash = 'abcd' 194 | const originalSchema = `directive @Entry on OBJECT 195 | 196 | type Item @Entry { 197 | id: ID! 198 | box: Box! 199 | } 200 | 201 | type Box @Entry { 202 | id: ID! 203 | }` 204 | 205 | const commitMessage = 'My message' 206 | const boxId = 'box' 207 | const item1Id = 'item1' 208 | const item2Id = 'item2' 209 | const postCommitHash = 'ef01' 210 | 211 | const commitResult: Commit = { 212 | ref: postCommitHash, 213 | } 214 | const box: Entry = { 215 | id: boxId, 216 | metadata: { 217 | type: 'Box', 218 | referencedBy: [item1Id, item2Id], 219 | }, 220 | } 221 | const item1: Entry = { 222 | id: item1Id, 223 | metadata: { 224 | type: 'Item', 225 | }, 226 | data: { 227 | box: { id: boxId }, 228 | }, 229 | } 230 | const item2: Entry = { 231 | id: item2Id, 232 | metadata: { 233 | type: 'Item', 234 | }, 235 | data: { 236 | box: { id: boxId }, 237 | }, 238 | } 239 | const updatedBox: Entry = { 240 | id: boxId, 241 | metadata: { 242 | type: 'Box', 243 | referencedBy: [item2Id], 244 | }, 245 | } 246 | 247 | const commitDraft: CommitDraft = { 248 | ref: gitRef, 249 | parentSha: commitHash, 250 | entries: [ 251 | { ...item1, deletion: true }, 252 | { ...updatedBox, deletion: false }, 253 | ], 254 | message: commitMessage, 255 | } 256 | 257 | const commitDraftMatcher = new Matcher((actualValue) => { 258 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 259 | }, '') 260 | 261 | gitAdapter.getLatestCommitHash 262 | .calledWith(gitRef) 263 | .mockResolvedValue(commitHash) 264 | gitAdapter.getSchema 265 | .calledWith(commitHash) 266 | .mockResolvedValue(originalSchema) 267 | gitAdapter.getEntries 268 | .calledWith(commitHash) 269 | .mockResolvedValue([box, item1, item2]) 270 | gitAdapter.createCommit 271 | .calledWith(commitDraftMatcher) 272 | .mockResolvedValue(commitResult) 273 | gitAdapter.getEntries 274 | .calledWith(postCommitHash) 275 | .mockResolvedValue([updatedBox, item2]) 276 | 277 | const apiService = await getApiService() 278 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 279 | query: `mutation ($id: ID!, $commitMessage: String!) { 280 | data: deleteItem(id: $id, commitMessage: $commitMessage) 281 | }`, 282 | variables: { 283 | id: item1Id, 284 | commitMessage: commitMessage, 285 | }, 286 | }) 287 | 288 | expect(result.errors).toBeUndefined() 289 | expect(result.data).toEqual({ 290 | data: item1Id, 291 | }) 292 | expect(result.ref).toBe(postCommitHash) 293 | }) 294 | }) 295 | -------------------------------------------------------------------------------- /tests/integration/mutation/update.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Commit, 3 | CommitDraft, 4 | Entry, 5 | GitAdapter, 6 | } from '@commitspark/git-adapter' 7 | import { Matcher, mock } from 'jest-mock-extended' 8 | import { getApiService } from '../../../src' 9 | 10 | describe('"Update" mutation resolvers', () => { 11 | it('should update an entry', async () => { 12 | const gitAdapter = mock() 13 | const gitRef = 'myRef' 14 | const commitHash = 'abcd' 15 | const originalSchema = `directive @Entry on OBJECT 16 | 17 | type EntryA @Entry { 18 | id: ID! 19 | name: String 20 | }` 21 | 22 | const commitMessage = 'My message' 23 | const entryAId = 'A' 24 | const postCommitHash = 'ef01' 25 | 26 | const mutationData = { 27 | name: 'My name', 28 | } 29 | const commitResult: Commit = { 30 | ref: postCommitHash, 31 | } 32 | const originalEntry: Entry = { 33 | id: entryAId, 34 | metadata: { 35 | type: 'EntryA', 36 | }, 37 | data: { 38 | name: `${mutationData.name}1`, 39 | }, 40 | } 41 | 42 | const updatedEntry: Entry = { 43 | id: entryAId, 44 | metadata: { 45 | type: 'EntryA', 46 | }, 47 | data: { 48 | name: `${mutationData.name}2`, 49 | }, 50 | } 51 | 52 | const commitDraft: CommitDraft = { 53 | ref: gitRef, 54 | parentSha: commitHash, 55 | entries: [{ ...updatedEntry, deletion: false }], 56 | message: commitMessage, 57 | } 58 | 59 | const commitDraftMatcher = new Matcher((actualValue) => { 60 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 61 | }, '') 62 | 63 | gitAdapter.getLatestCommitHash 64 | .calledWith(gitRef) 65 | .mockResolvedValue(commitHash) 66 | gitAdapter.getSchema 67 | .calledWith(commitHash) 68 | .mockResolvedValue(originalSchema) 69 | gitAdapter.getEntries 70 | .calledWith(commitHash) 71 | .mockResolvedValue([originalEntry]) 72 | gitAdapter.createCommit 73 | .calledWith(commitDraftMatcher) 74 | .mockResolvedValue(commitResult) 75 | gitAdapter.getEntries 76 | .calledWith(postCommitHash) 77 | .mockResolvedValue([updatedEntry]) 78 | 79 | const apiService = await getApiService() 80 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 81 | query: `mutation ($id: ID!, $mutationData: EntryAInput!, $commitMessage: String!) { 82 | data: updateEntryA(id: $id, data: $mutationData, commitMessage: $commitMessage) { 83 | id 84 | name 85 | } 86 | }`, 87 | variables: { 88 | id: entryAId, 89 | mutationData: { 90 | name: `${mutationData.name}2`, 91 | }, 92 | commitMessage: commitMessage, 93 | }, 94 | }) 95 | 96 | expect(result.errors).toBeUndefined() 97 | expect(result.data).toEqual({ 98 | data: { 99 | id: entryAId, 100 | name: `${mutationData.name}2`, 101 | }, 102 | }) 103 | expect(result.ref).toBe(postCommitHash) 104 | }) 105 | 106 | it('should update an entry only where data was provided (partial update)', async () => { 107 | const gitAdapter = mock() 108 | const gitRef = 'myRef' 109 | const commitHash = 'abcd' 110 | const originalSchema = `directive @Entry on OBJECT 111 | 112 | type EntryA @Entry { 113 | id: ID! 114 | fieldChanged: String 115 | fieldNulled: String 116 | fieldNotSpecified: String 117 | fieldUndefinedData: String 118 | subTypeChanged: SubType 119 | subTypeNulled: SubType 120 | subTypeNotSpecified: SubType 121 | subTypeUndefinedData: SubType 122 | arrayChanged: [SubType!] 123 | arrayNulled: [SubType!] 124 | arrayNotSpecified: [SubType!] 125 | arrayUndefinedData: [SubType!] 126 | } 127 | 128 | type SubType { 129 | field1: String 130 | field2: String 131 | }` 132 | 133 | const commitMessage = 'My message' 134 | const entryId = 'A' 135 | const postCommitHash = 'ef01' 136 | 137 | const originalValue = 'original' 138 | const commitResult: Commit = { 139 | ref: postCommitHash, 140 | } 141 | const originalEntry: Entry = { 142 | id: entryId, 143 | metadata: { 144 | type: 'EntryA', 145 | }, 146 | data: { 147 | fieldChanged: originalValue, 148 | fieldNulled: originalValue, 149 | fieldNotSpecified: originalValue, 150 | subTypeChanged: { 151 | field1: originalValue, 152 | }, 153 | subTypeNulled: { 154 | field1: originalValue, 155 | }, 156 | subTypeNotSpecified: { 157 | field1: originalValue, 158 | }, 159 | arrayChanged: [{ field1: originalValue }], 160 | arrayNulled: [{ field1: originalValue }], 161 | arrayNotSpecified: [{ field1: originalValue }], 162 | }, 163 | } 164 | 165 | const changedValue = 'changed' 166 | const mutationData = { 167 | fieldChanged: changedValue, 168 | fieldNulled: null, 169 | fieldUndefinedData: changedValue, 170 | subTypeChanged: { field2: changedValue }, 171 | subTypeNulled: null, 172 | subTypeUndefinedData: { field2: changedValue }, 173 | arrayChanged: [{ field2: changedValue }], 174 | arrayNulled: null, 175 | arrayUndefinedData: [{ field2: changedValue }], 176 | } 177 | 178 | const updatedEntry: Entry = { 179 | id: entryId, 180 | metadata: { 181 | type: 'EntryA', 182 | }, 183 | data: { 184 | fieldChanged: changedValue, 185 | fieldNulled: null, 186 | fieldNotSpecified: originalValue, 187 | subTypeChanged: { 188 | field2: changedValue, 189 | }, 190 | subTypeNulled: null, 191 | subTypeNotSpecified: { 192 | field1: originalValue, 193 | }, 194 | arrayChanged: [{ field2: changedValue }], 195 | arrayNulled: null, 196 | arrayNotSpecified: [{ field1: originalValue }], 197 | // we only do a dumb equality check using JSON below, so order matters and these fields were added 198 | fieldUndefinedData: changedValue, 199 | subTypeUndefinedData: { 200 | field2: changedValue, 201 | }, 202 | arrayUndefinedData: [{ field2: changedValue }], 203 | }, 204 | } 205 | 206 | const commitDraft: CommitDraft = { 207 | ref: gitRef, 208 | parentSha: commitHash, 209 | entries: [{ ...updatedEntry, deletion: false }], 210 | message: commitMessage, 211 | } 212 | 213 | const commitDraftMatcher = new Matcher((actualValue) => { 214 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 215 | }, '') 216 | 217 | gitAdapter.getLatestCommitHash 218 | .calledWith(gitRef) 219 | .mockResolvedValue(commitHash) 220 | gitAdapter.getSchema 221 | .calledWith(commitHash) 222 | .mockResolvedValue(originalSchema) 223 | gitAdapter.getEntries 224 | .calledWith(commitHash) 225 | .mockResolvedValue([originalEntry]) 226 | gitAdapter.createCommit 227 | .calledWith(commitDraftMatcher) 228 | .mockResolvedValue(commitResult) 229 | gitAdapter.getEntries 230 | .calledWith(postCommitHash) 231 | .mockResolvedValue([updatedEntry]) 232 | 233 | const apiService = await getApiService() 234 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 235 | query: `mutation ($id: ID!, $mutationData: EntryAInput!, $commitMessage: String!) { 236 | data: updateEntryA(id: $id, data: $mutationData, commitMessage: $commitMessage) { 237 | id 238 | } 239 | }`, 240 | variables: { 241 | id: entryId, 242 | mutationData: mutationData, 243 | commitMessage: commitMessage, 244 | }, 245 | }) 246 | 247 | expect(result.errors).toBeUndefined() 248 | expect(result.data).toEqual({ data: { id: entryId } }) 249 | expect(result.ref).toBe(postCommitHash) 250 | }) 251 | 252 | it('should return an error when trying to update a non-existent entry', async () => { 253 | const gitAdapter = mock() 254 | const gitRef = 'myRef' 255 | const commitHash = 'abcd' 256 | const originalSchema = `directive @Entry on OBJECT 257 | 258 | type EntryA @Entry { 259 | id: ID! 260 | name: String 261 | }` 262 | 263 | const commitMessage = 'My message' 264 | const entryAId = 'A' 265 | 266 | gitAdapter.getLatestCommitHash 267 | .calledWith(gitRef) 268 | .mockResolvedValue(commitHash) 269 | gitAdapter.getSchema 270 | .calledWith(commitHash) 271 | .mockResolvedValue(originalSchema) 272 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue([]) 273 | 274 | const apiService = await getApiService() 275 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 276 | query: `mutation ($id: ID!, $mutationData: EntryAInput!, $commitMessage: String!) { 277 | data: updateEntryA(id: $id, data: $mutationData, commitMessage: $commitMessage) { 278 | id 279 | name 280 | } 281 | }`, 282 | variables: { 283 | id: entryAId, 284 | mutationData: { 285 | name: '2', 286 | }, 287 | commitMessage: commitMessage, 288 | }, 289 | }) 290 | 291 | expect(result.errors).toMatchObject([ 292 | { extensions: { argumentName: 'id', code: 'BAD_USER_INPUT' } }, 293 | ]) 294 | expect(result.data).toEqual({ data: null }) 295 | expect(result.ref).toBe(commitHash) 296 | }) 297 | 298 | it('should update reference metadata of other entries when updating an entry', async () => { 299 | const gitAdapter = mock() 300 | const gitRef = 'myRef' 301 | const commitHash = 'abcd' 302 | const originalSchema = `directive @Entry on OBJECT 303 | 304 | type Item @Entry { 305 | id: ID! 306 | box: Box! 307 | } 308 | 309 | type Box @Entry { 310 | id: ID! 311 | }` 312 | 313 | const commitMessage = 'My message' 314 | const box1Id = 'box1' 315 | const box2Id = 'box2' 316 | const item1Id = 'item1' 317 | const item2Id = 'item2' 318 | const postCommitHash = 'ef01' 319 | 320 | const commitResult: Commit = { 321 | ref: postCommitHash, 322 | } 323 | const box1: Entry = { 324 | id: box1Id, 325 | metadata: { 326 | type: 'Box', 327 | referencedBy: [item1Id], 328 | }, 329 | } 330 | const box2: Entry = { 331 | id: box2Id, 332 | metadata: { 333 | type: 'Box', 334 | referencedBy: [item2Id], 335 | }, 336 | } 337 | const item1: Entry = { 338 | id: item1Id, 339 | metadata: { 340 | type: 'Item', 341 | }, 342 | data: { 343 | box: { id: box1Id }, 344 | }, 345 | } 346 | const item2: Entry = { 347 | id: item2Id, 348 | metadata: { 349 | type: 'Item', 350 | }, 351 | data: { 352 | box: { id: box2Id }, 353 | }, 354 | } 355 | const updatedItem1: Entry = { 356 | id: item1Id, 357 | metadata: { 358 | type: 'Item', 359 | }, 360 | data: { 361 | box: { id: box2Id }, 362 | }, 363 | } 364 | const updatedBox1: Entry = { 365 | id: box1Id, 366 | metadata: { 367 | type: 'Box', 368 | referencedBy: [], 369 | }, 370 | } 371 | const updatedBox2: Entry = { 372 | id: box2Id, 373 | metadata: { 374 | type: 'Box', 375 | referencedBy: [item1Id, item2Id], 376 | }, 377 | } 378 | 379 | const commitDraft: CommitDraft = { 380 | ref: gitRef, 381 | parentSha: commitHash, 382 | entries: [ 383 | { ...updatedItem1, deletion: false }, 384 | { ...updatedBox1, deletion: false }, 385 | { ...updatedBox2, deletion: false }, 386 | ], 387 | message: commitMessage, 388 | } 389 | 390 | const commitDraftMatcher = new Matcher((actualValue) => { 391 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 392 | }, '') 393 | 394 | gitAdapter.getLatestCommitHash 395 | .calledWith(gitRef) 396 | .mockResolvedValue(commitHash) 397 | gitAdapter.getSchema 398 | .calledWith(commitHash) 399 | .mockResolvedValue(originalSchema) 400 | gitAdapter.getEntries 401 | .calledWith(commitHash) 402 | .mockResolvedValue([box1, box2, item1, item2]) 403 | gitAdapter.createCommit 404 | .calledWith(commitDraftMatcher) 405 | .mockResolvedValue(commitResult) 406 | gitAdapter.getEntries 407 | .calledWith(postCommitHash) 408 | .mockResolvedValue([updatedBox1, updatedBox2, updatedItem1, item2]) 409 | 410 | const apiService = await getApiService() 411 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 412 | query: `mutation ($id: ID!, $mutationData: ItemInput!, $commitMessage: String!) { 413 | data: updateItem(id: $id, data: $mutationData, commitMessage: $commitMessage) { 414 | id 415 | } 416 | }`, 417 | variables: { 418 | id: item1Id, 419 | mutationData: { 420 | box: { id: box2Id }, 421 | }, 422 | commitMessage: commitMessage, 423 | }, 424 | }) 425 | 426 | expect(result.errors).toBeUndefined() 427 | expect(result.data).toEqual({ 428 | data: { 429 | id: item1Id, 430 | }, 431 | }) 432 | expect(result.ref).toBe(postCommitHash) 433 | }) 434 | 435 | it('should not add more than one reference in metadata of other entries when updating an entry', async () => { 436 | const gitAdapter = mock() 437 | const gitRef = 'myRef' 438 | const commitHash = 'abcd' 439 | const originalSchema = `directive @Entry on OBJECT 440 | 441 | type Item @Entry { 442 | id: ID! 443 | box: Box 444 | boxAlias: Box 445 | } 446 | 447 | type Box @Entry { 448 | id: ID! 449 | }` 450 | 451 | const commitMessage = 'My message' 452 | const boxId = 'box' 453 | const itemId = 'item' 454 | const postCommitHash = 'ef01' 455 | 456 | const commitResult: Commit = { 457 | ref: postCommitHash, 458 | } 459 | const box: Entry = { 460 | id: boxId, 461 | metadata: { 462 | type: 'Box', 463 | referencedBy: [itemId], 464 | }, 465 | } 466 | const item: Entry = { 467 | id: itemId, 468 | metadata: { 469 | type: 'Item', 470 | }, 471 | data: { 472 | box: { id: boxId }, 473 | }, 474 | } 475 | const updatedItem: Entry = { 476 | id: itemId, 477 | metadata: { 478 | type: 'Item', 479 | }, 480 | data: { 481 | box: { id: boxId }, 482 | boxAlias: { id: boxId }, 483 | }, 484 | } 485 | const updatedBox: Entry = { 486 | id: boxId, 487 | metadata: { 488 | type: 'Box', 489 | referencedBy: [itemId], 490 | }, 491 | } 492 | 493 | const commitDraft: CommitDraft = { 494 | ref: gitRef, 495 | parentSha: commitHash, 496 | entries: [{ ...updatedItem, deletion: false }], 497 | message: commitMessage, 498 | } 499 | 500 | const commitDraftMatcher = new Matcher((actualValue) => { 501 | return JSON.stringify(actualValue) === JSON.stringify(commitDraft) 502 | }, '') 503 | 504 | gitAdapter.getLatestCommitHash 505 | .calledWith(gitRef) 506 | .mockResolvedValue(commitHash) 507 | gitAdapter.getSchema 508 | .calledWith(commitHash) 509 | .mockResolvedValue(originalSchema) 510 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue([box, item]) 511 | gitAdapter.createCommit 512 | .calledWith(commitDraftMatcher) 513 | .mockResolvedValue(commitResult) 514 | gitAdapter.getEntries 515 | .calledWith(postCommitHash) 516 | .mockResolvedValue([updatedBox, updatedItem]) 517 | 518 | const apiService = await getApiService() 519 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 520 | query: `mutation ($id: ID!, $mutationData: ItemInput!, $commitMessage: String!) { 521 | data: updateItem(id: $id, data: $mutationData, commitMessage: $commitMessage) { 522 | id 523 | } 524 | }`, 525 | variables: { 526 | id: itemId, 527 | mutationData: { 528 | boxAlias: { id: boxId }, 529 | }, 530 | commitMessage: commitMessage, 531 | }, 532 | }) 533 | 534 | expect(result.errors).toBeUndefined() 535 | expect(result.data).toEqual({ 536 | data: { 537 | id: itemId, 538 | }, 539 | }) 540 | expect(result.ref).toBe(postCommitHash) 541 | }) 542 | }) 543 | -------------------------------------------------------------------------------- /tests/integration/query/query.test.ts: -------------------------------------------------------------------------------- 1 | import { Entry, GitAdapter } from '@commitspark/git-adapter' 2 | import { mock } from 'jest-mock-extended' 3 | import { getApiService } from '../../../src' 4 | 5 | describe('Query resolvers', () => { 6 | it('should resolve references to a second @Entry', async () => { 7 | const gitAdapter = mock() 8 | const gitRef = 'myRef' 9 | const commitHash = 'abcd' 10 | const originalSchema = `directive @Entry on OBJECT 11 | 12 | type EntryA @Entry { 13 | id: ID! 14 | name: String 15 | } 16 | 17 | type EntryB @Entry { 18 | id: ID! 19 | entryA: EntryA 20 | entryAList: [EntryA!]! 21 | }` 22 | 23 | const entryAId = 'A' 24 | const entryBId = 'B' 25 | 26 | const entries = [ 27 | { 28 | id: entryAId, 29 | metadata: { 30 | type: 'EntryA', 31 | }, 32 | data: { 33 | name: 'My name', 34 | }, 35 | } as Entry, 36 | { 37 | id: entryBId, 38 | metadata: { 39 | type: 'EntryB', 40 | }, 41 | data: { 42 | entryA: { 43 | id: entryAId, 44 | }, 45 | entryAList: [ 46 | { 47 | id: entryAId, 48 | }, 49 | ], 50 | }, 51 | } as Entry, 52 | ] 53 | 54 | gitAdapter.getLatestCommitHash 55 | .calledWith(gitRef) 56 | .mockResolvedValue(commitHash) 57 | gitAdapter.getSchema 58 | .calledWith(commitHash) 59 | .mockResolvedValue(originalSchema) 60 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 61 | 62 | const apiService = await getApiService() 63 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 64 | query: `query { 65 | data: EntryB(id:"${entryBId}") { 66 | id 67 | entryA { 68 | id 69 | name 70 | } 71 | entryAList { 72 | id 73 | name 74 | } 75 | } 76 | }`, 77 | }) 78 | 79 | expect(result.errors).toBeUndefined() 80 | expect(result.data).toEqual({ 81 | data: { 82 | id: entryBId, 83 | entryA: { 84 | id: entryAId, 85 | name: 'My name', 86 | }, 87 | entryAList: [ 88 | { 89 | id: entryAId, 90 | name: 'My name', 91 | }, 92 | ], 93 | }, 94 | }) 95 | expect(result.ref).toBe(commitHash) 96 | }) 97 | 98 | it('should resolve a non-@Entry-based union', async () => { 99 | const gitAdapter = mock() 100 | const gitRef = 'myRef' 101 | const commitHash = 'abcd' 102 | const originalSchema = `directive @Entry on OBJECT 103 | 104 | type MyEntry @Entry { 105 | id: ID! 106 | union: MyUnion 107 | } 108 | 109 | union MyUnion = 110 | | TypeA 111 | | TypeB 112 | 113 | type TypeA { 114 | field1: String 115 | } 116 | 117 | type TypeB { 118 | field2: String 119 | }` 120 | 121 | const entryId = 'A' 122 | const field2Value = 'Field2 value' 123 | 124 | const entries = [ 125 | { 126 | id: entryId, 127 | metadata: { 128 | type: 'MyEntry', 129 | }, 130 | data: { 131 | union: { 132 | TypeB: { 133 | field2: field2Value, 134 | }, 135 | }, 136 | }, 137 | } as Entry, 138 | ] 139 | 140 | gitAdapter.getLatestCommitHash 141 | .calledWith(gitRef) 142 | .mockResolvedValue(commitHash) 143 | gitAdapter.getSchema 144 | .calledWith(commitHash) 145 | .mockResolvedValue(originalSchema) 146 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 147 | 148 | const apiService = await getApiService() 149 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 150 | query: `query { 151 | data: MyEntry(id:"${entryId}") { 152 | id 153 | union { 154 | __typename 155 | ... on TypeB { 156 | field2 157 | } 158 | } 159 | } 160 | }`, 161 | }) 162 | 163 | expect(result.errors).toBeUndefined() 164 | expect(result.data).toEqual({ 165 | data: { 166 | id: entryId, 167 | union: { 168 | __typename: 'TypeB', 169 | field2: field2Value, 170 | }, 171 | }, 172 | }) 173 | expect(result.ref).toBe(commitHash) 174 | }) 175 | 176 | it('should resolve an array of non-@Entry-based unions that is null', async () => { 177 | const gitAdapter = mock() 178 | const gitRef = 'myRef' 179 | const commitHash = 'abcd' 180 | const originalSchema = `directive @Entry on OBJECT 181 | 182 | type MyEntry @Entry { 183 | id: ID! 184 | union: [MyUnion!] 185 | } 186 | 187 | union MyUnion = 188 | | TypeA 189 | | TypeB 190 | 191 | type TypeA { 192 | field1: String 193 | } 194 | 195 | type TypeB { 196 | field2: String 197 | }` 198 | 199 | const entryId = 'A' 200 | 201 | const entries = [ 202 | { 203 | id: entryId, 204 | metadata: { 205 | type: 'MyEntry', 206 | }, 207 | data: { 208 | union: null, 209 | }, 210 | } as Entry, 211 | ] 212 | 213 | gitAdapter.getLatestCommitHash 214 | .calledWith(gitRef) 215 | .mockResolvedValue(commitHash) 216 | gitAdapter.getSchema 217 | .calledWith(commitHash) 218 | .mockResolvedValue(originalSchema) 219 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 220 | 221 | const apiService = await getApiService() 222 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 223 | query: `query { 224 | data: MyEntry(id:"${entryId}") { 225 | id 226 | union { 227 | __typename 228 | } 229 | } 230 | }`, 231 | }) 232 | 233 | expect(result.errors).toBeUndefined() 234 | expect(result.data).toEqual({ 235 | data: { 236 | id: entryId, 237 | union: null, 238 | }, 239 | }) 240 | expect(result.ref).toBe(commitHash) 241 | }) 242 | 243 | it('should resolve an empty array of non-@Entry-based unions', async () => { 244 | const gitAdapter = mock() 245 | const gitRef = 'myRef' 246 | const commitHash = 'abcd' 247 | const originalSchema = `directive @Entry on OBJECT 248 | 249 | type MyEntry @Entry { 250 | id: ID! 251 | union: [MyUnion!] 252 | } 253 | 254 | union MyUnion = 255 | | TypeA 256 | | TypeB 257 | 258 | type TypeA { 259 | field1: String 260 | } 261 | 262 | type TypeB { 263 | field2: String 264 | }` 265 | 266 | const entryId = 'A' 267 | 268 | const entries = [ 269 | { 270 | id: entryId, 271 | metadata: { 272 | type: 'MyEntry', 273 | }, 274 | data: { 275 | union: [], 276 | }, 277 | } as Entry, 278 | ] 279 | 280 | gitAdapter.getLatestCommitHash 281 | .calledWith(gitRef) 282 | .mockResolvedValue(commitHash) 283 | gitAdapter.getSchema 284 | .calledWith(commitHash) 285 | .mockResolvedValue(originalSchema) 286 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 287 | 288 | const apiService = await getApiService() 289 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 290 | query: `query { 291 | data: MyEntry(id:"${entryId}") { 292 | id 293 | union { 294 | __typename 295 | } 296 | } 297 | }`, 298 | }) 299 | 300 | expect(result.errors).toBeUndefined() 301 | expect(result.data).toEqual({ 302 | data: { 303 | id: entryId, 304 | union: [], 305 | }, 306 | }) 307 | expect(result.ref).toBe(commitHash) 308 | }) 309 | 310 | it('should resolve @Entry-based unions', async () => { 311 | const gitAdapter = mock() 312 | const gitRef = 'myRef' 313 | const commitHash = 'abcd' 314 | const originalSchema = `directive @Entry on OBJECT 315 | 316 | type MyEntry @Entry { 317 | id: ID! 318 | union: MyUnion 319 | nonNullUnion: MyUnion! 320 | listUnion: [MyUnion] 321 | listNonNullUnion: [MyUnion!] 322 | nonNullListUnion: [MyUnion]! 323 | nonNullListNonNullUnion: [MyUnion!]! 324 | } 325 | 326 | union MyUnion = 327 | | EntryA 328 | | EntryB 329 | 330 | type EntryA @Entry { 331 | id: ID! 332 | field1: String 333 | } 334 | 335 | type EntryB @Entry { 336 | id: ID! 337 | field2: String 338 | }` 339 | 340 | const myEntryId = 'My' 341 | const entryBId = 'B' 342 | const field2Value = 'Field2 value' 343 | 344 | const entries = [ 345 | { 346 | id: myEntryId, 347 | metadata: { 348 | type: 'MyEntry', 349 | }, 350 | data: { 351 | union: { 352 | id: entryBId, 353 | }, 354 | nonNullUnion: { 355 | id: entryBId, 356 | }, 357 | listUnion: [ 358 | { 359 | id: entryBId, 360 | }, 361 | ], 362 | listNonNullUnion: [ 363 | { 364 | id: entryBId, 365 | }, 366 | ], 367 | nonNullListUnion: [ 368 | { 369 | id: entryBId, 370 | }, 371 | ], 372 | nonNullListNonNullUnion: [ 373 | { 374 | id: entryBId, 375 | }, 376 | ], 377 | }, 378 | } as Entry, 379 | { 380 | id: entryBId, 381 | metadata: { 382 | type: 'EntryB', 383 | }, 384 | data: { 385 | field2: field2Value, 386 | }, 387 | } as Entry, 388 | ] 389 | 390 | gitAdapter.getLatestCommitHash 391 | .calledWith(gitRef) 392 | .mockResolvedValue(commitHash) 393 | gitAdapter.getSchema 394 | .calledWith(commitHash) 395 | .mockResolvedValue(originalSchema) 396 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 397 | 398 | const apiService = await getApiService() 399 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 400 | query: `query { 401 | data: MyEntry(id:"${myEntryId}") { 402 | id 403 | union { 404 | __typename 405 | ... on EntryB { 406 | field2 407 | } 408 | } 409 | union { 410 | __typename 411 | ... on EntryB { 412 | field2 413 | } 414 | } 415 | nonNullUnion { 416 | __typename 417 | ... on EntryB { 418 | field2 419 | } 420 | } 421 | listUnion { 422 | __typename 423 | ... on EntryB { 424 | field2 425 | } 426 | } 427 | listNonNullUnion { 428 | __typename 429 | ... on EntryB { 430 | field2 431 | } 432 | } 433 | nonNullListUnion { 434 | __typename 435 | ... on EntryB { 436 | field2 437 | } 438 | } 439 | nonNullListNonNullUnion { 440 | __typename 441 | ... on EntryB { 442 | field2 443 | } 444 | } 445 | } 446 | }`, 447 | }) 448 | 449 | expect(result.errors).toBeUndefined() 450 | expect(result.data).toEqual({ 451 | data: { 452 | id: myEntryId, 453 | union: { 454 | __typename: 'EntryB', 455 | field2: field2Value, 456 | }, 457 | nonNullUnion: { 458 | __typename: 'EntryB', 459 | field2: field2Value, 460 | }, 461 | listUnion: [ 462 | { 463 | __typename: 'EntryB', 464 | field2: field2Value, 465 | }, 466 | ], 467 | listNonNullUnion: [ 468 | { 469 | __typename: 'EntryB', 470 | field2: field2Value, 471 | }, 472 | ], 473 | nonNullListUnion: [ 474 | { 475 | __typename: 'EntryB', 476 | field2: field2Value, 477 | }, 478 | ], 479 | nonNullListNonNullUnion: [ 480 | { 481 | __typename: 'EntryB', 482 | field2: field2Value, 483 | }, 484 | ], 485 | }, 486 | }) 487 | expect(result.ref).toBe(commitHash) 488 | }) 489 | 490 | it('should resolve missing optional data to null', async () => { 491 | // the behavior asserted here is meant to reduce the need to migrate existing entries after adding 492 | // new object type fields to a schema; we expect this to be done by resolving missing (undefined) 493 | // nullable data to null 494 | const gitAdapter = mock() 495 | const gitRef = 'myRef' 496 | const commitHash = 'abcd' 497 | const originalSchema = `directive @Entry on OBJECT 498 | 499 | type MyEntry @Entry { 500 | id: ID! 501 | oldField: String 502 | newField: String 503 | newNestedTypeField: NestedType 504 | } 505 | 506 | type NestedType { 507 | myField: String 508 | }` 509 | 510 | const entryId = 'A' 511 | 512 | const entries = [ 513 | { 514 | id: entryId, 515 | metadata: { 516 | type: 'MyEntry', 517 | }, 518 | data: { 519 | oldField: 'Old value', 520 | // we pretend that this entry was committed when only `id` and `oldField` existed in the schema 521 | }, 522 | } as Entry, 523 | ] 524 | 525 | gitAdapter.getLatestCommitHash 526 | .calledWith(gitRef) 527 | .mockResolvedValue(commitHash) 528 | gitAdapter.getSchema 529 | .calledWith(commitHash) 530 | .mockResolvedValue(originalSchema) 531 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 532 | 533 | const apiService = await getApiService() 534 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 535 | query: `query { 536 | data: MyEntry(id:"${entryId}") { 537 | id 538 | oldField 539 | newField 540 | newNestedTypeField { 541 | myField 542 | } 543 | } 544 | }`, 545 | }) 546 | 547 | expect(result.errors).toBeUndefined() 548 | expect(result.data).toEqual({ 549 | data: { 550 | id: entryId, 551 | oldField: 'Old value', 552 | newField: null, 553 | newNestedTypeField: null, 554 | }, 555 | }) 556 | expect(result.ref).toBe(commitHash) 557 | }) 558 | 559 | it('should resolve missing array data to a default value', async () => { 560 | // the behavior asserted here is meant to reduce the need to migrate existing entries after adding 561 | // new array fields to a schema; we expect this to be done by resolving missing (undefined) 562 | // array data to an empty array 563 | const gitAdapter = mock() 564 | const gitRef = 'myRef' 565 | const commitHash = 'abcd' 566 | const originalSchema = `directive @Entry on OBJECT 567 | 568 | type MyEntry @Entry { 569 | id: ID! 570 | oldField: String 571 | newNestedTypeArrayField: [NestedType] 572 | newNestedTypeNonNullArrayField: [NestedType]! 573 | } 574 | 575 | type NestedType { 576 | myField: String 577 | }` 578 | 579 | const entryId = 'A' 580 | 581 | const entries = [ 582 | { 583 | id: entryId, 584 | metadata: { 585 | type: 'MyEntry', 586 | }, 587 | data: { 588 | oldField: 'Old value', 589 | // we pretend that this entry was committed when only `id` and `oldField` existed in the schema 590 | }, 591 | } as Entry, 592 | ] 593 | 594 | gitAdapter.getLatestCommitHash 595 | .calledWith(gitRef) 596 | .mockResolvedValue(commitHash) 597 | gitAdapter.getSchema 598 | .calledWith(commitHash) 599 | .mockResolvedValue(originalSchema) 600 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 601 | 602 | const apiService = await getApiService() 603 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 604 | query: `query { 605 | data: MyEntry(id:"${entryId}") { 606 | id 607 | oldField 608 | newNestedTypeArrayField { 609 | myField 610 | } 611 | newNestedTypeNonNullArrayField { 612 | myField 613 | } 614 | } 615 | }`, 616 | }) 617 | 618 | expect(result.errors).toBeUndefined() 619 | expect(result.data).toEqual({ 620 | data: { 621 | id: entryId, 622 | oldField: 'Old value', 623 | newNestedTypeArrayField: null, 624 | newNestedTypeNonNullArrayField: [], 625 | }, 626 | }) 627 | expect(result.ref).toBe(commitHash) 628 | }) 629 | 630 | it('should resolve missing non-null object type data to an error', async () => { 631 | const gitAdapter = mock() 632 | const gitRef = 'myRef' 633 | const commitHash = 'abcd' 634 | const originalSchema = `directive @Entry on OBJECT 635 | 636 | type MyEntry @Entry { 637 | id: ID! 638 | oldField: String 639 | newNestedTypeField: NestedType! 640 | } 641 | 642 | type NestedType { 643 | myField: String 644 | }` 645 | 646 | const entryId = 'A' 647 | 648 | const entries = [ 649 | { 650 | id: entryId, 651 | metadata: { 652 | type: 'MyEntry', 653 | }, 654 | data: { 655 | oldField: 'Old value', 656 | // we pretend that this entry was committed when only `id` and `oldField` existed in the schema 657 | }, 658 | } as Entry, 659 | ] 660 | 661 | gitAdapter.getLatestCommitHash 662 | .calledWith(gitRef) 663 | .mockResolvedValue(commitHash) 664 | gitAdapter.getSchema 665 | .calledWith(commitHash) 666 | .mockResolvedValue(originalSchema) 667 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 668 | 669 | const apiService = await getApiService() 670 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 671 | query: `query { 672 | data: MyEntry(id:"${entryId}") { 673 | id 674 | oldField 675 | newNestedTypeField { 676 | myField 677 | } 678 | } 679 | }`, 680 | }) 681 | 682 | expect(result.errors).toHaveLength(1) 683 | expect(result.data).toEqual({ data: null }) 684 | expect(result.ref).toBe(commitHash) 685 | }) 686 | 687 | it('should resolve missing optional enum data to null', async () => { 688 | const gitAdapter = mock() 689 | const gitRef = 'myRef' 690 | const commitHash = 'abcd' 691 | const originalSchema = `directive @Entry on OBJECT 692 | 693 | type MyEntry @Entry { 694 | id: ID! 695 | oldField: String 696 | newEnumField: EnumType 697 | } 698 | 699 | enum EnumType { 700 | A 701 | B 702 | }` 703 | 704 | const entryId = 'A' 705 | 706 | const entries = [ 707 | { 708 | id: entryId, 709 | metadata: { 710 | type: 'MyEntry', 711 | }, 712 | data: { 713 | oldField: 'Old value', 714 | // we pretend that this entry was committed when only `id` and `oldField` existed in the schema 715 | }, 716 | } as Entry, 717 | ] 718 | 719 | gitAdapter.getLatestCommitHash 720 | .calledWith(gitRef) 721 | .mockResolvedValue(commitHash) 722 | gitAdapter.getSchema 723 | .calledWith(commitHash) 724 | .mockResolvedValue(originalSchema) 725 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 726 | 727 | const apiService = await getApiService() 728 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 729 | query: `query { 730 | data: MyEntry(id:"${entryId}") { 731 | id 732 | oldField 733 | newEnumField 734 | } 735 | }`, 736 | }) 737 | 738 | expect(result.errors).toBeUndefined() 739 | expect(result.data).toEqual({ 740 | data: { 741 | id: entryId, 742 | oldField: 'Old value', 743 | newEnumField: null, 744 | }, 745 | }) 746 | }) 747 | 748 | it('should resolve missing non-null enum data to an error', async () => { 749 | const gitAdapter = mock() 750 | const gitRef = 'myRef' 751 | const commitHash = 'abcd' 752 | const originalSchema = `directive @Entry on OBJECT 753 | 754 | type MyEntry @Entry { 755 | id: ID! 756 | oldField: String 757 | newEnumField: EnumType! 758 | } 759 | 760 | enum EnumType { 761 | A 762 | B 763 | }` 764 | 765 | const entryId = 'A' 766 | 767 | const entries = [ 768 | { 769 | id: entryId, 770 | metadata: { 771 | type: 'MyEntry', 772 | }, 773 | data: { 774 | oldField: 'Old value', 775 | // we pretend that this entry was committed when only `id` and `oldField` existed in the schema 776 | }, 777 | } as Entry, 778 | ] 779 | 780 | gitAdapter.getLatestCommitHash 781 | .calledWith(gitRef) 782 | .mockResolvedValue(commitHash) 783 | gitAdapter.getSchema 784 | .calledWith(commitHash) 785 | .mockResolvedValue(originalSchema) 786 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 787 | 788 | const apiService = await getApiService() 789 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 790 | query: `query { 791 | data: MyEntry(id:"${entryId}") { 792 | id 793 | oldField 794 | newEnumField 795 | } 796 | }`, 797 | }) 798 | 799 | expect(result.errors).toHaveLength(1) 800 | expect(result.data).toEqual({ data: null }) 801 | expect(result.ref).toBe(commitHash) 802 | }) 803 | 804 | it('should resolve missing non-null union data to an error', async () => { 805 | const gitAdapter = mock() 806 | const gitRef = 'myRef' 807 | const commitHash = 'abcd' 808 | const originalSchema = `directive @Entry on OBJECT 809 | 810 | type MyEntry @Entry { 811 | id: ID! 812 | oldField: String 813 | newUnionField: MyUnion! 814 | } 815 | 816 | union MyUnion = 817 | | TypeA 818 | | TypeB 819 | 820 | type TypeA { 821 | field1: String 822 | } 823 | 824 | type TypeB { 825 | field2: String 826 | }` 827 | 828 | const entryId = 'A' 829 | 830 | const entries = [ 831 | { 832 | id: entryId, 833 | metadata: { 834 | type: 'MyEntry', 835 | }, 836 | data: { 837 | oldField: 'Old value', 838 | // we pretend that this entry was committed when only `id` and `oldField` existed in the schema 839 | }, 840 | } as Entry, 841 | ] 842 | 843 | gitAdapter.getLatestCommitHash 844 | .calledWith(gitRef) 845 | .mockResolvedValue(commitHash) 846 | gitAdapter.getSchema 847 | .calledWith(commitHash) 848 | .mockResolvedValue(originalSchema) 849 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 850 | 851 | const apiService = await getApiService() 852 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 853 | query: `query { 854 | data: MyEntry(id:"${entryId}") { 855 | id 856 | oldField 857 | newUnionField { 858 | __typename 859 | } 860 | } 861 | }`, 862 | }) 863 | 864 | expect(result.errors).toHaveLength(1) 865 | expect(result.data).toEqual({ data: null }) 866 | expect(result.ref).toBe(commitHash) 867 | }) 868 | 869 | it('should return entries that only have an `id` field', async () => { 870 | const gitAdapter = mock() 871 | const gitRef = 'myRef' 872 | const commitHash = 'abcd' 873 | const originalSchema = `directive @Entry on OBJECT 874 | 875 | type MyEntry @Entry { 876 | id: ID! 877 | }` 878 | 879 | const entryId = 'A' 880 | 881 | const entries = [ 882 | { 883 | id: entryId, 884 | metadata: { 885 | type: 'MyEntry', 886 | }, 887 | } as Entry, 888 | ] 889 | 890 | gitAdapter.getLatestCommitHash 891 | .calledWith(gitRef) 892 | .mockResolvedValue(commitHash) 893 | gitAdapter.getSchema 894 | .calledWith(commitHash) 895 | .mockResolvedValue(originalSchema) 896 | gitAdapter.getEntries.calledWith(commitHash).mockResolvedValue(entries) 897 | 898 | const apiService = await getApiService() 899 | const result = await apiService.postGraphQL(gitAdapter, gitRef, { 900 | query: `query { 901 | data: MyEntry(id:"${entryId}") { 902 | id 903 | } 904 | }`, 905 | }) 906 | 907 | expect(result.errors).toBeUndefined() 908 | expect(result.data).toEqual({ 909 | data: { 910 | id: entryId, 911 | }, 912 | }) 913 | expect(result.ref).toBe(commitHash) 914 | }) 915 | }) 916 | -------------------------------------------------------------------------------- /tests/integration/schema/schema-generator.test.ts: -------------------------------------------------------------------------------- 1 | import { GitAdapter } from '@commitspark/git-adapter' 2 | import { mock } from 'jest-mock-extended' 3 | import { getApiService } from '../../../src' 4 | 5 | describe('Schema generator', () => { 6 | it('should extend schema with a CRUD API for an @Entry type', async () => { 7 | const gitAdapter = mock() 8 | const gitRef = 'myRef' 9 | const commitHash = 'abcd' 10 | const originalSchema = `directive @Entry on OBJECT 11 | directive @Ui(visibleList:Boolean) on FIELD_DEFINITION 12 | 13 | type MyEntry @Entry { 14 | id: ID! 15 | name: String @Ui(visibleList:true) 16 | nestedType: NestedType 17 | } 18 | 19 | type NestedType { 20 | nestedField: String 21 | }` 22 | 23 | gitAdapter.getLatestCommitHash 24 | .calledWith(gitRef) 25 | .mockResolvedValue(commitHash) 26 | gitAdapter.getSchema 27 | .calledWith(commitHash) 28 | .mockResolvedValue(originalSchema) 29 | 30 | const apiService = await getApiService() 31 | const result = await apiService.getSchema(gitAdapter, gitRef) 32 | 33 | const expectedSchema = `directive @Entry on OBJECT 34 | 35 | directive @Ui(visibleList: Boolean) on FIELD_DEFINITION 36 | 37 | type MyEntry @Entry { 38 | id: ID! 39 | name: String @Ui(visibleList: true) 40 | nestedType: NestedType 41 | } 42 | 43 | type NestedType { 44 | nestedField: String 45 | } 46 | 47 | schema { 48 | query: Query 49 | mutation: Mutation 50 | } 51 | 52 | type Query { 53 | allMyEntrys: [MyEntry!] 54 | _allMyEntrysMeta: ListMetadata 55 | MyEntry(id: ID!): MyEntry 56 | _typeName(id: ID!): String! 57 | } 58 | 59 | type Mutation { 60 | createMyEntry(id: ID!, data: MyEntryInput!, commitMessage: String): MyEntry 61 | updateMyEntry(id: ID!, data: MyEntryInput!, commitMessage: String): MyEntry 62 | deleteMyEntry(id: ID!, commitMessage: String): ID 63 | } 64 | 65 | type ListMetadata { 66 | count: Int! 67 | } 68 | 69 | input MyEntryIdInput { 70 | id: ID! 71 | } 72 | 73 | input MyEntryInput { 74 | name: String 75 | nestedType: NestedTypeInput 76 | } 77 | 78 | input NestedTypeInput { 79 | nestedField: String 80 | } 81 | ` 82 | 83 | expect(result.data).toBe(expectedSchema) 84 | expect(result.ref).toBe(commitHash) 85 | }) 86 | 87 | it('should extend schema with a CRUD API for two @Entry types with reference', async () => { 88 | const gitAdapter = mock() 89 | const gitRef = 'myRef' 90 | const commitHash = 'abcd' 91 | const originalSchema = `directive @Entry on OBJECT 92 | 93 | type EntryA @Entry { 94 | id: ID! 95 | name: String 96 | } 97 | 98 | type EntryB @Entry { 99 | id: ID! 100 | entryA: EntryA 101 | }` 102 | 103 | gitAdapter.getLatestCommitHash 104 | .calledWith(gitRef) 105 | .mockResolvedValue(commitHash) 106 | gitAdapter.getSchema 107 | .calledWith(commitHash) 108 | .mockResolvedValue(originalSchema) 109 | 110 | const apiService = await getApiService() 111 | const result = await apiService.getSchema(gitAdapter, gitRef) 112 | 113 | const expectedSchema = `directive @Entry on OBJECT 114 | 115 | type EntryA @Entry { 116 | id: ID! 117 | name: String 118 | } 119 | 120 | type EntryB @Entry { 121 | id: ID! 122 | entryA: EntryA 123 | } 124 | 125 | schema { 126 | query: Query 127 | mutation: Mutation 128 | } 129 | 130 | type Query { 131 | allEntryAs: [EntryA!] 132 | allEntryBs: [EntryB!] 133 | _allEntryAsMeta: ListMetadata 134 | _allEntryBsMeta: ListMetadata 135 | EntryA(id: ID!): EntryA 136 | EntryB(id: ID!): EntryB 137 | _typeName(id: ID!): String! 138 | } 139 | 140 | type Mutation { 141 | createEntryA(id: ID!, data: EntryAInput!, commitMessage: String): EntryA 142 | createEntryB(id: ID!, data: EntryBInput!, commitMessage: String): EntryB 143 | updateEntryA(id: ID!, data: EntryAInput!, commitMessage: String): EntryA 144 | updateEntryB(id: ID!, data: EntryBInput!, commitMessage: String): EntryB 145 | deleteEntryA(id: ID!, commitMessage: String): ID 146 | deleteEntryB(id: ID!, commitMessage: String): ID 147 | } 148 | 149 | type ListMetadata { 150 | count: Int! 151 | } 152 | 153 | input EntryAIdInput { 154 | id: ID! 155 | } 156 | 157 | input EntryBIdInput { 158 | id: ID! 159 | } 160 | 161 | input EntryAInput { 162 | name: String 163 | } 164 | 165 | input EntryBInput { 166 | entryA: EntryAIdInput 167 | } 168 | ` 169 | 170 | expect(result.data).toBe(expectedSchema) 171 | expect(result.ref).toBe(commitHash) 172 | }) 173 | 174 | it('should extend schema with a CRUD API for an @Entry-based union type', async () => { 175 | const gitAdapter = mock() 176 | const gitRef = 'myRef' 177 | const commitHash = 'abcd' 178 | const originalSchema = `directive @Entry on OBJECT 179 | 180 | type MyEntry @Entry { 181 | id: ID! 182 | union: MyUnion 183 | } 184 | 185 | union MyUnion = 186 | | EntryA 187 | | EntryB 188 | 189 | type EntryA @Entry { 190 | id: ID! 191 | field1: String 192 | } 193 | 194 | type EntryB @Entry { 195 | id: ID! 196 | field2: String 197 | }` 198 | 199 | gitAdapter.getLatestCommitHash 200 | .calledWith(gitRef) 201 | .mockResolvedValue(commitHash) 202 | gitAdapter.getSchema 203 | .calledWith(commitHash) 204 | .mockResolvedValue(originalSchema) 205 | 206 | const apiService = await getApiService() 207 | const result = await apiService.getSchema(gitAdapter, gitRef) 208 | 209 | const expectedSchema = `directive @Entry on OBJECT 210 | 211 | type MyEntry @Entry { 212 | id: ID! 213 | union: MyUnion 214 | } 215 | 216 | union MyUnion = EntryA | EntryB 217 | 218 | type EntryA @Entry { 219 | id: ID! 220 | field1: String 221 | } 222 | 223 | type EntryB @Entry { 224 | id: ID! 225 | field2: String 226 | } 227 | 228 | schema { 229 | query: Query 230 | mutation: Mutation 231 | } 232 | 233 | type Query { 234 | allMyEntrys: [MyEntry!] 235 | allEntryAs: [EntryA!] 236 | allEntryBs: [EntryB!] 237 | _allMyEntrysMeta: ListMetadata 238 | _allEntryAsMeta: ListMetadata 239 | _allEntryBsMeta: ListMetadata 240 | MyEntry(id: ID!): MyEntry 241 | EntryA(id: ID!): EntryA 242 | EntryB(id: ID!): EntryB 243 | _typeName(id: ID!): String! 244 | } 245 | 246 | type Mutation { 247 | createMyEntry(id: ID!, data: MyEntryInput!, commitMessage: String): MyEntry 248 | createEntryA(id: ID!, data: EntryAInput!, commitMessage: String): EntryA 249 | createEntryB(id: ID!, data: EntryBInput!, commitMessage: String): EntryB 250 | updateMyEntry(id: ID!, data: MyEntryInput!, commitMessage: String): MyEntry 251 | updateEntryA(id: ID!, data: EntryAInput!, commitMessage: String): EntryA 252 | updateEntryB(id: ID!, data: EntryBInput!, commitMessage: String): EntryB 253 | deleteMyEntry(id: ID!, commitMessage: String): ID 254 | deleteEntryA(id: ID!, commitMessage: String): ID 255 | deleteEntryB(id: ID!, commitMessage: String): ID 256 | } 257 | 258 | type ListMetadata { 259 | count: Int! 260 | } 261 | 262 | input MyEntryIdInput { 263 | id: ID! 264 | } 265 | 266 | input EntryAIdInput { 267 | id: ID! 268 | } 269 | 270 | input EntryBIdInput { 271 | id: ID! 272 | } 273 | 274 | input MyUnionIdInput { 275 | id: ID! 276 | } 277 | 278 | input MyEntryInput { 279 | union: MyUnionIdInput 280 | } 281 | 282 | input EntryAInput { 283 | field1: String 284 | } 285 | 286 | input EntryBInput { 287 | field2: String 288 | } 289 | ` 290 | 291 | expect(result.data).toBe(expectedSchema) 292 | expect(result.ref).toBe(commitHash) 293 | }) 294 | 295 | it('should create a CRUD API for an inline union type', async () => { 296 | const gitAdapter = mock() 297 | const gitRef = 'myRef' 298 | const commitHash = 'abcd' 299 | const originalSchema = `directive @Entry on OBJECT 300 | 301 | type MyEntry @Entry { 302 | id: ID! 303 | union: MyUnion 304 | } 305 | 306 | union MyUnion = 307 | | TypeA 308 | | TypeB 309 | 310 | type TypeA { 311 | field1: String 312 | } 313 | 314 | type TypeB { 315 | field2: String 316 | }` 317 | 318 | gitAdapter.getLatestCommitHash 319 | .calledWith(gitRef) 320 | .mockResolvedValue(commitHash) 321 | gitAdapter.getSchema 322 | .calledWith(commitHash) 323 | .mockResolvedValue(originalSchema) 324 | 325 | const apiService = await getApiService() 326 | const result = await apiService.getSchema(gitAdapter, gitRef) 327 | 328 | const expectedSchema = `directive @Entry on OBJECT 329 | 330 | type MyEntry @Entry { 331 | id: ID! 332 | union: MyUnion 333 | } 334 | 335 | union MyUnion = TypeA | TypeB 336 | 337 | type TypeA { 338 | field1: String 339 | } 340 | 341 | type TypeB { 342 | field2: String 343 | } 344 | 345 | schema { 346 | query: Query 347 | mutation: Mutation 348 | } 349 | 350 | type Query { 351 | allMyEntrys: [MyEntry!] 352 | _allMyEntrysMeta: ListMetadata 353 | MyEntry(id: ID!): MyEntry 354 | _typeName(id: ID!): String! 355 | } 356 | 357 | type Mutation { 358 | createMyEntry(id: ID!, data: MyEntryInput!, commitMessage: String): MyEntry 359 | updateMyEntry(id: ID!, data: MyEntryInput!, commitMessage: String): MyEntry 360 | deleteMyEntry(id: ID!, commitMessage: String): ID 361 | } 362 | 363 | type ListMetadata { 364 | count: Int! 365 | } 366 | 367 | input MyEntryIdInput { 368 | id: ID! 369 | } 370 | 371 | input MyUnionIdInput { 372 | id: ID! 373 | } 374 | 375 | input MyEntryInput { 376 | union: MyUnionInput 377 | } 378 | 379 | input TypeAInput { 380 | field1: String 381 | } 382 | 383 | input TypeBInput { 384 | field2: String 385 | } 386 | 387 | input MyUnionInput @oneOf { 388 | TypeA: TypeAInput 389 | TypeB: TypeBInput 390 | } 391 | 392 | directive @oneOf on INPUT_OBJECT 393 | ` 394 | 395 | expect(result.data).toBe(expectedSchema) 396 | expect(result.ref).toBe(commitHash) 397 | }) 398 | }) 399 | -------------------------------------------------------------------------------- /tsconfig.build.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.build.json", 4 | "compilerOptions": { 5 | "module": "commonjs", 6 | "target": "es6", 7 | "outDir": "./dist/cjs", 8 | "declarationDir": "./dist/types", 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.build.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.build.json", 4 | "compilerOptions": { 5 | "outDir": "./dist/esm", 6 | "declarationDir": "./dist/types" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.json", 4 | "exclude": [ 5 | "node_modules", 6 | "dist", 7 | "tests" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.build.json", 4 | "compilerOptions": { 5 | "declaration": true, 6 | "declarationMap": true, 7 | "emitDeclarationOnly": true, 8 | "outDir": "./dist/types" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "module": "ES2015", 5 | "target": "ES2022", 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "resolveJsonModule": true, 9 | 10 | "composite": true, 11 | "strict": true, 12 | "incremental": true, 13 | "declaration": true, 14 | "sourceMap": true, 15 | 16 | "rootDir": "./src", 17 | "outDir": "./dist" 18 | }, 19 | "include": [ 20 | "./src/**/*.ts", 21 | "./tests/**/*.ts" 22 | ] 23 | } 24 | --------------------------------------------------------------------------------