diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..0c880bd262 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,112 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env* + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +packages/db/fixtures/sqlite/db +packages/db/test/fixtures/sqlite/db + +# packages/dashboard specific rules +packages/db-dashboard/build/ +playwright-report +.DS_Store +.swp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..1bfe7e4da5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,227 @@ +name: Run tests + +on: + push: + branches: + - main + paths-ignore: + - 'docs/**' + - '**.md' + pull_request: + paths-ignore: + - 'docs/**' + - '**.md' + +# This allows a subsequently queued workflow run to interrupt previous runs +concurrency: + group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" + cancel-in-progress: true + +env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + +jobs: + setup-node_modules: + runs-on: ${{matrix.os}} + timeout-minutes: 15 + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v2.2.2 + - uses: actions/setup-node@v3 + with: + node-version: 18 + cache: 'pnpm' + - name: pnpm fetch + run: pnpm fetch + + ci-cli: + needs: setup-node_modules + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + matrix: + node-version: [16, 18] + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v2.2.2 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: pnpm install + run: pnpm install + - name: Run test suite + run: cd packages/cli && pnpm test + + ci-db-dashboard: + needs: setup-node_modules + runs-on: ${{matrix.os}} + timeout-minutes: 5 + strategy: + matrix: + node-version: [16, 18] + os: [ubuntu-latest] + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v2.2.2 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: pnpm install + run: pnpm install + - name: Builds the dashboard + run: npm run dashboard:build + - name: Run test suite Dashboard + run: cd packages/db-dashboard && pnpm test + + ci-config: + needs: setup-node_modules + runs-on: ${{matrix.os}} + timeout-minutes: 15 + strategy: + matrix: + node-version: [16, 18] + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v2.2.2 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: pnpm install + run: pnpm install --offline + - name: Run test suite config manager + run: cd packages/config && pnpm test + + ci-db: + needs: setup-node_modules + runs-on: ${{matrix.os}} + timeout-minutes: 15 + strategy: + matrix: + node-version: [16, 18] + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v2.2.2 + - uses: ikalnytskyi/action-setup-postgres@v3 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: pnpm install + run: pnpm install --offline + - name: Builds the dashboard + run: pnpm run dashboard:build + - name: Run test suite core + run: cd packages/db-core && pnpm test + - name: Run test suite Platformatic DB + run: cd packages/db && pnpm test + + ci-db-authorization: + needs: setup-node_modules + runs-on: ${{matrix.os}} + timeout-minutes: 5 + strategy: + matrix: + node-version: [16, 18] + os: [ubuntu-latest] + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v2.2.2 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: Start docker containers for testing + run: docker-compose up -d postgresql + - name: pnpm install + run: pnpm install --offline + - name: Run test suite + run: cd packages/db-authorization && pnpm test + + ci-db-core: + needs: setup-node_modules + runs-on: ${{matrix.os}} + timeout-minutes: 5 + strategy: + matrix: + db: [postgresql, mariadb, mysql, mysql8, sqlite] + node-version: [16, 18] + os: [ubuntu-latest] + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v2.2.2 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: Start docker containers for testing + run: docker-compose up -d ${{ matrix.db }} + if: ${{ matrix.db != 'sqlite' }} + - name: pnpm install + run: pnpm install --offline + - name: Wait for DB + run: sleep 10 + if: ${{ matrix.db != 'sqlite' }} + - name: Run test suite sql-mapper + run: cd packages/sql-mapper && pnpm run test:typescript && pnpm run test:${{ matrix.db }}; cd ../.. + - name: Run test suite sql-json-schema-mapper + run: cd packages/sql-json-schema-mapper && pnpm run test:${{ matrix.db }}; cd ../.. + - name: Run test suite sql-openapi + run: cd packages/sql-openapi && pnpm run test:typescript && pnpm run test:${{ matrix.db }}; cd ../.. + - name: Run test suite sql-graphql + run: cd packages/sql-graphql && pnpm run test:typescript && pnpm run test:${{ matrix.db }}; cd .. + + ci-auth-login: + needs: setup-node_modules + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + strategy: + matrix: + node-version: [16, 18] + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v2.2.2 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: pnpm install + run: pnpm install --offline + - name: Run test suite + run: cd packages/authenticate && pnpm test; cd ../.. + + playwright-e2e: + needs: setup-node_modules + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v2.2.2 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: Start docker containers for testing + run: docker-compose up -d postgresql + - name: pnpm install + run: pnpm install --offline --frozen-lockfile + - name: Builds the dashboard + run: pnpm run dashboard:build + - name: Install Playwright browsers + run: cd packages/db-dashboard && pnpm exec playwright install + - name: Wait for DB + run: sleep 10 + - name: Run Platformatic DB server and E2E tests + run: | + node ./packages/cli/cli.js db --config=./packages/db-dashboard/test/e2e/fixtures/e2e-test-config.json & + sleep 5 && + cd packages/db-dashboard && pnpm run test:e2e diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000000..b87605de55 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,29 @@ +name: docker build + +on: + push: + branches: + - main + +jobs: + buildx: + runs-on: ubuntu-latest + environment: main + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v3 + with: + push: true + tags: platformatic/platformatic-private:latest + platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml new file mode 100644 index 0000000000..40bab1d870 --- /dev/null +++ b/.github/workflows/issues.yml @@ -0,0 +1,25 @@ +name: Add new issue/PR to project + +on: + issues: + types: + - opened + +jobs: + add-to-project: + name: Add issue or PR to project + runs-on: ubuntu-latest + steps: + - name: Generate token + id: generate_token + uses: vidavidorra/github-app-token@v1.0.0 + with: + appId: ${{ secrets.INTERNAL_GH_APP_ID }} + privateKey: ${{ secrets.INTERNAL_GH_APP_SECRET }} + - name: Add to Project + env: + TOKEN: ${{ steps.generate_token.outputs.token }} + uses: actions/add-to-project@338ac1805ece459f9c25a3e7a2b749fec994576d + with: + project-url: https://github.com/orgs/platformatic/projects/1 + github-token: ${{ env.TOKEN }} diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml new file mode 100644 index 0000000000..a0960e65a6 --- /dev/null +++ b/.github/workflows/update-docs.yml @@ -0,0 +1,23 @@ +name: "Trigger OSS repo" +on: + release: + types: [published] + + push: + branches: + - main + paths: + - 'docs/**' + - '**.md' +jobs: + build-and-publish: + runs-on: ubuntu-latest + steps: + - name: Update docs + if: ${{ github.event_name == 'release' }} + run: | + curl -XPOST -u "${{ secrets.GH_API_USERNAME }}:${{ secrets.GH_API_TOKEN }}" -H "Accept: application/vnd.github.everest-preview+json" -H "Content-Type: application/json" https://api.github.com/repos/platformatic/oss/dispatches --data '{"event_type": "update_docs"}' + - name: Force update docs + if: ${{ github.event_name == 'push' }} + run: | + curl -XPOST -u "${{ secrets.GH_API_USERNAME }}:${{ secrets.GH_API_TOKEN }}" -H "Accept: application/vnd.github.everest-preview+json" -H "Content-Type: application/json" https://api.github.com/repos/platformatic/oss/dispatches --data '{"event_type": "update_docs", "inputs": { "force": true }}' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..0c880bd262 --- /dev/null +++ b/.gitignore @@ -0,0 +1,112 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env* + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +packages/db/fixtures/sqlite/db +packages/db/test/fixtures/sqlite/db + +# packages/dashboard specific rules +packages/db-dashboard/build/ +playwright-report +.DS_Store +.swp diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000..9459312b53 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +package-lock=true +auto-install-peers=true +strict-peer-dependencies=false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..19e1bf63d6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,94 @@ +# Platformatic + +## Running and Developing DB + +### Preparation + +1. Clone this repository +1. Install pnpm `npm i pnpm --location=global` +2. Install dependencies for root project: `pnpm i` +4. Install docker with Docker Desktop or [Colima](https://github.com/abiosoft/colima) + + +### Start the RDBMS + +We use Docker to start all the databases we develop against. + +On Linux, execute: `docker compose up` + +On Intel Macs: `docker compose up -f docker-compose-mac.yml` + +On Apple Silicon Macs: `docker compose up -f docker-compose-apple-silicon.yml` + +### Start platformatic db + +Create directories to work from: + +```sh +mkdir -p my-demo/migrations +``` + +Install all dependencies: +```sh +pnpm i +``` + +The CLI package is now available at **./node_modules/.bin/platformatic**. Use +`pnpm link` to use `platformatic` everywhere. +```sh +(cd packages/cli && pnpm link) +``` + +### Run dashboard development server + +Use the command +```sh +npm run dashboard:start +``` + +This will start a webpack server on port `3000` by default, with watcher and hot-reload (as a standard `create-react-app` application). + +Note that GraphiQL will _not_ work because platformatic-db has not been started +yet. + +### Run platformatic-db service + +First build the dashboard for production with the command +```sh +pnpm run dashboard:build +``` + +This will create compressed files and assets under **packages/dashboard/build** directory. +To run the service: +```sh +platformatic db +``` +This will load config from local directory (i.e using config file **platformatic.db.json**). + +If you want to use another config file use the option `--config=/path/to/some.json`. + +### Testing + +1. [Run docker](#run-docker) +1. Run `npm run dashboard:build` +1. Run tests: `npm test` + +### Releasing + +All platformatic modules share the same release number and are released +in a single process. In order to avoid internal breakages, dependencies as +part of this repository are using the `workspace:*` which will be replaced +by precise versions during publish by pnpm. + +The procedure to release is simple: + +1. Update the version of the root `package.json` +1. run `./scripts/sync-version.sh` +1. run `pnpm -r publish` + +### Creating and merging a PR +On the top of the PR description, if this is a fix of a github issue, add: +``` +fixes #issuenum +``` +When all checks are passed and the changes are approved, merge the PR with `squash and merge` option diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..ada0bb99da --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +FROM node:18-alpine + +ENV HOME=/home +ENV PLT_HOME=$HOME/platformatic/ +ENV PNPM_HOME=$HOME/pnpm +ENV APP_HOME=$HOME/app +ENV PATH=/home/pnpm:$PATH + +RUN mkdir $PNPM_HOME + +# Install Platformatic in the $PLT_HOME folder +WORKDIR $PLT_HOME + +# Install required packages +RUN apk update && apk add --no-cache dumb-init python3 libc-dev make g++ + +# Install pnpm +RUN npm i pnpm --location=global + +# Copy lock files +COPY package.json ./ +COPY pnpm-lock.yaml ./ +COPY pnpm-workspace.yaml ./ + +# Fetch all dependencies +RUN pnpm fetch --prod + +# Copy files +COPY . . + +# Install all the deps in the source code +RUN pnpm install --frozen-lockfile --prod --offline + +# Add platformatic to path +RUN cd packages/cli && pnpm link --global + +# Move to the app directory +WORKDIR $APP_HOME + +# Reduce our permissions from root to a normal user +RUN chown node:node . +USER node + +ENTRYPOINT ["dumb-init"] +CMD ["platformatic"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000000..a7d8a8414a --- /dev/null +++ b/NOTICE @@ -0,0 +1,13 @@ + Copyright 2022 Platformatic + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000000..a1bed5dc36 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Platformatic + +Platformatic is a set a Open Source tools that you can use to build your own +_Internal Developer Platform_. + +The first of these tools is **Platformatic DB** — more will follow! + +## Install + +```bash +npm install platformatic + +# Start a new project +npx platformatic db init +``` + +Follow our [Quick Start Guide](https://oss.platformatic.dev/docs/getting-started/quick-start-guide) +guide to get up and running with Platformatic DB. + +## Documentation + +- [Getting Started](https://oss.platformatic.dev/docs/category/getting-started) +- [Reference](https://oss.platformatic.dev/docs/category/reference) +- [Guides](https://oss.platformatic.dev/docs/category/guides) + +Check out our full documentation at [oss.platformatic.dev](https://oss.platformatic.dev). + +## Support + +Having issues? Drop in to the [Platformatic Discord](https://discord.com/channels/1011258196905689118/1011258204371554307) +for help. + +## License + +Apache 2.0 diff --git a/demo/auth/migrations/001.do.sql b/demo/auth/migrations/001.do.sql new file mode 100644 index 0000000000..0a09b9f9cb --- /dev/null +++ b/demo/auth/migrations/001.do.sql @@ -0,0 +1,4 @@ +CREATE TABLE pages ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL +); diff --git a/demo/auth/migrations/001.undo.sql b/demo/auth/migrations/001.undo.sql new file mode 100644 index 0000000000..f5465cf307 --- /dev/null +++ b/demo/auth/migrations/001.undo.sql @@ -0,0 +1 @@ +DROP TABLE pages; diff --git a/demo/auth/migrations/002.do.sql b/demo/auth/migrations/002.do.sql new file mode 100644 index 0000000000..098ff52de4 --- /dev/null +++ b/demo/auth/migrations/002.do.sql @@ -0,0 +1,5 @@ +CREATE TABLE categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL +); +ALTER TABLE pages ADD COLUMN category_id INTEGER REFERENCES categories(id); diff --git a/demo/auth/migrations/002.undo.sql b/demo/auth/migrations/002.undo.sql new file mode 100644 index 0000000000..048007a86d --- /dev/null +++ b/demo/auth/migrations/002.undo.sql @@ -0,0 +1,2 @@ +ALTER TABLE pages DROP COLUMN category_id; +DROP TABLE categories; diff --git a/demo/auth/migrations/003.do.sql b/demo/auth/migrations/003.do.sql new file mode 100644 index 0000000000..08ce54b5a2 --- /dev/null +++ b/demo/auth/migrations/003.do.sql @@ -0,0 +1 @@ +ALTER TABLE pages ADD COLUMN user_id INTEGER; diff --git a/demo/auth/migrations/003.undo.sql b/demo/auth/migrations/003.undo.sql new file mode 100644 index 0000000000..9fcc1cee49 --- /dev/null +++ b/demo/auth/migrations/003.undo.sql @@ -0,0 +1 @@ +ALTER TABLE pages DROP COLUMN user_id; diff --git a/demo/auth/platformatic.db.json b/demo/auth/platformatic.db.json new file mode 100644 index 0000000000..7ba2c3b6f9 --- /dev/null +++ b/demo/auth/platformatic.db.json @@ -0,0 +1,51 @@ +{ + "server": { + "logger": { + "level": "info" + }, + "hostname": "127.0.0.1", + "port": "3042" + }, + "core": { + "connectionString": "postgres://postgres:postgres@127.0.0.1:5432/postgres", + "graphql": { + "graphiql": true + } + }, + "migrations": { + "dir": "./migrations" + }, + "plugin": { + "path": "./plugin.js" + }, + "authorization": { + "adminSecret": "platformatic", + "rules": [ + { + "role": "user", + "entity": "page", + "delete": false, + "defaults": { + "userId": "X-PLATFORMATIC-USER-ID" + }, + "find": { + "checks": { + "userId": "X-PLATFORMATIC-USER-ID" + } + }, + "save": { + "checks": { + "userId": "X-PLATFORMATIC-USER-ID" + } + } + }, + { + "role": "anonymous", + "entity": "page", + "find": false, + "delete": false, + "save": false + } + ] + } +} diff --git a/demo/auth/plugin.js b/demo/auth/plugin.js new file mode 100644 index 0000000000..7f681f3a06 --- /dev/null +++ b/demo/auth/plugin.js @@ -0,0 +1,34 @@ +'use strict' + +module.exports = async function app (app) { + app.log.info('loaded') + + app.get('/hello', async function () { + return { + message: 'Hello World!' + } + }) + + // console.log(await app.platformatic.entities.page.find({ fields: ['title'] })) + + app.graphql.extendSchema(` + extend type Query { + hello: String, + titles: [String] + } + `) + app.graphql.defineResolvers({ + Query: { + hello: () => 'Hello World!', + titles: async () => { + const { db, sql } = app.platformatic + + const titles = await db.query(sql` + SELECT title FROM pages + `) + + return titles.map(({ title }) => title) + } + } + }) +} diff --git a/demo/basic/migrations/001.do.sql b/demo/basic/migrations/001.do.sql new file mode 100644 index 0000000000..0a09b9f9cb --- /dev/null +++ b/demo/basic/migrations/001.do.sql @@ -0,0 +1,4 @@ +CREATE TABLE pages ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL +); diff --git a/demo/basic/migrations/001.undo.sql b/demo/basic/migrations/001.undo.sql new file mode 100644 index 0000000000..f5465cf307 --- /dev/null +++ b/demo/basic/migrations/001.undo.sql @@ -0,0 +1 @@ +DROP TABLE pages; diff --git a/demo/basic/migrations/002.do.sql b/demo/basic/migrations/002.do.sql new file mode 100644 index 0000000000..098ff52de4 --- /dev/null +++ b/demo/basic/migrations/002.do.sql @@ -0,0 +1,5 @@ +CREATE TABLE categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL +); +ALTER TABLE pages ADD COLUMN category_id INTEGER REFERENCES categories(id); diff --git a/demo/basic/migrations/002.undo.sql b/demo/basic/migrations/002.undo.sql new file mode 100644 index 0000000000..048007a86d --- /dev/null +++ b/demo/basic/migrations/002.undo.sql @@ -0,0 +1,2 @@ +ALTER TABLE pages DROP COLUMN category_id; +DROP TABLE categories; diff --git a/demo/basic/platformatic.db.json b/demo/basic/platformatic.db.json new file mode 100644 index 0000000000..663ed5382e --- /dev/null +++ b/demo/basic/platformatic.db.json @@ -0,0 +1,18 @@ +{ + "server": { + "logger": { + "level": "info" + }, + "hostname": "127.0.0.1", + "port": "3042" + }, + "core": { + "connectionString": "postgres://postgres:postgres@127.0.0.1:5432/postgres", + "graphql": { + "graphiql": true + } + }, + "migrations": { + "dir": "./migrations" + } +} diff --git a/docker-compose-apple-silicon.yml b/docker-compose-apple-silicon.yml new file mode 100644 index 0000000000..ca869e6c51 --- /dev/null +++ b/docker-compose-apple-silicon.yml @@ -0,0 +1,33 @@ +version: "3.3" +services: + postgresql: + ports: + - "5432:5432" + image: "arm64v8/postgres:14-alpine" + environment: + - POSTGRES_PASSWORD=postgres + mariadb: + ports: + - "3307:3306" + image: "arm64v8/mariadb:10.9" + environment: + - MYSQL_ALLOW_EMPTY_PASSWORD=yes + - MYSQL_DATABASE=graph + mysql: + platform: 'linux/amd64' + ports: + - "3306:3306" + image: "mysql:5.7" + environment: + - MYSQL_ALLOW_EMPTY_PASSWORD=yes + - MYSQL_DATABASE=graph + mysql8: + ports: + - "3308:3306" + image: "arm64v8/mysql:8-oracle" + environment: + - MYSQL_ALLOW_EMPTY_PASSWORD=yes + - MYSQL_DATABASE=graph + + + diff --git a/docker-compose-mac.yml b/docker-compose-mac.yml new file mode 100644 index 0000000000..f542d63bd4 --- /dev/null +++ b/docker-compose-mac.yml @@ -0,0 +1,32 @@ +version: "3.3" +services: + postgresql: + ports: + - "5432:5432" + image: "postgres:14-alpine" + environment: + - POSTGRES_PASSWORD=postgres + mariadb: + ports: + - "3307:3306" + image: "mariadb:10.9" + environment: + - MYSQL_ALLOW_EMPTY_PASSWORD=yes + - MYSQL_DATABASE=graph + mysql: + ports: + - "3306:3306" + image: "mysql:5.7" + environment: + - MYSQL_ALLOW_EMPTY_PASSWORD=yes + - MYSQL_DATABASE=graph + mysql8: + ports: + - "3308:3306" + image: "mysql:8" + environment: + - MYSQL_ALLOW_EMPTY_PASSWORD=yes + - MYSQL_DATABASE=graph + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..94ae3cdf3e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: "3.3" +services: + postgresql: + ports: + - "127.0.0.1:5432:5432" + image: "postgres:14-alpine" + environment: + - POSTGRES_PASSWORD=postgres + mariadb: + ports: + - "127.0.0.1:3307:3306" + image: "mariadb:10.9" + environment: + - MYSQL_ALLOW_EMPTY_PASSWORD=yes + - MYSQL_DATABASE=graph + mysql: + ports: + - "127.0.0.1:3306:3306" + image: "mysql:5.7" + environment: + - MYSQL_ALLOW_EMPTY_PASSWORD=yes + - MYSQL_DATABASE=graph + mysql8: + ports: + - "127.0.0.1:3308:3306" + image: "mysql:8" + environment: + - MYSQL_ALLOW_EMPTY_PASSWORD=yes + - MYSQL_DATABASE=graph + + + diff --git a/docs/contributing/contributing.md b/docs/contributing/contributing.md new file mode 100644 index 0000000000..b53f66837e --- /dev/null +++ b/docs/contributing/contributing.md @@ -0,0 +1,3 @@ +# Contributing + +Details coming soon. diff --git a/docs/contributing/documentation-style-guide.md b/docs/contributing/documentation-style-guide.md new file mode 100644 index 0000000000..fbb6c09891 --- /dev/null +++ b/docs/contributing/documentation-style-guide.md @@ -0,0 +1,238 @@ +--- +credits: https://github.com/fastify/fastify/blob/main/docs/Guides/Style-Guide.md +--- + +# Documentation Style Guide + +Welcome to the *Platformatic Documentation Style Guide*. This guide is here to provide +you with a conventional writing style for users writing developer documentation on +our Open Source framework. Each topic is precise and well explained to help you write +documentation users can easily understand and implement. + +## Who is this guide for? + +This guide is for anyone who loves to build with Platformatic or wants to contribute +to our documentation. You do not need to be an expert in writing technical +documentation. This guide is here to help you. + +Visit [CONTRIBUTING.md](https://github.com/platformatic/platformatic/blob/main/CONTRIBUTING.md) +file on GitHub to join our Open Source folks. + +## Before you write + +You should have a basic understanding of: + +* JavaScript +* Node.js +* Git +* GitHub +* Markdown +* HTTP +* NPM + +### Consider your Audience + +Before you start writing, think about your audience. In this case, your audience +should already know HTTP, JavaScript, NPM, and Node.js. It is necessary to keep +your readers in mind because they are the ones consuming your content. You want +to give as much useful information as possible. Consider the vital things they +need to know and how they can understand them. Use words and references that +readers can relate to easily. Ask for feedback from the community, it can help +you write better documentation that focuses on the user and what you want to +achieve. + +### Get straight to the point + +Give your readers a clear and precise action to take. Start with what is most +important. This way, you can help them find what they need faster. Mostly, +readers tend to read the first content on a page, and many will not scroll +further. + +**Example** + +Less like this: Colons are very important to register a parametric path. It lets +the framework know there is a new parameter created. You can place the colon +before the parameter name so the parametric path can be created. + +More Like this: To register a parametric path, put a colon before the parameter +name. Using a colon lets the framework know it is a parametric path and not a +static path. + +### Images and video should enhance the written documentation + + +Images and video should only be added if they complement the written +documentation, for example to help the reader form a clearer mental model of a +concept or pattern. + +Images can be directly embedded, but videos should be included by linking to an +external site, such as YouTube. You can add links by using +`[Title](https://www.websitename.com)` in the Markdown. + + + + +### Avoid plagiarism + +Make sure you avoid copying other people's work. Keep it as original as +possible. You can learn from what they have done and reference where it is from +if you used a particular quote from their work. + + +## Word Choice + +There are a few things you need to use and avoid when writing your documentation +to improve readability for readers and make documentation neat, direct, and +clean. + + +### When to use the second person "you" as the pronoun + +When writing articles or guides, your content should communicate directly to +readers in the second person ("you") addressed form. It is easier to give them +direct instruction on what to do on a particular topic. To see an example, visit +the [Quick Start Guide](../getting-started/quick-start-guide.md). + +**Example** + +Less like this: we can use the following plugins. + +More like this: You can use the following plugins. + +> According to [Wikipedia](#), ***You*** is usually a second person pronoun. +> Also, used to refer to an indeterminate person, as a more common alternative +> to a very formal indefinite pronoun. + +## When to avoid the second person "you" as the pronoun + +One of the main rules of formal writing such as reference documentation, or API +documentation, is to avoid the second person ("you") or directly addressing the +reader. + +**Example** + +Less like this: You can use the following recommendation as an example. + +More like this: As an example, the following recommendations should be +referenced. + +To view a live example, refer to the [Decorators](../reference/configuration.md) +reference document. + + +### Avoid using contractions + +Contractions are the shortened version of written and spoken forms of a word, +i.e. using "don't" instead of "do not". Avoid contractions to provide a more +formal tone. + +### Avoid using condescending terms + +Condescending terms are words that include: + +* Just +* Easy +* Simply +* Basically +* Obviously + +The reader may not find it easy to use Platformatic; avoid +words that make it sound simple, easy, offensive, or insensitive. Not everyone +who reads the documentation has the same level of understanding. + +### Starting with a verb + +Mostly start your description with a verb, which makes it simple and precise for +the reader to follow. Prefer using present tense because it is easier to read +and understand than the past or future tense. + +**Example** + + Less like this: There is a need for Node.js to be installed before you can be + able to use Platformatic. + + More like this: Install Node.js to make use of Platformatic. + +### Grammatical moods + +Grammatical moods are a great way to express your writing. Avoid sounding too +bossy while making a direct statement. Know when to switch between indicative, +imperative, and subjunctive moods. + + +**Indicative** - Use when making a factual statement or question. + +Example: Since there is no testing framework available, "Platformatic recommends ways +to write tests". + +**Imperative** - Use when giving instructions, actions, commands, or when you +write your headings. + +Example: Install dependencies before starting development. + + +**Subjunctive** - Use when making suggestions, hypotheses, or non-factual +statements. + +Example: Reading the documentation on our website is recommended to get +comprehensive knowledge of the framework. + +### Use **active** voice instead of **passive** + +Using active voice is a more compact and direct way of conveying your +documentation. + +**Example** + + +Passive: The node dependencies and packages are installed by npm. + +Active: npm installs packages and node dependencies. + +## Writing Style + +### Documentation titles + +When creating a new guide, API, or reference in the `/docs/` directory, use +short titles that best describe the topic of your documentation. Name your files +in kebab-cases and avoid Raw or camelCase. To learn more about kebab-case you +can visit this medium article on [Case +Styles](https://medium.com/better-programming/string-case-styles-camel-pascal-snake-and-kebab-case-981407998841). + +**Examples**: + +>`hook-and-plugins.md`, + + `adding-test-plugins.md`, + + `removing-requests.md`. + +### Hyperlinks + +Hyperlinks should have a clear title of what it references. Here is how your +hyperlink should look: + +```MD + + +// Add clear & brief description +[Fastify Plugins] (https://www.fastify.io/docs/latest/Plugins/) + + + +// incomplete description +[Fastify] (https://www.fastify.io/docs/latest/Plugins/) + +// Adding title in link brackets +[](https://www.fastify.io/docs/latest/Plugins/ "fastify plugin") + +// Empty title +[](https://www.fastify.io/docs/latest/Plugins/) + +// Adding links localhost URLs instead of using code strings (``) +[http://localhost:3000/](http://localhost:3000/) + +``` + +Include in your documentation as many essential references as possible, but +avoid having numerous links when writing for beginners to avoid distractions. diff --git a/docs/getting-started/architecture.md b/docs/getting-started/architecture.md new file mode 100644 index 0000000000..6974c22547 --- /dev/null +++ b/docs/getting-started/architecture.md @@ -0,0 +1,28 @@ +# Architecture + +Platformatic is a collection of Open Source tools designed to eliminate friction +in backend development. The first of those tools is Platformatic DB, which is developed +as `@platformatic/db`. + +## Platformatic DB + +Platformatic DB can expose a SQL database by dynamically mapping it to REST/OpenAPI +and GraphQL endpoints. It supports a limited subset of the SQL query language, but +also allows developers to add their own custom routes and resolvers. + +![Platformatic DB Architecture](./platformatic-architecture.png) + +Platformatic DB is composed of a few key libraries: + +1. `@platformatic/sql-mapper` - follows the [Data Mapper pattern](https://en.wikipedia.org/wiki/Data_mapper_pattern) to build an API on top of a SQL database. + Internally it uses the [`@database` project](https://www.atdatabases.org/). +1. `@platformatic/sql-openapi` - uses `sql-mapper` to create a series of REST routes and matching OpenAPI definitions. + Internally it uses [`@fastify/swagger`](https://github.com/fastify/fastify-swagger). +1. `@platformatic/sql-graphql` - uses `sql-mapper` to create a GraphQL endpoint and schema. `sql-graphql` also support Federation. + Internally it uses [`mercurius`](https://github.com/mercuriusjs/mercurius). + +Platformatic DB allows you to load a [Fastify plugin](https://www.fastify.io/docs/latest/Reference/Plugins/) during server startup that contains your own application-specific code. +The plugin can add more routes or resolvers — these will automatically be shown in the OpenAPI and GraphQL schemas. + +SQL database migrations are also supported. They're implemented internally with the [`postgrator`](https://www.npmjs.com/package/postgrator) library. + diff --git a/docs/getting-started/movie-quotes-app-tutorial.md b/docs/getting-started/movie-quotes-app-tutorial.md new file mode 100644 index 0000000000..04cb64c874 --- /dev/null +++ b/docs/getting-started/movie-quotes-app-tutorial.md @@ -0,0 +1,1888 @@ +# Movie Quotes App Tutorial + +This tutorial will help you learn how to build a full stack application on top +of Platformatic DB. We're going to build an application that allows us to +save our favourite movie quotes. We'll also be building in custom API functionality +that allows for some neat user interaction on our frontend. + +You can find the complete code for the application that we're going to build +[on GitHub](https://github.com/platformatic/tutorial-movie-quotes-app). + +:::note + +We'll be building the frontend of our application with the [Astro](https://astro.build/) +framework, but the GraphQL API integration steps that we're going to cover can +be applied with most frontend frameworks. + +::: + +## What we're going to cover + +In this tutorial we'll learn how to: + +- Create a Platformatic API +- Apply database migrations +- Create relationships between our API entities +- Populate our database tables +- Build a frontend application that integrates with our GraphQL API +- Extend our API with custom functionality +- Enable CORS on our Platformatic API + +## Prerequisites + +To follow along with this tutorial you'll need to have these things installed: + +- [Node.js](https://nodejs.org/) >= v16.17.0 or >= v18.8.0 +- [npm](https://docs.npmjs.com/cli/) v7 or later +- A code editor, for example [Visual Studio Code](https://code.visualstudio.com/) + +You'll also need to have some experience with JavaScript, and be comfortable with +running commands in a terminal. + +## Build the backend + +### Create a Platformatic API + +First, let's create our project directory: + +```bash +mkdir -p tutorial-movie-quotes-app/apps/movie-quotes-api/ + +cd tutorial-movie-quotes-app/apps/movie-quotes-api/ +``` + +Then let's create a `package.json` file: + +```bash +npm init --yes +``` + +Now we can install the [platformatic](https://www.npmjs.com/package/platformatic) +CLI as a dependency: + +```bash +npm install platformatic +``` + +Let's also add some npm run scripts for convenience: + +```bash +npm pkg set scripts.start="platformatic db start" + +npm pkg set scripts.dev="npm start" +``` + +Now we're going to configure our API. Let's create our Platformatic configuration +file, **`platformatic.db.json`**: + +```json +{ + "server": { + "logger": { + "level": "{PLT_SERVER_LOGGER_LEVEL}" + }, + "hostname": "{PLT_SERVER_HOSTNAME}", + "port": "{PORT}" + }, + "core": { + "connectionString": "{DATABASE_URL}" + }, + "migrations": { + "dir": "./migrations" + } +} +``` + +Now we'll create a **`.env`** file with settings for our configuration to use: + +``` +PORT=3042 +PLT_SERVER_HOSTNAME=127.0.0.1 +PLT_SERVER_LOGGER_LEVEL=info +DATABASE_URL=sqlite://./movie-quotes.sqlite +``` + +:::info + +Take a look at the [Configuration reference](/reference/configuration.md) +to see all the supported configuration settings. + +::: + +### Define the database schema + +Let's create a new directory to store our migration files: + +```bash +mkdir migrations +``` + +Then we'll create a migration file named **`001.do.sql`** in the **`migrations`** +directory: + +```sql +CREATE TABLE quotes ( + id INTEGER PRIMARY KEY, + quote TEXT NOT NULL, + said_by VARCHAR(255) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +Let's also create `.gitignore` so that we avoid accidentally committing our +SQLite database: + +```bash +echo '*.sqlite' > .gitignore +``` + +Now we can start the Platformatic DB server: + +```bash +npm run dev +``` + +Our Platformatic DB server should start, and we'll see messages like these: + +``` +[11:26:48.772] INFO (15235): running 001.do.sql +[11:26:48.864] INFO (15235): server listening + url: "http://127.0.0.1:3042" +``` + +Let's open a new terminal and make a request to our server's REST API that +creates a new quote: + +```bash +curl --request POST --header "Content-Type: application/json" \ + -d "{ \"quote\": \"Toto, I've got a feeling we're not in Kansas anymore.\", \"saidBy\": \"Dorothy Gale\" }" \ + http://localhost:3042/quotes +``` + +We should receive a response like this from the API: + +```json +{"id":1,"quote":"Toto, I've got a feeling we're not in Kansas anymore.","saidBy":"Dorothy Gale","createdAt":"2022-09-13 10:39:35"} +``` + +### Create an entity relationship + +Now let's create a migration file named **`002.do.sql`** in the **`migrations`** +directory: + +```sql +CREATE TABLE movies ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +-- TODO: Add a foreign key constraint so quotes.movie_id must exist in movies.id +ALTER TABLE quotes ADD COLUMN movie_id INTEGER REFERENCES movies(id); +``` + +This SQL will create a new `movies` database table and also add a `movie_id` +column to the `quotes` table. This will allow us to store movie data in the +`movies` table and then reference them by ID in our `quotes` table. + +Let's stop the Platformatic DB server with `Ctrl + C`, and then start it again: + +```bash +npm run dev +``` + +The new migration should be automatically applied and we'll see the log message +`running 002.do.sql`. + +Our Platformatic DB server also provides a GraphQL API. Let's open up the GraphiQL +application in our web browser: + +> http://localhost:3042/graphiql + +Now let's run this query with GraphiQL to add the movie for the quote that we +added earlier: + +```graphql +mutation { + saveMovie(input: { name: "The Wizard of Oz" }) { + id + } +} +``` + +We should receive a response like this from the API: + +```json +{ + "data": { + "saveMovie": { + "id": "1" + } + } +} +``` + +Now we can update our quote to reference the movie: + +```graphql +mutation { + saveQuote(input: { id: 1, movieId: 1 }) { + id + quote + saidBy + createdAt + movie { + id + name + } + } +} +``` + +We should receive a response like this from the API: + +```json +{ + "data": { + "saveQuote": { + "id": "1", + "quote": "Toto, I've got a feeling we're not in Kansas anymore.", + "saidBy": "Dorothy Gale", + "movie": { + "id": "1", + "name": "The Wizard of Oz" + } + } + } +} +``` + +Our Platformatic DB server has automatically identified the relationship +between our `quotes` and `movies` database tables. This allows us to make +GraphQL queries that retrieve quotes and their associated movies at the same +time. For example, to retrieve all quotes from our database we can run: + +```graphql +query { + quotes { + id + quote + saidBy + createdAt + movie { + id + name + } + } +} +``` + +To view the GraphQL schema that's generated for our API by Platformatic DB, +we can run this command in our terminal: + +```bash +npx platformatic db schema graphql +``` + +The GraphQL schema shows all of the queries and mutations that we can run +against our GraphQL API, as well as the types of data that it expects as input. + +### Populate the database + +Our movie quotes database is looking a little empty! We're going to create a +"seed" script to populate it with some data. + +Let's create a new file named **`seed.js`** and copy and paste in this code: + +```javascript +'use strict' + +const quotes = [ + { + quote: "Toto, I've got a feeling we're not in Kansas anymore.", + saidBy: 'Dorothy Gale', + movie: 'The Wizard of Oz' + }, + { + quote: "You're gonna need a bigger boat.", + saidBy: 'Martin Brody', + movie: 'Jaws' + }, + { + quote: 'May the Force be with you.', + saidBy: 'Han Solo', + movie: 'Star Wars' + }, + { + quote: 'I have always depended on the kindness of strangers.', + saidBy: 'Blanche DuBois', + movie: 'A Streetcar Named Desire' + } +] + +module.exports = async function ({ entities, db, sql }) { + for (const values of quotes) { + const movie = await entities.movie.save({ input: { name: values.movie } }) + + console.log('Created movie:', movie) + + const quote = { + quote: values.quote, + saidBy: values.saidBy, + movieId: movie.id + } + + await entities.quote.save({ input: quote }) + + console.log('Created quote:', quote) + } +} +``` + + + +:::info +Take a look at the [Seed a Database](/guides/seed-a-database.md) guide to learn more +about how database seeding works with Platformatic DB. +::: + +Let's stop our Platformatic DB server running and remove our SQLite database: + +``` +rm movie-quotes.db +``` + +Now let's create a fresh SQLite database by running our migrations: + +```bash +npx platformatic db migrate +``` + +And then let's populate the `quotes` and `movies` tables with data using our +seed script: + +```bash +npx platformatic db seed seed.js +``` + +Our database is full of data, but we don't have anywhere to display it. It's +time to start building our frontend! + +## Build the frontend + +We're now going to use [Astro](https://astro.build/) to build our frontend +application. If you've not used it before, you might find it helpful +to read [this overview](https://docs.astro.build/en/core-concepts/astro-components/) +on how Astro components are structured. + +:::tip +Astro provide some extensions and tools to help improve your +[Editor Setup](https://docs.astro.build/en/editor-setup/) when building an +Astro application. +::: + +### Create an Astro application + +In the root of our project, let's create a new directory for our frontent +application: + +```bash +mkdir -p apps/movie-quotes-frontend/ + +cd apps/movie-quotes-frontend/ +``` + +And then we'll create a new `package.json` file: + +```bash +npm init --yes +``` + +Now we can install [astro](https://www.npmjs.com/package/astro) as a dependency: + +```bash +npm install --save-dev astro +``` + +Then let's set up some npm run scripts for convenience: + +```bash +npm pkg delete scripts.test +npm pkg set scripts.dev="astro dev --port 3000" +npm pkg set scripts.start="astro dev --port 3000" +npm pkg set scripts.build="astro build" +``` + +Now we'll create our Astro configuration file, **`astro.config.mjs`** and +copy and paste in this code: + +```javascript +import { defineConfig } from 'astro/config' + +// https://astro.build/config +export default defineConfig({ + output: 'server' +}) +``` + +And we'll also create a **`tsconfig.json`** file and add in this configuration: + +```json +{ + "extends": "astro/tsconfigs/base", + "compilerOptions": { + "types": ["astro/client"] + } +} +``` + +> We won't be writing our frontend application with TypeScript, but adding this +> configuration file allows Astro to provide TODO +> https://docs.astro.build/en/guides/typescript/ + +Now let's create the directories where we'll be adding the components for our +frontend application: + +```bash +mkdir -p src/pages src/layouts src/components +``` + +And inside the **`src/pages`** directory let's create our first page, **`index.astro`**: + +```astro +

