Repository: maoosi/prisma-appsync Branch: main Commit: edc151046c56 Files: 136 Total size: 1004.8 KB Directory structure: gitextract_frruakbg/ ├── .all-contributorsrc ├── .eslintrc ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ └── unit-tests.yml ├── .gitignore ├── .markdownlint.json ├── .npmignore ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── bin/ │ ├── build.mjs │ ├── cleans.mjs │ ├── dev.mjs │ ├── env.mjs │ ├── postinstall.mjs │ ├── publish/ │ │ ├── _pkg.core.cleanse.js │ │ ├── _pkg.core.restore.js │ │ └── _pkg.installer.cleanse.js │ ├── publish.mjs │ └── test.mjs ├── docs/ │ ├── .vitepress/ │ │ ├── config.ts │ │ └── theme/ │ │ ├── Layout.vue │ │ ├── index.ts │ │ └── styles/ │ │ └── vars.css │ ├── changelog/ │ │ ├── 1.0.0-rc.1.md │ │ ├── 1.0.0-rc.2.md │ │ ├── 1.0.0-rc.3.md │ │ ├── 1.0.0-rc.4.md │ │ ├── 1.0.0-rc.5.md │ │ ├── 1.0.0-rc.6.md │ │ ├── 1.0.0-rc.7.md │ │ ├── 1.0.0.md │ │ └── index.md │ ├── contributing.md │ ├── features/ │ │ ├── gql-schema.md │ │ ├── hooks.md │ │ └── resolvers.md │ ├── index.md │ ├── quick-start/ │ │ ├── deploy.md │ │ ├── getting-started.md │ │ ├── installation.md │ │ └── usage.md │ ├── security/ │ │ ├── appsync-authz.md │ │ ├── query-depth.md │ │ ├── rate-limiter.md │ │ ├── shield-acl.md │ │ └── xss-sanitizer.md │ ├── support.md │ └── tools/ │ └── appsync-gql-schema-diff.md ├── package.json ├── packages/ │ ├── boilerplate/ │ │ ├── cdk/ │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── appsync.ts │ │ │ │ └── index.ts │ │ │ └── tsconfig.json │ │ ├── cdk.json │ │ ├── handler.ts │ │ ├── prisma/ │ │ │ └── sqlite.prisma │ │ ├── server/ │ │ │ └── server.ts │ │ └── tsconfig.json │ ├── client/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── adapter.ts │ │ │ ├── consts.ts │ │ │ ├── core.ts │ │ │ ├── guard.ts │ │ │ ├── index.ts │ │ │ ├── inspector.ts │ │ │ ├── resolver.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ └── tsconfig.json │ ├── generator/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── client.ts │ │ │ ├── directives.ts │ │ │ ├── generator.ts │ │ │ ├── handler.ts │ │ │ ├── index.ts │ │ │ ├── resolvers.ts │ │ │ └── schema.ts │ │ └── tsconfig.json │ ├── installer/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── installer.ts │ │ └── tsconfig.json │ └── server/ │ ├── package.json │ ├── src/ │ │ ├── appsync-simulator.ts │ │ ├── index.d.ts │ │ ├── index.ts │ │ ├── lambdaRequest.vtl │ │ └── lambdaResponse.vtl │ └── tsconfig.json ├── pnpm-workspace.yaml ├── tests/ │ ├── client/ │ │ ├── adapter.test.ts │ │ ├── core.test.ts │ │ ├── guard.test.ts │ │ ├── mocks/ │ │ │ ├── graphql-json.ts │ │ │ ├── lambda-event.ts │ │ │ └── lambda-identity.ts │ │ ├── resolver.test.ts │ │ ├── utils/ │ │ │ └── index.ts │ │ └── utils.test.ts │ └── generator/ │ ├── @gql.test.ts │ ├── crud.test.ts │ ├── mock/ │ │ ├── appsync-directives.gql │ │ └── appsync-scalars.gql │ └── schemas/ │ ├── @gql.prisma │ ├── crud.gql │ ├── crud.prisma │ └── generated/ │ ├── @gql/ │ │ ├── client/ │ │ │ ├── adapter.d.ts │ │ │ ├── consts.d.ts │ │ │ ├── core.d.ts │ │ │ ├── guard.d.ts │ │ │ ├── index.d.ts │ │ │ ├── index.js │ │ │ ├── inspector.d.ts │ │ │ ├── resolver.d.ts │ │ │ ├── types.d.ts │ │ │ └── utils.d.ts │ │ ├── resolvers.yaml │ │ └── schema.gql │ └── crud/ │ ├── client/ │ │ ├── adapter.d.ts │ │ ├── consts.d.ts │ │ ├── core.d.ts │ │ ├── guard.d.ts │ │ ├── index.d.ts │ │ ├── index.js │ │ ├── inspector.d.ts │ │ ├── resolver.d.ts │ │ ├── types.d.ts │ │ └── utils.d.ts │ ├── resolvers.yaml │ └── schema.gql ├── tsconfig.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .all-contributorsrc ================================================ { "projectName": "prisma-appsync", "projectOwner": "maoosi", "repoType": "github", "repoHost": "https://github.com", "files": [ "README.md" ], "imageSize": 100, "commit": true, "types": { "creator": { "symbol": "🐙", "description": "Creator & maintainer" } }, "contributors": [ { "login": "maoosi", "name": "Sylvain", "avatar_url": "https://avatars.githubusercontent.com/u/4679377?v=4", "profile": "https://sylvainsimao.fr", "contributions": [ "creator", "code", "ideas", "doc" ] }, { "login": "Tenrys", "name": "Bell", "avatar_url": "https://avatars.githubusercontent.com/u/3979239?v=4", "profile": "http://bell.moe", "contributions": [ "code", "ideas" ] }, { "login": "cipriancaba", "name": "Ciprian Caba", "avatar_url": "https://avatars.githubusercontent.com/u/695515?v=4", "profile": "http://www.cipriancaba.com", "contributions": [ "code", "ideas" ] }, { "login": "tomschut", "name": "Tom", "avatar_url": "https://avatars.githubusercontent.com/u/4933446?v=4", "profile": "https://github.com/tomschut", "contributions": [ "code", "ideas" ] }, { "login": "ryparker", "name": "Ryan Parker", "avatar_url": "https://avatars.githubusercontent.com/u/17558268?v=4", "profile": "http://ryanparker.dev", "contributions": [ "code" ] }, { "login": "cjjenkinson", "name": "Cameron Jenkinson", "avatar_url": "https://avatars.githubusercontent.com/u/5429478?v=4", "profile": "https://www.cameronjjenkinson.com", "contributions": [ "code" ] }, { "login": "jeremy-white", "name": "jeremy-white", "avatar_url": "https://avatars.githubusercontent.com/u/42325631?v=4", "profile": "https://github.com/jeremy-white", "contributions": [ "code" ] }, { "login": "max-konin", "name": "Max Konin", "avatar_url": "https://avatars.githubusercontent.com/u/1570356?v=4", "profile": "https://github.com/max-konin", "contributions": [ "code" ] }, { "login": "michachan", "name": "Michael Chan", "avatar_url": "https://avatars.githubusercontent.com/u/27760344?v=4", "profile": "https://github.com/michachan", "contributions": [ "code" ] }, { "login": "nhu-mai-101", "name": "Nhu Mai", "avatar_url": "https://avatars.githubusercontent.com/u/84061316?v=4", "profile": "https://www.linkedin.com/in/nhu-mai/", "contributions": [ "code" ] } ], "commitConvention": "angular", "contributorsPerLine": 7, "commitType": "docs" } ================================================ FILE: .eslintrc ================================================ { "root": true, "extends": "@antfu", "rules": { "jsonc/indent": ["error", 4, {}], "@typescript-eslint/indent": [ "error", 4, { "offsetTernaryExpressions": true, "ignoredNodes": ["TemplateLiteral *", "TSTypeParameterInstantiation"], "SwitchCase": 1 } ], "@typescript-eslint/consistent-type-definitions": ["error", "type"] }, "globals": { "$": true, "chalk": true, "cd": true, "argv": true, "fs": true, "nothrow": true } } ================================================ FILE: .github/FUNDING.yml ================================================ github: [maoosi] ================================================ FILE: .github/workflows/unit-tests.yml ================================================ name: Unit Tests on: pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - name: Checkout repo uses: actions/checkout@v3 - name: Install pnpm uses: pnpm/action-setup@v2.2.4 with: version: 7 - name: Use Node.js 16 uses: actions/setup-node@v3 with: node-version: 16 cache: "pnpm" - name: Install global dependencies run: "pnpm add -g zx" - name: Install project dependencies run: "pnpm install" - name: Run tests run: "pnpm run test" ================================================ FILE: .gitignore ================================================ .DS_Store node_modules/ */**/node_modules/ # Testing tests/prisma/generated/ playground/ debug/ # Dist folder dist/ # Docs docs/.vitepress/dist/ docs/.vitepress/cache/ tmp.md # Boilerplate files packages/boilerplate/cdk/*.lock packages/boilerplate/cdk/cdk.out # Log files npm-debug.log* yarn-debug.log* yarn-error.log* # Editor directories and files .idea *.suo *.ntvs* *.njsproj *.sln *.sw* ================================================ FILE: .markdownlint.json ================================================ { "default": true, "no-inline-html": false, "line-length": false, "no-trailing-punctuation": false } ================================================ FILE: .npmignore ================================================ .DS_Store node_modules/ */**/node_modules/ # Source files packages/ bin/ tests/ playground/ docs/ .editorconfig .eslintrc .markdownlint.json .prettierignore .prettierrc.cjs tsconfig.json # Package cache files package-*.json pnpm-*.yaml # Log files npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # Workspace pnpm-workspace.yaml vite.config.ts # Installer dist/installer # Editor directories and files .idea .vscode .github *.suo *.ntvs* *.njsproj *.sln *.sw* ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "dbaeumer.vscode-eslint", "johnpapa.vscode-peacock" ] } ================================================ FILE: .vscode/settings.json ================================================ { // Visuals "peacock.color": "#1f08f6", "workbench.colorCustomizations": { "activityBar.activeBackground": "#4b38f9", "activityBar.activeBorder": "#f95745", "activityBar.background": "#4b38f9", "activityBar.foreground": "#e7e7e7", "activityBar.inactiveForeground": "#e7e7e799", "activityBarBadge.background": "#f95745", "activityBarBadge.foreground": "#15202b", "sash.hoverBorder": "#4b38f9", "statusBar.background": "#1f08f6", "statusBar.foreground": "#e7e7e7", "statusBarItem.hoverBackground": "#4b38f9", "statusBarItem.remoteBackground": "#1f08f6", "statusBarItem.remoteForeground": "#e7e7e7", "titleBar.activeBackground": "#1f08f6", "titleBar.activeForeground": "#e7e7e7", "titleBar.inactiveBackground": "#1f08f699", "titleBar.inactiveForeground": "#e7e7e799", "commandCenter.border": "#e7e7e799" }, // ESLint config "eslint.codeAction.showDocumentation": { "enable": true }, "eslint.probe": [ "javascript", "typescript", "javascriptreact", "typescriptreact", "vue", "html", "markdown", "json", "jsonc", "json5" ], "prettier.enable": false, // Editor "editor.formatOnSave": false, "editor.accessibilitySupport": "off", "editor.cursorSmoothCaretAnimation": "on", "editor.find.addExtraSpaceOnTop": false, "editor.guides.bracketPairs": "active", "editor.inlineSuggest.enabled": true, "editor.lineNumbers": "interval", "editor.multiCursorModifier": "ctrlCmd", "editor.renderWhitespace": "boundary", "editor.suggestSelection": "first", "editor.tabSize": 4, "editor.unicodeHighlight.invisibleCharacters": false, "editor.codeActionsOnSave": { "source.fixAll": "never", "source.fixAll.eslint": "explicit", "source.organizeImports": "never" }, "[markdown]": { "editor.formatOnSave": false }, "[prisma]": { "editor.defaultFormatter": "Prisma.prisma", "editor.formatOnSave": true }, "files.eol": "\n", "typescript.tsdk": "node_modules/typescript/lib", // Grammarly "grammarly.selectors": [ { "language": "markdown", "scheme": "file", "pattern": "docs/changelog/1.0.0-rc.1.md" }, { "language": "markdown", "scheme": "file", "pattern": "docs/contributing.md" }, { "language": "markdown", "scheme": "file", "pattern": "docs/essentials/concept.md" }, { "language": "markdown", "scheme": "file", "pattern": "docs/essentials/getting-started.md" }, { "language": "markdown", "scheme": "file", "pattern": "docs/advanced/securing-api.md" }, { "language": "markdown", "scheme": "file", "pattern": "docs/advanced/extending-api.md" }, { "language": "markdown", "scheme": "file", "pattern": "README.md" }, { "language": "markdown", "scheme": "file", "pattern": "docs/advanced/hooks.md" }, { "language": "markdown", "scheme": "file", "pattern": "docs/changelog/1.0.0-rc.4.md" }, { "language": "markdown", "scheme": "file", "pattern": "docs/changelog/1.0.0-rc.5.md" }, { "language": "markdown", "scheme": "file", "pattern": "docs/changelog/1.0.0-rc.6.md" } ] } ================================================ FILE: CHANGELOG.md ================================================ # Changelog [prisma-appsync.vercel.app/changelog/](https://prisma-appsync.vercel.app/changelog/) ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing [prisma-appsync.vercel.app/contributing.html](https://prisma-appsync.vercel.app/contributing.html) ================================================ FILE: LICENSE.txt ================================================ BSD 2-Clause License Copyright (c) 2024, Sylvain Simao All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: README.md ================================================

Prisma-AppSync

# Prisma-AppSync · [![TypeScript](https://img.shields.io/badge/-TypeScript-2D3748?logo=typescript&colorA=0096ff&logoColor=fff)](/packages/client/src/) [![AWS AppSync](https://img.shields.io/badge/-AWS%20AppSync-2D3748?logo=amazon-aws&colorA=EB5F05&logoColor=fff)](https://aws.amazon.com/appsync/) [![Prisma](https://img.shields.io/badge/-Prisma%20Generator-2D3748?logo=prisma&colorA=5B67D8&logoColor=fff)](https://www.prisma.io) **Prisma-AppSync** turns your [Prisma Schema](https://www.prisma.io) into a fully-featured GraphQL API, tailored for [AWS AppSync](https://aws.amazon.com/appsync/). ## ✔️ Features 💎 **Use your ◭ Prisma Schema**
Quickly define your data model and deploy a GraphQL API tailored for AWS AppSync. ⚡️ **Auto-generated CRUD operations**
Using Prisma syntax, with a robust TS Client designed for AWS Lambda Resolvers. ⛑ **Pre-configured security**
Built-in XSS protection, query depth limitation, and in-memory rate limiting. 🔐 **Fine-grained ACL and authorization**
Flexible security options such as API keys, IAM, Cognito, and more. 🔌 **Fully extendable features**
Customize your GraphQL schema, API resolvers, and data flow as needed. ## 🚀 Getting started Run the following command and follow the prompts 🙂 ```shell npx create-prisma-appsync-app@latest ``` ## 📓 Documentation [Read the documentation](https://prisma-appsync.vercel.app) to learn how to use Prisma-AppSync. ## 🙏 Contributors
Sylvain
Sylvain

🐙 💻 🤔 📖
Bell
Bell

💻 🤔
Ciprian Caba
Ciprian Caba

💻 🤔
Tom
Tom

💻 🤔
Ryan Parker
Ryan Parker

💻
Cameron Jenkinson
Cameron Jenkinson

💻
jeremy-white
jeremy-white

💻
Max Konin
Max Konin

💻
Michael Chan
Michael Chan

💻
Nhu Mai
Nhu Mai