Movie Quotes

+``` + +Now we can start up the Astro development server with: + +```bash +npm run dev +``` + +And then load up the frontend in our browser at [http://localhost:3000](http://localhost:3000) + +### Create a layout + +In the **`src/layouts`** directory, let's create a new file named **`Layout.astro`**: + +```astro +--- +export interface Props { + title: string; + page?: string; +} +const { title, page } = Astro.props; +--- + + + + + + + {title} + + +
+

🎬 Movie Quotes

+
+ +
+ +
+ + +``` + +The code between the `---` is known as the component script, and the +code after that is the component template. The component script will *only* run +on the server side when a web browser makes a request. The component template +is rendered server side and sent back as an HTML response to the web browser. + +Now we'll update **`src/pages/index.astro`** to use this `Layout` component. +Let's replace the contents of **`src/pages/index.astro`** with this code: + +```astro +--- +import Layout from '../layouts/Layout.astro'; +--- + + +
+

We'll list all the movie quotes here.

+
+
+``` + +### Integrate the urql GraphQL client + +We're now going to integrate the [URQL](https://formidable.com/open-source/urql/) +GraphQL client into our frontend application. This will allow us to run queries +and mutations against our Platformatic GraphQL API. + +Let's first install [@urql/core](https://www.npmjs.com/package/@urql/core) and +[graphql](https://www.npmjs.com/package/graphql) as project dependencies: + +```bash +npm install @urql/core graphql +``` + +Then let's create a new **`.env`** file and add this configuration: + +``` +PUBLIC_GRAPHQL_API_ENDPOINT=http://127.0.0.1:3042/graphql +``` + +Now we'll create a new directory: + +```bash +mkdir src/lib +``` + +And then create a new file named **`src/lib/quotes-api.js`**. In that file we'll +create a new URQL client: + +```javascript +// src/lib/quotes-api.js + +import { createClient } from '@urql/core'; + +const graphqlClient = createClient({ + url: import.meta.env.PUBLIC_GRAPHQL_API_ENDPOINT, + requestPolicy: "network-only" +}); +``` + +We'll also add a thin wrapper around the client that does some basic error +handling for us: + +```javascript +// src/lib/quotes-api.js + +async function graphqlClientWrapper(method, gqlQuery, queryVariables = {}) { + const queryResult = await graphqlClient[method]( + gqlQuery, + queryVariables + ).toPromise(); + + if (queryResult.error) { + console.error("GraphQL error:", queryResult.error); + } + + return { + data: queryResult.data, + error: queryResult.error, + }; +} + +export const quotesApi = { + async query(gqlQuery, queryVariables = {}) { + return await graphqlClientWrapper("query", gqlQuery, queryVariables); + }, + async mutation(gqlQuery, queryVariables = {}) { + return await graphqlClientWrapper("mutation", gqlQuery, queryVariables); + } +} +``` + +And lastly, we'll export `gql` from the `@urql/core` package, to make it +simpler for us to write GraphQL queries in our pages: + +```javascript +// src/lib/quotes-api.js + +export { gql } from "@urql/core"; +``` + +Stop the Astro dev server and then start it again so it picks up the **`.env`** +file: + +```bash +npm run dev +``` + +### Display all quotes + +Let's display all the movie quotes in **`src/pages/index.astro`**. + +First, we'll update the component script at the top and add in a query to +our GraphQL API for quotes: + +```astro +--- +import Layout from '../layouts/Layout.astro'; +// highlight-start +import { quotesApi, gql } from '../lib/quotes-api'; + +const { data } = await quotesApi.query(gql` + query { + quotes { + id + quote + saidBy + createdAt + movie { + id + name + } + } + } +`); + +const quotes = data?.quotes || []; +// highlight-end +--- +``` + +Then we'll update the component template to display the quotes: + +```astro + +
+// highlight-start + {quotes.length > 0 ? quotes.map((quote) => ( +
+
+

{quote.quote}

+
+

+ — {quote.saidBy}, {quote.movie?.name} +

+
+ Added {new Date(quote.createdAt).toUTCString()} +
+
+ )) : ( +

No movie quotes have been added.

+ )} +// highlight-end +
+
+``` + +And just like that, we have all the movie quotes displaying on the page! + +### Integrate Tailwind for styling + +Automatically add the [@astrojs/tailwind integration](https://docs.astro.build/en/guides/integrations-guide/tailwind/): + +```bash +npx astro add tailwind --yes +``` + +Add the Tailwind CSS [Typography](https://tailwindcss.com/docs/typography-plugin) +and [Forms](https://github.com/tailwindlabs/tailwindcss-forms) plugins: + +```bash +npm install --save-dev @tailwindcss/typography @tailwindcss/forms +``` + +Import the plugins in our Tailwind configuration file: + +```javascript +// tailwind.config.cjs + +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], + theme: { + extend: {} + }, +// highlight-start + plugins: [ + require('@tailwindcss/forms'), + require('@tailwindcss/typography') + ] +// highlight-end +} +``` + +Stop the Astro dev server and then start it again so it picks up all the +configuration changes: + +```bash +npm run dev +``` + +### Style the listing page + +To style our listing page, let's add CSS classes to the component template in +**`src/layouts/Layout.astro`**: + +```astro +--- +export interface Props { + title: string; + page?: string; +} + +const { title, page } = Astro.props; + +// highlight-next-line +const navActiveClasses = "font-bold bg-yellow-400 no-underline"; +--- + + + + + + + {title} + +// highlight-next-line + +// highlight-next-line +
+