💻
Wanting to help? Get started with our [contribution guide](https://prisma-appsync.vercel.app/contributing.html) or consider [💛 Github sponsors](https://github.com/sponsors/maoosi). ## 🌟 Sponsors **Thanks go to these wonderful sponsors!** [![Kuizto — The Everyday Cooking App](https://prisma-appsync.vercel.app/sponsors/kuizto-banner.png "Kuizto — The Everyday Cooking App")](https://kuizto.co/?utm_source=prisma_appsync&utm_medium=github) [Kuizto.co](https://kuizto.co/?utm_source=prisma_appsync&utm_medium=github) is a cooking app that adds a unique twist to everyday cooking. Netflix-like feed to explore tailored recipes. Get inspired by others, save to cooklists, plan instantly! ================================================ FILE: bin/build.mjs ================================================ #!/usr/bin/env zx /* eslint-disable no-console */ /* eslint-disable n/prefer-global/process */ import './env.mjs' try { // cleanup previous generated files console.log(chalk.blue('\n🧹 [chore] cleanup\n')) await $`rm -rf dist`.quiet() if (!argv?.ignoreGenerator) { console.log(chalk.blue('🛠️ [build] packages/generator')) // build Prisma-AppSync Generator await $`esbuild packages/generator/src/index.ts --bundle --format=cjs --keep-names --platform=node --target=node18 --external:fsevents --external:_http_common --outfile=dist/generator.js --define:import.meta.url='_importMetaUrl' --banner:js="const _importMetaUrl=require('url').pathToFileURL(__filename)"`.quiet() } if (!argv?.ignoreClient) { console.log(chalk.blue('🛠️ [build] packages/client')) // build Prisma-AppSync Client await $`esbuild packages/client/src/index.ts --bundle '--define:process.env.NODE_ENV="production"' --format=cjs --minify --keep-names --platform=node --target=node18 --external:fsevents --external:@prisma/client --outfile=dist/client/index.js --legal-comments=inline`.quiet() console.log(chalk.blue('🛠️ [build] packages/client types')) // build Prisma-AppSync Client TS Declarations await $`tsc packages/client/src/*.ts --outDir dist/client/ --declaration --emitDeclarationOnly --esModuleInterop --downlevelIteration`.nothrow().quiet() } if (!argv?.ignoreInstaller) { if (process.env.COMPILE_MODE === 'preview') { console.log(chalk.blue('🛠️ [build] packages/installer (preview mode)')) // build installer (preview mode) await $`esbuild packages/installer/src/index.ts --bundle '--define:process.env.NODE_ENV="production"' '--define:process.env.COMPILE_MODE="preview"' --format=cjs --minify --keep-names --platform=node --target=node18 --external:fsevents --external:_http_common --outfile=dist/installer/bin/index.js`.quiet() } else { console.log(chalk.blue('🛠️ [build] packages/installer')) // build installer (default) await $`esbuild packages/installer/src/index.ts --bundle '--define:process.env.NODE_ENV="production"' --format=cjs --minify --keep-names --platform=node --target=node18 --external:fsevents --external:_http_common --outfile=dist/installer/bin/index.js`.quiet() } } if (!argv?.ignoreServer) { console.log(chalk.blue('🛠️ [build] packages/server')) // build server await $`esbuild packages/server/src/index.ts --bundle --format=cjs --minify --keep-names --platform=node --target=node18 --external:fsevents --external:@prisma/client --external:amplify-appsync-simulator --external:_http_common --outfile=dist/server/index.js`.quiet() // build server TS Declarations await $`cp packages/server/src/index.d.ts dist/server/index.d.ts && chmod -R 755 dist`.quiet() // copy server .vtl files into build folder await $`cp -R packages/server/src/*.vtl dist/server && chmod -R 755 dist`.quiet() } } catch (error) { console.log(chalk.red(`🚨 [build] error\n\n${error}`)) } ================================================ FILE: bin/cleans.mjs ================================================ #!/usr/bin/env zx import './env.mjs' console.log(chalk.blue('\n🧹 [chore] deleting all `node_modules` folders\n')) await $`find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +` console.log(chalk.blue('🧹 [chore] deleting all `dist` folders\n')) await $`find . -name 'dist' -type d -prune -exec rm -rf '{}' +` console.log(chalk.blue('🧹 [chore] deleting all `cdk.out` folders\n')) await $`find . -name 'cdk.out' -type d -prune -exec rm -rf '{}' +` console.log(chalk.blue('🧹 [chore] deleting all `generated` folders\n')) await $`find . -name 'generated' -type d -prune -exec rm -rf '{}' +` console.log(chalk.blue('🧹 [chore] deleting all `yarn.lock` files\n')) await $`find . -name 'yarn.lock' -type f -prune -exec rm -rf '{}' +` console.log(chalk.blue('🧹 [chore] deleting all `pnpm-lock.yaml` files\n')) await $`find . -name 'pnpm-lock.yaml' -type f -prune -exec rm -rf '{}' +` console.log(chalk.blue('\n📦 [install] re-installing all dependencies\n')) await $`pnpm install` ================================================ FILE: bin/dev.mjs ================================================ #!/usr/bin/env zx import './env.mjs' // path const playgroundPath = 'playground' // reset if (argv?.reset) { console.log(chalk.blue('\n💻 [dev] reset `playground` dir')) await fs.remove(playgroundPath) } // build project await $`zx bin/build.mjs` // install const playgroundExists = await fs.pathExists(playgroundPath) console.log('') if (!playgroundExists) { console.log(chalk.blue('💻 [dev] create `playground` dir')) await fs.ensureDir(playgroundPath) console.log(chalk.blue('💻 [dev] run installer')) cd(playgroundPath) process.env.INSTALL_MODE = 'contributor' await $`node ../dist/installer/bin/index.js` } else { console.log(chalk.blue('💻 [dev] run prisma generate\n')) cd(playgroundPath) await $`npx prisma generate` } // start dev server console.log(chalk.blue('💻 [dev] start dev server\n')) await $`yarn dev` ================================================ FILE: bin/env.mjs ================================================ #!/usr/bin/env zx process.env.FORCE_COLOR = 3 ================================================ FILE: bin/postinstall.mjs ================================================ #!/usr/bin/env zx import './env.mjs' // set DATABASE_URL env variable to docker instance process.env.DATABASE_URL = 'postgresql://prisma:prisma@localhost:5433/tests' // install boilerplate dependencies using Yarn console.log(chalk.blue('\n📦 [post-install] install cdk boilerplate dependencies\n')) await $`cd packages/boilerplate/cdk && yarn install` ================================================ FILE: bin/publish/_pkg.core.cleanse.js ================================================ const fs = require('fs') const path = require('path') // Define absolute paths for original pkg and temporary pkg. const ORIG_PKG_PATH = path.resolve(__dirname, '../../package.json') const BACKUP_PKG_PATH = path.resolve(__dirname, '../../package-beforePublish.json') const RESTORE_PKG_PATH = path.resolve(__dirname, '../../package-afterPublish.json') // Obtain original `package.json` contents. const pkgData = require(ORIG_PKG_PATH) // Write/cache the original `package.json` data to `package-beforePublish.json` file. fs.writeFile(BACKUP_PKG_PATH, JSON.stringify(pkgData, null, 4), (err) => { if (err) throw err }) // Write/cache the original `package.json` data to `package-afterPublish.json` file. fs.writeFile(RESTORE_PKG_PATH, JSON.stringify(pkgData, null, 4), (err) => { if (err) throw err }) // Remove all scripts from the scripts section. delete pkgData.scripts // Remove all pkgs from the devDependencies section. delete pkgData.devDependencies // Remove pnpm engine delete pkgData.engines.pnpm // Overwrite original `package.json` with new data (i.e. minus the specific data). fs.writeFile(ORIG_PKG_PATH, JSON.stringify(pkgData, null, 4), (err) => { if (err) throw err }) ================================================ FILE: bin/publish/_pkg.core.restore.js ================================================ const fs = require('fs') const path = require('path') // Define absolute paths for original pkg and temporary pkg. const ORIG_PKG_PATH = path.resolve(__dirname, '../../package.json') const BACKUP_PKG_PATH = path.resolve(__dirname, '../../package-beforePublish.json') const RESTORE_PKG_PATH = path.resolve(__dirname, '../../package-afterPublish.json') // Obtain original/cached contents (with new version) from `package-afterPublish`. const pkgData = `${JSON.stringify(require(RESTORE_PKG_PATH), null, 4)}\n` // Write data from `package-afterPublish` back to original `package.json`. fs.writeFile(ORIG_PKG_PATH, pkgData, (err) => { if (err) throw err }) // Delete the temporary `package-beforePublish` file. fs.unlink(BACKUP_PKG_PATH, (err) => { if (err) throw err }) // Delete the temporary `package-afterPublish` file. fs.unlink(RESTORE_PKG_PATH, (err) => { if (err) throw err }) ================================================ FILE: bin/publish/_pkg.installer.cleanse.js ================================================ const fs = require('fs') const path = require('path') // Define absolute paths for original pkg and temporary pkg. const SRC_PKG_PATH = path.resolve(__dirname, '../../packages/installer/package.json') const DEST_PKG_PATH = path.resolve(__dirname, '../../dist/installer/package.json') // Obtain original `package.json` contents. const pkgData = require(SRC_PKG_PATH) // Remove all scripts from the scripts section. delete pkgData.scripts // Remove all pkgs from the dependencies section. delete pkgData.dependencies // Remove all pkgs from the devDependencies section. delete pkgData.devDependencies // Remove private tag delete pkgData.private // Create new `package.json` with new data (i.e. minus the specific data). fs.writeFile(DEST_PKG_PATH, JSON.stringify(pkgData, null, 4), (err) => { if (err) throw err }) ================================================ FILE: bin/publish.mjs ================================================ #!/usr/bin/env zx /* eslint-disable @typescript-eslint/no-unused-vars */ import Listr from 'listr' import prompts from 'prompts' await $`zx bin/env.mjs` $.verbose = false async function getPublishConfig() { const { tag } = await prompts({ type: 'select', name: 'tag', message: 'Select publish tag', choices: [ { title: 'preview', value: 'preview' }, { title: 'latest', value: 'latest' }, ], initial: 0, }) if (!tag) process.exit() let latestPublished = '0.0.9' try { latestPublished = String(await $`npm show prisma-appsync@${tag} version`)?.trim() } catch (err) { try { latestPublished = String(await $`npm show prisma-appsync version`)?.trim() } catch (err) {} } const minorPos = latestPublished.lastIndexOf('.') const possibleFutureVersion = `${latestPublished.slice(0, minorPos)}.${ parseInt(latestPublished.slice(minorPos + 1)) + 1 }` const { publishVersion } = await prompts({ type: 'text', name: 'publishVersion', message: `Enter new version for @${tag}? (latest = "${latestPublished}")`, initial: possibleFutureVersion, }) if (!publishVersion || publishVersion === latestPublished) process.exit() const { versionOk } = await prompts({ type: 'confirm', name: 'versionOk', message: `Run "pnpm publish --tag ${tag} --no-git-checks" with pkg version "${publishVersion}"?`, initial: false, }) return { versionOk, publishVersion, tag, } } async function publishCore({ tag }) { console.log('Publishing Core...') await new Listr([ { title: 'Cleansing package.json', task: async () => { await $`node bin/publish/_pkg.core.cleanse` }, }, { title: `Publishing on NPM with tag ${tag}`, task: async () => await $`pnpm publish --tag ${tag} --no-git-checks`, }, { title: 'Restoring package.json', task: async () => await $`node bin/publish/_pkg.core.restore`, }, ]).run().catch((err) => { console.error(err) }) } async function publishInstaller({ tag }) { console.log('Publishing Installer...') await new Listr([ { title: 'Copy + Cleanse package.json', task: async () => await $`node bin/publish/_pkg.installer.cleanse`, }, { title: 'Publishing on NPM', task: async () => await $`cd ./dist/installer/ && pnpm publish --tag ${tag} --no-git-checks`, }, ]).run().catch((err) => { console.error(err) }) } const publishConfig = await getPublishConfig() if (publishConfig.versionOk) { const corePkgFile = './package.json' const installerPkgFile = './packages/installer/package.json' // change package.json versions console.log(`\nSetting publish version to ${publishConfig.publishVersion}...`) const corePkg = await fs.readJson(corePkgFile) const installerPkg = await fs.readJson(installerPkgFile) corePkg.version = publishConfig.publishVersion installerPkg.version = publishConfig.publishVersion await fs.writeJson(corePkgFile, corePkg, { spaces: 4 }) await fs.writeJson(installerPkgFile, installerPkg, { spaces: 4 }) // preview? if (publishConfig.tag === 'preview') process.env.COMPILE_MODE = "preview" // build + test console.log('Building + Testing...') await $`zx bin/test.mjs` // publish packages await publishCore(publishConfig) await publishInstaller(publishConfig) console.log('Done!') } ================================================ FILE: bin/test.mjs ================================================ #!/usr/bin/env zx /* eslint-disable no-console */ import './env.mjs' // build await $`zx bin/build.mjs` // prisma client for tests console.log(chalk.blue('\n🧪 [test] run prisma generate')) await $`npx prisma generate --schema tests/generator/schemas/crud.prisma` await $`npx prisma generate --schema tests/generator/schemas/@gql.prisma` // unit tests console.log(chalk.blue('🧪 [test] run unit tests\n')) await $`VITE_CJS_IGNORE_WARNING=true vitest run tests` ================================================ FILE: docs/.vitepress/config.ts ================================================ export default { title: 'Prisma-AppSync', description: 'GraphQL API Generator for AWS and ◭ Prisma', head: [['link', { rel: 'icon', type: 'image/svg+xml', href: '/logo.svg' }]], vue: { reactivityTransform: true, }, lastUpdated: true, themeConfig: { logo: '/logo.svg', editLink: { text: 'Suggest changes to this page', pattern: 'https://github.com/maoosi/prisma-appsync/edit/main/docs/:path', }, socialLinks: [{ icon: 'github', link: 'https://github.com/maoosi/prisma-appsync' }], lastUpdatedText: 'Updated Date', footer: { message: 'Released under the BSD 2-Clause License.', copyright: 'Copyright © 2021-present Sylvain Simao', }, nav: [ { text: 'Documentation', link: '/quick-start/getting-started' }, { text: 'Changelog', link: '/changelog/1.0.0' }, { text: 'Support', link: '/support' }, { text: 'Tools', items: [ { text: 'AppSync GraphQL Schema Diff', link: '/tools/appsync-gql-schema-diff', }, ], }, { text: 'Links', items: [ { text: 'Report a bug', link: 'https://github.com/maoosi/prisma-appsync/issues', }, { text: 'Sponsor', link: 'https://github.com/sponsors/maoosi', }, { text: 'Roadmap', link: 'https://github.com/users/maoosi/projects/1', }, ], }, ], sidebar: [ { text: 'Quick start', items: [ { text: 'Getting started', link: '/quick-start/getting-started' }, { text: 'Installation', link: '/quick-start/installation' }, { text: 'Usage', link: '/quick-start/usage' }, { text: 'Deploy', link: '/quick-start/deploy' }, ], }, { text: 'Features', collapsible: true, items: [ { text: 'Lifecycle hooks', link: '/features/hooks' }, { text: 'Custom resolvers', link: '/features/resolvers' }, { text: 'Tweaking GQL schema', link: '/features/gql-schema' }, ], }, { text: 'Security', collapsible: true, items: [ { text: 'Authorization', link: '/security/appsync-authz' }, { text: 'Shield (ACL rules)', link: '/security/shield-acl' }, { text: 'XSS sanitizer', link: '/security/xss-sanitizer' }, { text: 'Query depth', link: '/security/query-depth' }, { text: 'Rate limiter (DOS)', link: '/security/rate-limiter' }, ], }, { text: 'Contributing', collapsible: true, items: [ { text: 'Contributions guide', link: '/contributing' }, ], }, { text: 'Changelog', collapsible: true, collapsed: true, items: [ { text: '(latest) v1.0.0', link: '/changelog/1.0.0' }, { text: 'Previous', link: '/changelog/' }, ], }, ], }, } ================================================ FILE: docs/.vitepress/theme/Layout.vue ================================================ ================================================ FILE: docs/.vitepress/theme/index.ts ================================================ import Theme from 'vitepress/theme' import Layout from './Layout.vue' import './styles/vars.css' export default { extends: Theme, Layout: Layout } ================================================ FILE: docs/.vitepress/theme/styles/vars.css ================================================ /** * Colors * -------------------------------------------------------------------------- */ :root { --vp-c-brand: #5379f4; --vp-c-brand-light: #747bff; --vp-c-brand-lighter: #9499ff; --vp-c-brand-dark: #535bf2; --vp-c-brand-darker: #454ce1; } /** * Component: Button * -------------------------------------------------------------------------- */ :root { --vp-button-brand-border: var(--vp-c-brand-light); --vp-button-brand-text: var(--vp-c-text-dark-1); --vp-button-brand-bg: var(--vp-c-brand); --vp-button-brand-hover-border: var(--vp-c-brand-light); --vp-button-brand-hover-text: var(--vp-c-text-dark-1); --vp-button-brand-hover-bg: var(--vp-c-brand-light); --vp-button-brand-active-border: var(--vp-c-brand-light); --vp-button-brand-active-text: var(--vp-c-text-dark-1); --vp-button-brand-active-bg: var(--vp-button-brand-bg); } /** * Component: Home * -------------------------------------------------------------------------- */ :root { --vp-home-hero-name-color: transparent; --vp-home-hero-name-background: -webkit-linear-gradient(120deg, #5379f4 30%, #f59533); } /** * Component: Algolia * -------------------------------------------------------------------------- */ .DocSearch { --docsearch-primary-color: var(--vp-c-brand) !important; } ================================================ FILE: docs/changelog/1.0.0-rc.1.md ================================================ --- editLink: false --- # 1.0.0-rc.1 ::: warning BREAKING 🚨 This release comes with a **major rewrite of the Prisma-AppSync Client API and breaking changes**. Please make sure to take a moment and read through the details below before upgrading. ::: ## Highlights - **Codebase rewrite**: Prisma-AppSync was rewritten from the ground to offer a simplified API with a more opinionated approach, end-to-end type safety for a better DX, advanced customisation options, as well as improved security and fine-grained access control. - **Installer CLI**: New interactive scaffolding CLI to quickly start new Prisma-AppSync projects, accessible from a single `npx create-prisma-appsync-app@latest` command. It can also plug into existing projects already using Prisma. - **Local AppSync Server**: New local development environment built for Prisma-AppSync (local database, auto-reload, TS support, GraphQL IDE). Iterate faster by simulating a GraphQL API running on AWS AppSync from your local machine. - **Documentation website**: New documentation website and contribution guide, built upon VitePress, and accessible from [prisma-appsync.vercel.app](https://prisma-appsync.vercel.app). - **Lots of improvements**: Implemented dozens of improvements, bug fixes and new Prisma features (atomic operations, order by relations, case sensitivity, etc). ## 🪓 Breaking changes ::: details Prisma-AppSync Client usage `BREAKING`Usage is simplified by adopting a more opinionated approach. Also provides a better TypeScript DX closer to Prisma Client. - Fine-gained access control and hooks have been re-engineered entirely to make them easier to use and more flexible for all project sizes. - Adopted a full TDD approach with both unit and integration tests. This is to help bring Prisma-AppSync to a stable version quicker. **Before:** ```ts // init prisma-appsync client const app = new PrismaAppSync({ connectionUrl: process.env.CONNECTION_URL }) // direct lambda resolver for appsync export const main = async (event) => { // parse the `event` from your Lambda function app.parseEvent(event) // handle CRUD operations / resolve query const result = await app.resolve() // close database connection await app.prisma.$disconnect() // return query result return Promise.resolve(result) } ``` **After:** ```ts const prismaAppSync = new PrismaAppSync() // direct lambda resolver for appsync export const resolver = async (event) => { return await prismaAppSync.resolve({ event }) } ``` ::: ::: details Prisma-AppSync Client options `BREAKING` - `connectionUrl` parameter renamed into `connectionString`. This parameter is optional and leverages Prisma Client naming convention and defaults. - `debug` parameter renamed into `logLevel`. This parameter is optional and allows specify server logging levels. - New `maxDepth` parameter that improves security and prevents clients from abusing query depth. Defaults to `3`. - New `maxReqPerUserMinute` parameter that provides in-memory rate-limiting and prevents common DDoS attacks. Defaults to `200`. ```ts const prismaAppSync = new PrismaAppSync({ // optional, DB connection string, default to env var `DATABASE_URL` connectionString, // optional, enable data sanitizer for DB storage (incl. XSS parser), default to true sanitize, // optional, specify server logging level (`INFO`, `WARN`, `ERROR`), default to `INFO` logLevel, // optional, pagination for listQueries, default to 50 defaultPagination, // optional, allowed graphql query depth, default to 3 maxDepth, // optional, per user, in-memory rate limiting, default to 200 maxReqPerUserMinute }) ``` ::: ::: details Custom Resolvers API `BREAKING` **Before:** ```ts app.registerCustomResolvers({ notify: async ({ args }: CustomResolverProps) => { return { message: `${args.message} from notify` } } }) ``` **After:** ```ts return await prismaAppSync.resolve<'notify'>({ event, resolvers: { notify: async ({ args }: QueryParams) => { return { message: `${args.message} from notify`, } }, } }) ``` ::: ::: details Hooks before/after API `BREAKING` **Before:** ```ts // execute before resolve app.beforeResolve(async (props) => {}) // execute after resolve app.afterResolve(async (props) => {}) ``` **After:** ```ts return await prismaAppSync.resolve<'likePost'>({ event, hooks: { // execute before any query 'before:**': async (params: BeforeHookParams) => params, // execute after any query 'after:**': async (params: AfterHookParams) => params, // execute after custom resolver query `likePost` // (e.g. `query { likePost(postId: 3) }`) 'after:likePost': async (params: AfterHookParams) => { await params.prismaClient.notification.create({ data: { event: 'POST_LIKED', targetId: params.args.postId, userId: params.authIdentity.sub, }, }) return params }, }, }) ``` ::: ::: details Fine-Grained Access Control API `BREAKING` **Before:** ```ts // before resolving any query app.beforeResolve(async ({ authIdentity }: BeforeResolveProps) => { // rules only apply to Cognito authorization type if (authIdentity.authorization !== AuthModes.AMAZON_COGNITO_USER_POOLS) return false // get current user from database, using Prisma // we only need to know the user ID const currentUser = await prisma.user.findUnique({ select: { id: true }, where: { cognitoSub: authIdentity.sub } }) || { id: null } // everyone can access (get + list) or create Posts app.allow({ action: AuthActions.access, subject: 'Post' }) app.allow({ action: AuthActions.create, subject: 'Post' }) // only the author is allowed to modify a given Post app.deny({ action: AuthActions.modify, subject: 'Post', // IF `Post.ownerId` NOT_EQUAL_TO `currentUser.id` THEN DENY_QUERY condition: { ownerId: { $ne: currentUser.id } } }) }) ``` **After:** ```ts return await prismaAppSync.resolve({ event, shield: ({ authorization, identity }: QueryParams) => { const isCognitoAuth = authorization === Authorizations.AMAZON_COGNITO_USER_POOLS const isOwner = { owner: { cognitoSub: identity?.sub } } // Prisma syntax return { '**': { rule: isCognitoAuth, reason: ({ model }) => `${model} access is restricted to logged-in users.`, }, '{update,upsert,delete}Post{,/**}': { rule: isOwner, reason: ({ model }) => `${model} can only be modified by their owner.`, }, } }, }) ``` ::: ::: details Prisma-AppSync Generator options `BREAKING` - `customSchema` parameter renamed into `extendSchema`. - `customResolvers` parameter renamed into `extendResolvers`. - new parameter `defaultDirective`, to globally apply default directives. ```ts generator appsync { provider = "prisma-appsync" // optional params output = "./generated/prisma-appsync" extendSchema = "./custom-schema.gql" extendResolvers = "./custom-resolvers.yaml" defaultDirective = "@auth(model: [{ allow: apiKey }])" } ``` ::: ::: details AppSync Authorizations modes `BREAKING` **Before:** ```json /// @PrismaAppSync.type: '@aws_api_key @aws_cognito_user_pools(cognito_groups: [\"admins\"])' model Post { id Int @id @default(autoincrement()) title String } ``` **After:** ```json /// @auth(model: [{ allow: apiKey }, { allow: userPools, groups: ["admins"] }]) model Post { id Int @id @default(autoincrement()) title String } ``` 👆 Output: `@aws_api_key @aws_cognito_user_pools(cognito_groups: ["admins"])` ::: ::: details Data sanitizer behaviour `BREAKING` Enabling the data sanitizer will now automatically "clarify/decode" the data before sending it back to the client. Meaning client-side decoding is not anymore necessary, while your data will still be parsed for XSS before storage. ::: ::: details Auto-generated documentation `BREAKING` To reduce the maintenance burden, auto-generated API docs have been removed from Prisma-AppSync in favour of a better description of the data (accessible via the native GraphQL documentation from your favourite GraphQL IDE). In case this is problematic for you and you’d like auto-generated docs to make a comes back, please consider [supporting the project](https://github.com/sponsors/maoosi) and [opening a new issue](https://github.com/maoosi/prisma-appsync/issues). ::: ## 🎉 New features ::: details New documentation website `NEW FEATURE` Accessible from [prisma-appsync.vercel.app](https://prisma-appsync.vercel.app). ::: ::: details New installer (scaffolding tool) `NEW FEATURE` New installer that can be run via `npx create-prisma-appsync-app@latest`. Nicely plug with existing Prisma projects (non-destroying), while also allowing scaffolding of new projects from scratch. ```bash ___ _ _ __ / _ \_ __(◭)___ _ __ ___ __ _ /_\ _ __ _ __ / _\_ _ _ __ ___ / /◭)/ '__| / __| '_ ` _ \ / _` |_____ //◭\\| '_ \| '_ \\ \| | | | '_ \ / __| / ___/| | | \__ \ | | | | | (◭| |_____/ _ \ |◭) | |◭) |\ \ |_| | | | | (__ \/ |_| |_|___/_| |_| |_|\__,_| \_/ \_/ .__/| .__/\__/\__, |_| |_|\___| |_| |_| |___/ ◭ Prisma-AppSync Installer v1.0.0 ``` ::: ::: details Local development environment built for Prisma-AppSync `NEW FEATURE` New local development environment built for Prisma-AppSync (local database, auto-reload, TS support, GraphQL IDE). Simulate a GraphQL API running on AWS AppSync + AWS Lambda Resolver + Prisma ORM + Database. ::: ::: details Support added for Prisma 4.5.x `NEW FEATURE` Codebase adapted and fully tested for Prisma 4.5.x support. ::: ::: details Customise GraphQL Schema output `NEW FEATURE` ```json /// @gql(queries: { list: 'posts' }, subscriptions: null) model Post { id Int @id @default(autoincrement()) title String } ``` 👆 Output: Default `listPosts` query renamed to `posts` / No subscriptions for the Post model ::: ::: details Support for Atomic Operations `NEW FEATURE` ```graphql mutation { updatePost( where: { id: 1 }, operation: { views: { increment: 1 } } ) { views } } ``` ::: ::: details Support for order by relational fields `NEW FEATURE` ```graphql query { listPosts( orderBy: { author: { name: ASC } } ) { title author { name } } } ``` ::: ::: details Support for Case Sensitivity (PostgreSQL and MongoDB connectors only) `NEW FEATURE` ```graphql query { listPosts( where: { title: { contains: "prisma", mode: "insensitive" } } ) { title } } ``` ::: ::: details CDK boilerplate upgraded to v2+ `IMPROVEMENT` CDK boilerplate was entirely rewritten to offer a more easy-to-use, simpler syntax making it more approachable for people who are new to AWS CDK. ::: ::: details TypeScript types improved for better DX `IMPROVEMENT` Hovering on Prisma-AppSync types and Client API methods using VSCode is now displaying docs and examples across the entire codebase. ::: ::: details Bundle size and performances `IMPROVEMENT` Noticeable gains in bundle size and runtime performances. Lots of dependencies were removed from the previous version, in favour of custom utils functions. ::: ::: details Errors handling and server logs improved `IMPROVEMENT` Both error handling and server logs (accessible from CloudWatch) have been improved to include more details and be easily readable. ```ts // Example object returned from the API { "error": "Query has depth of 4, which exceeds max depth of 3.", "type": "FORBIDDEN", "code": 401 } // Error codes const errorCodes = { FORBIDDEN: 401, BAD_USER_INPUT: 400, INTERNAL_SERVER_ERROR: 500, TOO_MANY_REQUESTS: 429, } ``` ::: ## 🐞 Bug fixes ::: details Added support for uniqueIndexes (🙏 contribution from [@cipriancaba](https://github.com/cipriancaba)) 🐙 [Issue #46](https://github.com/maoosi/prisma-appsync/issues/46) /[PR #47](https://github.com/maoosi/prisma-appsync/pull/47): Prisma is no longer providing idFields ::: ::: details Fixed casing issue on GraphQL relation fields (🙏 contribution from [@ryparker](https://github.com/ryparker)) 🐙 [Issue #37](https://github.com/maoosi/prisma-appsync/issues/37) / [PR #38](https://github.com/maoosi/prisma-appsync/pull/38): Generated GraphQL schema relation definitions using the incorrect case for type values ::: ::: details Fixed issue on nullable relation fields in mutations 🐙 [Issue #26](https://github.com/maoosi/prisma-appsync/issues/26): Issue using nullable relation fields with mutations ::: ::: details Replaced env var JEST_WORKER_ID with PRISMA_APPSYNC_TESTING 🐙 [Issue #32](https://github.com/maoosi/prisma-appsync/issues/32): Issue performing tests because of JEST_WORKER_ID ::: ## 💛 Github Sponsors Enjoy using Prisma-AppSync? Please consider sponsoring me at 🐙 [maoosi ↗](https://github.com/sponsors/maoosi) so that I can spend more time working on the project. ================================================ FILE: docs/changelog/1.0.0-rc.2.md ================================================ --- editLink: false --- # 1.0.0-rc.2 **🌟 Help us spread the word about Prisma-AppSync by starring the repo.** ## Major improvements ### Support for Prisma Fluent API syntax on Relation Filters > 🚨 Breaking change affecting syntax for Relation Filters. In this release, we are changing how to write relation filters. We are replacing the [original Prisma Client syntax](https://www.prisma.io/docs/concepts/components/prisma-client/relation-queries#filter-on--to-one-relations) (using `is` and `isNot` filters) with the newest [Fluent API syntax](https://www.prisma.io/docs/concepts/components/prisma-client/relation-queries#fluent-api) which feels more natural to write, but also allows using more complex Relation Filters such as `contains`, `endsWith`, `equals`, `gt`, `gte`, `lt`, `lte`, `in`, `not`, `notIn` and `startsWith`. **Before** ```graphql query { listPosts( where: { author: { is: { username: "xxx" } } } ) { title author { username } } } ``` **After** ```graphql query { listPosts( where: { author: { username: { equals: "xxx" } } } ) { title author { username } } } ``` ### Improved readability for underlying Prisma client errors In this release, we have improved readability for all [known errors](https://www.prisma.io/docs/reference/api-reference/error-reference) thrown by the underlying Prisma Client. For example, using an incorrect connection URL (`DATABASE_URL`) will now return the below message as part of the API response: > Error with Prisma client initialization. https://www.prisma.io/docs/reference/api-reference/error-reference#prismaclientinitializationerror The full error trace will still appear inside the terminal and/or CloudWatch logs. ### New documentation guide: "Adding Hooks" In this release, we have added a new guide on "Adding Hooks". Particularly useful to trigger actions and/or manipulate data `before` or `after` queries. https://prisma-appsync.vercel.app/advanced/hooks.html **Example snippet:** ```tsx return await prismaAppSync.resolve<'likePost'>({ event, hooks: { // execute before any query 'before:**': async (params: BeforeHookParams) => params, // execute after any query 'after:**': async (params: AfterHookParams) => params, // execute after custom resolver query `likePost` // (e.g. `query { likePost(postId: 3) }`) 'after:likePost': async (params: AfterHookParams) => { await params.prismaClient.notification.create({ data: { event: 'POST_LIKED', targetId: params.args.postId, userId: params.authIdentity.sub, }, }) return params }, }, }) ``` ## Fixes - [Issue using the `@aws_auth` directive along with additional authorization modes.](https://github.com/maoosi/prisma-appsync/pull/52) - [Issue with `before` and `after` hook responses.](https://github.com/maoosi/prisma-appsync/pull/54) ## Credits
Sylvain
Sylvain

🧙‍♂️ 💻 🤔 📖
Ciprian Caba
Ciprian Caba

💻 🤔
## Github sponsors Enjoy using Prisma-AppSync? Please consider [💛 Github sponsors](https://github.com/sponsors/maoosi). ================================================ FILE: docs/changelog/1.0.0-rc.3.md ================================================ --- editLink: false --- # 1.0.0-rc.3 **🌟 Help us spread the word about Prisma-AppSync by starring the repo.** ## Fixes - [Issue with Queries returning a single parameter (such as `count` queries)](https://github.com/maoosi/prisma-appsync/issues/61) - [Issue with generated GraphQL input `CreateInput` when using `@default(uuid())` inside Prisma Schema](https://github.com/maoosi/prisma-appsync/issues/62) ## Credits
Sylvain
Sylvain

🧙‍♂️ 💻 🤔 📖
## Github sponsors Enjoy using Prisma-AppSync? Please consider [💛 Github sponsors](https://github.com/sponsors/maoosi). ================================================ FILE: docs/changelog/1.0.0-rc.4.md ================================================ --- editLink: false --- # 1.0.0-rc.4 **🌟 Help us spread the word about Prisma-AppSync by starring the repo.** ## Highlights ### ⚡️ Local dev server is now using `vite-node` instead of `ts-node-dev` Due to some incompatibilities between `ts-node-dev` and some of the newest changes, Vite is now used as the underlying Node runtime for the Prisma-AppSync local dev server. To migrate an existing project using the local dev server, you'll need to edit the `dev` script inside your `package.json` and replace the following part: ```shell npx ts-node-dev --rs --transpile-only --watch './*.ts' -- ./server.ts ``` with: ```shell npx vite-node ./server.ts --watch -- ``` ### ⚡️ Local dev server upgraded to GraphQL Yoga v3, with the ability to use custom options When using Prisma-AppSync local dev server, it is now possible to pass custom options from the `server.ts` file. ```ts createServer({ yogaServerOptions: { cors: { origin: 'http://localhost:4000', credentials: true, allowedHeaders: ['X-Custom-Header'], methods: ['POST'] } /* ...other args */ } }) ``` For the full list of supported options, please refer to https://the-guild.dev/graphql/yoga-server/docs and the `createYoga` method. ## Fixes and improvements - [Auto-populated fields (autoincrement, uuid, updatedAt, …) are now visible and directly editable from the GraphQL schema (Issue #70)](https://github.com/maoosi/prisma-appsync/issues/70) - [Fixed issue with lists (arrays) in Prisma Schema not being properly cast into the GraphQL Schema (PR #78)](https://github.com/maoosi/prisma-appsync/pull/78) - [Added `cuid` as part of the auto-populated fields (PR #72)](https://github.com/maoosi/prisma-appsync/pull/72) - [Initialize `prismaArgs` with empty select (PR #69)](https://github.com/maoosi/prisma-appsync/pull/69) - [Added an optional generic type for QueryParams (PR #74)](https://github.com/maoosi/prisma-appsync/pull/74) - [Fixed issue with CDK boilerplate policy statements (Issue #64)](https://github.com/maoosi/prisma-appsync/issues/64) - [Fixed docs using the wrong syntax for fine-grained access control examples (Issue #79)](https://github.com/maoosi/prisma-appsync/issues/79) - CDK boilerplate Lambda function upgraded to `NODEJS_16_X` - CDK boilerplate `warmUp(boolean)` parameter becomes `useWarmUp(number)`, allowing to specify the number of warm-up functions to use (default `0`) ## Credits
Sylvain
Sylvain

🧙‍♂️ 💻 🤔 📖
Ciprian Caba
Ciprian Caba

💻 🤔
Cameron Jenkinson
Cameron Jenkinson

💻
Bell
Bell

💻
## Github sponsors Enjoy using Prisma-AppSync? Please consider [💛 Github sponsors](https://github.com/sponsors/maoosi). ================================================ FILE: docs/changelog/1.0.0-rc.5.md ================================================ --- editLink: false --- # 1.0.0-rc.5 **🌟 Help us spread the word about Prisma-AppSync by starring the repo.** ## Highlights ### ⚡️ Async shield rules Async Shield rules are now supported in Prisma-AppSync, opening up to 3 different ways to define fine-grained access control rules: ```ts return await prismaAppSync.resolve({ event, shield: () => { return { // Boolean 'listPosts{,/**}': { rule: true }, // Function 'listPosts{,/**}': { rule: () => true }, // (NEW) Async Function 'listPosts{,/**}': { rule: async () => { await sleep(1000) return true }, }, } }, }) ``` ### ⚡️ Support for deeply nested relation filters Deeply nested relation filters are now supported in Prisma-AppSync, allowing to perform the following queries: ```graphql query { listComments( where: { author: { # deeply nested relation filter posts: { every: { published: { equals: true } } } } } ) } ``` ```graphql query { listUsers( where: { posts: { every: { # deeply nested relation filter comments: { every: { message: { startsWith: 'hello' } } } } } } ) } ``` ### ⚡️ Support for `extendedWhereUnique` preview feature Using the `extendedWhereUnique` preview feature flag will enable filtering on non-unique fields in Prisma-AppSync, allowing to do the following: ```prisma generator client { provider = "prisma-client-js" previewFeatures = ["extendedWhereUnique"] } ``` ```graphql mutation($id: Int!, $version: Int) { updatePost( # version is a non-unique field where: { id: $id, version: { equals: $version } }, operation: { version: { increment: 1 } } ) { id version } } ``` See [Prisma Docs](https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#filter-on-non-unique-fields-with-userwhereuniqueinput) for more details. ## Fixes and improvements - [`maxDepth` parameter not working properly with Json fields (Issue #71).](https://github.com/maoosi/prisma-appsync/issues/71) - [Local dev server reads `undefined` when using nested arrays in query (Issue #83).](https://github.com/maoosi/prisma-appsync/issues/81) - [GraphQL input `WhereUniqueInput` shouldn’t include Relation fields (Issue #83).](https://github.com/maoosi/prisma-appsync/issues/83) - [Unit tests for Prisma to GraphQL schema conversion (Issue #84).](https://github.com/maoosi/prisma-appsync/issues/84) - [Local dev server returning `null` for `0` values (PR #82).](https://github.com/maoosi/prisma-appsync/pull/82) - [Issue: fields with `@default` should appear as required `!` in generated GraphQL schema base type (Issue #91).](https://github.com/maoosi/prisma-appsync/issues/91) - Improved, more readable, Prisma Client errors logs. ## Credits
Sylvain
Sylvain

🧙‍♂️ 💻 🤔 📖
Ciprian Caba
Ciprian Caba

💻 🤔
Bell
Bell

💻
## Github sponsors Enjoy using Prisma-AppSync? Please consider [💛 Github sponsors](https://github.com/sponsors/maoosi). ================================================ FILE: docs/changelog/1.0.0-rc.6.md ================================================ --- editLink: false --- # 1.0.0-rc.6 **🌟 Help us spread the word about Prisma-AppSync by starring the repo.** > 🚨 This release include breaking changes, so please make sure to read the below thoroughly before upgrading. ## Breaking ### 💔 Updated `upsert` mutation params to be similar to Prisma Client API This change is considered breaking if you are using `upsert` mutations. ```graphql # before mutation { upsertPost( where: { id: 1 } data: { title: "Hello world" } ) { title } } # after mutation { upsertPost( where: { id: 1 } create: { title: "Hello world" } update: { title: "Hello world" } ) { title } } ``` ### 💔 Updated `QueryParams.paths` format to fix various reported issues on Shield ACL rules This change fixes various reported issues on Shield ACL rules. [See full details here.](https://github.com/maoosi/prisma-appsync/issues/125) It also allows creating more granular rules such as [`createPost/**/connect{,/**}`](https://globster.xyz/?q=createPost%2F**%2Fconnect%7B%2C%2F**%7D&f=createPost%2CcreatePost%2Ftitle%2CcreatePost%2Fauthor%2CcreatePost%2Fauthor%2Fconnect%2CcreatePost%2Fauthor%2Fconnect%2Fid%2CgetPost%2CgetPost%2Fid%2CgetPost%2Ftitle). Only considered breaking if you have implemented advanced fine-grained access control rules, or if you are using `QueryParams.paths` for some custom business logic (most likely inside Hooks). **Example:** ```graphql mutation createPost { createPost( data: { title: "Hello people" author: { connect: { id: 1 } } } ) { id title } } ``` **Before:** ```json { "paths": [ "/create/post/title", "/create/post/author/id", "/get/post/id", "/get/post/title" ] } ``` **After:** ```json { "paths": [ "createPost", "createPost/title", "createPost/author", "createPost/author/connect", "createPost/author/connect/id", "getPost", "getPost/id", "getPost/title" ] } ``` ## Highlights ### ⚡️ Support for custom GraphQL scalars on fields **Prisma schema:** ```prisma /// @gql(scalars: { website: "AWSURL" }) model Company { id Int @id @default(autoincrement()) name String website String? } ``` **GraphQL output:** ```graphql type Company { id: Int! name: String! website: AWSURL } ``` ### ⚡️ Support for nullable in Query filters **Example #1:** ```graphql query { listUsers ( where: { fullname: { isNull: true } } ) { id } } ``` **Example #2:** ```graphql query { listPosts ( where: { author: { is: NULL } } ) { id } } ``` **Example #3:** ```graphql query { listPosts ( where: { author: { isNot: NULL } } ) { id } } ``` ### ⚡️ Refreshed documentation [Prisma-AppSync documentation](https://prisma-appsync.vercel.app) has been refreshed with new navigation, revised content, and a new guide on [Tweaking the GraphQL Schema](https://prisma-appsync.vercel.app/features/gql-schema.html). ## Fixes and improvements - [The local dev server now supports concurrent queries.](https://github.com/maoosi/prisma-appsync/issues/103) - [The local dev server now returns __typename (similar to AppSync)](https://github.com/maoosi/prisma-appsync/issues/115) - [All fields with `@default()` are now optional in GraphQL output](https://github.com/maoosi/prisma-appsync/issues/96) - [Improved performances on ACL Shield Functions (checks now runs in parallel).](https://github.com/maoosi/prisma-appsync/issues/92) - [Fixed issue with ACL Shield rules and WhereUniqueInput.](https://github.com/maoosi/prisma-appsync/issues/123) - [Fixed issue with using `is` and `isNot` inside `some` or `every`.](https://github.com/maoosi/prisma-appsync/issues/102) - [Fixed issue using arguments with no selectionSet on the local dev server.](https://github.com/maoosi/prisma-appsync/pull/104) - [Fixed issue with `UpdateRelationsInput`, `delete` and `deleteMany` input types.](https://github.com/maoosi/prisma-appsync/pull/99) ## Sponsors
kuizto.co
Solve and sparkle up your daily food life
travistravis.co
Collaborative travel planning
## Credits
Sylvain
Sylvain

🐙 💻 🤔 📖
Bell
Bell

💻 🤔
Tom
Tom

💻 🤔
jeremy-white
jeremy-white

💻
## Annoucements Sylvain **I am starting my Founder journey with [kuizto.co](https://kuizto.co).** Kuizto is a bit like Netflix for your daily food! Lots of visual cooking inspiration, auto-generated grocery lists, and a small social layer to share and discover deliciously simple meals. [Please register for early access](https://kuizto.co), launching later this year! Sylvain **[Prisma-EdgeQL](https://github.com/kuizto/prisma-edgeql) is an edge-compatible Prisma Client (using PlanetScale driver).** The project was initially built as part of my work at [kuizto.co](https://kuizto.co) and will be released open-source soon. Please go [Star the repo](https://github.com/kuizto/prisma-edgeql) if you are interested! ## Github sponsors Enjoy using Prisma-AppSync? **Please consider [💛 Github sponsors](https://github.com/sponsors/maoosi).** ================================================ FILE: docs/changelog/1.0.0-rc.7.md ================================================ --- editLink: false --- # 1.0.0-rc.7 **🌟 Support Prisma-AppSync by Starring Our Repo!** ## Highlights ### Local Dev Server Transitioned to Amplify AppSync Simulator > 🚨 Breaking Change: Please Read the Following Carefully Before Upgrading. The previous version of our Prisma-AppSync local development server relied on GraphQL Yoga, complemented by several custom functions to mimic AWS Lambda and AppSync's internal behaviors. This was an effective approach initially but began to cause issues as Prisma-AppSync was used for more complex use cases. To resolve these issues and simplify maintenance, we've opted to replace our bespoke implementation with Amplify AppSync Simulator. Amplify AppSync Simulator is an integral package within the [AWS Amplify CLI](https://github.com/aws-amplify/amplify-cli) and aims to accurately simulate the experience of using AppSync locally. **This migration brings numerous advantages to using the local development server:** - Enables the use of Codegen [Issue #137](https://github.com/maoosi/prisma-appsync/issues/137) - Supports using GraphQL Fragments [Issue #112](https://github.com/maoosi/prisma-appsync/issues/112) - Accommodates Authentication and Authorization modes provided by AWS AppSync, including Cognito User Pools. - Enables Subscription support, using a local WebSocket server. **For users already using the Prisma-AppSync local development server who wish to migrate, follow these steps:** 1. Substitute your local `server.ts` file with the newer version found at `packages/boilerplate/server.ts`. 2. Install the new required dependency using `yarn add js-yaml -D`. 3. Modify the CLI command to initiate the local development server within your `package.json` file (by default, this is the `dev` script). 4. Here are the corresponding before and after scripts: ```shell # before npx vite-node ./server.ts --watch -- --handler handler.ts --schema prisma/generated/prisma-appsync/schema.gql --port 4000 --watchers '[{"watch":["**/*.prisma","*.prisma"],"exec":"npx prisma generate && touch ./server.ts"}]' --headers '{"x-fingerprint":"123456"}' # removed # after npx vite-node ./server.ts --watch -- --handler handler.ts --schema prisma/generated/prisma-appsync/schema.gql --resolvers prisma/generated/prisma-appsync/resolvers.yaml # added --port 4000 --wsPort 4001 # added --watchers '[{"watch":["**/*.prisma","*.prisma"],"exec":"npx prisma generate && touch ./server.ts"}]' ``` ### Upgraded to Prisma 5.1.1+ > 🚨 Breaking Change: Please Read the Following Carefully Before Upgrading. Prisma-AppSync internals were updated to support Prisma 5.1.1. One potentially breaking change is that the [`extendedWhereUnique`](https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#filter-on-non-unique-fields-with-userwhereuniqueinput) preview feature was promoted to general availability. So newly generated `WhereUniqueInput` schema types exposes all fields on the model, not just unique fields. ### Updated Minimum NodeJS Version Requirement > 🚨 Breaking Change: Please Read the Following Carefully Before Upgrading. The compilation target of Prisma-AppSync was updated **from Node.js 14 to Node.js 16**. Please ensure you have the minimum required Node.js version (Node.js 16) enabled on your local environment and deployed Lambda function. ### Updated CDK Boilerplate The provided CDK Boilerplate has been updated to use the latest depdencies and recommended CDK packages. In addition, the default Lambda function version has been updated to use **Node 18.X**. ## Fixes and improvements - [GraphQL Schema adjusted to make some array elements and responses non-nullable.](https://github.com/maoosi/prisma-appsync/pull/133) - [Schema generation issue when using Prisma @@id attributes](https://github.com/maoosi/prisma-appsync/issues/149) ## Sponsors
kuizto.co
Reconnect with home cooking
## Credits
Sylvain
Sylvain

🐙 💻 🤔 📖
Tom
Tom

💻 🤔
Bell
Bell

💻 🤔
Max Konin
Max Konin

💻
## Github sponsors Enjoy using Prisma-AppSync? **Please consider [💛 Github sponsors](https://github.com/sponsors/maoosi).** ================================================ FILE: docs/changelog/1.0.0.md ================================================ --- editLink: false --- # v1.0.0 **Support Prisma-AppSync by Starring Our Repo!** ## 🌟 Sponsor [![Kuizto — The Everyday Cooking App](https://prisma-appsync.vercel.app/sponsors/kuizto-banner.png "Kuizto — The Everyday Cooking App")](https://kuizto.co/?utm_source=prisma_appsync&utm_medium=github) [Kuizto.co](https://kuizto.co/?utm_source=prisma_appsync&utm_medium=github) is a cooking app that adds a unique twist to everyday cooking. Netflix-like feed to explore tailored recipes. Get inspired by others, save to cooklists, plan instantly! ## 🚀 Release Summary - Prisma-AppSync officially stable! 🎉 - Breaking change to context alias values - Breaking change to maximum query depth defaults - Enhanced `@gql` and `@auth` directives for finer control - Generator Revamp & New Diff Tool for improved GraphQL Schema output - Streamlined Model Relations: `Create[Model]Without[Relation]Input` - Default input values are now visible in your GraphQL IDE - Added support for `AWS_LAMBDA` authorization mode ## 👀 Full Changelog ### 👉 Prisma-AppSync officially stable! 🎉 Exciting news! Prisma-AppSync has achieved stability and is already in use in multiple production projects. Time to celebrate the release of v1.0.0! ### 👉 Breaking change to context alias values To streamline values in `Context.alias` (accessible from hooks and custom resolvers params), the `modify` alias has been renammed to `mutate`, and `batchModify` is now referred to as `batchMutate`. ### 👉 Breaking change to maximum query depth defaults To align maximum query depth with the latest changes from v1.0.0, the `maxDepth` default value was changed from `3` to `4`. To limit side effects, you have the option to manually set it to its previous value via: ```ts const prismaAppSync = new PrismaAppSync({ maxDepth: 3 }) ``` ### 👉 Enhanced `@gql` and `@auth` directives for finer control The `@gql` directive has been updated to provide more detailed control over CRUD operations: ```prisma // before: only top-level rules were supported @gql(queries: null, mutations: null) // after: define specific rules for each CRUD operation @gql(queries: { list: null, count: null }, mutations: { update: null, delete: null }) ``` Same goes with the `@auth` directive, allowing granular access rules per operation: ```prisma // before: only top-level rules were supported @auth(queries: [{ allow: iam }]) // after: individual rules for specific query operations @auth(queries: { list: [{ allow: iam }] }) ``` Field-level authorization is now possible with the `@auth` directive: ```prisma // newly supported field-level authorization rules @auth(fields: { password: [{ allow: apiKey }] }) ``` The `defaultDirective` in the prisma-appsync generator config is now optional, providing flexibility in configurations: ```prisma generator appsync { provider = "prisma-appsync" // `defaultDirective` can be specified or omitted defaultDirective = "@auth(model: [{ allow: iam }])" } ``` When provided, `defaultDirective` seamlessly integrates with model-specific directives: ```prisma // specified 'defaultDirective' for all models: @auth(model: [{ allow: iam }]) // additional 'model directive' for enhanced control: @auth(model: [{ allow: apiKey }]) // resulting merged directive for the model: @auth(model: [{ allow: iam }, { allow: apiKey }]) ``` ### 👉 Generator Revamp for improved GraphQL Schema output The Generator package has been totally rewritten to address reported issues and unlock a slew of new features. This not only makes the GraphQL Schema output more concise and well-optimized but also ensures Prisma-AppSync is ready for what's next. With the largest production schemas, this revamp has led to a reduction of up to 500 lines in the GraphQL Schema output. ::: info Free online tool: AppSync GraphQL Schema Diff To see the before/after with your own schema or simply compare two different AppSync Schemas, we've published a free online tool: [AppSync GraphQL Schema Diff](https://prisma-appsync.vercel.app/tools/appsync-gql-schema-diff.html). ::: ### 👉 Streamlined Model Relations: Create[Model]Without[Relation]Input With the generator revamp, you can now create, update, or upsert any Model Relation (like Author) tied to a particular Model (such as Post) in just one GraphQL query. This eliminates the previous, more cumbersome process of inserting each Model separately and then manually associating them. The improvement is in sync with the Prisma Client API, offering a more streamlined and developer-friendly approach. ```gql mutation { createPost( data: { title: "Example post" author: { connectOrCreate: { where: { name: "John Doe" } create: { name: "John Doe" } } } } ) { title author { name } } } ``` ### 👉 Default input values are now visible in your GraphQL IDE Considering the Prisma model example below: ```prisma model Post { content String views Int @default(0) isPublished Boolean @default(false) } ``` This model will result in the following GraphQL schema: ```gql input PostCreateInput { content: String! views: Int = 0 isPublished: Boolean = false } ``` This update automatically fills the default values for `views` (0) and `isPublished` (false) in your GraphQL IDE, making it easier to see and work with your schema defaults. ### 👉 Added support for `AWS_LAMBDA` authorization mode You can now utilize `AWS_LAMBDA` as an authorization mode with the `@auth` directive: ```prisma // AWS_LAMBDA @auth(model: [{ allow: lambda }]) ``` ## 🙏 Credits Sylvain
Sylvain

🐙 💻 🤔 📖 ## 💛 Github Sponsors Enjoy using Prisma-AppSync? **Please consider [💛 Github sponsors](https://github.com/sponsors/maoosi).** ================================================ FILE: docs/changelog/index.md ================================================ # Changelog - [(latest) v1.0.0](/changelog/1.0.0.html) - [1.0.0-rc.7](/changelog/1.0.0-rc.7.html) - [1.0.0-rc.6](/changelog/1.0.0-rc.6.html) - [1.0.0-rc.5](/changelog/1.0.0-rc.5.html) - [1.0.0-rc.4](/changelog/1.0.0-rc.4.html) - [1.0.0-rc.3](/changelog/1.0.0-rc.3.html) - [1.0.0-rc.2](/changelog/1.0.0-rc.2.html) - [1.0.0-rc.1](/changelog/1.0.0-rc.1.html) ================================================ FILE: docs/contributing.md ================================================ # Contributions guide Thanks for your interest in contributing! ## 👉 Discuss first Before starting to work on a pull request, it's always better to open an issue first to confirm its desirability and discuss the approach with the maintainers. ## 👉 Project packages
**`packages/generator`** Generator for [Prisma ORM](https://www.prisma.io/), whose role is to parse your Prisma Schema and generate all the necessary components to run and deploy a GraphQL API tailored for AWS AppSync.
**`packages/client`** Think of it as [Prisma Client](https://www.prisma.io/client) for GraphQL. Fully typed and designed for AWS Lambda AppSync Resolvers. It can handle CRUD operations with just a single line of code, or be fully extended.
**`packages/installer`** Interactive CLI tool that streamlines the setup of new Prisma-AppSync projects, making it as simple as running `npx create-prisma-appsync-app@latest`.
**`packages/server`** Local dev environment that mimics running Prisma-AppSync in production. It includes an AppSync simulator, local Lambda resolvers execution, a GraphQL IDE, hot-reloading, and authorizations.
## 👉 Repository setup We use `pnpm` as the core package manager, `yarn` + `docker` for creating the AWS CDK bundle before deployment, `zx` for running scripts, `aws` + `cdk` CLIs for deployment. **Start with cloning the repo on your local machine:** ```bash git clone https://github.com/maoosi/prisma-appsync.git ``` **Checkout the `dev` branch (working branch):** ```bash git checkout dev ``` **Install pre-requirements:** | Step | |:-------------| | 1. Install NodeJS, [latest LTS is recommended ↗](https://nodejs.org/en/about/releases/) | | 2. Install [pnpm ↗](https://pnpm.js.org/) | | 3. Install [yarn@1 ↗](https://classic.yarnpkg.com/en/docs/install/) | | 4. Install [zx ↗](https://github.com/google/zx) | | 5. Install [docker ↗](https://www.docker.com/products/docker-desktop) | | 6. Install the [AWS CLI ↗](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-install.html) | | 7. Install the [AWS CDK ↗](https://github.com/aws/aws-cdk) | **Verify installation:** ```bash node -v && pnpm --version && yarn --version && zx --version && docker --version && aws --version && cdk --version ``` **Install dependencies:** ```bash pnpm install ``` **Run local dev playground:** ```bash pnpm dev ``` > See list of commands below for more details about `pnpm dev`. ## 👉 Commands | Command | Description | | ------------- |:-------------| | `pnpm install` | Install project dependencies. | | `pnpm test` | Run all unit tests and e2e tests. | | `pnpm build` | Build the entire prisma-appsync library. | | `pnpm dev` | Creates local dev setup, useful for contributing [1]. | > [1] Auto-generates a 'playground' folder (if not there already) and launches a local GraphQL + AWS AppSync server. This simulates the Prisma-AppSync AWS environment for local development, with 'playground' contents pointing to local source packages. ## 👉 Commit convention We use [Conventional Commits ↗](https://www.conventionalcommits.org/) for commit messages such as: ```ts [optional scope]: ``` > - Possible types: `feat` / `fix` / `chore` / `docs` > - Possible scopes: `client` / `generator` / `cli` / `boilerplate` / `server` > - Description: Short description, with issue number when relevant. Here are some examples: | Type | Commit message | |:------------- |:------------- | | Bug fix | `fix(client): issue #234 - JEST_WORKER_ID replaced` | | New feature | `feat(generator): new defaultDirective parameter` | | Routine task | `chore: deps updated to latest` | | Docs update | `docs: fix typo inside home` | ## 👉 Coding guidelines ### ESLint We use [ESLint ↗](https://eslint.org/) for both linting and formatting.
#### IDE Setup We recommend using [VS Code ↗](https://code.visualstudio.com/) along with the [ESLint extension ↗](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). With the settings on the right, you can have auto-fix and formatting when you save the code you are editing.
VS Code's `settings.json` ```json { "editor.codeActionsOnSave": { "source.fixAll": false, "source.fixAll.eslint": true } } ```
### No Prettier Since ESLint is already configured to format the code, there is no need to duplicate the functionality with Prettier. If you have Prettier installed in your editor, we recommend you disable it when working on the project to avoid conflict. ## 👉 License When you contribute code to the Prisma-AppSync project, you grant the maintainers permission to use and share your code under the project's BSD 2-Clause License. You also affirm that you are the original author of the code and have the authority to license it. ================================================ FILE: docs/features/gql-schema.md ================================================ # Tweaking GraphQL Schema Prisma-AppSync provides ways to tweak and customise the GraphQL Schema output. ## 👉 Models directives Tweaking the GraphQL schema for a given model require to write directives via AST comments (triple-slash `///`). ```prisma /// @gql(mutations: null, subscriptions: null) /// @gql(fields: { password: null }) /// @gql(scalars: { email: "AWSEmail" }) model User { id Int @id @default(autoincrement()) email String password String } ``` ## 👉 Usage with @gql syntax ### Disabling an entire model ```prisma // Disable all queries, mutations and subscriptions @gql(model: null) ``` ### Disabling queries ```prisma // Disable all queries (get, list, count, ...) @gql(queries: null) // Disable granular queries @gql(queries: { list: null, count: null }) ``` ### Disabling mutations ```prisma // Disable all mutations (create, update, upsert, delete, ...) @gql(mutations: null) // Disable granular mutations @gql(mutations: { update: null, delete: null }) ``` > **Cascading Rules:** > > - Disabling `update` **will also disable** `upsert` > - Disabling `create` **will also disable** `upsert` > - Disabling `mutations` **will also disable** `subscriptions` ### Disabling subscriptions ```prisma // Disable all subscriptions (onCreated, onUpdated, ...) @gql(subscriptions: null) // Disable granular subscriptions @gql(mutations: { onCreated: null, onUpdated: null }) ``` ### Hiding fields ```prisma // If applied to a model with a `password` field: // hide `password` field from the generated Type @gql(fields: { password: null }) ``` > **Note:** To maintain Prisma Client integrity, hidden fields remain writable in mutation operations. ### Custom scalars on fields ```prisma // If applied to a model with a `website` (string) field: // use scalar `AWSURL` instead of default `String` @gql(scalars: { website: "AWSURL" }) ``` ================================================ FILE: docs/features/hooks.md ================================================ # Lifecycle hooks Hooks let you “hook into” Prisma-AppSync lifecycle to either trigger custom business logic or manipulate data at runtime. ## 👉 Example code Basic example: ```ts return await prismaAppSync.resolve({ event, hooks: { // Mutate Post title before creation on database 'before:createPost': async (params: BeforeHookParams) => { params.prismaArgs.data.title = 'New post title' return params }, // Override query result using always the same Post title 'after:listPosts': async (params: AfterHookParams) => { params.result = params.result.map(r => r.title = 'Always the same title') return params }, }, }) ``` Advanced example: ```ts return await prismaAppSync.resolve<'likePost'>({ event, hooks: { // execute before any query 'before:**': async (params: BeforeHookParams) => params, // execute after any query 'after:**': async (params: AfterHookParams) => params, // execute after custom resolver query `likePost` // (e.g. `query { likePost(postId: 3) }`) 'after:likePost': async (params: AfterHookParams) => { await params.prismaClient.notification.create({ data: { event: 'POST_LIKED', targetId: params.args.postId, userId: params.authIdentity.sub, }, }) return params }, }, }) ``` ## 👉 Types ```ts export interface QueryParams { type: GraphQLType operation: string context: Context fields: string[] paths: string[] args: any prismaArgs: PrismaArgs authorization: Authorization identity: Identity headers: any prismaClient: PrismaClient } type BeforeHookParams = QueryParams type AfterHookParams = QueryParams & { result: any | any[] } ``` ## 👉 Usage rules - Hooks are made of a **Path** (e.g. `after:updatePost`) and an async function. - **Path** syntax always starts with `before:` or `after:`. > `before` or `after` querying data from the database. - **Path** syntax after `:` uses [Micromatch syntax](https://github.com/micromatch/micromatch). - Hooks are fully typed, so VSCode IntelliSense will give you the full list of Hooks Paths you can use while typing. Example: ![Prisma-AppSync hooks on VS Code](/guides/hooks-autocompletion.png) - Hooks functions all receive a single object as a parameter. Here is an example object received inside `after:getPost`: ```json { "type": "Query", "operation": "getPost", "context": { "action": "get", "alias": "access", "model": "Post" }, "fields": ["title", "status"], "paths": ["get/post/title", "get/post/status"], "args": { "where": { "id": 5 } }, "prismaArgs": { "where": { "id": 5 }, "select": { "title": true, "status": true } }, "authorization": "API_KEY", "identity": {}, "result": { "title": "My first post", "status": "PUBLISHED" } } ``` - Key `result` is only available inside `after` hooks. - Hooks async functions MUST return the object received as a parameter (either mutated or untouched). - Using hooks on custom resolvers requires explicitly listing resolvers using a TypeScript Generic `prismaAppSync.resolve`: ```ts // Using custom resolver `likePost` return await prismaAppSync.resolve<'likePost'>({ event, hooks }) // Using multiple custom resolvers return await prismaAppSync.resolve<'likePost' | 'unlikePost'>({ event, hooks }) ``` ================================================ FILE: docs/features/resolvers.md ================================================ # Custom resolvers Let's assume we want to extend our GraphQL CRUD API and add a custom mutation `incrementPostsViews` based on our Prisma Schema: ```prisma model Post { id Int @id @default(autoincrement()) views Int } ``` ## 👉 1. Extending our GraphQL Schema To extend our auto-generated `schema.gql`, we will create a new `custom-schema.gql` file next to our `schema.prisma` file: ```graphql extend type Mutation { """ Increment post views by +1 """ incrementPostsViews(postId: Int!): Post } ``` For Prisma-AppSync to merge our `custom-schema.gql` with the auto-generated schema, we edit the `schema.prisma` generator config: ```json{3} generator appsync { provider = "prisma-appsync" extendSchema = "./custom-schema.gql" } ``` ## 👉 2. Extending our Resolvers Config For AWS AppSync to be able to use our new `incrementPostsViews` mutation, we also create a new `custom-resolvers.yaml` next to our `schema.prisma` file: ```yaml - typeName: Mutation fieldName: incrementPostsViews dataSource: prisma-appsync ``` For Prisma-AppSync to merge our `custom-resolvers.yaml` with the auto-generated resolvers config, we edit the `schema.prisma` generator config: ```json{4} generator appsync { provider = "prisma-appsync" extendSchema = "./custom-schema.gql" extendResolvers = "./custom-resolvers.yaml" } ``` ## 👉 3. Coding our new Resolver Function Querying the `incrementPostsViews` mutation will automatically run a Resolver Function inside our Lambda `handler.ts` file. This is where we will code our custom business logic. ```ts return await prismaAppSync.resolve<'incrementPostsViews'>({ event, resolvers: { // code for our new resolver function incrementPostsViews: async ({ args, prismaClient }: QueryParamsCustom) => { return await prismaClient.post.update({ data: { views: { increment: 1 } }, where: { id: args.postId } }) }, } }) ``` ## 👉 4. Updating our CDK file for bundling To make sure our `custom-schema.gql` and `custom-resolvers.yaml` are properly bundled and deployed on AWS, we update the `beforeBundling` function inside `cdk/index.ts`: ```ts function: { bundling: { commandHooks: { beforeBundling(inputDir: string, outputDir: string): string[] { const schema = path.join(inputDir, 'prisma/schema.prisma') const gql = path.join(inputDir, 'prisma/custom-schema.gql') const yaml = path.join(inputDir, 'prisma/custom-resolvers.yaml') return [ `cp ${schema} ${outputDir}`, `cp ${gql} ${outputDir}`, `cp ${yaml} ${outputDir}`, ] }, }, } } ``` 🚀 **Done! Next time we deploy on AWS, we will be able to use our new `incrementPostsViews` mutation.** ================================================ FILE: docs/index.md ================================================ --- layout: home hero: name: Prisma-AppSync text: GQL API Generator for Prisma ORM tagline: Turns your Prisma Schema into a fully-featured GraphQL API, tailored for AWS AppSync. image: src: /logo.svg alt: Prisma-AppSync actions: - theme: brand text: Try Prisma-AppSync link: /quick-start/getting-started - theme: alt text: View on GitHub ↗ link: https://github.com/maoosi/prisma-appsync features: - icon: ◭ title: Prisma Schema to CRUD API details: Deploy a GraphQL API from your Prisma Schema with auto-generated CRUD. - icon: ⚡️ title: GraphQL on AWS AppSync details: Serverless GraphQL with real-time updates and built-in security on AppSync. - icon: 🧑‍💻 title: Fast and Flexible DX details: Build and deploy a working API in minutes, easily customise to your needs. --- ================================================ FILE: docs/quick-start/deploy.md ================================================ # Deploy ## 👉 1. Prepare your local machine Make sure to install the below on your local machine: - [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) - [AWS CDK CLI](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html#getting_started_install) - [Docker](https://docs.docker.com/get-docker/) Then [configure your local environment](https://docs.aws.amazon.com/cdk/v2/guide/cli.html#cli-environment) with the AWS Account of your choice. ## 👉 2. Setup a Database Setup the database of your choice. It doesn't have to be hosted on Amazon AWS, you can use any database supported by Prisma. If you are not sure what to use, we recommend using [PlanetScale](https://planetscale.com) and read the following [integration guide](https://planetscale.com/docs/tutorials/prisma-quickstart). ## 👉 3. Deploy on AWS Run the below CDK CLI command: > Where `DATABASE_URL` is your own [database connection url](https://www.prisma.io/docs/reference/database-reference/connection-urls). ```bash DATABASE_URL=mysql://xxx yarn deploy ``` **🚀 Done! Your GraphQL API is now ready to use.** ================================================ FILE: docs/quick-start/getting-started.md ================================================ # Getting started **Prisma-AppSync** seamlessly transforms your [Prisma Schema](https://www.prisma.io) into a comprehensive GraphQL API, tailored for [AWS AppSync](https://aws.amazon.com/appsync/).
**From `schema.prisma`:** ```prisma model Post { id Int title String } ``` **To full-blown GraphQL API:** ```graphql query list { listPosts { id title } } ```
## 👉 Features 💎 **Use your ◭ Prisma Schema**
Quickly define your data model and deploy a GraphQL API tailored for AWS AppSync. ⚡️ **Auto-generated CRUD operations**
Using Prisma syntax, with a robust TS Client designed for AWS Lambda Resolvers. ⛑ **Pre-configured security**
Built-in XSS protection, query depth limitation, and in-memory rate limiting. 🔐 **Fine-grained ACL and authorization**
Flexible security options such as API keys, IAM, Cognito, and more. 🔌 **Fully extendable features**
Customize your GraphQL schema, API resolvers, and data flow as needed. ## 👉 Built around 4 packages
**`packages/generator`** Generator for [Prisma ORM](https://www.prisma.io/), whose role is to parse your Prisma Schema and generate all the necessary components to run and deploy a GraphQL API tailored for AWS AppSync.
**`packages/client`** Think of it as [Prisma Client](https://www.prisma.io/client) for GraphQL. Fully typed and designed for AWS Lambda AppSync Resolvers. It can handle CRUD operations with just a single line of code, or be fully extended.
**`packages/installer`** Interactive CLI tool that streamlines the setup of new Prisma-AppSync projects, making it as simple as running `npx create-prisma-appsync-app@latest`.
**`packages/server`** Local dev environment that mimics running Prisma-AppSync in production. It includes an AppSync simulator, local Lambda resolvers execution, a GraphQL IDE, hot-reloading, and authorizations.
================================================ FILE: docs/quick-start/installation.md ================================================ # Installation ## 👉 Option 1: Using the CLI Installer (recommended) Run the following command and follow the prompts 🙂 ```bash npx create-prisma-appsync-app@latest ``` 🚀 Done! ## 👉 Option 2: Manual Install Add `prisma-appsync` to your project dependencies. ```bash # using yarn yarn add prisma-appsync # using npm npm i prisma-appsync ``` Edit your `schema.prisma` file and add: ```json generator appsync { provider = "prisma-appsync" } ``` Also make sure to use the right binary targets: ```json{3} generator client { provider = "prisma-client-js" binaryTargets = ["native", "rhel-openssl-1.0.x"] } ``` Generate your Prisma Client (this will also generate your Prisma-AppSync client): ```bash npx prisma generate ``` Create your `handler.ts` Lambda handler (AppSync Direct Lambda Resolver): ```ts // Import generated Prisma-AppSync client (adjust path as necessary) import { PrismaAppSync } from './prisma/generated/prisma-appsync/client' // Instantiate Prisma-AppSync Client const prismaAppSync = new PrismaAppSync() // Lambda handler (AppSync Direct Lambda Resolver) export const main = async (event: any) => { return await prismaAppSync.resolve({ event }) } ``` Either copy the AWS CDK boilerplate provided with Prisma-AppSync into your project, OR just use it as a reference for your own CDK config: ```bash # path to cdk boilerplate ./node_modules/prisma-appsync/dist/boilerplate/cdk/ ``` Refer to [AWS CDK Toolkit docs ↗](https://docs.aws.amazon.com/cdk/v2/guide/cli.html) for more info. ================================================ FILE: docs/quick-start/usage.md ================================================ # Usage ## 👉 Folder structure Using the CLI Installer (recommended): ```bash project/ |__ handler.ts # lambda function handler (API resolver) |__ server.ts # local server (for dev) |__ cdk/ # AWS CDK deploy boilerplate |__ prisma/ |__ schema.prisma # prisma schema (data source) |__ generated/ # auto-generated after each `npx prisma generate` ``` ## 👉 Generating the API Run the below command from the project root directory: ```bash npx prisma generate ``` After each `prisma generate`, files inside `prisma/generated` will be auto-generated. ## 👉 Local dev server Run the local server and try Prisma-AppSync locally (only if using the CLI Installer): ```bash yarn run dev ``` This will automatically push your Prisma Schema changes to a SQLite database, as well as launch a local GraphQL IDE server (with auto-reload and TS support). ================================================ FILE: docs/security/appsync-authz.md ================================================ # AppSync Authorization modes AWS AppSync provides [authz directives ↗](https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html) for configuring security and data protection. ::: warning SECURITY MUST NEVER BE TAKEN FOR GRANTED Prisma-AppSync implements a basic mechanism to help mitigate some common issues. However, accuracy is not guaranteed and you should always test your own API security implementation. ::: ## 👉 Models directives Applying AppSync authorization modes for a given model require to write directives using AST comments (triple-slash `///`). ```prisma /// @auth(model: [{ allow: iam }, { allow: apiKey }]) model Post { id Int @id @default(autoincrement()) title String } ``` ## 👉 Usage with @auth syntax > **Note:** For now, `@auth` only works supports the `allow` key. ### Entire model ```prisma // Apply to all queries, mutations and subscriptions @auth(model: [{ allow: iam }]) ``` ### Queries ```prisma // Apply to all queries (get, list, count, ...) @auth(queries: [{ allow: iam }]) // Apply to granular queries @auth(queries: { list: [{ allow: iam }] }) ``` ### Mutations ```prisma // Apply to all mutations (create, update, upsert, delete, ...) @auth(mutations: [{ allow: iam }]) // Apply to granular mutations @auth(mutations: { create: [{ allow: iam }] }) ``` ### Subscriptions ```prisma // Apply to all subscriptions (onCreated, onUpdated, ...) @auth(subscriptions: [{ allow: iam }]) // Apply to granular subscriptions @auth(subscriptions: { onCreated: [{ allow: iam }] }) ``` ### Fields ```prisma // Apply to specific Type fields @auth(fields: { password: [{ allow: apiKey }] }) ``` ## 👉 Supported Authorization modes ```prisma // API_KEY Authorization @auth(model: [{ allow: apiKey }]) // AWS_IAM @auth(model: [{ allow: iam }]) // OPENID_CONNECT @auth(model: [{ allow: oidc }]) // AWS_LAMBDA @auth(model: [{ allow: lambda }]) // AMAZON_COGNITO_USER_POOLS @auth(model: [{ allow: userPools }]) // AMAZON_COGNITO_USER_POOLS with groups @auth(model: [{ allow: userPools, groups: ["users", "admins"] }]) // Allow multiples @auth(model: [{ allow: apiKey }, { allow: userPools, groups: ["admins"] }]) ``` ## 👉 Default directive It is also possible to set a `defaultDirective`, that will apply to all generated Types: ```prisma{3} generator appsync { provider = "prisma-appsync" defaultDirective = "@auth(model: [{ allow: iam }])" } ``` When provided, `defaultDirective` seamlessly integrates with model-specific directives: ```prisma // specified 'defaultDirective' for all models: @auth(model: [{ allow: iam }]) // additional 'model directive' for enhanced control: @auth(model: [{ allow: apiKey }]) // resulting merged directive for the model: @auth(model: [{ allow: iam }, { allow: apiKey }]) ``` ================================================ FILE: docs/security/query-depth.md ================================================ # Query depth ## 👉 Usage Prisma-AppSync automatically prevents from abusing query depth, by limiting query complexity. **For example, it will prevent from doing this:** ```graphql query IAmEvil { author(id: "abc") { posts { author { posts { author { posts { author { # that could go on as deep as the client wants! } } } } } } } } ``` Default value for the maximum query depth is set to `4`. It is possible to change the default max depth value via the `maxDepth` option: ```ts const prismaAppSync = new PrismaAppSync({ maxDepth: 3 }) ``` ================================================ FILE: docs/security/rate-limiter.md ================================================ # Rate limiter (DOS) ::: warning WARNING NOTICE Limits are kept in memory and are not shared between function instantiations. This means limits can reset arbitrarily when new instances get spawned or different instances are used to serve requests. ::: ## 👉 Usage Prisma-AppSync uses in-memory rate-limiting to try protect your Database from most common DOS attacks. To change the default value (default to 200 requests per user, per minute), you can adjust the `maxReqPerUserMinute` option when instantiating the Client: ```ts const prismaAppSync = new PrismaAppSync({ maxReqPerUserMinute: 500 }) ``` ## 👉 Disable rate limiter If you prefer to disable the in-memory rate limiter, set the option to false: ```ts const prismaAppSync = new PrismaAppSync({ maxReqPerUserMinute: false }) ``` ================================================ FILE: docs/security/shield-acl.md ================================================ # Shield (Access Control Rules) Fine-grained access control rules can be used via the `shield` property of Prisma-AppSync client, directly inside the Lambda Handler function. ::: warning SECURITY MUST NEVER BE TAKEN FOR GRANTED Prisma-AppSync implements a basic mechanism to help mitigate some common issues. However, accuracy is not guaranteed and you should always test your own API security implementation. ::: ## 👉 Basic example For example, we might want to only allow access to `PUBLISHED` posts: ```ts return await prismaAppSync.resolve({ event, shield: () => { // Prisma filtering syntax // https://www.prisma.io/docs/concepts/components/prisma-client/filtering-and-sorting const isPublished = { status: { equals: 'PUBLISHED' } } return { // Micromatch syntax // https://github.com/micromatch/micromatch 'getPost{,/**}': { rule: isPublished, reason: () => 'Unpublished Posts cannot be accessed.', }, } }, }) ``` Useful links to create shield rules: - [Micromatch syntax](https://github.com/micromatch/micromatch) - [Micromatch tester](https://globster.xyz/?q=getPost%7B%2C%2F**%7D&f=getPost%2Ftitle%2CgetPost%2Fstatus) ## 👉 Usage with AppSync Authorization modes Combining fine-grained access control with [AppSync Authorization modes](/security/appsync-authz) allows to implement powerful controls around data. Let's assume we want to restrict API access to users logged in via `AMAZON_COGNITO_USER_POOLS` and only allow the owner of a given Post to modify it: ```ts return await prismaAppSync.resolve({ event, shield: ({ authorization, identity }: QueryParams) => { const isCognitoAuth = authorization === Authorizations.AMAZON_COGNITO_USER_POOLS const isOwner = { owner: { cognitoSub: identity?.sub } } return { '**': { rule: isCognitoAuth, reason: ({ model }) => `${model} access is restricted to logged-in users.`, }, '{update,upsert,delete}Post{,/**}': { rule: isOwner, reason: ({ model }) => `${model} can only be modified by their owner.`, }, } }, }) ``` > The above example implies using Cognito User Pools Authorization. Plus having set up an `Owner` relation on the `Post` model, and a `cognitoSub` field on the `User` model (containing all users `sub`). ## 🚨 Order matters The latest matching rule ALWAYS overrides previous ones. ```ts // Bad - Second rule overrides first one return { 'listUsers/password': false, 'listUsers{,/**}': true, } // Good - Always write the more specific rules last return { 'listUsers{,/**}': true, 'listUsers/password': false } ``` ================================================ FILE: docs/security/xss-sanitizer.md ================================================ # XSS sanitizer ## 👉 Usage Prisma-AppSync automatically perform XSS sanitization and encode all data coming through the GraphQL API. **Take a look at this example:**
1/ Assuming the following GraphQL Input: ```graphql mutation maliciousPost($title: String!) { createPost(data: { title: $title }) { title } } ``` ```json { "title": "" } ```
2/ Prisma-AppSync will automatically remove the malicious code and encode Html, before storing anything in the database: | Column name | Value | | ------------- |:-------------| | title | `<img src>` |
3/ Finally, the GraphQL API will also automatically clarify (decode) all data before sending the response: ```ts console.log(post.title) // output: "" ```
## 👉 Disable xss sanitization If you prefer to disable data sanitization, set the `sanitize` option to false when instantiating the Client: ```ts const prismaAppSync = new PrismaAppSync({ sanitize: false }) ``` ================================================ FILE: docs/support.md ================================================ # Support ## Sylvain **👋 Hi, I’m Sylvain! [On-demand CTO](https://sylvainsimao.com/freelance/) and creator of ◭ Prisma-AppSync.** Using Prisma-AppSync in production and looking for hourly paid support? You can contact me on [Twitter](https://twitter.com/Sylvain_Simao), message me on [LinkedIn](https://www.linkedin.com/in/sylvainsimao/), or [send me an email](https://sylvainsimao.com/contact). ================================================ FILE: docs/tools/appsync-gql-schema-diff.md ================================================ --- aside: false --- # AppSync GraphQL Schema Diff Compare changes between AppSync GraphQL Schemas.
  1. {{ log.level }}: {{ log.message }}
No differences.
================================================ FILE: package.json ================================================ { "name": "prisma-appsync", "version": "1.0.2", "description": "⚡ AppSync GraphQL API Generator for ◭ Prisma ORM.", "author": "maoosi ", "license": "BSD-2-Clause", "repository": "git@github.com:maoosi/prisma-appsync.git", "keywords": [ "api", "appsync", "aws", "crud", "generator", "graphql", "prisma", "prisma-appsync", "appsync-crud-api" ], "bin": "./dist/generator.js", "engines": { "node": ">=14", "pnpm": ">=6" }, "scripts": { "preinstall": "npx only-allow pnpm", "postinstall": "zx bin/postinstall.mjs", "build": "zx bin/build.mjs", "test": "zx bin/test.mjs", "dev": "zx bin/dev.mjs", "cleans": "zx bin/cleans.mjs", "publish": "zx bin/publish.mjs", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:serve": "vitepress serve docs" }, "devDependencies": { "@antfu/eslint-config": "^2.4.6", "@graphql-inspector/core": "^5.0.2", "@graphql-tools/schema": "^10.0.2", "@prisma/client": "^5.7.1", "@types/lodash": "^4.14.202", "@types/node": "^20.10.5", "@zerollup/ts-transform-paths": "^1.7.18", "all-contributors-cli": "^6.26.1", "easygraphql-tester": "^6.0.1", "esbuild": "^0.19.10", "eslint": "^8.56.0", "graphql": "16.8.1", "listr": "^0.14.3", "lodash": "^4.17.21", "pluralize": "^8.0.0", "prisma": "^5.7.1", "prompts": "^2.4.2", "sass": "^1.69.5", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.3.3", "vite": "^5.0.10", "vite-tsconfig-paths": "^4.2.2", "vitepress": "1.0.0-rc.32", "vitest": "^1.1.0" } } ================================================ FILE: packages/boilerplate/cdk/package.json ================================================ { "name": "prisma-appsync-cdk", "version": "1.0.0", "description": "Sample AWS CDK template for Prisma-AppSync", "author": "maoosi ", "private": true, "license": "BSD-2-Clause", "devDependencies": { "@types/js-yaml": "^4.0.5", "@types/node": "^20.4.8", "aws-cdk-lib": "^2.90.0", "constructs": "^10.2.69", "js-yaml": "^4.1.0", "scule": "^1.0.0", "ts-node": "^10.9.1", "typescript": "^5.1.6" } } ================================================ FILE: packages/boilerplate/cdk/src/appsync.ts ================================================ /* eslint-disable no-new */ import { readFileSync } from 'fs' import type { Construct } from 'constructs' import { camelCase, kebabCase, pascalCase } from 'scule' import { load } from 'js-yaml' import { Duration, RemovalPolicy, Stack, aws_appsync as appSync, aws_iam as iam, aws_lambda as lambda, aws_lambda_nodejs as lambdaNodejs, type StackProps } from 'aws-cdk-lib' export interface AppSyncStackProps { resourcesPrefix: string cognitoUserPoolId?: string schema: string resolvers: string function: { code: string memorySize: number useWarmUp: number policies?: iam.PolicyStatementProps[] bundling?: lambdaNodejs.BundlingOptions environment?: {} } additionalApiKeys?: string[] authorizationConfig: appSync.AuthorizationConfig } export class AppSyncStack extends Stack { private props: AppSyncStackProps private resourcesPrefix: string private resourcesPrefixCamel: string private graphqlApi: appSync.GraphqlApi private directResolverFn: lambda.Alias private apiRole: iam.Role private dataSources: { lambda?: appSync.LambdaDataSource none?: appSync.NoneDataSource } constructor(scope: Construct, id: string, tplProps: AppSyncStackProps, props?: StackProps) { super(scope, id, props) // stack naming convention this.props = tplProps this.resourcesPrefix = kebabCase(this.props.resourcesPrefix) this.resourcesPrefixCamel = camelCase(this.resourcesPrefix) this.createGraphQLApi() this.createLambdaResolver() this.createDataSources() this.createPrismaAppSyncResolvers() } createGraphQLApi() { // create appsync instance this.graphqlApi = new appSync.GraphqlApi(this, `${this.resourcesPrefixCamel}Api`, { name: this.resourcesPrefix, schema: appSync.SchemaFile.fromAsset(this.props.schema), authorizationConfig: this.props.authorizationConfig, logConfig: { fieldLogLevel: appSync.FieldLogLevel.ERROR, }, xrayEnabled: true, }) // create default API key new appSync.CfnApiKey(this, `${this.resourcesPrefixCamel}ApiKey`, { apiId: this.graphqlApi.apiId, description: `${this.resourcesPrefix}_api-key`, expires: Math.floor(new Date().setDate(new Date().getDate() + 365) / 1000.0), }) // create additional API keys if (this.props.additionalApiKeys) { this.props.additionalApiKeys.forEach((apiKey: string) => { new appSync.CfnApiKey(this, `${this.resourcesPrefixCamel}ApiKey${pascalCase(apiKey)}`, { apiId: this.graphqlApi.apiId, description: `${this.resourcesPrefix}_api-key_${kebabCase(apiKey)}`, expires: Math.floor(new Date().setDate(new Date().getDate() + 365) / 1000.0), }) }) } } createLambdaResolver() { // create function execution role const lambdaExecutionRole = new iam.Role(this, `${this.resourcesPrefixCamel}FnExecRole`, { roleName: `${this.resourcesPrefix}_fn-exec-role`, assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')], ...(this.props.function?.policies && this.props.function.policies.length > 0 && { inlinePolicies: { customApiFunctionPolicy: new iam.PolicyDocument({ statements: this.props.function.policies.map((statement) => { return new iam.PolicyStatement(statement) }), }), }, }), }) // create lambda function datasource const lambdaFunction = new lambdaNodejs.NodejsFunction(this, `${this.resourcesPrefixCamel}Fn`, { functionName: `${this.resourcesPrefix}_fn`, role: lambdaExecutionRole, environment: this.props.function.environment || {}, runtime: lambda.Runtime.NODEJS_18_X, timeout: Duration.seconds(10), handler: 'main', entry: this.props.function.code, memorySize: this.props.function.memorySize, tracing: lambda.Tracing.ACTIVE, currentVersionOptions: { removalPolicy: RemovalPolicy.RETAIN, retryAttempts: 2, }, ...(this.props.function.bundling && { bundling: this.props.function.bundling, }), }) // create alias (from latest version) this.directResolverFn = new lambda.Alias(this, `${this.resourcesPrefixCamel}_FnAliasLive`, { aliasName: 'live', version: lambdaFunction.currentVersion, ...(this.props.function.useWarmUp > 0 && { provisionedConcurrentExecutions: this.props.function.useWarmUp, }), }) // create IAM role this.apiRole = new iam.Role(this, `${this.resourcesPrefixCamel}ApiRole`, { roleName: `${this.resourcesPrefix}_api-role`, assumedBy: new iam.ServicePrincipal('appsync.amazonaws.com'), inlinePolicies: { allowEc2DescribeNetworkInterfaces: new iam.PolicyDocument({ statements: [ new iam.PolicyStatement({ actions: ['lambda:InvokeAsync', 'lambda:InvokeFunction'], resources: [this.directResolverFn.functionArn], }), ], }), }, }) } createPrismaAppSyncResolvers() { // read resolvers from yaml const resolvers = load(readFileSync(this.props.resolvers, 'utf8')) // create resolvers if (Array.isArray(resolvers)) { resolvers.forEach((resolver: any) => { const resolvername = `${resolver.fieldName}${resolver.typeName}_resolver` if (['lambda', 'prisma-appsync'].includes(resolver.dataSource) && this.dataSources.lambda) { new appSync.Resolver(this, resolvername, { api: this.graphqlApi, typeName: resolver.typeName, fieldName: resolver.fieldName, dataSource: this.dataSources.lambda, }) } else if (resolver.dataSource === 'none' && this.dataSources.none) { new appSync.Resolver(this, resolvername, { api: this.graphqlApi, typeName: resolver.typeName, fieldName: resolver.fieldName, dataSource: this.dataSources.none, requestMappingTemplate: appSync.MappingTemplate.fromString( resolver.requestMappingTemplate, ), responseMappingTemplate: appSync.MappingTemplate.fromString( resolver.responseMappingTemplate, ), }) } }) } } createDataSources() { this.dataSources = {} // create datasource of type "lambda" this.dataSources.lambda = new appSync.LambdaDataSource( this, `${this.resourcesPrefixCamel}LambdaDatasource`, { api: this.graphqlApi, name: `${this.resourcesPrefixCamel}LambdaDataSource`, lambdaFunction: this.directResolverFn, serviceRole: this.apiRole, }, ) // create datasource of type "none" this.dataSources.none = new appSync.NoneDataSource(this, `${this.resourcesPrefixCamel}NoneDatasource`, { api: this.graphqlApi, name: `${this.resourcesPrefixCamel}NoneDataSource`, }) } } ================================================ FILE: packages/boilerplate/cdk/src/index.ts ================================================ /* eslint-disable no-new */ import { join } from 'path' import { App } from 'aws-cdk-lib' import { AuthorizationType } from 'aws-cdk-lib/aws-appsync' import { kebabCase } from 'scule' import { AppSyncStack } from './appsync' const app = new App() new AppSyncStack(app, kebabCase('{{ projectName }}'), { resourcesPrefix: '{{ projectName }}', schema: join(process.cwd(), '{{ relativeGqlSchemaPath }}'), resolvers: join(process.cwd(), '{{ relativeYmlResolversPath }}'), function: { code: join(process.cwd(), '{{ relativeHandlerPath }}'), memorySize: 1536, useWarmUp: 0, // useWarmUp > 0 will incur extra costs environment: { NODE_ENV: 'production', DATABASE_URL: process.env.DATABASE_URL, }, bundling: { minify: true, sourceMap: true, forceDockerBundling: true, commandHooks: { beforeBundling(inputDir: string, outputDir: string): string[] { return [`cp ${inputDir}/{{ relativePrismaSchemaPath }} ${outputDir}`] }, beforeInstall() { return [] }, afterBundling() { return [ 'npx prisma generate', 'rm -rf generated', // npm + yarn 1.x 'rm -rf node_modules/@prisma/engines', 'rm -rf node_modules/@prisma/client/node_modules', 'rm -rf node_modules/.bin', 'rm -rf node_modules/prisma', 'rm -rf node_modules/prisma-appsync', ] }, }, nodeModules: ['prisma', '@prisma/client'], environment: { NODE_ENV: 'production', }, }, }, authorizationConfig: { defaultAuthorization: { authorizationType: AuthorizationType.API_KEY, }, }, }) app.synth() ================================================ FILE: packages/boilerplate/cdk/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2018", "module": "commonjs", "lib": ["es2016", "es2017.object", "es2017.string"], "strict": true, "noImplicitAny": true, "strictNullChecks": true, "noImplicitThis": true, "alwaysStrict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": false, "inlineSourceMap": true, "inlineSources": true, "experimentalDecorators": true, "strictPropertyInitialization": false } } ================================================ FILE: packages/boilerplate/cdk.json ================================================ { "app": "npx ts-node --prefer-ts-exts cdk/src/index.ts" } ================================================ FILE: packages/boilerplate/handler.ts ================================================ import type { AppSyncResolverEvent } from './prisma/generated/prisma-appsync/client' import { PrismaAppSync } from './prisma/generated/prisma-appsync/client' // Instantiate Prisma-AppSync Client const prismaAppSync = new PrismaAppSync() // Lambda handler (AppSync Direct Lambda Resolver) export const main = async (event: AppSyncResolverEvent) => { return await prismaAppSync.resolve({ event }) } ================================================ FILE: packages/boilerplate/prisma/sqlite.prisma ================================================ datasource db { provider = "sqlite" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" binaryTargets = ["native", "rhel-openssl-1.0.x"] } generator appsync { provider = "prisma-appsync" } /// @gql(fields: { passwordHash: null }) model User { id Int @id @default(autoincrement()) email String @unique passwordHash String posts Post[] createdAt DateTime @default(now()) } /// @gql(scalars: { source: "AWSURL" }) model Post { id Int @id @default(autoincrement()) title String source String? author User? @relation(fields: [authorId], references: [id]) authorId Int? updatedAt DateTime @updatedAt createdAt DateTime @default(now()) } ================================================ FILE: packages/boilerplate/server/server.ts ================================================ import { join } from 'path' import { readFileSync } from 'fs' import { load } from 'js-yaml' import { argv, createServer } from 'prisma-appsync/dist/server' (async () => { const schema = readFileSync(join(process.cwd(), argv.flags.schema), { encoding: 'utf-8' }) const lambdaHandler = await import(join(process.cwd(), argv.flags.handler)) const resolvers = load(readFileSync(join(process.cwd(), argv.flags.resolvers), { encoding: 'utf-8' })) const port = argv.flags.port const wsPort = argv.flags.wsPort const watchers = argv.flags.watchers ? JSON.parse(argv.flags.watchers) : [] createServer({ schema, lambdaHandler, resolvers, port, wsPort, watchers, }) })() ================================================ FILE: packages/boilerplate/tsconfig.json ================================================ { "$schema": "https://json.schemastore.org/tsconfig", "display": "Default", "compilerOptions": { "target": "ES2018", "module": "commonjs", "lib": ["es2018"], "declaration": false, "strict": true, "noImplicitAny": false, "strictNullChecks": true, "noImplicitThis": true, "alwaysStrict": true, "noUnusedLocals": false, "noImplicitReturns": true, "noFallthroughCasesInSwitch": false, "inlineSourceMap": true, "inlineSources": true, "esModuleInterop": true, "experimentalDecorators": true, "strictPropertyInitialization": false, "baseUrl": "." }, "include": ["**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] } ================================================ FILE: packages/client/package.json ================================================ { "name": "prisma-appsync-client", "private": true, "version": "1.0.0", "author": "maoosi ", "license": "BSD-2-Clause", "devDependencies": { "@types/aws-lambda": "^8.10.126", "@types/micromatch": "^4.0.5", "deepmerge": "^4.3.1", "html-entities": "^2.4.0", "lambda-rate-limiter": "^4.0.0", "micromatch": "^4.0.5", "wild-wild-path": "^4.0.0", "wild-wild-utils": "^5.0.0", "xss": "^1.0.14" } } ================================================ FILE: packages/client/src/adapter.ts ================================================ import { CustomError } from './inspector' import { sanitize } from './guard' import { clone, isEmpty, isObject, isUndefined, lowerFirst, merge, objectToPaths, uniq, walk, } from './utils' import type { Action, ActionsAlias, AppSyncEvent, Authorization, Context, GraphQLType, Identity, Model, Options, PrismaArgs, QueryParams, } from './types' import { Actions, ActionsAliasesList, Authorizations, BatchActionsList, Prisma_ReservedKeysForPaths, } from './consts' /** * #### Parse AppSync direct resolver `event` and returns Query Params. * * @param {AppSyncEvent} appsyncEvent - AppSync event received in Lambda. * @param {Required} options - PrismaAppSync Client options. * @param {any|null} customResolvers? - Custom Resolvers. * @returns `{ type, operation, context, fields, paths, args, prismaArgs, authorization, identity }` - QueryParams */ export async function parseEvent(appsyncEvent: AppSyncEvent, options: Options, customResolvers?: any | null): Promise { if ( isEmpty(appsyncEvent?.info?.fieldName) || isUndefined(appsyncEvent?.info?.selectionSetList) || isEmpty(appsyncEvent?.info?.parentTypeName) || isUndefined(appsyncEvent?.arguments) ) throw new CustomError('Error reading required parameters from appsyncEvent.', { type: 'INTERNAL_SERVER_ERROR' }) const operation = getOperation({ fieldName: appsyncEvent.info.fieldName }) const context = getContext({ customResolvers, options, operation }) const { identity, authorization } = getAuthIdentity({ appsyncEvent, }) const fields = getFields({ _selectionSetList: appsyncEvent.info.selectionSetList, }) const sanitizedArgs = options.sanitize ? await sanitize(await addNullables(appsyncEvent.arguments)) : await addNullables(appsyncEvent.arguments) const args = clone(sanitizedArgs) const prismaArgs = getPrismaArgs({ action: context.action, defaultPagination: options.defaultPagination, _arguments: clone(sanitizedArgs), _selectionSetList: appsyncEvent.info.selectionSetList, }) const type = getType({ _parentTypeName: appsyncEvent.info.parentTypeName, }) const paths = getPaths({ operation, context, prismaArgs, }) const headers = appsyncEvent?.request?.headers || {} return { operation, context, fields, args, prismaArgs, type, authorization, identity, paths, headers, } } /** * #### Convert `is: NULL` and `isNot: NULL` to `is: null` and `isNot: null` * * @param {any} data * @returns any */ export async function addNullables(data: any): Promise { return await walk(data, async ({ key, value }, node) => { if (key === 'is' || key === 'isNot') { value = value === 'NULL' ? null : undefined node.ignoreChilds() } else if (value && isObject(value) && Object.keys(value).includes('isNull')) { const { isNull, ...val } = value as any if (isNull === true) value = { ...val, equals: null } else value = { ...val, not: null } node.ignoreChilds() } return { key, value } }) } /** * #### Returns authorization and identity. * * @param {any} options * @param {AppSyncEvent} options.appsyncEvent - AppSync event received in Lambda. * @returns `{ authorization, identity }` * * https://docs.aws.amazon.com/appsync/latest/devguide/resolver-context-reference.html#aws-appsync-resolver-context-reference-identity */ export function getAuthIdentity({ appsyncEvent }: { appsyncEvent: AppSyncEvent }): { identity: Identity authorization: Authorization } { let authorization: Authorization = null let identity: Identity = null // API_KEY authorization if (isEmpty(appsyncEvent?.identity)) { authorization = Authorizations.API_KEY identity = { ...(appsyncEvent?.request?.headers && typeof appsyncEvent.request.headers['x-api-key'] !== 'undefined' && { requestApiKey: appsyncEvent.request.headers['x-api-key'], }), ...(appsyncEvent?.request?.headers && typeof appsyncEvent.request.headers['user-agent'] !== 'undefined' && { requestUserAgent: appsyncEvent.request.headers['user-agent'], }), } } // AWS_LAMBDA authorization else if (appsyncEvent?.identity && typeof (appsyncEvent.identity as any).resolverContext !== 'undefined') { authorization = Authorizations.AWS_LAMBDA identity = appsyncEvent.identity } // AWS_IAM authorization else if ( appsyncEvent?.identity && typeof (appsyncEvent.identity as any).cognitoIdentityAuthType !== 'undefined' && typeof (appsyncEvent.identity as any).cognitoIdentityAuthProvider !== 'undefined' && typeof (appsyncEvent.identity as any).cognitoIdentityPoolId !== 'undefined' && typeof (appsyncEvent.identity as any).cognitoIdentityId !== 'undefined' ) { authorization = Authorizations.AWS_IAM identity = appsyncEvent.identity } // AMAZON_COGNITO_USER_POOLS authorization else if ( appsyncEvent?.identity && typeof (appsyncEvent.identity as any).sub !== 'undefined' && typeof (appsyncEvent.identity as any).issuer !== 'undefined' && typeof (appsyncEvent.identity as any).username !== 'undefined' && typeof (appsyncEvent.identity as any).claims !== 'undefined' && typeof (appsyncEvent.identity as any).sourceIp !== 'undefined' ) { authorization = Authorizations.AMAZON_COGNITO_USER_POOLS identity = appsyncEvent.identity } // OPENID_CONNECT authorization else if ( appsyncEvent?.identity && typeof (appsyncEvent.identity as any).sub !== 'undefined' && typeof (appsyncEvent.identity as any).issuer !== 'undefined' && typeof (appsyncEvent.identity as any).claims !== 'undefined' ) { authorization = Authorizations.OPENID_CONNECT identity = appsyncEvent.identity } // ERROR else { throw new CustomError('Couldn\'t detect caller identity.', { type: 'INTERNAL_SERVER_ERROR', }) } return { authorization, identity } } /** * #### Returns context (`action`, `alias` and `model`). * * @param {any} options * @param {any|null} options.customResolvers * @param {string} options.operation * @param {Options} options.options * @returns Context */ export function getContext({ customResolvers, operation, options, }: { customResolvers?: any | null operation: string options: Options }): Context { const context: Context = { action: String(), alias: null, model: null, } if (customResolvers && typeof customResolvers[operation] !== 'undefined') { context.action = operation context.alias = 'custom' context.model = null } else { context.action = getAction({ operation }) context.model = getModel({ operation, action: context.action, options }) context.alias = getActionAlias({ action: context.action }) } return context } /** * #### Returns operation (`getPost`, `listUsers`, ..). * * @param {any} options * @param {string} options.fieldName * @returns Operation */ export function getOperation({ fieldName }: { fieldName: string }): string { const operation = fieldName if (!(operation.length > 0)) throw new CustomError('Error parsing \'operation\' from input event.', { type: 'INTERNAL_SERVER_ERROR' }) return operation } /** * #### Returns action (`get`, `list`, `create`, ...). * * @param {any} options * @param {string} options.operation * @returns Action */ export function getAction({ operation }: { operation: string }): Action { const actionsList = Object.keys(Actions).sort().reverse() const action = actionsList.find((action: Action) => { return operation.toLowerCase().startsWith(String(action).toLowerCase()) }) as Action if (!(typeof action !== 'undefined' && String(action).length > 0)) { throw new CustomError( 'Error parsing \'action\' from input event. If you are trying to query a custom resolver, make sure it is properly declared inside \'prismaAppSync.resolve({ event, resolvers: { /* HERE */ } })\'.', { type: 'INTERNAL_SERVER_ERROR' }, ) } return action } /** * #### Returns action alias (`access`, `create`, `modify`, `subscribe`). * * @param {any} options * @param {Action} options.action * @returns ActionsAlias */ export function getActionAlias({ action }: { action: Action }): ActionsAlias { let actionAlias: ActionsAlias = null for (const alias in ActionsAliasesList) { const actionsList = ActionsAliasesList[alias] if (actionsList.includes(action)) { actionAlias = alias as ActionsAlias break } } if (!(typeof action !== 'undefined' && String(action).length > 0)) throw new CustomError('Error parsing \'actionAlias\' from input event.', { type: 'INTERNAL_SERVER_ERROR' }) return actionAlias } /** * #### Returns model (`Post`, `User`, ...). * * @param {any} options * @param {string} options.operation * @param {Action} options.action * @param {Options} options.options * @returns Model */ export function getModel( { operation, action, options }: { operation: string; action: Action; options: Options }, ): Model { const actionModel = operation.replace(String(action), '') if (!(actionModel.length > 0)) throw new CustomError('Error parsing \'model\' from input event.', { type: 'INTERNAL_SERVER_ERROR' }) const model = options?.modelsMapping?.[actionModel] if (!model) { throw new CustomError(`Resolver "${actionModel}" not found. If it's a custom resolver, please ensure it's available within your Lambda function.`, { type: 'INTERNAL_SERVER_ERROR', }) } return model } /** * #### Returns fields (`title`, `author`, ...). * * @param {any} options * @param {string[]} options._selectionSetList * @returns string[] */ export function getFields({ _selectionSetList }: { _selectionSetList: string[] }): string[] { const fields: string[] = [] _selectionSetList.forEach((item: string) => { const field = item.split('/')[0] if (!fields.includes(field) && !field.startsWith('__')) fields.push(item) }) return fields } /** * #### Returns GraphQL type (`Query`, `Mutation` or `Subscription`). * * @param {any} options * @param {string} options._parentTypeName * @returns GraphQLType */ export function getType({ _parentTypeName }: { _parentTypeName: string }): GraphQLType { const type = _parentTypeName if (!['Query', 'Mutation', 'Subscription'].includes(type)) throw new CustomError('Error parsing \'type\' from input event.', { type: 'INTERNAL_SERVER_ERROR' }) return type as GraphQLType } /** * #### Returns Prisma args (`where`, `data`, `orderBy`, ...). * * @param {any} options * @param {Action} options.action * @param {Options['defaultPagination']} options.defaultPagination * @param {any} options._arguments * @param {any} options._selectionSetList * @returns PrismaArgs */ export function getPrismaArgs({ action, defaultPagination, _arguments, _selectionSetList, }: { action: Action defaultPagination: Options['defaultPagination'] _arguments: any _selectionSetList: any }): PrismaArgs { const prismaArgs: PrismaArgs = {} if (typeof _arguments.data !== 'undefined' && typeof _arguments.operation !== 'undefined') { throw new CustomError('Using \'data\' and \'operation\' together is not possible.', { type: 'BAD_USER_INPUT', }) } if (typeof _arguments.data !== 'undefined') prismaArgs.data = _arguments.data else if (typeof _arguments.operation !== 'undefined') prismaArgs.data = _arguments.operation if (typeof _arguments.create !== 'undefined') prismaArgs.create = _arguments.create if (typeof _arguments.update !== 'undefined') prismaArgs.update = _arguments.update if (typeof _arguments.where !== 'undefined') prismaArgs.where = _arguments.where if (typeof _arguments.orderBy !== 'undefined') prismaArgs.orderBy = parseOrderBy(_arguments.orderBy) if (typeof _arguments.skipDuplicates !== 'undefined') prismaArgs.skipDuplicates = _arguments.skipDuplicates if (typeof _selectionSetList !== 'undefined') prismaArgs.select = parseSelectionList(_selectionSetList) if (isEmpty(prismaArgs.select)) delete prismaArgs.select if (typeof _arguments.skip !== 'undefined') prismaArgs.skip = Number.parseInt(_arguments.skip) else if (defaultPagination !== false && action === Actions.list) prismaArgs.skip = 0 if (typeof _arguments.take !== 'undefined') prismaArgs.take = Number.parseInt(_arguments.take) else if (defaultPagination !== false && action === Actions.list) prismaArgs.take = defaultPagination return prismaArgs } /** * #### Returns individual `orderBy` record formatted for Prisma. * * @param {any} sortObj * @returns any */ function getOrderBy(sortObj: any): any { if (Object.keys(sortObj).length > 1) throw new CustomError('Wrong \'orderBy\' input format.', { type: 'BAD_USER_INPUT' }) const key: any = Object.keys(sortObj)[0] const value = typeof sortObj[key] === 'object' ? getOrderBy(sortObj[key]) : sortObj[key].toLowerCase() return { [key]: value } } /** * #### Returns Prisma `orderBy` from parsed `event.arguments.orderBy`. * * @param {any} orderByInputs * @returns any[] */ function parseOrderBy(orderByInputs: any): any[] { const orderByOutput: any = [] const orderByInputsArray = Array.isArray(orderByInputs) ? orderByInputs : [orderByInputs] orderByInputsArray.forEach((orderByInput: any) => { orderByOutput.push(getOrderBy(orderByInput)) }) return orderByOutput } /** * #### Returns individual `include` field formatted for Prisma. * * @param {any} parts * @returns any */ function getInclude(parts: any): any { const field = parts[0] const value = parts.length > 1 ? getSelect(parts.splice(1)) : true return { include: { [field]: value, }, } } /** * #### Returns individual `select` field formatted for Prisma. * * @param {any} parts * @returns any */ function getSelect(parts: any): any { const field = parts[0] const value = parts.length > 1 ? getSelect(parts.splice(1)) : true return { select: { [field]: value, }, } } /** * #### Return Prisma `select` from parsed `event.arguments.info.selectionSetList`. * * @param {any} selectionSetList * @returns any */ function parseSelectionList(selectionSetList: any): any { let prismaArgs: any = { select: {} } for (let i = 0; i < selectionSetList.length; i++) { const path = selectionSetList[i] const parts = path.split('/') if (!parts.includes('__typename')) { if (parts.length > 1) prismaArgs = merge(prismaArgs, getInclude(parts)) else prismaArgs = merge(prismaArgs, getSelect(parts)) } } if (prismaArgs.include) { for (const include in prismaArgs.include) { if (typeof prismaArgs.select[include] !== 'undefined') delete prismaArgs.select[include] } prismaArgs.select = merge(prismaArgs.select, prismaArgs.include) delete prismaArgs.include } return typeof prismaArgs.select !== 'undefined' ? prismaArgs.select : {} } /** * #### Returns req and res paths (`updatePost/title`, `getPost/date`, ..). * * @param {any} options * @param {string} options.operation * @param {Context} options.context * @param {PrismaArgs} options.prismaArgs * @returns string[] */ export function getPaths({ operation, context, prismaArgs, }: { operation: string context: Context prismaArgs: PrismaArgs }): string[] { const paths: string[] = [ operation, ...objectToPaths({ ...(prismaArgs?.data && { data: prismaArgs.data, }), ...(prismaArgs?.select && { select: prismaArgs.select, }), }), ] paths.forEach((path: string, index: number) => { if (path.startsWith('data')) { paths[index] = path.replace('data', operation) } else if (path.startsWith('select')) { const action = BatchActionsList.includes(context.action) ? Actions.list : Actions.get if (context.model !== null) { const model = action === Actions.list ? context.model.plural : context.model.singular paths[index] = path.replace('select', `${lowerFirst(action)}${model}`) } else { paths[index] = path.replace('select', operation) } } }) return uniq( paths.map( (path: string) => path .split('/') .filter(k => !Prisma_ReservedKeysForPaths.includes(k)) .join('/'), ).filter(Boolean), ) } ================================================ FILE: packages/client/src/consts.ts ================================================ import type { Action } from './types' import { uniq } from './utils' // Enums export enum Actions { // queries get = 'get', list = 'list', count = 'count', // mutations (multiple) createMany = 'createMany', updateMany = 'updateMany', deleteMany = 'deleteMany', // mutations (single) create = 'create', update = 'update', upsert = 'upsert', delete = 'delete', // subscriptions (multiple) onCreatedMany = 'onCreatedMany', onUpdatedMany = 'onUpdatedMany', onDeletedMany = 'onDeletedMany', onMutatedMany = 'onMutatedMany', // subscriptions (single) onCreated = 'onCreated', onUpdated = 'onUpdated', onUpserted = 'onUpserted', onDeleted = 'onDeleted', onMutated = 'onMutated', } export enum ActionsAliases { access = 'access', batchAccess = 'batchAccess', create = 'create', batchCreate = 'batchCreate', delete = 'delete', batchDelete = 'batchDelete', modify = 'modify', batchModify = 'batchModify', subscribe = 'subscribe', batchSubscribe = 'batchSubscribe', } /** * ### Authorizations * * - `API_KEY`: Via hard-coded API key passed into `x-api-key` header. * - `AWS_IAM`: Via IAM identity and associated IAM policy rules. * - `AMAZON_COGNITO_USER_POOLS`: Via Amazon Cognito user token. * - `AWS_LAMBDA`: Via an AWS Lambda function. * - `OPENID_CONNECT`: Via Open ID connect such as Auth0. * * https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html */ export enum Authorizations { API_KEY = 'API_KEY', AWS_IAM = 'AWS_IAM', AMAZON_COGNITO_USER_POOLS = 'AMAZON_COGNITO_USER_POOLS', AWS_LAMBDA = 'AWS_LAMBDA', OPENID_CONNECT = 'OPENID_CONNECT', } // Consts export const Prisma_QueryOptions = [ 'where', 'data', 'select', 'orderBy', 'include', 'distinct', ] export const Prisma_NestedQueries = [ 'create', 'createMany', 'set', 'connect', 'connectOrCreate', 'disconnect', 'update', 'upsert', 'delete', 'updateMany', 'deleteMany', ] export const Prisma_FilterConditionsAndOperatos = [ 'equals', 'not', 'in', 'notIn', 'lt', 'lte', 'gt', 'gte', 'contains', 'search', 'mode', 'startsWith', 'endsWith', 'AND', 'OR', 'NOT', ] export const Prisma_FilterRelationFilters = [ 'some', 'every', 'none', 'is', 'isNot', ] export const Prisma_ScalarListMethods = [ 'set', 'push', 'unset', ] export const Prisma_ScalarListFilters = [ 'has', 'hasEvery', 'hasSome', 'isEmpty', 'isSet', 'equals', ] export const Prisma_CompositeTypeMethods = [ 'set', 'unset', 'update', 'upsert', 'push', ] export const Prisma_CompositeTypeFilters = [ 'equals', 'is', 'isNot', 'isEmpty', 'every', 'some', 'none', ] export const Prisma_AtomicNumberOperations = [ 'increment', 'decrement', 'multiply', 'divide', 'set', ] export const Prisma_JSONFilters = [ 'path', 'string_contains', 'string_starts_with', 'string_ends_with', 'array_contains', 'array_starts_with', 'array_ends_with', ] export const Prisma_ReservedKeysForPaths = uniq([ ...Prisma_QueryOptions, ...Prisma_FilterConditionsAndOperatos, ...Prisma_FilterRelationFilters, ...Prisma_ScalarListFilters, ...Prisma_CompositeTypeFilters, ...Prisma_JSONFilters, ]) export const Prisma_ReservedKeys = uniq([ ...Prisma_QueryOptions, ...Prisma_NestedQueries, ...Prisma_FilterConditionsAndOperatos, ...Prisma_FilterRelationFilters, ...Prisma_ScalarListMethods, ...Prisma_ScalarListFilters, ...Prisma_CompositeTypeMethods, ...Prisma_CompositeTypeFilters, ...Prisma_AtomicNumberOperations, ...Prisma_JSONFilters, ]) export const ActionsAliasesList = { access: [Actions.get, Actions.list, Actions.count], batchAccess: [Actions.list, Actions.count], create: [Actions.create, Actions.createMany], batchCreate: [Actions.createMany], modify: [Actions.upsert, Actions.update, Actions.updateMany, Actions.delete, Actions.deleteMany], batchModify: [Actions.updateMany, Actions.deleteMany], delete: [Actions.delete, Actions.deleteMany], batchDelete: [Actions.deleteMany], subscribe: [ Actions.onCreatedMany, Actions.onUpdatedMany, Actions.onDeletedMany, Actions.onMutatedMany, Actions.onCreated, Actions.onUpdated, Actions.onUpserted, Actions.onDeleted, Actions.onMutated, ], batchSubscribe: [Actions.onCreatedMany, Actions.onUpdatedMany, Actions.onDeletedMany, Actions.onMutatedMany], } as const let actionsListMultiple: Action[] = [] let actionsListSingle: Action[] = [] for (const actionAlias in ActionsAliasesList) { if (actionAlias.startsWith('batch')) actionsListMultiple = actionsListMultiple.concat(ActionsAliasesList[actionAlias]) else actionsListSingle = actionsListSingle.concat(ActionsAliasesList[actionAlias]) } export const ActionsList = actionsListSingle.filter((item, pos) => actionsListSingle.indexOf(item) === pos) export const BatchActionsList = actionsListMultiple.filter((item, pos) => actionsListMultiple.indexOf(item) === pos) export const DebugTestingKey = '__prismaAppsync' ================================================ FILE: packages/client/src/core.ts ================================================ /* eslint-disable @typescript-eslint/ban-types */ /* eslint-disable no-restricted-globals */ /* eslint-disable n/prefer-global/process */ import type { AfterHookParams, InjectedConfig, Options, PrismaAppSyncOptionsType, ResolveParams, Shield, ShieldAuthorization, } from './types' import { Prisma, PrismaClient, } from './types' import { BatchActionsList, DebugTestingKey, } from './consts' import { CustomError, log, parseError } from './inspector' import { clarify, getDepth, getShieldAuthorization, preventDOS, runHooks, } from './guard' import { parseEvent } from './adapter' import { isEmpty, omit } from './utils' import { prismaQueryJoin } from './resolver' import * as queries from './resolver' /** * ## Auto-injected at generation time */ // eslint-disable-next-line spaced-comment const injectedConfig: InjectedConfig = {} //! inject:config /** * ## Prisma-AppSync Client ʲˢ * * Type-safe Prisma AppSync client for TypeScript & Node.js * @example * ``` * const prismaAppSync = new PrismaAppSync() * * // lambda handler (AppSync Direct Lambda Resolver) * export const resolver = async (event: any, context: any) => { * return await prismaAppSync.resolve({ event }) * } * ``` * * * Read more in our [docs](https://prisma-appsync.vercel.app). */ export class PrismaAppSync { public options: Options public prismaClient: PrismaClient /** * ### Client Constructor * * Instantiate Prisma-AppSync Client. * @example * ``` * const prismaAppSync = new PrismaAppSync() * ``` * * @param {PrismaAppSyncOptionsType} options * @param {string} options.connectionString? - Prisma connection string (database connection URL). * @param {boolean} options.sanitize? - Enable sanitize inputs (parse xss + encode html). * @param {'INFO' | 'WARN' | 'ERROR'} options.logLevel? - Server logs level (visible in CloudWatch). * @param {number|false} options.defaultPagination? - Default pagination for list Query (items per page). * @param {number} options.maxDepth? - Maximum allowed GraphQL query depth. * @param {number} options.maxReqPerUserMinute? - Maximum allowed requests per user, per minute. * * @default * ``` * { * connectionString: process.env.DATABASE_URL, * sanitize: true, * logLevel: 'INFO', * defaultPagination: 50, * maxDepth: 4, * maxReqPerUserMinute: 200 * } * ``` * * * Read more in our [docs](https://prisma-appsync.vercel.app). */ constructor(options?: PrismaAppSyncOptionsType) { // Set ENV variable DATABASE_URL if connectionString option is set if (typeof options?.connectionString !== 'undefined') process.env.DATABASE_URL = options.connectionString // Set client options using constructor options this.options = { modelsMapping: {}, fieldsMapping: {}, connectionString: String(process.env.DATABASE_URL), sanitize: typeof options?.sanitize !== 'undefined' ? options.sanitize : true, logLevel: typeof options?.logLevel !== 'undefined' ? options.logLevel : 'INFO', defaultPagination: typeof options?.defaultPagination !== 'undefined' ? options.defaultPagination : 50, maxDepth: typeof options?.maxDepth !== 'undefined' ? options.maxDepth : 4, maxReqPerUserMinute: typeof options?.maxReqPerUserMinute !== 'undefined' ? options.maxReqPerUserMinute : 200, } this.options.modelsMapping = {} // Read injected config if (injectedConfig?.modelsMapping) { this.options.modelsMapping = injectedConfig.modelsMapping } else if (process?.env?.PRISMA_APPSYNC_INJECTED_CONFIG) { try { this.options.modelsMapping = JSON.parse( process.env.PRISMA_APPSYNC_INJECTED_CONFIG, ).modelsMapping } catch {} } if (injectedConfig?.fieldsMapping) { this.options.fieldsMapping = injectedConfig.fieldsMapping } else if (process?.env?.PRISMA_APPSYNC_INJECTED_CONFIG) { try { this.options.fieldsMapping = JSON.parse( process.env.PRISMA_APPSYNC_INJECTED_CONFIG, ).fieldsMapping } catch {} } // Make sure injected config isn't empty if (Object.keys(this.options.modelsMapping).length === 0) { throw new CustomError('Issue with auto-injected models mapping config.', { type: 'INTERNAL_SERVER_ERROR', }) } // Set ENV variable for log level process.env.PRISMA_APPSYNC_LOG_LEVEL = this.options.logLevel // Debug logs // eslint-disable-next-line unused-imports/no-unused-vars const { fieldsMapping, ...newInstanceLogs } = this.options log('New Prisma-AppSync instance created:', newInstanceLogs) // Prisma client options const prismaLogDef: Prisma.LogDefinition[] = [ { emit: 'event', level: 'query' }, { emit: 'event', level: 'error' }, { emit: 'event', level: 'info' }, { emit: 'event', level: 'warn' }, ] // Create new Prisma Client if (process?.env?.PRISMA_APPSYNC_TESTING === 'true') { if (!global.prisma) global.prisma = new PrismaClient({ log: prismaLogDef }) this.prismaClient = global.prisma } else { this.prismaClient = new PrismaClient({ log: prismaLogDef }) } // Prisma logs if (!(process?.env?.PRISMA_APPSYNC_TESTING === 'true')) { this.prismaClient.$on('query', (e: any) => log('Prisma Client query:', e, 'INFO')) this.prismaClient.$on('info', (e: any) => log('Prisma Client info:', e, 'INFO')) this.prismaClient.$on('warn', (e: any) => log('Prisma Client warn:', e, 'WARN')) this.prismaClient.$on('error', (e: any) => log('Prisma Client error:', e, 'ERROR')) } } /** * ### Resolver * * Resolve the API request, based on the AppSync `event` received by the Direct Lambda Resolver. * @example * ``` * await prismaAppSync.resolve({ event }) * * // custom resolvers * await prismaAppSync.resolve<'notify'|'listPosts'>( * event, * resolvers: { * // extend CRUD API with a custom `notify` query * notify: async ({ args }) => { return { message: args.message } }, * * // disable one of the generated CRUD API query * listPosts: false, * } * }) * ``` * * @param {ResolveParams} resolveParams * @param {any} resolveParams.event - AppSync event received by the Direct Lambda Resolver. * @param {any} resolveParams.resolvers? - Custom resolvers (to extend the CRUD API). * @param {function} resolveParams.shield? - Shield configuration (to protect your API). * @param {function} resolveParams.hooks? - Hooks (to trigger functions based on events). * @returns Promise * * * Read more in our [docs](https://prisma-appsync.vercel.app). */ public async resolve( resolveParams: ResolveParams<'//! inject:type:operations', Extract>, ): Promise { let result: any = null try { log('Resolving API request w/ event (truncated):', { arguments: resolveParams.event.arguments, identity: resolveParams.event.identity, info: omit(resolveParams.event.info, 'selectionSetGraphQL'), }) // Adapter :: parse appsync event let QueryParams = await parseEvent( resolveParams.event, this.options, resolveParams.resolvers, ) log('Parsed event:', QueryParams) // Guard :: rate limiting const callerUuid = (QueryParams.identity as any)?.sourceIp?.[0] || (QueryParams.identity as any)?.sourceIp || (QueryParams.identity as any)?.sub || JSON.stringify(QueryParams.identity) if (this.options.maxReqPerUserMinute && callerUuid) { const { limitExceeded, count } = await preventDOS({ callerUuid, maxReqPerMinute: this.options.maxReqPerUserMinute, }) if (limitExceeded) { throw new CustomError( `Rate limit (maxReqPerUserMinute=${this.options.maxReqPerUserMinute}) exceeded for caller "${callerUuid}".`, { type: 'TOO_MANY_REQUESTS', }, ) } else { log(`Rate limit check for caller "${callerUuid}" returned ${count}/${this.options.maxReqPerUserMinute} (last minute).`) } } // Guard :: block queries with a depth > maxDepth const depth = getDepth({ paths: QueryParams.paths, context: QueryParams.context, fieldsMapping: this.options.fieldsMapping, }) if (depth > this.options.maxDepth) { throw new CustomError( `Query has depth of ${depth}, which exceeds max depth of ${this.options.maxDepth}.`, { type: 'FORBIDDEN', }, ) } else { log(`Query has depth of ${depth} (max allowed is ${this.options.maxDepth}).`) } // Guard :: create shield from config const shield: Shield = resolveParams?.shield ? await resolveParams.shield(QueryParams) : {} // Guard :: get shield authorization config const shieldAuth: ShieldAuthorization = await getShieldAuthorization({ shield, paths: QueryParams.paths, context: QueryParams.context, }) if (Object.keys(shield).length === 0) log('Query shield authorization: No Shield setup detected.', null, 'WARN') else log('Query shield authorization:', shieldAuth) // Guard :: if `canAccess` if equal to `false`, we reject the API call if (!shieldAuth.canAccess) { const reason = typeof shieldAuth.reason === 'string' ? shieldAuth.reason : shieldAuth.reason({ action: QueryParams.context.action, model: QueryParams.context.model?.singular || QueryParams.context.action, }) throw new CustomError(reason, { type: 'FORBIDDEN' }) } // Guard :: if `prismaFilter` is set, combine with current Prisma query if (!isEmpty(shieldAuth.prismaFilter)) { log('QueryParams before adding Shield filters:', QueryParams) QueryParams.prismaArgs = prismaQueryJoin( [QueryParams.prismaArgs, { where: shieldAuth.prismaFilter }], [ 'where', 'data', 'orderBy', 'skip', 'take', 'skipDuplicates', 'select', ], ) log('QueryParams after adding Shield filters:', QueryParams) } // Guard: get and run all before hooks functions matching query if (!isEmpty(resolveParams?.hooks)) { QueryParams = await runHooks({ when: 'before', hooks: resolveParams.hooks, prismaClient: this.prismaClient, QueryParams, }) } // Resolver :: resolve query for UNIT TESTS if (process?.env?.PRISMA_APPSYNC_TESTING === 'true') { log('Resolving query for UNIT TESTS.') const isBatchAction = BatchActionsList.includes( QueryParams?.context?.action, ) const getTestResult = () => { return { ...QueryParams.fields.reduce((a, v) => { const value = !isEmpty(QueryParams?.prismaArgs?.data?.[v]) ? QueryParams.prismaArgs.data[v] : (Math.random() + 1).toString(36).substring(7) return { ...a, [v]: String(value) } }, {}), [DebugTestingKey]: { QueryParams, }, } } if (isBatchAction) result = [getTestResult(), getTestResult()] else result = getTestResult() } // Resolver :: query is disabled else if ( resolveParams?.resolvers && typeof resolveParams.resolvers[QueryParams.operation] === 'boolean' && resolveParams.resolvers[QueryParams.operation] === false ) { throw new CustomError( `Query resolver for ${QueryParams.operation} is disabled.`, { type: 'FORBIDDEN' }, ) } // Resolver :: resolve query with Custom Resolver else if ( typeof resolveParams?.resolvers?.[QueryParams.operation] === 'function' ) { log(`Resolving query for Custom Resolver "${QueryParams.operation}".`) const customResolverFn = resolveParams.resolvers[ QueryParams.operation ] as Function result = await customResolverFn({ ...QueryParams, prismaClient: this.prismaClient, }) } // Resolver :: resolve query with built-in CRUD else if (!isEmpty(QueryParams?.context?.model)) { log(`Resolving query for built-in CRUD operation "${QueryParams.operation}".`) try { result = await queries[`${QueryParams.context.action}Query`]( this.prismaClient, QueryParams, ) } catch (err: any) { if (err instanceof Prisma.PrismaClientKnownRequestError) { throw new CustomError( `Prisma Client known request error${err?.code ? ` (code ${err.code})` : ''}. https://www.prisma.io/docs/reference/api-reference/error-reference#prismaclientknownrequesterror`, { type: 'INTERNAL_SERVER_ERROR', cause: err }, ) } else if (err instanceof Prisma.PrismaClientUnknownRequestError) { throw new CustomError( 'Prisma Client unknown request error. https://www.prisma.io/docs/reference/api-reference/error-reference#prismaclientunknownrequesterror', { type: 'INTERNAL_SERVER_ERROR', cause: err }, ) } else if (err instanceof Prisma.PrismaClientRustPanicError) { throw new CustomError( 'Prisma Client Rust panic error. https://www.prisma.io/docs/reference/api-reference/error-reference#prismaclientrustpanicerror', { type: 'INTERNAL_SERVER_ERROR', cause: err }, ) } else if (err instanceof Prisma.PrismaClientInitializationError) { throw new CustomError( `Prisma Client initialization error${err?.errorCode ? ` (errorCode ${err.errorCode})` : ''}. https://www.prisma.io/docs/reference/api-reference/error-reference#prismaclientinitializationerror`, { type: 'INTERNAL_SERVER_ERROR', cause: err }, ) } else if (err instanceof Prisma.PrismaClientValidationError) { throw new CustomError( 'Prisma Client validation error. https://www.prisma.io/docs/reference/api-reference/error-reference#prismaclientvalidationerror', { type: 'INTERNAL_SERVER_ERROR', cause: err }, ) } else { throw new CustomError( err?.message?.split('\n')?.pop() || 'Unknown error during query.', { type: 'INTERNAL_SERVER_ERROR', cause: err }, ) } } } // Resolver :: query resolver not found else { throw new CustomError( `Query resolver for ${QueryParams.operation} could not be found.`, { type: 'INTERNAL_SERVER_ERROR', }, ) } // Guard: get and run all after hooks functions matching query if (!isEmpty(resolveParams?.hooks)) { const q: AfterHookParams = await runHooks({ when: 'after', hooks: resolveParams.hooks, prismaClient: this.prismaClient, QueryParams, result, }) result = q.result } } catch (error) { // Return error return Promise.reject(parseError(error as Error)) } // Guard :: clarify result (decode html) const resultClarified = this.options.sanitize ? await clarify(result) : result log('Returning response to API request w/ result:', resultClarified) return resultClarified } } ================================================ FILE: packages/client/src/guard.ts ================================================ import lambdaRateLimiter from 'lambda-rate-limiter' import type { Context, PrismaClient, QueryParams, Shield, ShieldAuthorization, ShieldRule, } from './types' import { DebugTestingKey } from './consts' import { decode, encode, filterXSS, isEmpty, isMatchingGlob, merge, walk } from './utils' import { CustomError } from './inspector' // https:// github.com/blackflux/lambda-rate-limiter const limiter = lambdaRateLimiter({ interval: 60 * 1000, // 60 seconds = 1 minute uniqueTokenPerInterval: 1000, }) /** * #### Sanitize data (parse xss + encode html). * * @param {any} data * @returns any */ export async function sanitize(data: any): Promise { return await walk(data, async ({ key, value }, node) => { if (typeof key === 'string' && key === DebugTestingKey) node.ignoreChilds() if (typeof value === 'string') value = encode(filterXSS(value)) return { key, value } }) } /** * #### Clarify data (decode html). * * @param {any} data * @returns any */ export async function clarify(data: any): Promise { return await walk(data, async ({ key, value }, node) => { if (typeof key === 'string' && key === DebugTestingKey) node.ignoreChilds() if (typeof value === 'string') value = decode(value) return { key, value } }) } /** * #### Returns an Shield authorization object for a given field. * * @param {any} options * @param {Shield} options.shield * @param {ShieldRule} options.shieldRule * @param {string} options.globPattern * @param {string} options.matcher * @param {Context} options.context * @returns Promise */ async function getFieldAuthorization( { shield, shieldRule, globPattern, matcher, context }: { shield: Shield shieldRule: ShieldRule globPattern: string matcher: string context: Context }, ): Promise { const authorization: ShieldAuthorization = { canAccess: true, reason: String(), prismaFilter: {}, matcher: String(), globPattern: String(), } if (typeof shieldRule === 'boolean') { authorization.canAccess = shield[matcher] as boolean } else { if (typeof shieldRule.rule === 'undefined') throw new Error('Badly formed shield rule.') if (typeof shieldRule.rule === 'boolean') { authorization.canAccess = shieldRule.rule } else if (typeof shieldRule.rule === 'function') { const ruleResult = shieldRule.rule(context) if (ruleResult instanceof Promise) authorization.canAccess = await ruleResult else if (typeof ruleResult === 'boolean') authorization.canAccess = ruleResult else throw new Error('Shield rule must return a boolean.') } else { authorization.canAccess = true if (!authorization.prismaFilter) authorization.prismaFilter = {} authorization.prismaFilter = merge(authorization.prismaFilter, shieldRule.rule) } } authorization.matcher = matcher authorization.globPattern = globPattern const isReasonDefined = typeof shieldRule !== 'boolean' && typeof shieldRule.reason !== 'undefined' let reason = `Matcher: ${authorization.matcher}` if (isReasonDefined && typeof shieldRule.reason === 'function') reason = shieldRule.reason({ action: context.action, model: context.model?.singular || context.action }) else if (isReasonDefined && typeof shieldRule.reason === 'string') reason = shieldRule.reason authorization.reason = reason return authorization } /** * #### Returns an authorization object from a Shield configuration passed as input. * * @param {Shield} options.shield * @param {string[]} options.paths * @param {Context} options.context * @returns ShieldAuthorization */ export async function getShieldAuthorization({ shield, paths, context, }: { shield: Shield paths: string[] context: Context }): Promise { let authorization: ShieldAuthorization = { canAccess: true, reason: String(), prismaFilter: {}, matcher: String(), globPattern: String(), } for (const matcher in shield) { const concurrentFieldsAuthCheck: Promise[] = [] const globPattern = matcher for (let i = paths.length - 1; i >= 0; i--) { const reqPath: string = paths[i] if (isMatchingGlob(reqPath, globPattern)) { const shieldRule = shield[matcher] concurrentFieldsAuthCheck.push( getFieldAuthorization({ shield, shieldRule, globPattern, matcher, context }), ) } } const fieldsAuthCheckResults = await Promise.allSettled(concurrentFieldsAuthCheck) for (let fieldIndex = 0; fieldIndex < fieldsAuthCheckResults.length; fieldIndex++) { const fieldAuthCheckResult = fieldsAuthCheckResults[fieldIndex] if (fieldAuthCheckResult.status === 'rejected') { throw new CustomError(fieldAuthCheckResult.reason, { type: 'INTERNAL_SERVER_ERROR' }) } else { authorization = fieldAuthCheckResult.value if (!fieldAuthCheckResult.value.canAccess) break } } } return authorization } /** * #### Returns GraphQL query depth for any given Query. * * @param {any} options * @param {string[]} options.paths * @param {Context} options.context * @param {any} options.fieldsMapping * @returns number */ export function getDepth( { paths, context, fieldsMapping }: { paths: string[]; context: Context; fieldsMapping: any }, ): number { let depth = 0 const stopPaths: string[] = [] if (!isEmpty(fieldsMapping)) { for (const fieldMap in fieldsMapping) { if (fieldsMapping[fieldMap].type.toLowerCase() === 'json') stopPaths.push(String(fieldMap)) } } paths.forEach((path: string) => { const stopPath = stopPaths.find((p: string) => path.includes(p)) const stopIndex = stopPath ? stopPath.split('/').length - 1 : undefined const parts = path.split('/').filter(Boolean).slice(1, stopIndex ? stopIndex + 1 : undefined) const pathDepth = parts.length if (pathDepth > depth) depth = pathDepth }) if (context.model === null) depth += 1 return depth } /** * #### Execute hooks that apply to a given Query. * * @param {any} options * @param {'before' | 'after'} options.when * @param {any} options.hooks * @param {PrismaClient} options.prismaClient * @param {QueryParams} options.QueryParams * @param {any | any[]} options.result * @returns Promise */ export async function runHooks({ when, hooks, prismaClient, QueryParams, result, }: { when: 'before' | 'after' hooks: any prismaClient: PrismaClient QueryParams: QueryParams result?: any | any[] }): Promise { const matchingHooks = Object.keys(hooks).filter((hookPath: string) => { const hookParts = hookPath.split(':') const hookWhen = hookParts[0] const hookGlob = hookParts[1] const currentPath = QueryParams.operation return hookWhen === when && isMatchingGlob(currentPath, hookGlob) }) let hookResponse = when === 'after' ? { ...QueryParams, result } : QueryParams if (matchingHooks.length > 0) { for (let index = 0; index < matchingHooks.length; index++) { const hookPath = matchingHooks[index] if (Object.prototype.hasOwnProperty.call(hooks, hookPath)) { hookResponse = await hooks[hookPath]({ ...QueryParams, ...(typeof result !== 'undefined' && when === 'after' && { result }), prismaClient, }) } } } return hookResponse } export async function preventDOS({ callerUuid, maxReqPerMinute, }: { callerUuid: string maxReqPerMinute: number }): Promise<{ limitExceeded: boolean count: number }> { let limitExceeded = false let count = -1 try { count = await limiter.check(maxReqPerMinute, callerUuid) } catch (error) { limitExceeded = true count = maxReqPerMinute } return { limitExceeded, count, } } ================================================ FILE: packages/client/src/index.ts ================================================ import { clone, decode, dotate, encode, filterXSS, isEmpty, isMatchingGlob, isObject, isUndefined, lowerFirst, merge, replaceAll, walk, } from './utils' export { PrismaAppSync } from './core' export { CustomError, log } from './inspector' export { queryBuilder } from './resolver' export { QueryParams, QueryParamsCustom, BeforeHookParams, AfterHookParams, Authorization, AppSyncEvent, Identity, API_KEY, AWS_IAM, AMAZON_COGNITO_USER_POOLS, AWS_LAMBDA, OPENID_CONNECT, AppSyncResolverHandler, AppSyncResolverEvent, AppSyncIdentity, } from './types' export { Authorizations } from './consts' const _ = { merge, clone, decode, encode, dotate, isMatchingGlob, filterXSS, isEmpty, isUndefined, lowerFirst, isObject, walk, replaceAll, } export { _ } ================================================ FILE: packages/client/src/inspector.ts ================================================ /* eslint-disable n/prefer-global/process */ /* eslint-disable no-console */ import { inspect as nodeInspect } from 'node:util' import type { logLevel } from './types' const errorCodes = { FORBIDDEN: 401, BAD_USER_INPUT: 400, INTERNAL_SERVER_ERROR: 500, TOO_MANY_REQUESTS: 429, } export type ErrorExtensions = { type: keyof typeof errorCodes cause?: any } export type ErrorDetails = { error: string type: ErrorExtensions['type'] code: number cause?: ErrorExtensions['cause'] } export class CustomError extends Error { public error: ErrorDetails['error'] public type: ErrorDetails['type'] public code: ErrorDetails['code'] public cause: ErrorDetails['cause'] public details: ErrorDetails constructor(message: string, extensions: ErrorExtensions) { super(message) this.error = message this.type = extensions.type this.cause = extensions?.cause?.meta?.cause || extensions?.cause this.code = typeof errorCodes[this.type] !== 'undefined' ? errorCodes[this.type] : errorCodes.INTERNAL_SERVER_ERROR this.message = JSON.stringify({ error: this.error, type: this.type, code: this.code, }) const maxCauseMessageLength = 500 if (this.cause?.message?.length > maxCauseMessageLength) this.cause.message = `... ${this.cause.message.slice(this.cause.message.length - maxCauseMessageLength)}` this.details = { error: this.error, type: this.type, code: this.code, ...(this.cause && { cause: this.cause }), } if (!(process?.env?.PRISMA_APPSYNC_TESTING === 'true')) log(message, this.details, 'ERROR') } } export function parseError(error: Error): CustomError { if (error instanceof CustomError) { return error } else { return new CustomError(error.message, { type: 'INTERNAL_SERVER_ERROR', cause: error, }) } } export function log(message: string, obj?: any, level?: logLevel): void { if (canPrintLog(level || 'INFO')) { printLog(message, level || 'INFO') if (obj) { console.log( nodeInspect(obj, { compact: false, depth: 5, breakLength: 80, maxStringLength: 800, ...(!process.env.LAMBDA_TASK_ROOT && { colors: true, }), }), ) } } } export function printLog(message: any, level: logLevel): void { const timestamp = new Date().toLocaleString(undefined, { day: 'numeric', month: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', }) const prefix = `◭ ${timestamp} <<${level}>>` const log = [prefix, message].join(' ') if (level === 'ERROR' && canPrintLog(level)) console.error(`\x1B[31m${log}`) else if (level === 'WARN' && canPrintLog(level)) console.warn(`\x1B[33m${log}`) else if (level === 'INFO' && canPrintLog(level)) console.info(`\x1B[36m${log}`) } function canPrintLog(level: logLevel): boolean { if (process?.env?.PRISMA_APPSYNC_TESTING === 'true') return false const logLevel = String(process.env.PRISMA_APPSYNC_LOG_LEVEL) as logLevel return (logLevel === 'ERROR' && level === 'ERROR') || (logLevel === 'WARN' && ['WARN', 'ERROR'].includes(level)) || (logLevel === 'INFO') } ================================================ FILE: packages/client/src/resolver.ts ================================================ import type { PrismaArgs, PrismaClient, PrismaCount, PrismaCreate, PrismaCreateMany, PrismaDelete, PrismaDeleteMany, PrismaGet, PrismaList, PrismaOperator, PrismaUpdate, PrismaUpdateMany, PrismaUpsert, QueryBuilder, QueryParams, } from './types' import { merge } from './utils' /** * #### Query Builder */ export function prismaQueryJoin(queries: PrismaArgs[], operators: PrismaOperator[]): T { const prismaArgs: PrismaArgs = {} // 'where', 'orderBy', 'select', 'skip', 'take', ... operators.forEach((operator: PrismaOperator) => { queries.forEach((query: PrismaArgs) => { if (query?.[operator]) { if (operator === 'where') { if (prismaArgs.where?.AND) { prismaArgs.where.AND.push(query.where) } else if (prismaArgs.where) { prismaArgs.where = { ...prismaArgs.where, AND: [query.where], } } else { prismaArgs.where = query.where } } else if (prismaArgs?.[operator]) { prismaArgs[operator] = merge(prismaArgs[operator], query[operator]) as never } else { prismaArgs[operator] = query[operator] as never } } }) }) return prismaArgs as T } export const queryBuilder: QueryBuilder = { prismaGet: (...prismaQueries: PrismaArgs[]) => { return prismaQueryJoin(prismaQueries, ['where', 'select']) }, prismaList: (...prismaQueries: PrismaArgs[]) => { return prismaQueryJoin(prismaQueries, ['where', 'orderBy', 'select', 'skip', 'take']) }, prismaCount: (...prismaQueries: PrismaArgs[]) => { return prismaQueryJoin(prismaQueries, ['where', 'orderBy', 'select', 'skip', 'take']) }, prismaCreate: (...prismaQueries: PrismaArgs[]) => { return prismaQueryJoin(prismaQueries, ['data', 'select']) }, prismaCreateMany: (...prismaQueries: PrismaArgs[]) => { return prismaQueryJoin(prismaQueries, ['data', 'skipDuplicates']) }, prismaUpdate: (...prismaQueries: PrismaArgs[]) => { return prismaQueryJoin(prismaQueries, ['data', 'where', 'select']) }, prismaUpdateMany: (...prismaQueries: PrismaArgs[]) => { return prismaQueryJoin(prismaQueries, ['data', 'where']) }, prismaUpsert: (...prismaQueries: PrismaArgs[]) => { return prismaQueryJoin(prismaQueries, ['where', 'create', 'update', 'select']) }, prismaDelete: (...prismaQueries: PrismaArgs[]) => { return prismaQueryJoin(prismaQueries, ['where', 'select']) }, prismaDeleteMany: (...prismaQueries: PrismaArgs[]) => { return prismaQueryJoin(prismaQueries, ['where']) }, } /** * #### Query :: Find Unique * * https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#findunique * @param {PrismaClient} prismaClient * @param {QueryParams} query */ export async function getQuery(prismaClient: PrismaClient, query: QueryParams) { if (query.context.model === null) return const results = await prismaClient[query.context.model.prismaRef].findUnique(queryBuilder.prismaGet(query.prismaArgs)) return results } /** * #### Query :: Find Many * * https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#findmany * @param {PrismaClient} prismaClient * @param {QueryParams} query */ export async function listQuery(prismaClient: PrismaClient, query: QueryParams) { if (query.context.model === null) return const results = await prismaClient[query.context.model.prismaRef].findMany(queryBuilder.prismaList(query.prismaArgs)) return results } /** * #### Query :: Count * * https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#count * @param {PrismaClient} prismaClient * @param {QueryParams} query */ export async function countQuery(prismaClient: PrismaClient, query: QueryParams) { if (query.context.model === null) return const results = await prismaClient[query.context.model.prismaRef].count(queryBuilder.prismaCount(query.prismaArgs)) return results } /** * #### Mutation :: Create * * https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#create * @param {PrismaClient} prismaClient * @param {QueryParams} query */ export async function createQuery(prismaClient: PrismaClient, query: QueryParams) { if (query.context.model === null) return const results = await prismaClient[query.context.model.prismaRef].create(queryBuilder.prismaCreate(query.prismaArgs)) return results } /** * #### Mutation :: Create Many * * https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#createmany * @param {PrismaClient} prismaClient * @param {QueryParams} query */ export async function createManyQuery(prismaClient: PrismaClient, query: QueryParams) { if (query.context.model === null) return const results = await prismaClient[query.context.model.prismaRef].createMany(queryBuilder.prismaCreateMany(query.prismaArgs)) return results } /** * #### Mutation :: Update * * https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#update * @param {PrismaClient} prismaClient * @param {QueryParams} query */ export async function updateQuery(prismaClient: PrismaClient, query: QueryParams) { if (query.context.model === null) return const results = await prismaClient[query.context.model.prismaRef].update(queryBuilder.prismaUpdate(query.prismaArgs)) return results } /** * #### Mutation :: Update Many * * https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#updatemany * @param {PrismaClient} prismaClient * @param {QueryParams} query */ export async function updateManyQuery(prismaClient: PrismaClient, query: QueryParams) { if (query.context.model === null) return const results = await prismaClient[query.context.model.prismaRef].updateMany(queryBuilder.prismaUpdateMany(query.prismaArgs)) return results } /** * #### Mutation :: Upsert * * https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#upsert * @param {PrismaClient} prismaClient * @param {QueryParams} query */ export async function upsertQuery(prismaClient: PrismaClient, query: QueryParams) { if (query.context.model === null) return const results = await prismaClient[query.context.model.prismaRef].upsert(queryBuilder.prismaUpsert(query.prismaArgs)) return results } /** * #### Mutation :: Delete * * https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#delete * @param {PrismaClient} prismaClient * @param {QueryParams} query */ export async function deleteQuery(prismaClient: PrismaClient, query: QueryParams) { if (query.context.model === null) return const results = await prismaClient[query.context.model.prismaRef].delete(queryBuilder.prismaDelete(query.prismaArgs)) return results } /** * #### Mutation :: Delete Many * * https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#deletemany * @param {PrismaClient} prismaClient * @param {QueryParams} query */ export async function deleteManyQuery(prismaClient: PrismaClient, query: QueryParams) { if (query.context.model === null) return const results = await prismaClient[query.context.model.prismaRef].deleteMany(queryBuilder.prismaDeleteMany(query.prismaArgs)) return results } ================================================ FILE: packages/client/src/types.ts ================================================ import { Prisma, PrismaClient } from '@prisma/client' import type { AppSyncIdentity, AppSyncIdentityCognito, AppSyncIdentityIAM, AppSyncIdentityLambda, AppSyncIdentityOIDC, AppSyncResolverEvent, AppSyncResolverHandler, } from 'aws-lambda' import type { Actions, ActionsAliases, Authorizations } from './consts' export type logLevel = 'INFO' | 'WARN' | 'ERROR' export type PrismaAppSyncOptionsType = { connectionString?: string sanitize?: boolean logLevel?: logLevel defaultPagination?: number | false maxDepth?: number maxReqPerUserMinute?: number | false } export type Options = Required & { modelsMapping: any fieldsMapping: any } export type InjectedConfig = { modelsMapping?: { [modelVariant: string]: { prismaRef: string; singular: string; plural: string } } fieldsMapping?: { [fieldPath: string]: { type: string; isRelation: boolean } } operations?: string } export type RuntimeConfig = { modelsMapping: { [modelVariant: string]: { prismaRef: string; singular: string; plural: string } } fieldsMapping: { [fieldPath: string]: { type: string; isRelation: boolean } } operations: string[] } export type Action = typeof Actions[keyof typeof Actions] | string export type ActionsAlias = typeof ActionsAliases[keyof typeof ActionsAliases] | 'custom' | null export type ActionsAliasStr = keyof typeof ActionsAliases export type Context = { action: Action alias: ActionsAlias model: Model } export type Model = { prismaRef: string; singular: string; plural: string } | null export type { AppSyncResolverHandler, AppSyncResolverEvent, AppSyncIdentity } /** * ### QueryParams * * @example * ``` * { * type: 'Query', * operation: 'getPost', * context: { action: 'get', alias: 'access', model: 'Post' }, * fields: ['title', 'status'], * paths: ['get/post/title', 'get/post/status'], * args: { where: { id: 5 } }, * prismaArgs: { * where: { id: 5 }, * select: { title: true, status: true }, * }, * authorization: 'API_KEY', * identity: { ... }, * } * ``` */ export type QueryParams = { type: GraphQLType operation: string context: Context fields: string[] paths: string[] args: T prismaArgs: PrismaArgs authorization: Authorization identity: Identity headers: any } export type Authorization = typeof Authorizations[keyof typeof Authorizations] | null export type PrismaGet = Pick, 'where'> & Pick export type PrismaList = Pick export type PrismaCount = Pick export type PrismaCreate = Pick, 'data'> & Pick export type PrismaCreateMany = Pick, 'data'> & Pick export type PrismaUpdate = Pick, 'data' | 'where'> & Pick export type PrismaUpdateMany = Pick, 'data' | 'where'> export type PrismaUpsert = Pick, 'where'> & Pick & Pick & Pick export type PrismaDelete = Pick, 'where'> & Pick export type PrismaDeleteMany = Pick, 'where'> export type QueryBuilder = { prismaGet: (...prismaArgs: PrismaArgs[]) => PrismaGet prismaList: (...prismaArgs: PrismaArgs[]) => PrismaList prismaCount: (...prismaArgs: PrismaArgs[]) => PrismaCount prismaCreate: (...prismaArgs: PrismaArgs[]) => PrismaCreate prismaCreateMany: (...prismaArgs: PrismaArgs[]) => PrismaCreateMany prismaUpdate: (...prismaArgs: PrismaArgs[]) => PrismaUpdate prismaUpdateMany: (...prismaArgs: PrismaArgs[]) => PrismaUpdateMany prismaUpsert: (...prismaArgs: PrismaArgs[]) => PrismaUpsert prismaDelete: (...prismaArgs: PrismaArgs[]) => PrismaDelete prismaDeleteMany: (...prismaArgs: PrismaArgs[]) => PrismaDeleteMany } export type QueryParamsCustom = QueryParams & { prismaClient: PrismaClient } export type BeforeHookParams = QueryParams & { prismaClient: PrismaClient } /** * ### AfterHookParams * * @example * ``` * { * type: 'Query', * operation: 'getPost', * context: { action: 'get', alias: 'access', model: 'Post' }, * fields: ['title', 'status'], * paths: ['get/post/title', 'get/post/status'], * args: { where: { id: 5 } }, * prismaArgs: { * where: { id: 5 }, * select: { title: true, status: true }, * }, * authorization: 'API_KEY', * identity: { ... }, * result: { title: 'Hello World', status: 'PUBLISHED' } * } * ``` */ export type AfterHookParams = QueryParams & { prismaClient: PrismaClient result: any | any[] } export type ShieldContext = { action: Action model: string } export type Reason = string | ((context: ShieldContext) => string) export type ShieldRule = boolean | ((context: ShieldContext) => boolean | Promise) | any export type Shield = { [matcher: string]: | boolean | { rule: ShieldRule reason?: Reason } } export type HooksProps = { before: BeforeHookParams after: AfterHookParams } export type HooksReturn = { before: Promise after: Promise } export type HookPath = Operations | CustomResolvers export type HooksParameter< HookType extends 'before' | 'after', Operations extends string, CustomResolvers extends string, > = `${HookType}:${HookPath}` | `${HookType}:**` export type HooksParameters< HookType extends 'before' | 'after', Operations extends string, CustomResolvers extends string, > = { [matcher in HooksParameter]?: ( props: HooksProps[HookType], ) => HooksReturn[HookType] } export type Hooks = | HooksParameters<'before', Operations, CustomResolvers> | HooksParameters<'after', Operations, CustomResolvers> export type ShieldAuthorization = { canAccess: boolean reason: Reason prismaFilter: any matcher: string globPattern: string } export type ResolveParams = { event: AppSyncEvent resolvers?: { [resolver in CustomResolvers]: ((props: QueryParamsCustom) => Promise) | boolean } shield?: (props: QueryParams) => Shield hooks?: Hooks } // Prisma-related Types export { PrismaClient, Prisma } export type PrismaArgs = { where?: any create?: any update?: any data?: any select?: any orderBy?: any skip?: number | undefined take?: number | undefined skipDuplicates?: boolean | undefined } export type PrismaOperator = keyof Required export type AppSyncEvent = AppSyncResolverEvent export type GraphQLType = 'Query' | 'Mutation' | 'Subscription' export type API_KEY = null | { [key: string]: any } export type AWS_LAMBDA = AppSyncIdentityLambda export type AWS_IAM = AppSyncIdentityIAM export type AMAZON_COGNITO_USER_POOLS = AppSyncIdentityCognito export type OPENID_CONNECT = AppSyncIdentityOIDC export type Identity = API_KEY | AWS_LAMBDA | AWS_IAM | AMAZON_COGNITO_USER_POOLS | OPENID_CONNECT ================================================ FILE: packages/client/src/utils.ts ================================================ /* eslint-disable @typescript-eslint/ban-types */ import { flatten } from 'wild-wild-utils' import { isMatch } from 'micromatch' import deepmerge from 'deepmerge' import { decode as decodeHtml, encode as encodeHtml } from 'html-entities' import xss from 'xss' /** * #### Deep merge objects (without mutating the target object). * * @example const newObj = merge(obj1, obj2, obj3) * * @param {any[]} sources * @returns any */ export function merge(...sources: any[]): any { return deepmerge.all([{}, ...sources]) } /** * #### Deep clone object. * * @example const newObj = clone(sourceObj) * * @param {any} source * @returns any */ export function clone(source: any): any { return deepmerge({}, source) } /** * #### Returns decoded text, replacing HTML special characters. * * @example decode('< > " ' & © ∆') * // returns '< > " \' & © ∆' * * @param {string} str * @returns string */ export function decode(str: string): string { return decodeHtml(str) } /** * #### Returns encoded text, version of string. * * @example encode('