🎬 Movie Quotes

+
+// highlight-next-line + +// highlight-next-line +
+ +
+ + +``` + +Then let's add CSS classes to the component template in **`src/pages/index.astro`**: + +```astro + +
+ {quotes.length > 0 ? quotes.map((quote) => ( +// highlight-next-line +
+// highlight-next-line +
+// highlight-next-line +

{quote.quote}

+
+// highlight-next-line +

+ — {quote.saidBy}, {quote.movie?.name} +

+// highlight-next-line +
+// highlight-next-line + Added {new Date(quote.createdAt).toUTCString()} +
+
+ )) : ( +

No movie quotes have been added.

+ )} +
+
+``` + +Our listing page is now looking much more user friendly! + +### Create an add quote page + +We're going to create a form component that we can use for adding and editing +quotes. + +First let's create a new component file, **`src/components/QuoteForm.astro`**: + +```astro +--- +export interface QuoteFormData { + id?: number; + quote?: string; + saidBy?: string; + movie?: string; +} + +export interface Props { + action: string; + values?: QuoteFormData; + saveError?: boolean; + loadError?: boolean; + submitLabel: string; +} + +const { action, values = {}, saveError, loadError, submitLabel } = Astro.props; +--- + +{saveError &&

There was an error saving the quote. Please try again.

} +{loadError &&

There was an error loading the quote. Please try again.

} + +
+ + + + +
+``` + +Create a new page file, **`src/pages/add.astro`**: + +```astro +--- +import Layout from '../layouts/Layout.astro'; +import QuoteForm from '../components/QuoteForm.astro'; +import type { QuoteFormData } from '../components/QuoteForm.astro'; + +let formData: QuoteFormData = {}; +let saveError = false; +--- + + +
+

Add a quote

+ +
+
+``` + +And now let's add a link to this page in the layout navigation in **`src/layouts/Layout.astro`**: + +```astro + +``` + +### Send form data to the API + +When a user submits the add quote form we want to send the form data to our API +so it can then save it to our database. Let's wire that up now. + +First we're going to create a new file, **`src/lib/request-utils.js`**: + +```javascript +export function isPostRequest (request) { + return request.method === 'POST' +} + +export async function getFormData (request) { + const formData = await request.formData() + + return Object.fromEntries(formData.entries()) +} +``` + + + +Then let's update the component script in **`src/pages/add.astro`** to use +these new request utility functions: + +```astro +--- +import Layout from '../layouts/Layout.astro'; +import QuoteForm from '../components/QuoteForm.astro'; +import type { QuoteFormData } from '../components/QuoteForm.astro'; + +// highlight-next-line +import { isPostRequest, getFormData } from '../lib/request-utils'; + +let formData: QuoteFormData = {}; +let saveError = false; + +// highlight-start +if (isPostRequest(Astro.request)) { + formData = await getFormData(Astro.request); +} +// highlight-end +--- +``` + + + +When we create a new quote entity record via our API, we need to include a +`movieId` field that references a movie entity record. This means that when a +user submits the add quote form we need to: + +- Check if a movie entity record already exists with that movie name +- Return the movie `id` if it does exist +- If it doesn't exist, create a new movie entity record and return the movie ID + +Let's update the `import` statement at the top of **`src/lib/quotes-api.js`** + +```diff +-import { createClient } from '@urql/core' ++import { createClient, gql } from '@urql/core' +``` + +And then add a new method that will return a movie ID for us: + +```javascript +async function getMovieId (movieName) { + movieName = movieName.trim() + + let movieId = null + + // Check if a movie already exists with the provided name. + const queryMoviesResult = await quotesApi.query( + gql` + query ($movieName: String!) { + movies(where: { name: { eq: $movieName } }) { + id + } + } + `, + { movieName } + ) + + if (queryMoviesResult.error) { + return null + } + + const movieExists = queryMoviesResult.data?.movies.length === 1 + if (movieExists) { + movieId = queryMoviesResult.data.movies[0].id + } else { + // Create a new movie entity record. + const saveMovieResult = await quotesApi.mutation( + gql` + mutation ($movieName: String!) { + saveMovie(input: { name: $movieName }) { + id + } + } + `, + { movieName } + ) + + if (saveMovieResult.error) { + return null + } + + movieId = saveMovieResult.data?.saveMovie.id + } + + return movieId +} +``` + +And let's export it too: + +```javascript +export const quotesApi = { + async query (gqlQuery, queryVariables = {}) { + return await graphqlClientWrapper('query', gqlQuery, queryVariables) + }, + async mutation (gqlQuery, queryVariables = {}) { + return await graphqlClientWrapper('mutation', gqlQuery, queryVariables) + }, +// highlight-next-line + getMovieId +} +``` + +Now we can wire up the last parts in the **`src/pages/add.astro`** component +script: + +```astro +--- +import Layout from '../layouts/Layout.astro'; +import QuoteForm from '../components/QuoteForm.astro'; +import type { QuoteFormData } from '../components/QuoteForm.astro'; + +// highlight-next-line +import { quotesApi, gql } from '../lib/quotes-api'; +import { isPostRequest, getFormData } from '../lib/request-utils'; + +let formData: QuoteFormData = {}; +let saveError = false; + +if (isPostRequest(Astro.request)) { + formData = await getFormData(Astro.request); + +// highlight-start + const movieId = await quotesApi.getMovieId(formData.movie); + + if (movieId) { + const quote = { + quote: formData.quote, + saidBy: formData.saidBy, + movieId, + }; + + const { error } = await quotesApi.mutation(gql` + mutation($quote: QuoteInput!) { + saveQuote(input: $quote) { + id + } + } + `, { quote }); + + if (!error) { + return Astro.redirect('/'); + } else { + saveError = true; + } + } else { + saveError = true; + } +// highlight-end +} +``` + + + +### Add autosuggest for movies + +We can create a better experience for our users by autosuggesting the movie name +when they're adding a new quote. + +Let's open up **`src/components/QuoteForm.astro`** and import our API helper methods +in the component script: + +```astro +import { quotesApi, gql } from '../lib/quotes-api.js'; +``` + +Then let's add in a query to our GraphQL API for all movies: + +```astro +const { data } = await quotesApi.query(gql` + query { + movies { + name + } + } +`); + +const movies = data?.movies || []; +``` + +Now lets update the *Movie* field in the component template to use the +array of movies that we've retrieved from the API: + +```astro + +``` + + + +### Create an edit quote page + +Let's create a new directory, **`src/pages/edit/`**: + +```bash +mkdir src/pages/edit/ +``` + +And inside of it, let's create a new page, **`[id].astro`**: + +```astro +--- +import Layout from '../../layouts/Layout.astro'; +import QuoteForm, { QuoteFormData } from '../../components/QuoteForm.astro'; + +const id = Number(Astro.params.id); + +let formValues: QuoteFormData = {}; +let loadError = false; +let saveError = false; +--- + + +
+

Edit quote

+ +
+
+``` + +You'll see that we're using the same `QuoteForm` component that our add quote +page uses. Now we're going to wire up our edit page so that it can load an +existing quote from our API and save changes back to the API when the form is +submitted. + +In the **`[id.astro]`** component script, let's add some code to take care of +these tasks: + +```astro +--- +import Layout from '../../layouts/Layout.astro'; +import QuoteForm, { QuoteFormData } from '../../components/QuoteForm.astro'; + +// highlight-start +import { quotesApi, gql } from '../../lib/quotes-api'; +import { isPostRequest, getFormData } from '../../lib/request-utils'; +// highlight-end + +const id = Number(Astro.params.id); + +let formValues: QuoteFormData = {}; +let loadError = false; +let saveError = false; + +// highlight-start +if (isPostRequest(Astro.request)) { + const formData = await getFormData(Astro.request); + formValues = formData; + + const movieId = await quotesApi.getMovieId(formData.movie); + + if (movieId) { + const quote = { + id, + quote: formData.quote, + saidBy: formData.saidBy, + movieId, + }; + + const { error } = await quotesApi.mutation(gql` + mutation($quote: QuoteInput!) { + saveQuote(input: $quote) { + id + } + } + `, { quote }); + + if (!error) { + return Astro.redirect('/'); + } else { + saveError = true; + } + } else { + saveError = true; + } +} else { + const { data } = await quotesApi.query(gql` + query($id: ID!) { + getQuoteById(id: $id) { + id + quote + saidBy + movie { + id + name + } + } + } + `, { id }); + + if (data?.getQuoteById) { + formValues = { + ...data.getQuoteById, + movie: data.getQuoteById.movie.name + }; + } else { + loadError = true; + } +} +// highlight-end +--- +``` + + + +Load up [http://localhost:3000/edit/1](http://localhost:3000/edit/1) in your +browser to test out the edit quote page. + +Now we're going to add edit links to the quotes listing page. Let's start by +creating a new component **`src/components/QuoteActionEdit.astro`**: + +```astro +--- +export interface Props { + id: number; +} + +const { id } = Astro.props; +--- + + + + + + Edit + +``` + +Then let's import this component and use it in our listing page, +**`src/pages/index.astro`**: + +```astro +--- +import Layout from '../layouts/Layout.astro'; +// highlight-next-line +import QuoteActionEdit from '../components/QuoteActionEdit.astro'; +import { quotesApi, gql } from '../lib/quotes-api'; + +// ... +--- + + +
+ {quotes.length > 0 ? quotes.map((quote) => ( +
+ ... +
+// highlight-start + + + + Added {new Date(quote.createdAt).toUTCString()} +// highlight-end +
+
+ )) : ( +

No movie quotes have been added.

+ )} +
+
+``` + +### Add delete quote functionality + +Our Movie Quotes app can create, retrieve and update quotes. Now we're going +to implement the D in CRUD — delete! + +First let's create a new component, **`src/components/QuoteActionDelete.astro`**: + +```astro +--- +export interface Props { + id: number; +} + +const { id } = Astro.props; +--- +
+ +
+``` + + + +And then we'll drop it into our listing page, **`src/pages/index.astro`**: + +```astro +--- +import Layout from '../layouts/Layout.astro'; +import QuoteActionEdit from '../components/QuoteActionEdit.astro'; +// highlight-next-line +import QuoteActionDelete from '../components/QuoteActionDelete.astro'; +import { quotesApi, gql } from '../lib/quotes-api'; + +// ... +--- + + +
+ {quotes.length > 0 ? quotes.map((quote) => ( +
+ ... +
+ + +// highlight-next-line + + + Added {new Date(quote.createdAt).toUTCString()} +
+
+... +``` + +At the moment when a delete form is submitted from our listing page, we get +an Astro 404 page. Let's fix this by creating a new directory, **`src/pages/delete/`**: + +```bash +mkdir src/pages/delete/ +``` + +And inside of it, let's create a new page, **`[id].astro`**: + +```astro +--- +import Layout from '../../layouts/Layout.astro'; + +import { quotesApi, gql } from '../../lib/quotes-api'; +import { isPostRequest } from '../../lib/request-utils'; + +if (isPostRequest(Astro.request)) { + const id = Number(Astro.params.id); + + const { error } = await quotesApi.mutation(gql` + mutation($id: ID!) { + deleteQuotes(where: { id: { eq: $id }}) { + id + } + } + `, { id }); + + if (!error) { + return Astro.redirect('/'); + } +} +--- + +
+

Delete quote

+

There was an error deleting the quote. Please try again.

+
+
+``` + + + +Now if we click on a delete quote button on our listings page, it should call our +GraphQL API to delete the quote. To make this a little more user friendly, let's +add in a confirmation dialog so that users don't delete a quote by accident. + + + + +Let's create a new directory, **`src/scripts/`**: + +```bash +mkdir src/scripts/ +``` + +And inside of that directory let's create a new file, **`quote-actions.js`**: + +```javascript +// src/scripts/quote-actions.js + +export function confirmDeleteQuote (form) { + if (confirm('Are you sure want to delete this quote?')) { + form.submit() + } +} +``` + +Then we can pull it in as client side JavaScript on our listing page, +**`src/pages/index.astro`**: + +```astro + + ... + + + +``` + + + +## Build a "like" quote feature + +We've built all the basic CRUD (Create, Retrieve, Update & Delete) features +into our application. Now let's build a feature so that users can interact +and "like" their favourite movie quotes. + +To build this feature we're going to add custom functionality to our API +and then add a new component, along with some client side JavaScript, to +our frontend. + +### Create an API migration + +We're now going to work on the code for API, under the **`apps/movie-quotes-api`** +directory. + +First let's create a migration that adds a `likes` column to our `quotes` +database table. We'll create a new migration file, **`migrations/003.do.sql`**: + +```sql +ALTER TABLE quotes ADD COLUMN likes INTEGER default 0; +``` + +This migration will automatically be applied when we next start our Platformatic +API. + +### Create an API plugin + +To add custom functionality to our Platformatic API, we need to create a +[Fastify plugin](https://www.fastify.io/docs/latest/Reference/Plugins/) and +update our API configuration to use it. + +Let's create a new file, **`plugin.js`**, and inside it we'll add the skeleton +structure for our plugin: + +```javascript +// plugin.js + +'use strict' + +module.exports = async function plugin (app) { + app.log.info('plugin loaded') +} +``` + +Now let's register our plugin in our API configuration file, **`platformatic.db.json`**: + +```json +{ + ... + "migrations": { + "dir": "./migrations" +// highlight-start + }, + "plugin": { + "path": "./plugin.js" + } +// highlight-end +} +``` + +And then we'll start up our Platformatic API: + +```bash +npm run dev +``` + +We should see log messages that tell us that our new migration has been +applied and our plugin has been loaded: + +``` +[10:09:20.052] INFO (146270): running 003.do.sql +[10:09:20.129] INFO (146270): plugin loaded +[10:09:20.209] INFO (146270): server listening + url: "http://127.0.0.1:3042" +``` + +Now it's time to start adding some custom functionality inside our plugin. + +### Add a REST API route + + + +We're going to add a REST route to our API that increments the count of +likes for a specific quote: `/quotes/:id/like` + +First let's add [fluent-json-schema](https://www.npmjs.com/package/fluent-json-schema) as a dependency for our API: + +```bash +npm install fluent-json-schema +``` + +We'll use `fluent-json-schema` to help us generate a JSON Schema. We can then +use this schema to validate the request path parameters for our route (`id`). + +Now let's add our REST API route in **`plugin.js`**: + +```javascript +'use strict' + +// highlight-next-line +const S = require('fluent-json-schema') + +module.exports = async function plugin (app) { + app.log.info('plugin loaded') + + // This JSON Schema will validate the request path parameters. + // It reuses part of the schema that Platormatic DB has + // automatically generated for our Quote entity. +// highlight-start + const schema = { + params: S.object().prop('id', app.getSchema('Quote').properties.id) + } + + app.post('/quotes/:id/like', { schema }, async function (request, response) { + return {} + }) +// highlight-end +} +``` + +We can now make a `POST` request to our new API route: + +```bash +curl --request POST http://localhost:3042/quotes/1/like +``` + +:::info +Learn more about how validation works in the +[Fastify validation documentation](https://www.fastify.io/docs/latest/Reference/Validation-and-Serialization/). +::: + +Our API route is currently returning an empty object (`{}`). Let's wire things +up so that it increments the number of likes for the quote with the specified ID. +To do this we'll add a new function inside of our plugin: + +```javascript +module.exports = async function plugin (app) { + app.log.info('plugin loaded') + +// highlight-start + async function incrementQuoteLikes (id) { + const { db, sql } = app.platformatic + + const result = await db.query(sql` + UPDATE quotes SET likes = likes + 1 WHERE id=${id} RETURNING likes + `) + + return result[0]?.likes + } +// highlight-end + + // ... +} +``` + +And then we'll call that function in our route handler function: + +```javascript +app.post('/quotes/:id/like', { schema }, async function (request, response) { +// highlight-next-line + return { likes: await incrementQuoteLikes(request.params.id) } +}) +``` + +Now when we make a `POST` request to our API route: + +```bash +curl --request POST http://localhost:3042/quotes/1/like +``` + +We should see that the `likes` value for the quote is incremented every time +we make a request to the route. + +```json +{"likes":1} +``` + + + +### Add a GraphQL API mutation + +We can add a `likeQuote` mutation to our GraphQL API by reusing the +`incrementQuoteLikes` function that we just created. + +Let's add this code at the end of our plugin, inside **`plugin.js`**: + +```javascript +module.exports = async function plugin (app) { + // ... + +// highlight-start + app.graphql.extendSchema(` + extend type Mutation { + likeQuote(id: ID!): Int + } + `) + + app.graphql.defineResolvers({ + Mutation: { + likeQuote: async (_, { id }) => await incrementQuoteLikes(id) + } + }) +// highlight-end +} +``` + +The code we've just added extends our API's GraphQL schema and defines +a corresponding resolver for the `likeQuote` mutation. + +We can now load up GraphiQL in our web browser and try out our new `likeQuote` +mutation with this GraphQL query: + +```graphql +mutation { + likeQuote(id: 1) +} +``` + +:::info +Learn more about how to extend the GraphQL schema and define resolvers in the +[Mercurius API documentation](https://mercurius.dev/#/docs/api/options). +::: + +### Enable CORS on the API + +When we build "like" functionality into our frontend, we'll be making a client +side HTTP request to our GraphQL API. Our backend API and our frontend are running +on different origins, so we need to configure our API to allow requests from +the frontend. This is known as Cross-Origin Resource Sharing (CORS). + +To enable CORS on our API, let's open up our API's **`.env`** file and add in +a new setting: + +``` +PLT_SERVER_CORS_ORIGIN=http://localhost:3000 +``` + +The value of `PLT_SERVER_CORS_ORIGIN` is our frontend application's origin. + +Now we can add a `cors` configuration object in our API's configuration file, +**`platformatic.db.json`**: + +```json +{ + "server": { + "logger": { + "level": "{PLT_SERVER_LOGGER_LEVEL}" + }, + "hostname": "{PLT_SERVER_HOSTNAME}", + "port": "{PORT}", +// highlight-start + "cors": { + "origin": "{PLT_SERVER_CORS_ORIGIN}" + } +// highlight-end + }, + ... +} +``` + +The HTTP responses from all endpoints on our API will now include the header: + +``` +access-control-allow-origin: http://localhost:3000 +``` + +This will allow JavaScript running on web pages under the `http://localhost:3000` +origin to make requests to our API. + +### Add like quote functionality + +Now that our API supports "liking" a quote, let's integrate it as a feature in +our frontend. + +First we'll create a new component, **`src/components/QuoteActionLike.astro`**: + +```astro +--- +export interface Props { + id: number; + likes: number; +} + +const { id, likes } = Astro.props; +--- + + + +``` + +And in our listing page, **`src/pages/index.astro`**, let's import our new +component and add it into the interface: + +```astro +--- +import Layout from '../layouts/Layout.astro'; +import QuoteActionEdit from '../components/QuoteActionEdit.astro'; +import QuoteActionDelete from '../components/QuoteActionDelete.astro'; +// highlight-next-line +import QuoteActionLike from '../components/QuoteActionLike.astro'; +import { quotesApi, gql } from '../lib/quotes-api'; + +// ... +--- + + +
+ {quotes.length > 0 ? quotes.map((quote) => ( +
+ ... +
+ +// highlight-next-line + + + + + Added {new Date(quote.createdAt).toUTCString()} +
+
+... +``` + +Then let's update the GraphQL query in this component's script to retrieve the +`likes` field for all quotes: + +```javascript +const { data } = await quotesApi.query(gql` + query { + quotes { + id + quote + saidBy +// highlight-next-line + likes + createdAt + movie { + id + name + } + } + } +`); +``` + +Now we have the likes showing for each quote, let's wire things up so that +clicking on the like component for a quote will call our API and add a like. + +Let's open up **`src/scripts/quote-actions.js`** and add a new function that +makes a request to our GraphQL API: + +```javascript +// highlight-next-line +import { quotesApi, gql } from '../lib/quotes-api.js' + +export function confirmDeleteQuote (form) { + if (confirm('Are you sure want to delete this quote?')) { + form.submit() + } +} + +// highlight-start +export async function likeQuote (likeQuote) { + likeQuote.classList.add('liked') + likeQuote.classList.remove('cursor-pointer') + + const id = Number(likeQuote.dataset.quoteId) + + const { data } = await quotesApi.mutation(gql` + mutation($id: ID!) { + likeQuote(id: $id) + } + `, { id }) + + if (data?.likeQuote) { + likeQuote.querySelector('.likes-count').innerText = data.likeQuote + } +} +// highlight-end +``` + +And then let's attach the `likeQuote` function to the click event for each +like quote component on our listing page. We can do this by adding a little +extra code inside the ` +``` + +### Sort the listing by top quotes + +Now that users can like their favourite quotes, as a final step, we'll allow +for sorting quotes on the listing page by the number of likes they have. + +Let's update **`src/pages/index.astro`** to read a `sort` query string parameter +and use it the GraphQL query that we make to our API: + +```astro +--- +// ... + +// highlight-start +const allowedSortFields = ["createdAt", "likes"]; +const searchParamSort = new URL(Astro.request.url).searchParams.get("sort"); +const sort = allowedSortFields.includes(searchParamSort) ? searchParamSort : "createdAt"; +// highlight-end + +const { data } = await quotesApi.query(gql` + query { +// highlight-next-line + quotes(orderBy: {field: ${sort}, direction: DESC}) { + id + quote + saidBy + likes + createdAt + movie { + id + name + } + } + } +`); + +const quotes = data?.quotes || []; +--- +// highlight-next-line + +... +``` + +Then let's replace the 'All quotes' link in the `