From d895173cf7728845dc05c7abe4724518da90f058 Mon Sep 17 00:00:00 2001 From: geromegrignon Date: Wed, 7 Dec 2022 02:38:29 +0100 Subject: [PATCH] feat(api): add project --- .eslintrc.json | 3 +- .github/ISSUE_TEMPLATE/BUG_REPORT.yml | 60 +- .github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml | 76 +- .github/PULL_REQUEST_TEMPLATE.md | 8 +- .github/dependabot.yml | 26 +- .github/workflows/api.yaml | 34 + .github/workflows/test-documentation.yml | 12 +- .gitignore | 8 +- .husky/.gitignore | 1 + .husky/pre-commit | 4 + .npmrc | 1 + .prettierignore | 3 +- .prettierrc | 6 +- CODE_OF_CONDUCT.md | 22 +- CONTRIBUTING.md | 16 +- README.md | 6 +- api/Conduit.postman_collection.json | 1740 +- apps/api/.eslintrc.json | 18 + apps/api/jest.config.ts | 18 + apps/api/project.json | 60 + .../src/app/controllers/article.controller.ts | 251 + .../src/app/controllers/auth.controller.ts | 70 + .../src/app/controllers/profile.controller.ts | 67 + .../api/src/app/controllers/tag.controller.ts | 22 + apps/api/src/app/index.ts | 60 + apps/api/src/app/mappers/article.mapper.ts | 16 + apps/api/src/app/mappers/author.mapper.ts | 12 + apps/api/src/app/models/article.model.ts | 10 + apps/api/src/app/models/comment.model.ts | 9 + .../src/app/models/http-exception.model.ts | 10 + apps/api/src/app/models/profile.model.ts | 6 + .../src/app/models/register-input.model.ts | 8 + .../src/app/models/registered-user.model.ts | 7 + apps/api/src/app/models/tag.model.ts | 3 + apps/api/src/app/models/user.model.ts | 17 + apps/api/src/app/routes/routes.ts | 13 + apps/api/src/app/services/article.service.ts | 657 + apps/api/src/app/services/auth.service.ts | 196 + apps/api/src/app/services/profile.service.ts | 65 + apps/api/src/app/services/tag.service.ts | 40 + apps/api/src/app/utils/auth.ts | 27 + apps/api/src/app/utils/cron.ts | 86 + apps/api/src/app/utils/profile.utils.ts | 13 + apps/api/src/app/utils/token.utils.ts | 7 + apps/api/src/app/utils/user-request.d.ts | 7 + apps/api/src/assets/demo-avatar.png | Bin 0 -> 1711 bytes apps/api/src/assets/smiley-cyrus.jpeg | Bin 0 -> 1343 bytes apps/api/src/environments/environment.prod.ts | 3 + apps/api/src/environments/environment.ts | 3 + apps/api/src/main.ts | 21 + .../20210924225358_initial/migration.sql | 114 + .../migration.sql | 36 + .../20211105153605_api_url/migration.sql | 2 + .../migration.sql | 11 + .../src/prisma/migrations/migration_lock.toml | 3 + apps/api/src/prisma/prisma-client.ts | 23 + apps/api/src/prisma/schema.prisma | 56 + apps/api/src/prisma/seed.ts | 64 + apps/api/src/tests/prisma-mock.ts | 17 + .../tests/services/article.service.test.ts | 138 + .../src/tests/services/auth.service.test.ts | 254 + .../tests/services/profile.service.test.ts | 147 + .../src/tests/services/tag.service.test.ts | 6 + .../api/src/tests/utils/profile.utils.test.ts | 79 + apps/api/tsconfig.app.json | 10 + apps/api/tsconfig.json | 13 + apps/api/tsconfig.spec.json | 9 + apps/demo/project.json | 3 +- apps/demo/proxy.conf.json | 6 + apps/demo/src/app/app.spec.tsx | 4 +- apps/demo/src/app/app.tsx | 3 +- apps/demo/src/app/nx-welcome.tsx | 17 +- apps/demo/src/main.tsx | 6 +- .../documentation/docs/community/resources.md | 16 +- .../docs/community/special-thanks.md | 2 +- .../implementation-creation/expectations.md | 10 +- .../docs/implementation-creation/features.md | 4 +- .../implementation-creation/introduction.md | 4 +- apps/documentation/docs/intro.mdx | 8 +- .../docs/specs/backend-specs/endpoints.md | 36 +- .../docs/specs/backend-specs/introduction.md | 1 - .../docs/specs/backend-specs/postman.md | 2 - .../docs/specs/frontend-specs/api.md | 21 +- .../docs/specs/frontend-specs/routing.md | 22 +- .../docs/specs/frontend-specs/styles.md | 5 +- .../docs/specs/frontend-specs/templates.md | 755 +- .../docs/specs/mobile-specs/introduction.md | 3 +- apps/documentation/docusaurus.config.js | 27 +- apps/documentation/sidebars.js | 2 +- apps/documentation/src/css/custom.css | 14 +- apps/documentation/src/pages/index.js | 70 +- apps/documentation/tsconfig.json | 6 +- apps/swagger/src/app/app.tsx | 12 +- apps/swagger/src/assets/swagger.json | 181 +- apps/swagger/src/main.tsx | 6 +- .../ios/AppIcon.appiconset/Contents.json | 346 +- media/mobile_icons/ios/README.md | 8 +- .../watchkit/AppIcon.appiconset/Contents.json | 134 +- nx.json | 41 +- package-lock.json | 46464 ++++++++++++++++ package.json | 44 +- yarn.lock | 18978 ------- 102 files changed, 50982 insertions(+), 21089 deletions(-) create mode 100644 .github/workflows/api.yaml create mode 100644 .husky/.gitignore create mode 100755 .husky/pre-commit create mode 100644 .npmrc create mode 100644 apps/api/.eslintrc.json create mode 100644 apps/api/jest.config.ts create mode 100644 apps/api/project.json create mode 100644 apps/api/src/app/controllers/article.controller.ts create mode 100644 apps/api/src/app/controllers/auth.controller.ts create mode 100644 apps/api/src/app/controllers/profile.controller.ts create mode 100644 apps/api/src/app/controllers/tag.controller.ts create mode 100644 apps/api/src/app/index.ts create mode 100644 apps/api/src/app/mappers/article.mapper.ts create mode 100644 apps/api/src/app/mappers/author.mapper.ts create mode 100644 apps/api/src/app/models/article.model.ts create mode 100644 apps/api/src/app/models/comment.model.ts create mode 100644 apps/api/src/app/models/http-exception.model.ts create mode 100644 apps/api/src/app/models/profile.model.ts create mode 100644 apps/api/src/app/models/register-input.model.ts create mode 100644 apps/api/src/app/models/registered-user.model.ts create mode 100644 apps/api/src/app/models/tag.model.ts create mode 100644 apps/api/src/app/models/user.model.ts create mode 100644 apps/api/src/app/routes/routes.ts create mode 100644 apps/api/src/app/services/article.service.ts create mode 100644 apps/api/src/app/services/auth.service.ts create mode 100644 apps/api/src/app/services/profile.service.ts create mode 100644 apps/api/src/app/services/tag.service.ts create mode 100644 apps/api/src/app/utils/auth.ts create mode 100644 apps/api/src/app/utils/cron.ts create mode 100644 apps/api/src/app/utils/profile.utils.ts create mode 100644 apps/api/src/app/utils/token.utils.ts create mode 100644 apps/api/src/app/utils/user-request.d.ts create mode 100644 apps/api/src/assets/demo-avatar.png create mode 100644 apps/api/src/assets/smiley-cyrus.jpeg create mode 100644 apps/api/src/environments/environment.prod.ts create mode 100644 apps/api/src/environments/environment.ts create mode 100644 apps/api/src/main.ts create mode 100644 apps/api/src/prisma/migrations/20210924225358_initial/migration.sql create mode 100644 apps/api/src/prisma/migrations/20211001143221_implicit_tags/migration.sql create mode 100644 apps/api/src/prisma/migrations/20211105153605_api_url/migration.sql create mode 100644 apps/api/src/prisma/migrations/20211221184529_deprecated_preview/migration.sql create mode 100644 apps/api/src/prisma/migrations/migration_lock.toml create mode 100644 apps/api/src/prisma/prisma-client.ts create mode 100644 apps/api/src/prisma/schema.prisma create mode 100644 apps/api/src/prisma/seed.ts create mode 100644 apps/api/src/tests/prisma-mock.ts create mode 100644 apps/api/src/tests/services/article.service.test.ts create mode 100644 apps/api/src/tests/services/auth.service.test.ts create mode 100644 apps/api/src/tests/services/profile.service.test.ts create mode 100644 apps/api/src/tests/services/tag.service.test.ts create mode 100644 apps/api/src/tests/utils/profile.utils.test.ts create mode 100644 apps/api/tsconfig.app.json create mode 100644 apps/api/tsconfig.json create mode 100644 apps/api/tsconfig.spec.json create mode 100644 apps/demo/proxy.conf.json create mode 100644 package-lock.json delete mode 100644 yarn.lock diff --git a/.eslintrc.json b/.eslintrc.json index 7c52faa3c..f5624d78a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -18,7 +18,8 @@ } ] } - ] + ], + "@typescript-eslint/ban-ts-comment": "off" } }, { diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml index f2754e8a1..fa3bd26d0 100644 --- a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml @@ -1,30 +1,30 @@ -name: ๐Ÿž Bug report -description: Report a bug in the RealWorld project -title: '[Bug]: ' -labels: - - bug -assignees: - - geromegrignon -body: - - type: dropdown - attributes: - label: Relevant scope - description: What is the scope of this request? - options: - - Frontend specs - - Backend specs - - Deployed demo - - 'Other: describe below' - validations: - required: true - - type: textarea - attributes: - label: Description - description: A clear and concise description of the problem - validations: - required: true - - type: markdown - attributes: - value: >- - This template was generated with [Issue Forms - Creator](https://www.issue-forms-creator.app/) +name: ๐Ÿž Bug report +description: Report a bug in the RealWorld project +title: '[Bug]: ' +labels: + - bug +assignees: + - geromegrignon +body: + - type: dropdown + attributes: + label: Relevant scope + description: What is the scope of this request? + options: + - Frontend specs + - Backend specs + - Deployed demo + - 'Other: describe below' + validations: + required: true + - type: textarea + attributes: + label: Description + description: A clear and concise description of the problem + validations: + required: true + - type: markdown + attributes: + value: >- + This template was generated with [Issue Forms + Creator](https://www.issue-forms-creator.app/) diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml index 2c39da2e5..507d0564d 100644 --- a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml +++ b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml @@ -1,38 +1,38 @@ -name: ๐Ÿš€ Feature request -description: Suggest a feature for RealWorld project -title: '[Feature Request]:' -assignees: - - geromegrignon -body: - - type: markdown - attributes: - value: '# Feature Request' - - type: dropdown - attributes: - label: Relevant Scope - description: What is the scope of this request? - options: - - Frontend specs - - Backend specs - - 'Other: describe below' - validations: - required: true - - type: textarea - attributes: - label: Description - description: ' ' - validations: - required: true - - type: textarea - attributes: - label: Describe the solution you'd like - description: If you have a solution in mind, please describe it. - - type: textarea - attributes: - label: Describe alternatives you've considered - description: Have you considered any alternative solutions or workarounds? - - type: markdown - attributes: - value: >- - This template was generated with [Issue Forms - Creator](https://www.issue-forms-creator.app/) +name: ๐Ÿš€ Feature request +description: Suggest a feature for RealWorld project +title: '[Feature Request]:' +assignees: + - geromegrignon +body: + - type: markdown + attributes: + value: '# Feature Request' + - type: dropdown + attributes: + label: Relevant Scope + description: What is the scope of this request? + options: + - Frontend specs + - Backend specs + - 'Other: describe below' + validations: + required: true + - type: textarea + attributes: + label: Description + description: ' ' + validations: + required: true + - type: textarea + attributes: + label: Describe the solution you'd like + description: If you have a solution in mind, please describe it. + - type: textarea + attributes: + label: Describe alternatives you've considered + description: Have you considered any alternative solutions or workarounds? + - type: markdown + attributes: + value: >- + This template was generated with [Issue Forms + Creator](https://www.issue-forms-creator.app/) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a3247786b..d957572d7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,9 +1,11 @@ ## PR Checklist + Please check if your PR fulfills the following requirements: - [ ] The commit message follows our guidelines: https://github.com/gothinkster/realworld/blob/master/CONTRIBUTING.md#commit ## PR Type + What kind of change does this PR introduce? @@ -12,23 +14,19 @@ What kind of change does this PR introduce? - [ ] Feature - [ ] Other... Please describe: - ## What is the current behavior? + Issue Number: N/A - ## What is the new behavior? - ## Does this PR introduce a breaking change? - [ ] Yes - [ ] No - - ## Other information diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 386b22355..7d9963bea 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,19 +1,13 @@ version: 2 updates: -- package-ecosystem: github-actions - directory: "/" - schedule: - interval: weekly - open-pull-requests-limit: 10 + - package-ecosystem: github-actions + directory: '/' + schedule: + interval: weekly + open-pull-requests-limit: 10 -- package-ecosystem: npm - directory: "/" - schedule: - interval: weekly - open-pull-requests-limit: 10 - -- package-ecosystem: npm - directory: "/documentation" - schedule: - interval: weekly - open-pull-requests-limit: 10 + - package-ecosystem: npm + directory: '/' + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/api.yaml b/.github/workflows/api.yaml new file mode 100644 index 000000000..d6ba02bd2 --- /dev/null +++ b/.github/workflows/api.yaml @@ -0,0 +1,34 @@ +name: CI + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + branches: + - '**' + +jobs: + run_tests: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [12.x] + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v3 + with: + node-version: '16' + cache: 'npm' + - name: Install dependencies + run: npm ci --no-audit --prefer-offline --progress=false + - name: Check prettier + run: npm run prettier:check + - name: Check ESLinter + run: npm run lint:check + - name: Check unit tests + run: npm run test --ci --lastCommit --maxWorkers=50% + env: + CI: true diff --git a/.github/workflows/test-documentation.yml b/.github/workflows/test-documentation.yml index 17c8f50f2..5e16bc80a 100644 --- a/.github/workflows/test-documentation.yml +++ b/.github/workflows/test-documentation.yml @@ -13,12 +13,8 @@ jobs: - uses: actions/setup-node@v3 with: node-version: '16' - cache: 'yarn' - - name: Run install - uses: borales/actions-yarn@v4 - with: - cmd: install + cache: 'npm' + - name: Install dependencies + run: npm ci --no-audit --prefer-offline --progress=false - name: Build production bundle - uses: borales/actions-yarn@v4 - with: - cmd: nx build documentation + run: npx nx build documentation diff --git a/.gitignore b/.gitignore index c2f2d1606..783c8f30e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,13 +4,13 @@ dist tmp /out-tsc +_generated_ + +# Keep environment variables out of version control +.env # dependencies node_modules -.yarn/* -!.yarn/releases -!.yarn/plugins -.pnp.* # IDEs and editors /.idea diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 000000000..31354ec13 --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..36af21989 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx lint-staged diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..521a9f7c0 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/.prettierignore b/.prettierignore index ae3008687..7faf65fed 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ .prettierignore -.docusaurus/ \ No newline at end of file +.docusaurus/ +/dist diff --git a/.prettierrc b/.prettierrc index 544138be4..f45ef308e 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,7 @@ { - "singleQuote": true + "printWidth": 100, + "singleQuote": true, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "avoid" } diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 6f61f2ff9..cc0cd255f 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -17,23 +17,23 @@ diverse, inclusive, and healthy community. Examples of behavior that contributes to a positive environment for our community include: -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the +- Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: -* The use of sexualized language or imagery, and sexual attention or +- The use of sexualized language or imagery, and sexual attention or advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities @@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an +standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e4ee4262..29e80826f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,13 +85,13 @@ Before you submit your Pull Request (PR) consider the following guidelines: - If we suggest changes then: - - Make the required updates. - - Rebase your branch and force push to your GitHub repository (this will update your Pull Request): + - Make the required updates. + - Rebase your branch and force push to your GitHub repository (this will update your Pull Request): - ```bash - git rebase master -i - git push -f - ``` + ```bash + git rebase master -i + git push -f + ``` That's it! Thank you for your contribution! @@ -127,7 +127,6 @@ from the master (upstream) repository: ## Commit Message Guidelines > These guidelines have been added to the project starting from -> We have very precise rules over how our git commit messages can be formatted. This leads to **more readable messages** that are easy to follow when looking through the **project history**. @@ -167,6 +166,7 @@ The version in our package.json gets copied to the one we publish, and users nee ### Type Must be one of the following: + - **docs**: Documentation only changes - **feat**: A new feature - **fix**: A bug fix @@ -207,7 +207,7 @@ Close #394 ``` ``` -BREAKING CHANGE: +BREAKING CHANGE: change login route to /users/login ``` diff --git a/README.md b/README.md index 164a7c54a..c64b76f42 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,18 @@ ![RealWorld Example Applications](media/realworld-dual-mode.png) -

-### See how *the exact same* Medium.com clone (called [Conduit](https://demo.realworld.io)) is built using different [frontends](https://codebase.show/projects/realworld?category=frontend) and [backends](https://codebase.show/projects/realworld?category=backend). Yes, you can mix and match them, because **they all adhere to the same [API spec](https://realworld-docs.netlify.app/docs/specs/backend-specs/introduction)** ๐Ÿ˜ฎ๐Ÿ˜Ž +### See how _the exact same_ Medium.com clone (called [Conduit](https://demo.realworld.io)) is built using different [frontends](https://codebase.show/projects/realworld?category=frontend) and [backends](https://codebase.show/projects/realworld?category=backend). Yes, you can mix and match them, because **they all adhere to the same [API spec](https://realworld-docs.netlify.app/docs/specs/backend-specs/introduction)** ๐Ÿ˜ฎ๐Ÿ˜Ž While most "todo" demos provide an excellent cursory glance at a framework's capabilities, they typically don't convey the knowledge & perspective required to actually build _real_ applications with it. **RealWorld** solves this by allowing you to choose any frontend (React, Angular, & more) and any backend (Node, Django, & more) and see how they power a real-world, beautifully designed full-stack app called [**Conduit**](https://demo.realworld.io). -*Read the [full blog post announcing RealWorld on Medium.](https://medium.com/@ericsimons/introducing-realworld-6016654d36b5)* +_Read the [full blog post announcing RealWorld on Medium.](https://medium.com/@ericsimons/introducing-realworld-6016654d36b5)_ Join us on [GitHub Discussions!](https://github.com/gothinkster/realworld/discussions) ๐ŸŽ‰ @@ -51,5 +50,4 @@ Gรฉrรดme is a Software Engineer at Sfeir. He's an open-source enthusiast.
< Manuel is an independent Software Engineer, creator of the [Layr framework](https://layrjs.com) and the [CodebaseShow website](https://codebase.show/).

- [![Brought to you by Thinkster](media/end.png)](https://thinkster.io) diff --git a/api/Conduit.postman_collection.json b/api/Conduit.postman_collection.json index c387b73ac..a539e3cca 100644 --- a/api/Conduit.postman_collection.json +++ b/api/Conduit.postman_collection.json @@ -1,22 +1,22 @@ { - "info":{ - "_postman_id":"0574ad8a-a525-43ae-8e1e-5fd9756037f4", - "name":"Conduit", - "description":"Collection for testing the Conduit API\n\nhttps://github.com/gothinkster/realworld", - "schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + "info": { + "_postman_id": "0574ad8a-a525-43ae-8e1e-5fd9756037f4", + "name": "Conduit", + "description": "Collection for testing the Conduit API\n\nhttps://github.com/gothinkster/realworld", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, - "item":[ + "item": [ { - "name":"Auth", - "item":[ + "name": "Auth", + "item": [ { - "name":"Register", - "event":[ + "name": "Register", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "if (!(environment.isIntegrationTest)) {", "var responseJSON = JSON.parse(responseBody);", "", @@ -35,44 +35,38 @@ } } ], - "request":{ - "method":"POST", - "header":[ + "request": { + "method": "POST", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" } ], - "body":{ - "mode":"raw", - "raw":"{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\", \"username\":\"{{USERNAME}}\"}}" + "body": { + "mode": "raw", + "raw": "{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\", \"username\":\"{{USERNAME}}\"}}" }, - "url":{ - "raw":"{{APIURL}}/users", - "host":[ - "{{APIURL}}" - ], - "path":[ - "users" - ] + "url": { + "raw": "{{APIURL}}/users", + "host": ["{{APIURL}}"], + "path": ["users"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Login", - "event":[ + "name": "Login", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var responseJSON = JSON.parse(responseBody);", "", "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", @@ -89,46 +83,39 @@ } } ], - "request":{ - "method":"POST", - "header":[ + "request": { + "method": "POST", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" } ], - "body":{ - "mode":"raw", - "raw":"{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\"}}" + "body": { + "mode": "raw", + "raw": "{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\"}}" }, - "url":{ - "raw":"{{APIURL}}/users/login", - "host":[ - "{{APIURL}}" - ], - "path":[ - "users", - "login" - ] + "url": { + "raw": "{{APIURL}}/users/login", + "host": ["{{APIURL}}"], + "path": ["users", "login"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Login and Remember Token", - "event":[ + "name": "Login and Remember Token", + "event": [ { - "listen":"test", - "script":{ - "id":"a7674032-bf09-4ae7-8224-4afa2fb1a9f9", - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "id": "a7674032-bf09-4ae7-8224-4afa2fb1a9f9", + "type": "text/javascript", + "exec": [ "var responseJSON = JSON.parse(responseBody);", "", "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", @@ -151,45 +138,38 @@ } } ], - "request":{ - "method":"POST", - "header":[ + "request": { + "method": "POST", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" } ], - "body":{ - "mode":"raw", - "raw":"{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\"}}" + "body": { + "mode": "raw", + "raw": "{\"user\":{\"email\":\"{{EMAIL}}\", \"password\":\"{{PASSWORD}}\"}}" }, - "url":{ - "raw":"{{APIURL}}/users/login", - "host":[ - "{{APIURL}}" - ], - "path":[ - "users", - "login" - ] + "url": { + "raw": "{{APIURL}}/users/login", + "host": ["{{APIURL}}"], + "path": ["users", "login"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Current User", - "event":[ + "name": "Current User", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var responseJSON = JSON.parse(responseBody);", "", "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", @@ -206,48 +186,42 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/user", - "host":[ - "{{APIURL}}" - ], - "path":[ - "user" - ] + "url": { + "raw": "{{APIURL}}/user", + "host": ["{{APIURL}}"], + "path": ["user"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Update User", - "event":[ + "name": "Update User", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var responseJSON = JSON.parse(responseBody);", "", "tests['Response contains \"user\" property'] = responseJSON.hasOwnProperty('user');", @@ -264,53 +238,47 @@ } } ], - "request":{ - "method":"PUT", - "header":[ + "request": { + "method": "PUT", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"{\"user\":{\"email\":\"{{EMAIL}}\"}}" + "body": { + "mode": "raw", + "raw": "{\"user\":{\"email\":\"{{EMAIL}}\"}}" }, - "url":{ - "raw":"{{APIURL}}/user", - "host":[ - "{{APIURL}}" - ], - "path":[ - "user" - ] + "url": { + "raw": "{{APIURL}}/user", + "host": ["{{APIURL}}"], + "path": ["user"] } }, - "response":[ - - ] + "response": [] } ] }, { - "name":"Articles", - "item":[ + "name": "Articles", + "item": [ { - "name":"All Articles", - "event":[ + "name": "All Articles", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var is200Response = responseCode.code === 200;", "", "tests['Response code is 200 OK'] = is200Response;", @@ -348,44 +316,38 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles" - ] + "url": { + "raw": "{{APIURL}}/articles", + "host": ["{{APIURL}}"], + "path": ["articles"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Articles by Author", - "event":[ + "name": "Articles by Author", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var is200Response = responseCode.code === 200;", "", "tests['Response code is 200 OK'] = is200Response;", @@ -423,50 +385,44 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles?author=johnjacob", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles" - ], - "query":[ + "url": { + "raw": "{{APIURL}}/articles?author=johnjacob", + "host": ["{{APIURL}}"], + "path": ["articles"], + "query": [ { - "key":"author", - "value":"johnjacob" + "key": "author", + "value": "johnjacob" } ] } }, - "response":[ - - ] + "response": [] }, { - "name":"Articles Favorited by Username", - "event":[ + "name": "Articles Favorited by Username", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var is200Response = responseCode.code === 200;", "", "tests['Response code is 200 OK'] = is200Response;", @@ -504,50 +460,44 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles?favorited={{USERNAME}}", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles" - ], - "query":[ + "url": { + "raw": "{{APIURL}}/articles?favorited={{USERNAME}}", + "host": ["{{APIURL}}"], + "path": ["articles"], + "query": [ { - "key":"favorited", - "value":"{{USERNAME}}" + "key": "favorited", + "value": "{{USERNAME}}" } ] } }, - "response":[ - - ] + "response": [] }, { - "name":"Articles by Tag", - "event":[ + "name": "Articles by Tag", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var is200Response = responseCode.code === 200;", "", "tests['Response code is 200 OK'] = is200Response;", @@ -585,56 +535,50 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles?tag=dragons", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles" - ], - "query":[ + "url": { + "raw": "{{APIURL}}/articles?tag=dragons", + "host": ["{{APIURL}}"], + "path": ["articles"], + "query": [ { - "key":"tag", - "value":"dragons" + "key": "tag", + "value": "dragons" } ] } }, - "response":[ - - ] + "response": [] } ] }, { - "name":"Articles, Favorite, Comments", - "item":[ + "name": "Articles, Favorite, Comments", + "item": [ { - "name":"Create Article", - "event":[ + "name": "Create Article", + "event": [ { - "listen":"test", - "script":{ - "id":"e711dbf8-8065-4ba8-8b74-f1639a7d8208", - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "id": "e711dbf8-8065-4ba8-8b74-f1639a7d8208", + "type": "text/javascript", + "exec": [ "var responseJSON = JSON.parse(responseBody);", "", "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", @@ -662,48 +606,42 @@ } } ], - "request":{ - "method":"POST", - "header":[ + "request": { + "method": "POST", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"{\"article\":{\"title\":\"How to train your dragon\", \"description\":\"Ever wonder how?\", \"body\":\"Very carefully.\", \"tagList\":[\"training\", \"dragons\"]}}" + "body": { + "mode": "raw", + "raw": "{\"article\":{\"title\":\"How to train your dragon\", \"description\":\"Ever wonder how?\", \"body\":\"Very carefully.\", \"tagList\":[\"training\", \"dragons\"]}}" }, - "url":{ - "raw":"{{APIURL}}/articles", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles" - ] + "url": { + "raw": "{{APIURL}}/articles", + "host": ["{{APIURL}}"], + "path": ["articles"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Feed", - "event":[ + "name": "Feed", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var is200Response = responseCode.code === 200;", "", "tests['Response code is 200 OK'] = is200Response;", @@ -741,49 +679,42 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles/feed", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles", - "feed" - ] + "url": { + "raw": "{{APIURL}}/articles/feed", + "host": ["{{APIURL}}"], + "path": ["articles", "feed"] } }, - "response":[ - - ] + "response": [] }, { - "name":"All Articles", - "event":[ + "name": "All Articles", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var is200Response = responseCode.code === 200;", "", "tests['Response code is 200 OK'] = is200Response;", @@ -821,48 +752,42 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles" - ] + "url": { + "raw": "{{APIURL}}/articles", + "host": ["{{APIURL}}"], + "path": ["articles"] } }, - "response":[ - - ] + "response": [] }, { - "name":"All Articles with auth", - "event":[ + "name": "All Articles with auth", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var is200Response = responseCode.code === 200;", "", "tests['Response code is 200 OK'] = is200Response;", @@ -900,48 +825,42 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles" - ] + "url": { + "raw": "{{APIURL}}/articles", + "host": ["{{APIURL}}"], + "path": ["articles"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Articles by Author", - "event":[ + "name": "Articles by Author", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var is200Response = responseCode.code === 200;", "", "tests['Response code is 200 OK'] = is200Response;", @@ -979,54 +898,48 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles?author={{USERNAME}}", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles" - ], - "query":[ + "url": { + "raw": "{{APIURL}}/articles?author={{USERNAME}}", + "host": ["{{APIURL}}"], + "path": ["articles"], + "query": [ { - "key":"author", - "value":"{{USERNAME}}" + "key": "author", + "value": "{{USERNAME}}" } ] } }, - "response":[ - - ] + "response": [] }, { - "name":"Articles by Author with auth", - "event":[ + "name": "Articles by Author with auth", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var is200Response = responseCode.code === 200;", "", "tests['Response code is 200 OK'] = is200Response;", @@ -1064,54 +977,48 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles?author={{USERNAME}}", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles" - ], - "query":[ + "url": { + "raw": "{{APIURL}}/articles?author={{USERNAME}}", + "host": ["{{APIURL}}"], + "path": ["articles"], + "query": [ { - "key":"author", - "value":"{{USERNAME}}" + "key": "author", + "value": "{{USERNAME}}" } ] } }, - "response":[ - - ] + "response": [] }, { - "name":"Single Article by slug", - "event":[ + "name": "Single Article by slug", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var responseJSON = JSON.parse(responseBody);", "", "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", @@ -1137,49 +1044,42 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles/{{slug}}", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles", - "{{slug}}" - ] + "url": { + "raw": "{{APIURL}}/articles/{{slug}}", + "host": ["{{APIURL}}"], + "path": ["articles", "{{slug}}"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Articles by Tag", - "event":[ + "name": "Articles by Tag", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var is200Response = responseCode.code === 200;", "", "tests['Response code is 200 OK'] = is200Response;", @@ -1213,54 +1113,48 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles?tag=dragons", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles" - ], - "query":[ + "url": { + "raw": "{{APIURL}}/articles?tag=dragons", + "host": ["{{APIURL}}"], + "path": ["articles"], + "query": [ { - "key":"tag", - "value":"dragons" + "key": "tag", + "value": "dragons" } ] } }, - "response":[ - - ] + "response": [] }, { - "name":"Update Article", - "event":[ + "name": "Update Article", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "if (!(environment.isIntegrationTest)) {", "var responseJSON = JSON.parse(responseBody);", "", @@ -1288,49 +1182,42 @@ } } ], - "request":{ - "method":"PUT", - "header":[ + "request": { + "method": "PUT", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"{\"article\":{\"body\":\"With two hands\"}}" + "body": { + "mode": "raw", + "raw": "{\"article\":{\"body\":\"With two hands\"}}" }, - "url":{ - "raw":"{{APIURL}}/articles/{{slug}}", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles", - "{{slug}}" - ] + "url": { + "raw": "{{APIURL}}/articles/{{slug}}", + "host": ["{{APIURL}}"], + "path": ["articles", "{{slug}}"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Favorite Article", - "event":[ + "name": "Favorite Article", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var responseJSON = JSON.parse(responseBody);", "", "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", @@ -1358,50 +1245,42 @@ } } ], - "request":{ - "method":"POST", - "header":[ + "request": { + "method": "POST", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles/{{slug}}/favorite", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles", - "{{slug}}", - "favorite" - ] + "url": { + "raw": "{{APIURL}}/articles/{{slug}}/favorite", + "host": ["{{APIURL}}"], + "path": ["articles", "{{slug}}", "favorite"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Articles Favorited by Username", - "event":[ + "name": "Articles Favorited by Username", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var is200Response = responseCode.code === 200;", "", "tests['Response code is 200 OK'] = is200Response;", @@ -1432,54 +1311,48 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles?favorited={{USERNAME}}", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles" - ], - "query":[ + "url": { + "raw": "{{APIURL}}/articles?favorited={{USERNAME}}", + "host": ["{{APIURL}}"], + "path": ["articles"], + "query": [ { - "key":"favorited", - "value":"{{USERNAME}}" + "key": "favorited", + "value": "{{USERNAME}}" } ] } }, - "response":[ - - ] + "response": [] }, { - "name":"Articles Favorited by Username with auth", - "event":[ + "name": "Articles Favorited by Username with auth", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var is200Response = responseCode.code === 200;", "", "tests['Response code is 200 OK'] = is200Response;", @@ -1510,54 +1383,48 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles?favorited={{USERNAME}}", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles" - ], - "query":[ + "url": { + "raw": "{{APIURL}}/articles?favorited={{USERNAME}}", + "host": ["{{APIURL}}"], + "path": ["articles"], + "query": [ { - "key":"favorited", - "value":"{{USERNAME}}" + "key": "favorited", + "value": "{{USERNAME}}" } ] } }, - "response":[ - - ] + "response": [] }, { - "name":"Unfavorite Article", - "event":[ + "name": "Unfavorite Article", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var responseJSON = JSON.parse(responseBody);", "", "tests['Response contains \"article\" property'] = responseJSON.hasOwnProperty('article');", @@ -1584,51 +1451,43 @@ } } ], - "request":{ - "method":"DELETE", - "header":[ + "request": { + "method": "DELETE", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles/{{slug}}/favorite", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles", - "{{slug}}", - "favorite" - ] + "url": { + "raw": "{{APIURL}}/articles/{{slug}}/favorite", + "host": ["{{APIURL}}"], + "path": ["articles", "{{slug}}", "favorite"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Create Comment for Article", - "event":[ + "name": "Create Comment for Article", + "event": [ { - "listen":"test", - "script":{ - "id":"9f90c364-cc68-4728-961a-85eb00197d7b", - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "id": "9f90c364-cc68-4728-961a-85eb00197d7b", + "type": "text/javascript", + "exec": [ "var responseJSON = JSON.parse(responseBody);", "", "tests['Response contains \"comment\" property'] = responseJSON.hasOwnProperty('comment');", @@ -1649,50 +1508,42 @@ } } ], - "request":{ - "method":"POST", - "header":[ + "request": { + "method": "POST", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"{\"comment\":{\"body\":\"Thank you so much!\"}}" + "body": { + "mode": "raw", + "raw": "{\"comment\":{\"body\":\"Thank you so much!\"}}" }, - "url":{ - "raw":"{{APIURL}}/articles/{{slug}}/comments", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles", - "{{slug}}", - "comments" - ] + "url": { + "raw": "{{APIURL}}/articles/{{slug}}/comments", + "host": ["{{APIURL}}"], + "path": ["articles", "{{slug}}", "comments"] } }, - "response":[ - - ] + "response": [] }, { - "name":"All Comments for Article", - "event":[ + "name": "All Comments for Article", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var is200Response = responseCode.code === 200", "", "tests['Response code is 200 OK'] = is200Response;", @@ -1719,50 +1570,42 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles/{{slug}}/comments", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles", - "{{slug}}", - "comments" - ] + "url": { + "raw": "{{APIURL}}/articles/{{slug}}/comments", + "host": ["{{APIURL}}"], + "path": ["articles", "{{slug}}", "comments"] } }, - "response":[ - - ] + "response": [] }, { - "name":"All Comments for Article without login", - "event":[ + "name": "All Comments for Article without login", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var is200Response = responseCode.code === 200", "", "tests['Response code is 200 OK'] = is200Response;", @@ -1789,149 +1632,121 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles/{{slug}}/comments", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles", - "{{slug}}", - "comments" - ] + "url": { + "raw": "{{APIURL}}/articles/{{slug}}/comments", + "host": ["{{APIURL}}"], + "path": ["articles", "{{slug}}", "comments"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Delete Comment for Article", - "request":{ - "method":"DELETE", - "header":[ + "name": "Delete Comment for Article", + "request": { + "method": "DELETE", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles/{{slug}}/comments/{{commentId}}", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles", - "{{slug}}", - "comments", - "{{commentId}}" - ] + "url": { + "raw": "{{APIURL}}/articles/{{slug}}/comments/{{commentId}}", + "host": ["{{APIURL}}"], + "path": ["articles", "{{slug}}", "comments", "{{commentId}}"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Delete Article", - "request":{ - "method":"DELETE", - "header":[ + "name": "Delete Article", + "request": { + "method": "DELETE", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/articles/{{slug}}", - "host":[ - "{{APIURL}}" - ], - "path":[ - "articles", - "{{slug}}" - ] + "url": { + "raw": "{{APIURL}}/articles/{{slug}}", + "host": ["{{APIURL}}"], + "path": ["articles", "{{slug}}"] } }, - "response":[ - - ] + "response": [] } ], - "event":[ + "event": [ { - "listen":"prerequest", - "script":{ - "id":"67853a4a-e972-4573-a295-dad12a46a9d7", - "type":"text/javascript", - "exec":[ - "" - ] + "listen": "prerequest", + "script": { + "id": "67853a4a-e972-4573-a295-dad12a46a9d7", + "type": "text/javascript", + "exec": [""] } }, { - "listen":"test", - "script":{ - "id":"3057f989-15e4-484e-b8fa-a041043d0ac0", - "type":"text/javascript", - "exec":[ - "" - ] + "listen": "test", + "script": { + "id": "3057f989-15e4-484e-b8fa-a041043d0ac0", + "type": "text/javascript", + "exec": [""] } } ] }, { - "name":"Profiles", - "item":[ + "name": "Profiles", + "item": [ { - "name":"Register Celeb", - "event":[ + "name": "Register Celeb", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "if (!(environment.isIntegrationTest)) {", "var responseJSON = JSON.parse(responseBody);", "", @@ -1950,44 +1765,38 @@ } } ], - "request":{ - "method":"POST", - "header":[ + "request": { + "method": "POST", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" } ], - "body":{ - "mode":"raw", - "raw":"{\"user\":{\"email\":\"celeb_{{EMAIL}}\", \"password\":\"{{PASSWORD}}\", \"username\":\"celeb_{{USERNAME}}\"}}" + "body": { + "mode": "raw", + "raw": "{\"user\":{\"email\":\"celeb_{{EMAIL}}\", \"password\":\"{{PASSWORD}}\", \"username\":\"celeb_{{USERNAME}}\"}}" }, - "url":{ - "raw":"{{APIURL}}/users", - "host":[ - "{{APIURL}}" - ], - "path":[ - "users" - ] + "url": { + "raw": "{{APIURL}}/users", + "host": ["{{APIURL}}"], + "path": ["users"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Profile", - "event":[ + "name": "Profile", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "if (!(environment.isIntegrationTest)) {", "var is200Response = responseCode.code === 200;", "", @@ -2011,49 +1820,42 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/profiles/celeb_{{USERNAME}}", - "host":[ - "{{APIURL}}" - ], - "path":[ - "profiles", - "celeb_{{USERNAME}}" - ] + "url": { + "raw": "{{APIURL}}/profiles/celeb_{{USERNAME}}", + "host": ["{{APIURL}}"], + "path": ["profiles", "celeb_{{USERNAME}}"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Follow Profile", - "event":[ + "name": "Follow Profile", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "if (!(environment.isIntegrationTest)) {", "var is200Response = responseCode.code === 200;", "", @@ -2078,50 +1880,42 @@ } } ], - "request":{ - "method":"POST", - "header":[ + "request": { + "method": "POST", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"{\"user\":{\"email\":\"{{EMAIL}}\"}}" + "body": { + "mode": "raw", + "raw": "{\"user\":{\"email\":\"{{EMAIL}}\"}}" }, - "url":{ - "raw":"{{APIURL}}/profiles/celeb_{{USERNAME}}/follow", - "host":[ - "{{APIURL}}" - ], - "path":[ - "profiles", - "celeb_{{USERNAME}}", - "follow" - ] + "url": { + "raw": "{{APIURL}}/profiles/celeb_{{USERNAME}}/follow", + "host": ["{{APIURL}}"], + "path": ["profiles", "celeb_{{USERNAME}}", "follow"] } }, - "response":[ - - ] + "response": [] }, { - "name":"Unfollow Profile", - "event":[ + "name": "Unfollow Profile", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "if (!(environment.isIntegrationTest)) {", "var is200Response = responseCode.code === 200;", "", @@ -2146,55 +1940,47 @@ } } ], - "request":{ - "method":"DELETE", - "header":[ + "request": { + "method": "DELETE", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" }, { - "key":"Authorization", - "value":"Token {{token}}" + "key": "Authorization", + "value": "Token {{token}}" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/profiles/celeb_{{USERNAME}}/follow", - "host":[ - "{{APIURL}}" - ], - "path":[ - "profiles", - "celeb_{{USERNAME}}", - "follow" - ] + "url": { + "raw": "{{APIURL}}/profiles/celeb_{{USERNAME}}/follow", + "host": ["{{APIURL}}"], + "path": ["profiles", "celeb_{{USERNAME}}", "follow"] } }, - "response":[ - - ] + "response": [] } ] }, { - "name":"Tags", - "item":[ + "name": "Tags", + "item": [ { - "name":"All Tags", - "event":[ + "name": "All Tags", + "event": [ { - "listen":"test", - "script":{ - "type":"text/javascript", - "exec":[ + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ "var is200Response = responseCode.code === 200;", "", "tests['Response code is 200 OK'] = is200Response;", @@ -2210,35 +1996,29 @@ } } ], - "request":{ - "method":"GET", - "header":[ + "request": { + "method": "GET", + "header": [ { - "key":"Content-Type", - "value":"application/json" + "key": "Content-Type", + "value": "application/json" }, { - "key":"X-Requested-With", - "value":"XMLHttpRequest" + "key": "X-Requested-With", + "value": "XMLHttpRequest" } ], - "body":{ - "mode":"raw", - "raw":"" + "body": { + "mode": "raw", + "raw": "" }, - "url":{ - "raw":"{{APIURL}}/tags", - "host":[ - "{{APIURL}}" - ], - "path":[ - "tags" - ] + "url": { + "raw": "{{APIURL}}/tags", + "host": ["{{APIURL}}"], + "path": ["tags"] } }, - "response":[ - - ] + "response": [] } ] } diff --git a/apps/api/.eslintrc.json b/apps/api/.eslintrc.json new file mode 100644 index 000000000..9d9c0db55 --- /dev/null +++ b/apps/api/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/api/jest.config.ts b/apps/api/jest.config.ts new file mode 100644 index 000000000..619cb7abd --- /dev/null +++ b/apps/api/jest.config.ts @@ -0,0 +1,18 @@ +/* eslint-disable */ +export default { + clearMocks: true, + displayName: 'api', + preset: '../../jest.preset.js', + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + }, + }, + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': 'ts-jest', + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/apps/api', + setupFilesAfterEnv: ['/src/tests/prisma-mock.ts'], +}; diff --git a/apps/api/project.json b/apps/api/project.json new file mode 100644 index 000000000..fef5a864a --- /dev/null +++ b/apps/api/project.json @@ -0,0 +1,60 @@ +{ + "name": "api", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/api/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nrwl/webpack:webpack", + "outputs": ["{options.outputPath}"], + "options": { + "target": "node", + "compiler": "tsc", + "outputPath": "dist/apps/api", + "main": "apps/api/src/app/index.ts", + "tsConfig": "apps/api/tsconfig.app.json", + "assets": ["apps/api/src/assets"] + }, + "configurations": { + "production": { + "optimization": true, + "extractLicenses": true, + "inspect": false, + "fileReplacements": [ + { + "replace": "apps/api/src/environments/environment.ts", + "with": "apps/api/src/environments/environment.prod.ts" + } + ] + } + } + }, + "serve": { + "executor": "@nrwl/js:node", + "options": { + "buildTarget": "api:build" + }, + "configurations": { + "production": { + "buildTarget": "api:build:production" + } + } + }, + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/api/**/*.ts"] + } + }, + "test": { + "executor": "@nrwl/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/api/jest.config.ts", + "passWithNoTests": true + } + } + }, + "tags": [] +} diff --git a/apps/api/src/app/controllers/article.controller.ts b/apps/api/src/app/controllers/article.controller.ts new file mode 100644 index 000000000..9f0ee0d1b --- /dev/null +++ b/apps/api/src/app/controllers/article.controller.ts @@ -0,0 +1,251 @@ +import { NextFunction, Request, Response, Router } from 'express'; +import auth from '../utils/auth'; +import { + addComment, + createArticle, + deleteArticle, + deleteComment, + favoriteArticle, + getArticle, + getArticles, + getCommentsByArticle, + getFeed, + unfavoriteArticle, + updateArticle, +} from '../services/article.service'; + +const router = Router(); + +/** + * Get paginated articles + * @auth optional + * @route {GET} /articles + * @queryparam offset number of articles dismissed from the first one + * @queryparam limit number of articles returned + * @queryparam tag + * @queryparam author + * @queryparam favorited + * @returns articles: list of articles + */ +router.get('/articles', auth.optional, async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await getArticles(req.query, req.user?.username); + res.json(result); + } catch (error) { + next(error); + } +}); + +/** + * Get paginated feed articles + * @auth required + * @route {GET} /articles/feed + * @returns articles list of articles + */ +router.get( + '/articles/feed', + auth.required, + async (req: Request, res: Response, next: NextFunction) => { + try { + const result = await getFeed( + Number(req.query.offset), + Number(req.query.limit), + req.user?.username as string, + ); + res.json(result); + } catch (error) { + next(error); + } + }, +); + +/** + * Create article + * @route {POST} /articles + * @bodyparam title + * @bodyparam description + * @bodyparam body + * @bodyparam tagList list of tags + * @returns article created article + */ +router.post('/articles', auth.required, async (req: Request, res: Response, next: NextFunction) => { + try { + const article = await createArticle(req.body.article, req.user?.username as string); + res.json({ article }); + } catch (error) { + next(error); + } +}); + +/** + * Get unique article + * @auth optional + * @route {GET} /article/:slug + * @param slug slug of the article (based on the title) + * @returns article + */ +router.get( + '/articles/:slug', + auth.optional, + async (req: Request, res: Response, next: NextFunction) => { + try { + const article = await getArticle(req.params.slug, req.user?.username as string); + res.json({ article }); + } catch (error) { + next(error); + } + }, +); + +/** + * Update article + * @auth required + * @route {PUT} /articles/:slug + * @param slug slug of the article (based on the title) + * @bodyparam title new title + * @bodyparam description new description + * @bodyparam body new content + * @returns article updated article + */ +router.put( + '/articles/:slug', + auth.required, + async (req: Request, res: Response, next: NextFunction) => { + try { + const article = await updateArticle( + req.body.article, + req.params.slug, + req.user?.username as string, + ); + res.json({ article }); + } catch (error) { + next(error); + } + }, +); + +/** + * Delete article + * @auth required + * @route {DELETE} /article/:id + * @param slug slug of the article + */ +router.delete( + '/articles/:slug', + auth.required, + async (req: Request, res: Response, next: NextFunction) => { + try { + await deleteArticle(req.params.slug, req.user!.username as string); + res.sendStatus(204); + } catch (error) { + next(error); + } + }, +); + +/** + * Get comments from an article + * @auth optional + * @route {GET} /articles/:slug/comments + * @param slug slug of the article (based on the title) + * @returns comments list of comments + */ +router.get( + '/articles/:slug/comments', + auth.optional, + async (req: Request, res: Response, next: NextFunction) => { + try { + const comments = await getCommentsByArticle(req.params.slug, req.user?.username); + res.json({ comments }); + } catch (error) { + next(error); + } + }, +); + +/** + * Add comment to article + * @auth required + * @route {POST} /articles/:slug/comments + * @param slug slug of the article (based on the title) + * @bodyparam body content of the comment + * @returns comment created comment + */ +router.post( + '/articles/:slug/comments', + auth.required, + async (req: Request, res: Response, next: NextFunction) => { + try { + const comment = await addComment( + req.body.comment.body, + req.params.slug, + req.user?.username as string, + ); + res.json({ comment }); + } catch (error) { + next(error); + } + }, +); + +/** + * Delete comment + * @auth required + * @route {DELETE} /articles/:slug/comments/:id + * @param slug slug of the article (based on the title) + * @param id id of the comment + */ +router.delete( + '/articles/:slug/comments/:id', + auth.required, + async (req: Request, res: Response, next: NextFunction) => { + try { + await deleteComment(Number(req.params.id), req.user?.username as string); + res.status(200).json({}); + } catch (error) { + next(error); + } + }, +); + +/** + * Favorite article + * @auth required + * @route {POST} /articles/:slug/favorite + * @param slug slug of the article (based on the title) + * @returns article favorited article + */ +router.post( + '/articles/:slug/favorite', + auth.required, + async (req: Request, res: Response, next: NextFunction) => { + try { + const article = await favoriteArticle(req.params.slug, req.user?.username as string); + res.json({ article }); + } catch (error) { + next(error); + } + }, +); + +/** + * Unfavorite article + * @auth required + * @route {DELETE} /articles/:slug/favorite + * @param slug slug of the article (based on the title) + * @returns article unfavorited article + */ +router.delete( + '/articles/:slug/favorite', + auth.required, + async (req: Request, res: Response, next: NextFunction) => { + try { + const article = await unfavoriteArticle(req.params.slug, req.user?.username as string); + res.json({ article }); + } catch (error) { + next(error); + } + }, +); + +export default router; diff --git a/apps/api/src/app/controllers/auth.controller.ts b/apps/api/src/app/controllers/auth.controller.ts new file mode 100644 index 000000000..081299d1b --- /dev/null +++ b/apps/api/src/app/controllers/auth.controller.ts @@ -0,0 +1,70 @@ +import { NextFunction, Request, Response, Router } from 'express'; +import auth from '../utils/auth'; +import { createUser, getCurrentUser, login, updateUser } from '../services/auth.service'; + +const router = Router(); + +/** + * Create an user + * @auth none + * @route {POST} /users + * @bodyparam user User + * @returns user User + */ +router.post('/users', async (req: Request, res: Response, next: NextFunction) => { + try { + const user = await createUser({ ...req.body.user, demo: false }); + res.json({ user }); + } catch (error) { + next(error); + } +}); + +/** + * Login + * @auth none + * @route {POST} /users/login + * @bodyparam user User + * @returns user User + */ +router.post('/users/login', async (req: Request, res: Response, next: NextFunction) => { + try { + const user = await login(req.body.user); + res.json({ user }); + } catch (error) { + next(error); + } +}); + +/** + * Get current user + * @auth required + * @route {GET} /user + * @returns user User + */ +router.get('/user', auth.required, async (req: Request, res: Response, next: NextFunction) => { + try { + const user = await getCurrentUser(req.user?.username as string); + res.json({ user }); + } catch (error) { + next(error); + } +}); + +/** + * Update user + * @auth required + * @route {PUT} /user + * @bodyparam user User + * @returns user User + */ +router.put('/user', auth.required, async (req: Request, res: Response, next: NextFunction) => { + try { + const user = await updateUser(req.body.user, req.user?.username as string); + res.json({ user }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/apps/api/src/app/controllers/profile.controller.ts b/apps/api/src/app/controllers/profile.controller.ts new file mode 100644 index 000000000..f9e13cad0 --- /dev/null +++ b/apps/api/src/app/controllers/profile.controller.ts @@ -0,0 +1,67 @@ +import { NextFunction, Request, Response, Router } from 'express'; +import auth from '../utils/auth'; +import { followUser, getProfile, unfollowUser } from '../services/profile.service'; + +const router = Router(); + +/** + * Get profile + * @auth optional + * @route {GET} /profiles/:username + * @param username string + * @returns profile + */ +router.get( + '/profiles/:username', + auth.optional, + async (req: Request, res: Response, next: NextFunction) => { + try { + const profile = await getProfile(req.params.username, req.user?.username as string); + res.json({ profile }); + } catch (error) { + next(error); + } + }, +); + +/** + * Follow user + * @auth required + * @route {POST} /profiles/:username/follow + * @param username string + * @returns profile + */ +router.post( + '/profiles/:username/follow', + auth.required, + async (req: Request, res: Response, next: NextFunction) => { + try { + const profile = await followUser(req.params?.username, req.user?.username as string); + res.json({ profile }); + } catch (error) { + next(error); + } + }, +); + +/** + * Unfollow user + * @auth required + * @route {DELETE} /profiles/:username/follow + * @param username string + * @returns profiles + */ +router.delete( + '/profiles/:username/follow', + auth.required, + async (req: Request, res: Response, next: NextFunction) => { + try { + const profile = await unfollowUser(req.params.username, req.user?.username as string); + res.json({ profile }); + } catch (error) { + next(error); + } + }, +); + +export default router; diff --git a/apps/api/src/app/controllers/tag.controller.ts b/apps/api/src/app/controllers/tag.controller.ts new file mode 100644 index 000000000..fb74e5ac8 --- /dev/null +++ b/apps/api/src/app/controllers/tag.controller.ts @@ -0,0 +1,22 @@ +import { NextFunction, Request, Response, Router } from 'express'; +import auth from '../utils/auth'; +import getTags from '../services/tag.service'; + +const router = Router(); + +/** + * Get top 10 popular tags + * @auth optional + * @route {GET} /api/tags + * @returns tags list of tag names + */ +router.get('/tags', auth.optional, async (req: Request, res: Response, next: NextFunction) => { + try { + const tags = await getTags(req.user?.username); + res.json({ tags }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/apps/api/src/app/index.ts b/apps/api/src/app/index.ts new file mode 100644 index 000000000..3a24c39fc --- /dev/null +++ b/apps/api/src/app/index.ts @@ -0,0 +1,60 @@ +import * as express from 'express'; +import * as cors from 'cors'; +import * as bodyParser from 'body-parser'; +import routes from './routes/routes'; +import { generateFakeData } from './utils/cron'; +import HttpException from './models/http-exception.model'; + +const app = express(); + +/** + * App Configuration + */ + +app.use(cors()); +app.use(bodyParser.json()); +app.use(bodyParser.urlencoded({ extended: true })); +app.use(routes); + +// Serves images +app.use(express.static('public')); + +app.get('/', (req: express.Request, res: express.Response) => { + res.json({ status: 'API is running on /api' }); +}); + +/* eslint-disable */ +app.use( + ( + err: Error | HttpException, + req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => { + // @ts-ignore + if (err && err.name === 'UnauthorizedError') { + return res.status(401).json({ + status: 'error', + message: 'missing authorization credentials', + }); + // @ts-ignore + } else if (err && err.errorCode) { + // @ts-ignore + res.status(err.errorCode).json(err.message); + } else if (err) { + res.status(500).json(err.message); + } + }, +); + +generateFakeData(); + +/** + * Server activation + */ + +const PORT = process.env.PORT || 3000; + +app.listen(PORT, () => { + console.info(`server up on port ${PORT}`); +}); diff --git a/apps/api/src/app/mappers/article.mapper.ts b/apps/api/src/app/mappers/article.mapper.ts new file mode 100644 index 000000000..4c35cb5be --- /dev/null +++ b/apps/api/src/app/mappers/article.mapper.ts @@ -0,0 +1,16 @@ +import authorMapper from './author.mapper'; + +const articleMapper = (article: any, username?: string) => ({ + slug: article.slug, + title: article.title, + description: article.description, + body: article.body, + tagList: article.tagList.map((tag: any) => tag.name), + createdAt: article.createdAt, + updatedAt: article.updatedAt, + favorited: article.favoritedBy.some((item: any) => item.username === username), + favoritesCount: article.favoritedBy.length, + author: authorMapper(article.author, username), +}); + +export default articleMapper; diff --git a/apps/api/src/app/mappers/author.mapper.ts b/apps/api/src/app/mappers/author.mapper.ts new file mode 100644 index 000000000..9ac36e8a2 --- /dev/null +++ b/apps/api/src/app/mappers/author.mapper.ts @@ -0,0 +1,12 @@ +import { User } from '../models/user.model'; + +const authorMapper = (author: any, username?: string) => ({ + username: author.username, + bio: author.bio, + image: author.image, + following: username + ? author?.followedBy.some((followingUser: Partial) => followingUser.username === username) + : false, +}); + +export default authorMapper; diff --git a/apps/api/src/app/models/article.model.ts b/apps/api/src/app/models/article.model.ts new file mode 100644 index 000000000..2971d061b --- /dev/null +++ b/apps/api/src/app/models/article.model.ts @@ -0,0 +1,10 @@ +import { Comment } from './comment.model'; + +export interface Article { + id: number; + title: string; + slug: string; + description: string; + comments: Comment[]; + favorited: boolean; +} diff --git a/apps/api/src/app/models/comment.model.ts b/apps/api/src/app/models/comment.model.ts new file mode 100644 index 000000000..183c5ed4e --- /dev/null +++ b/apps/api/src/app/models/comment.model.ts @@ -0,0 +1,9 @@ +import { Article } from './article.model'; + +export interface Comment { + id: number; + createdAt: Date; + updatedAt: Date; + body: string; + article?: Article; +} diff --git a/apps/api/src/app/models/http-exception.model.ts b/apps/api/src/app/models/http-exception.model.ts new file mode 100644 index 000000000..3a88dde14 --- /dev/null +++ b/apps/api/src/app/models/http-exception.model.ts @@ -0,0 +1,10 @@ +class HttpException extends Error { + errorCode: number; + + constructor(errorCode: number, public readonly message: string | any) { + super(message); + this.errorCode = errorCode; + } +} + +export default HttpException; diff --git a/apps/api/src/app/models/profile.model.ts b/apps/api/src/app/models/profile.model.ts new file mode 100644 index 000000000..3ff457e14 --- /dev/null +++ b/apps/api/src/app/models/profile.model.ts @@ -0,0 +1,6 @@ +export interface Profile { + username: string; + bio: string; + image: string; + following: boolean; +} diff --git a/apps/api/src/app/models/register-input.model.ts b/apps/api/src/app/models/register-input.model.ts new file mode 100644 index 000000000..c1a317997 --- /dev/null +++ b/apps/api/src/app/models/register-input.model.ts @@ -0,0 +1,8 @@ +export interface RegisterInput { + email: string; + username: string; + password: string; + image?: string; + bio?: string; + demo?: boolean; +} diff --git a/apps/api/src/app/models/registered-user.model.ts b/apps/api/src/app/models/registered-user.model.ts new file mode 100644 index 000000000..b01192770 --- /dev/null +++ b/apps/api/src/app/models/registered-user.model.ts @@ -0,0 +1,7 @@ +export interface RegisteredUser { + email: string; + username: string; + bio: string | null; + image: string | null; + token: string; +} diff --git a/apps/api/src/app/models/tag.model.ts b/apps/api/src/app/models/tag.model.ts new file mode 100644 index 000000000..b6765b7bd --- /dev/null +++ b/apps/api/src/app/models/tag.model.ts @@ -0,0 +1,3 @@ +export interface Tag { + name: string; +} diff --git a/apps/api/src/app/models/user.model.ts b/apps/api/src/app/models/user.model.ts new file mode 100644 index 000000000..356bc8f6b --- /dev/null +++ b/apps/api/src/app/models/user.model.ts @@ -0,0 +1,17 @@ +import { Article } from './article.model'; +import { Comment } from './comment.model'; + +export interface User { + id: number; + username: string; + email: string; + password: string; + bio: string | null; + image: any | null; + articles: Article[]; + favorites: Article[]; + followedBy: User[]; + following: User[]; + comments: Comment[]; + demo: boolean; +} diff --git a/apps/api/src/app/routes/routes.ts b/apps/api/src/app/routes/routes.ts new file mode 100644 index 000000000..b96f6d7f0 --- /dev/null +++ b/apps/api/src/app/routes/routes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import tagsController from '../controllers/tag.controller'; +import articlesController from '../controllers/article.controller'; +import authController from '../controllers/auth.controller'; +import profileController from '../controllers/profile.controller'; + +const api = Router() + .use(tagsController) + .use(articlesController) + .use(profileController) + .use(authController); + +export default Router().use('/api', api); diff --git a/apps/api/src/app/services/article.service.ts b/apps/api/src/app/services/article.service.ts new file mode 100644 index 000000000..ff8afdb2d --- /dev/null +++ b/apps/api/src/app/services/article.service.ts @@ -0,0 +1,657 @@ +import slugify from 'slugify'; +import prisma from '../../prisma/prisma-client'; +import HttpException from '../models/http-exception.model'; +import { findUserIdByUsername } from './auth.service'; +import profileMapper from '../utils/profile.utils'; +import articleMapper from '../mappers/article.mapper'; +import { Tag } from '../models/tag.model'; + +const buildFindAllQuery = (query: any, username: string | undefined) => { + const queries: any = []; + const orAuthorQuery = []; + const andAuthorQuery = []; + + orAuthorQuery.push({ + demo: { + equals: true, + }, + }); + + if (username) { + orAuthorQuery.push({ + username: { + equals: username, + }, + }); + } + + if ('author' in query) { + andAuthorQuery.push({ + username: { + equals: query.author, + }, + }); + } + + const authorQuery = { + author: { + OR: orAuthorQuery, + AND: andAuthorQuery, + }, + }; + + queries.push(authorQuery); + + if ('tag' in query) { + queries.push({ + tagList: { + some: { + name: query.tag, + }, + }, + }); + } + + if ('favorited' in query) { + queries.push({ + favoritedBy: { + some: { + username: { + equals: query.favorited, + }, + }, + }, + }); + } + + return queries; +}; + +export const getArticles = async (query: any, username?: string) => { + const andQueries = buildFindAllQuery(query, username); + const articlesCount = await prisma.article.count({ + where: { + AND: andQueries, + }, + }); + + const articles = await prisma.article.findMany({ + where: { AND: andQueries }, + orderBy: { + createdAt: 'desc', + }, + skip: Number(query.offset) || 0, + take: Number(query.limit) || 10, + include: { + tagList: { + select: { + name: true, + }, + }, + author: { + select: { + username: true, + bio: true, + image: true, + followedBy: true, + }, + }, + favoritedBy: true, + _count: { + select: { + favoritedBy: true, + }, + }, + }, + }); + + return { + articles: articles.map((article: any) => articleMapper(article, username)), + articlesCount, + }; +}; + +export const getFeed = async (offset: number, limit: number, username: string) => { + const user = await findUserIdByUsername(username); + + const articlesCount = await prisma.article.count({ + where: { + author: { + followedBy: { some: { id: user?.id } }, + }, + }, + }); + + const articles = await prisma.article.findMany({ + where: { + author: { + followedBy: { some: { id: user?.id } }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + skip: offset || 0, + take: limit || 10, + include: { + tagList: { + select: { + name: true, + }, + }, + author: { + select: { + username: true, + bio: true, + image: true, + followedBy: true, + }, + }, + favoritedBy: true, + _count: { + select: { + favoritedBy: true, + }, + }, + }, + }); + + return { + articles: articles.map((article: any) => articleMapper(article, username)), + articlesCount, + }; +}; + +export const createArticle = async (article: any, username: string) => { + const { title, description, body, tagList } = article; + const tags = Array.isArray(tagList) ? tagList : []; + + if (!title) { + throw new HttpException(422, { errors: { title: ["can't be blank"] } }); + } + + if (!description) { + throw new HttpException(422, { errors: { description: ["can't be blank"] } }); + } + + if (!body) { + throw new HttpException(422, { errors: { body: ["can't be blank"] } }); + } + + const user = await findUserIdByUsername(username); + + const slug = `${slugify(title)}-${user?.id}`; + + const existingTitle = await prisma.article.findUnique({ + where: { + slug, + }, + select: { + slug: true, + }, + }); + + if (existingTitle) { + throw new HttpException(422, { errors: { title: ['must be unique'] } }); + } + + const { authorId, id, ...createdArticle } = await prisma.article.create({ + data: { + title, + description, + body, + slug, + tagList: { + connectOrCreate: tags.map((tag: string) => ({ + create: { name: tag }, + where: { name: tag }, + })), + }, + author: { + connect: { + id: user?.id, + }, + }, + }, + include: { + tagList: { + select: { + name: true, + }, + }, + author: { + select: { + username: true, + bio: true, + image: true, + followedBy: true, + }, + }, + favoritedBy: true, + _count: { + select: { + favoritedBy: true, + }, + }, + }, + }); + + return articleMapper(createdArticle, username); +}; + +export const getArticle = async (slug: string, username?: string) => { + const article = await prisma.article.findUnique({ + where: { + slug, + }, + include: { + tagList: { + select: { + name: true, + }, + }, + author: { + select: { + username: true, + bio: true, + image: true, + followedBy: true, + }, + }, + favoritedBy: true, + _count: { + select: { + favoritedBy: true, + }, + }, + }, + }); + + if (!article) { + throw new HttpException(404, { errors: { article: ['not found'] } }); + } + + return articleMapper(article, username); +}; + +const disconnectArticlesTags = async (slug: string) => { + await prisma.article.update({ + where: { + slug, + }, + data: { + tagList: { + set: [], + }, + }, + }); +}; + +export const updateArticle = async (article: any, slug: string, username: string) => { + let newSlug = null; + const user = await findUserIdByUsername(username); + + const existingArticle = await await prisma.article.findFirst({ + where: { + slug, + }, + select: { + author: { + select: { + username: true, + }, + }, + }, + }); + + if (!existingArticle) { + throw new HttpException(404, {}); + } + + if (existingArticle.author.username !== username) { + throw new HttpException(403, { + message: 'You are not authorized to update this article', + }); + } + + if (article.title) { + newSlug = `${slugify(article.title)}-${user?.id}`; + + if (newSlug !== slug) { + const existingTitle = await prisma.article.findFirst({ + where: { + slug: newSlug, + }, + select: { + slug: true, + }, + }); + + if (existingTitle) { + throw new HttpException(422, { errors: { title: ['must be unique'] } }); + } + } + } + + const tagList = + Array.isArray(article.tagList) && article.tagList?.length + ? article.tagList.map((tag: string) => ({ + create: { name: tag }, + where: { name: tag }, + })) + : []; + + await disconnectArticlesTags(slug); + + const updatedArticle = await prisma.article.update({ + where: { + slug, + }, + data: { + ...(article.title ? { title: article.title } : {}), + ...(article.body ? { body: article.body } : {}), + ...(article.description ? { description: article.description } : {}), + ...(newSlug ? { slug: newSlug } : {}), + updatedAt: new Date(), + tagList: { + connectOrCreate: tagList, + }, + }, + include: { + tagList: { + select: { + name: true, + }, + }, + author: { + select: { + username: true, + bio: true, + image: true, + followedBy: true, + }, + }, + favoritedBy: true, + _count: { + select: { + favoritedBy: true, + }, + }, + }, + }); + + return articleMapper(updatedArticle, username); +}; + +export const deleteArticle = async (slug: string, username: string) => { + const existingArticle = await await prisma.article.findFirst({ + where: { + slug, + }, + select: { + author: { + select: { + username: true, + }, + }, + }, + }); + + if (!existingArticle) { + throw new HttpException(404, {}); + } + + if (existingArticle.author.username !== username) { + throw new HttpException(403, { + message: 'You are not authorized to delete this article', + }); + } + await prisma.article.delete({ + where: { + slug, + }, + }); +}; + +export const getCommentsByArticle = async (slug: string, username?: string) => { + const queries = []; + + queries.push({ + author: { + demo: true, + }, + }); + + if (username) { + queries.push({ + author: { + username, + }, + }); + } + + const comments = await prisma.article.findUnique({ + where: { + slug, + }, + include: { + comments: { + where: { + OR: queries, + }, + select: { + id: true, + createdAt: true, + updatedAt: true, + body: true, + author: { + select: { + username: true, + bio: true, + image: true, + followedBy: true, + }, + }, + }, + }, + }, + }); + + const result = comments?.comments.map((comment: any) => ({ + ...comment, + author: { + username: comment.author.username, + bio: comment.author.bio, + image: comment.author.image, + following: comment.author.followedBy.some((follow: any) => follow.username === username), + }, + })); + + return result; +}; + +export const addComment = async (body: string, slug: string, username: string) => { + if (!body) { + throw new HttpException(422, { errors: { body: ["can't be blank"] } }); + } + + const user = await findUserIdByUsername(username); + + const article = await prisma.article.findUnique({ + where: { + slug, + }, + select: { + id: true, + }, + }); + + const comment = await prisma.comment.create({ + data: { + body, + article: { + connect: { + id: article?.id, + }, + }, + author: { + connect: { + id: user?.id, + }, + }, + }, + include: { + author: { + select: { + username: true, + bio: true, + image: true, + followedBy: true, + }, + }, + }, + }); + + return { + id: comment.id, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + body: comment.body, + author: { + username: comment.author.username, + bio: comment.author.bio, + image: comment.author.image, + following: comment.author.followedBy.some((follow: any) => follow.id === user?.id), + }, + }; +}; + +export const deleteComment = async (id: number, username: string) => { + const comment = await prisma.comment.findFirst({ + where: { + id, + author: { + username, + }, + }, + select: { + author: { + select: { + username: true, + }, + }, + }, + }); + + if (!comment) { + throw new HttpException(404, {}); + } + + if (comment.author.username !== username) { + throw new HttpException(403, { + message: 'You are not authorized to delete this comment', + }); + } + + await prisma.comment.delete({ + where: { + id, + }, + }); +}; + +export const favoriteArticle = async (slugPayload: string, usernameAuth: string) => { + const user = await findUserIdByUsername(usernameAuth); + + const { _count, ...article } = await prisma.article.update({ + where: { + slug: slugPayload, + }, + data: { + favoritedBy: { + connect: { + id: user?.id, + }, + }, + }, + include: { + tagList: { + select: { + name: true, + }, + }, + author: { + select: { + username: true, + bio: true, + image: true, + followedBy: true, + }, + }, + favoritedBy: true, + _count: { + select: { + favoritedBy: true, + }, + }, + }, + }); + + const result = { + ...article, + author: profileMapper(article.author, usernameAuth), + tagList: article?.tagList.map((tag: Tag) => tag.name), + favorited: article.favoritedBy.some((favorited: any) => favorited.id === user?.id), + favoritesCount: _count?.favoritedBy, + }; + + return result; +}; + +export const unfavoriteArticle = async (slugPayload: string, usernameAuth: string) => { + const user = await findUserIdByUsername(usernameAuth); + + const { _count, ...article } = await prisma.article.update({ + where: { + slug: slugPayload, + }, + data: { + favoritedBy: { + disconnect: { + id: user?.id, + }, + }, + }, + include: { + tagList: { + select: { + name: true, + }, + }, + author: { + select: { + username: true, + bio: true, + image: true, + followedBy: true, + }, + }, + favoritedBy: true, + _count: { + select: { + favoritedBy: true, + }, + }, + }, + }); + + const result = { + ...article, + author: profileMapper(article.author, usernameAuth), + tagList: article?.tagList.map((tag: Tag) => tag.name), + favorited: article.favoritedBy.some((favorited: any) => favorited.id === user?.id), + favoritesCount: _count?.favoritedBy, + }; + + return result; +}; diff --git a/apps/api/src/app/services/auth.service.ts b/apps/api/src/app/services/auth.service.ts new file mode 100644 index 000000000..376837772 --- /dev/null +++ b/apps/api/src/app/services/auth.service.ts @@ -0,0 +1,196 @@ +import * as bcrypt from 'bcryptjs'; +import { RegisterInput } from '../models/register-input.model'; +import prisma from '../../prisma/prisma-client'; +import HttpException from '../models/http-exception.model'; +import { RegisteredUser } from '../models/registered-user.model'; +import generateToken from '../utils/token.utils'; +import { User } from '../models/user.model'; + +const checkUserUniqueness = async (email: string, username: string) => { + const existingUserByEmail = await prisma.user.findUnique({ + where: { + email, + }, + select: { + id: true, + }, + }); + + const existingUserByUsername = await prisma.user.findUnique({ + where: { + username, + }, + select: { + id: true, + }, + }); + + if (existingUserByEmail || existingUserByUsername) { + throw new HttpException(422, { + errors: { + ...(existingUserByEmail ? { email: ['has already been taken'] } : {}), + ...(existingUserByUsername ? { username: ['has already been taken'] } : {}), + }, + }); + } +}; + +export const createUser = async (input: RegisterInput): Promise => { + const email = input.email?.trim(); + const username = input.username?.trim(); + const password = input.password?.trim(); + const { image, bio, demo } = input; + + if (!email) { + throw new HttpException(422, { errors: { email: ["can't be blank"] } }); + } + + if (!username) { + throw new HttpException(422, { errors: { username: ["can't be blank"] } }); + } + + if (!password) { + throw new HttpException(422, { errors: { password: ["can't be blank"] } }); + } + + await checkUserUniqueness(email, username); + + const hashedPassword = await bcrypt.hash(password, 10); + + const user = await prisma.user.create({ + data: { + username, + email, + password: hashedPassword, + ...(image ? { image } : {}), + ...(bio ? { bio } : {}), + ...(demo ? { demo } : {}), + }, + select: { + email: true, + username: true, + bio: true, + image: true, + }, + }); + + return { + ...user, + token: generateToken(user), + }; +}; + +export const login = async (userPayload: any) => { + const email = userPayload.email?.trim(); + const password = userPayload.password?.trim(); + + if (!email) { + throw new HttpException(422, { errors: { email: ["can't be blank"] } }); + } + + if (!password) { + throw new HttpException(422, { errors: { password: ["can't be blank"] } }); + } + + const user = await prisma.user.findUnique({ + where: { + email, + }, + select: { + email: true, + username: true, + password: true, + bio: true, + image: true, + }, + }); + + if (user) { + const match = await bcrypt.compare(password, user.password); + + if (match) { + return { + email: user.email, + username: user.username, + bio: user.bio, + image: user.image, + token: generateToken(user), + }; + } + } + + throw new HttpException(403, { + errors: { + 'email or password': ['is invalid'], + }, + }); +}; + +export const getCurrentUser = async (username: string) => { + const user = (await prisma.user.findUnique({ + where: { + username, + }, + select: { + email: true, + username: true, + bio: true, + image: true, + }, + })) as User; + + return { + ...user, + token: generateToken(user), + }; +}; + +export const updateUser = async (userPayload: any, loggedInUsername: string) => { + const { email, username, password, image, bio } = userPayload; + let hashedPassword; + + if (password) { + hashedPassword = await bcrypt.hash(password, 10); + } + + const user = await prisma.user.update({ + where: { + username: loggedInUsername, + }, + data: { + ...(email ? { email } : {}), + ...(username ? { username } : {}), + ...(password ? { password: hashedPassword } : {}), + ...(image ? { image } : {}), + ...(bio ? { bio } : {}), + }, + select: { + email: true, + username: true, + bio: true, + image: true, + }, + }); + + return { + ...user, + token: generateToken(user), + }; +}; + +export const findUserIdByUsername = async (username: string) => { + const user = await prisma.user.findUnique({ + where: { + username, + }, + select: { + id: true, + }, + }); + + if (!user) { + throw new HttpException(404, {}); + } + + return user; +}; diff --git a/apps/api/src/app/services/profile.service.ts b/apps/api/src/app/services/profile.service.ts new file mode 100644 index 000000000..e0b9c05d0 --- /dev/null +++ b/apps/api/src/app/services/profile.service.ts @@ -0,0 +1,65 @@ +import prisma from '../../prisma/prisma-client'; +import profileMapper from '../utils/profile.utils'; +import HttpException from '../models/http-exception.model'; +import { findUserIdByUsername } from './auth.service'; + +export const getProfile = async (usernamePayload: string, usernameAuth?: string) => { + const profile = await prisma.user.findUnique({ + where: { + username: usernamePayload, + }, + include: { + followedBy: true, + }, + }); + + if (!profile) { + throw new HttpException(404, {}); + } + + return profileMapper(profile, usernameAuth); +}; + +export const followUser = async (usernamePayload: string, usernameAuth: string) => { + const { id } = await findUserIdByUsername(usernameAuth); + + const profile = await prisma.user.update({ + where: { + username: usernamePayload, + }, + data: { + followedBy: { + connect: { + id, + }, + }, + }, + include: { + followedBy: true, + }, + }); + + return profileMapper(profile, usernameAuth); +}; + +export const unfollowUser = async (usernamePayload: string, usernameAuth: string) => { + const { id } = await findUserIdByUsername(usernameAuth); + + const profile = await prisma.user.update({ + where: { + username: usernamePayload, + }, + data: { + followedBy: { + disconnect: { + id, + }, + }, + }, + include: { + followedBy: true, + }, + }); + + return profileMapper(profile, usernameAuth); +}; diff --git a/apps/api/src/app/services/tag.service.ts b/apps/api/src/app/services/tag.service.ts new file mode 100644 index 000000000..58af1d148 --- /dev/null +++ b/apps/api/src/app/services/tag.service.ts @@ -0,0 +1,40 @@ +import prisma from '../../prisma/prisma-client'; +import { Tag } from '../models/tag.model'; + +const getTags = async (username?: string): Promise => { + const queries = []; + queries.push({ demo: true }); + + if (username) { + queries.push({ + username: { + equals: username, + }, + }); + } + + const tags = await prisma.tag.findMany({ + where: { + articles: { + some: { + author: { + OR: queries, + }, + }, + }, + }, + select: { + name: true, + }, + orderBy: { + articles: { + _count: 'desc', + }, + }, + take: 10, + }); + + return tags.map((tag: Tag) => tag.name); +}; + +export default getTags; diff --git a/apps/api/src/app/utils/auth.ts b/apps/api/src/app/utils/auth.ts new file mode 100644 index 000000000..cfeb072f3 --- /dev/null +++ b/apps/api/src/app/utils/auth.ts @@ -0,0 +1,27 @@ +import jwt from 'express-jwt'; + +const getTokenFromHeaders = (req: { headers: { authorization: string } }): string | null => { + if ( + (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Token') || + (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') + ) { + return req.headers.authorization.split(' ')[1]; + } + return null; +}; + +const auth = { + required: jwt({ + secret: process.env.JWT_SECRET || 'superSecret', + getToken: getTokenFromHeaders, + algorithms: ['HS256'], + }), + optional: jwt({ + secret: process.env.JWT_SECRET || 'superSecret', + credentialsRequired: false, + getToken: getTokenFromHeaders, + algorithms: ['HS256'], + }), +}; + +export default auth; diff --git a/apps/api/src/app/utils/cron.ts b/apps/api/src/app/utils/cron.ts new file mode 100644 index 000000000..13f02c31e --- /dev/null +++ b/apps/api/src/app/utils/cron.ts @@ -0,0 +1,86 @@ +import { createUser } from '../services/auth.service'; +import { RegisteredUser } from '../models/registered-user.model'; +import { addComment, createArticle } from '../services/article.service'; +import { getProfile } from '../services/profile.service'; + +export const generateUser = async (): Promise => + createUser({ + username: 'Gerome', + email: 'gerome@me', + password: (process.env.FAKE_PASSWORD as string) || '123456', + image: 'https://api.realworld.io/images/demo-avatar.png', + demo: true, + }); + +export const generateFakeData = async (): Promise => { + const existingAdmin = await getProfile('Gerome'); + if (existingAdmin) { + return; + } + + const user = await generateUser(); + + const introduction = await createArticle( + { + title: 'Welcome to RealWorld project', + description: + 'Exemplary fullstack Medium.com clone powered by React, Angular, Node, Django, and many more', + body: 'See how the exact same Medium.com clone (called Conduit) is built using different frontends and backends. Yes, you can mix and match them, because they all adhere to the same API spec', + tagList: ['welcome', 'introduction'], + }, + user.username, + ); + + await addComment( + 'While most "todo" demos provide an excellent cursory glance at a framework\'s capabilities, they typically don\'t convey the knowledge & perspective required to actually build real applications with it.', + introduction.slug, + user.username, + ); + + await addComment( + 'RealWorld solves this by allowing you to choose any frontend (React, Angular, & more) and any backend (Node, Django, & more) and see how they power a real-world, beautifully designed full-stack app called Conduit.', + introduction.slug, + user.username, + ); + + const codebaseShow = await createArticle( + { + title: 'Explore implementations', + description: 'discover the implementations created by the RealWorld community', + body: + 'Over 100 implementations have been created using various languages, libraries, and frameworks.\n' + + '\n' + + 'Explore them on CodebaseShow.', + tagList: ['codebaseShow', 'implementations'], + }, + user.username, + ); + + await addComment( + 'There are 3 categories: Frontend, Backend and FullStack', + codebaseShow.slug, + user.username, + ); + + const implementationCreation = await createArticle( + { + title: 'Create a new implementation', + description: 'join the community by creating a new implementation', + body: 'Share your knowledge and enpower the community by creating a new implementation', + tagList: ['implementations'], + }, + user.username, + ); + + await addComment( + 'Before starting a new implementation, please check if there is any work in progress for the stack you want to work on.', + implementationCreation.slug, + user.username, + ); + + await addComment( + 'If someone else has started working on an implementation, consider jumping in and helping them! by contacting the author.', + implementationCreation.slug, + user.username, + ); +}; diff --git a/apps/api/src/app/utils/profile.utils.ts b/apps/api/src/app/utils/profile.utils.ts new file mode 100644 index 000000000..6bfc8ae33 --- /dev/null +++ b/apps/api/src/app/utils/profile.utils.ts @@ -0,0 +1,13 @@ +import { User } from '../models/user.model'; +import { Profile } from '../models/profile.model'; + +const profileMapper = (user: any, username: string | undefined): Profile => ({ + username: user.username, + bio: user.bio, + image: user.image, + following: username + ? user?.followedBy.some((followingUser: Partial) => followingUser.username === username) + : false, +}); + +export default profileMapper; diff --git a/apps/api/src/app/utils/token.utils.ts b/apps/api/src/app/utils/token.utils.ts new file mode 100644 index 000000000..e70c1ef1f --- /dev/null +++ b/apps/api/src/app/utils/token.utils.ts @@ -0,0 +1,7 @@ +import * as jwt from 'jsonwebtoken'; +import { User } from '../models/user.model'; + +const generateToken = ({ email, username }: Partial): string => + jwt.sign({ email, username }, process.env.JWT_SECRET || 'superSecret', { expiresIn: '60d' }); + +export default generateToken; diff --git a/apps/api/src/app/utils/user-request.d.ts b/apps/api/src/app/utils/user-request.d.ts new file mode 100644 index 000000000..d6bcd7fb8 --- /dev/null +++ b/apps/api/src/app/utils/user-request.d.ts @@ -0,0 +1,7 @@ +declare namespace Express { + export interface Request { + user?: { + username?: string; + }; + } +} diff --git a/apps/api/src/assets/demo-avatar.png b/apps/api/src/assets/demo-avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..0a8cfd685bc795ae4a7acfdcc72838d4b82ceffb GIT binary patch literal 1711 zcmV;g22lBlP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x00(qQO+^Rh0T~km5HvzP6aWAPvq?ljR7l5dRy&U+M;Sfm ztLpoj$9i_>Vc3;1>jVNzMnX=!0wgCuMC6#5fP|Dkz<)q82^kqO5+ooJgzUtuMPOK& zodxggYi8%(y^rp$`VK+e?t5pT?p1Yl(C2*L`RaS{!ykNTBx%O=GIS>pBvCd1WiLwr zB&{D%S>7Kc$z6)u=tjH7QNUTCJDvgxK$HYfk_0(rUiw-3AXAjDw^7@WWLr&T(?`J12Qvwi*R zEBg<>{rE?}nBTb{@{lHiM#hZ}{nZpkJEN16V^K8H0P9l%5G2tweU$5EGtbTZ&5z&s z!_QoPcJG(7@zb3en;?P_Tjm!|0l~~f_hN-Xpy1G&!$DxYTOFLf_vq^%J-YkbI6JgF zdi>V!bXHCN^EP85jD*DS!8boB5jHcUnHj(;R+HgalDl8LyqHaAAgfT71ce}n4K=#B zeL0;!-}~(B^-rIie4J-?`;#Bve)7FsE#d2cMfNUWJ9Xj7VZ3}bJ)gczN^RZ7qfk|q zAb^la?jMccee%;=FWz~&`!_>4y8PDB?Cn%7=tepr3K0dg!|jDOnOx>vw9AFBCe@gk zFhJ|9E_LX&G%ZXZtxfH8IW-GrHVlJRTTiNS z-84z(wVl0T$mtPvKh?AA~20L#| z?idZ^v&DHr% z!@c8&=coUC_Qm_(`0l70E}NCn09?#2S4|x(ESseScXt=MCb#WN+)#5vL1U8}mtL(F zSuS{!npgF_PK}Y0K(5k?-Pz>_ys_?}MFp+Jh>*b2R4IZ`@^o|OU=RmwZVx7})xNAk zb#ro1S(QDnL4jwp7mH?jw0~<52Z3RCxRX7XQm!6fit5}FOV>s;JDWav`s!J3_Ce_#_5^Ziz_D^o^9Mo-dFuC#hvj=smZ{PUp(avG=tgRV9K$Ij>Tk4U# zSibzn^9O@axfCoId3y10mzO-QQ!}o{ZAi;@b-9|IUObwvrn|%4ZEhNG0DSu8{dqe-+`IMJ+25!241l{!@~+VP%BhHq^y+mHd4Uj3P zK$k`-UY|_+``zPV{Vb6vdvui&zUDB{VO&>pQR^{)SMWO*HXOY-ew9N}D(d$fAiUrb zWNBa^i+-EI4t?pl7S(NZ$%_XYj!qM94%)I`Tccy=#nsfkKED4KJNBrTH(bH!0000b zbVXQnWMOn=I%9HWVRU5xGB7eSEif@HGBs2&IXW;iIx#jaFf}?bFytE)?EnA(C3Hnt zbYx+4WjbwdWNBu305UK#GA%GMEiyG!F*!OgGdeLgEig4YFff%ytWf{}002ovPDHLk FV1hUs8pQwr literal 0 HcmV?d00001 diff --git a/apps/api/src/assets/smiley-cyrus.jpeg b/apps/api/src/assets/smiley-cyrus.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..784a6b9f904a72c7f9712f84748c5f0023e6e9ff GIT binary patch literal 1343 zcma))dpOez7{`A<+gK)<%XnPoG|r>smM$zIwOppBQ#&2E>Xe8_%xx}}Tx#W36tc`U zXBu^Qa>+KficC~vXfaGIHk??9<$88>{^_stJm>R#|9GG0eV_09$M>rcD#ic}ysL*R z0B&LdS^$7z7&rnzl$5?}6X>R3a2N~+@QQM}ghtk&5)Yj2fv;YV=kOHKDAQDh;0Yd-;Ko|%DKTrmtFcmmNX>+?*0|23* z5)`Tof?NK*00E`Vx5`MB?YmvznkW6Ww4<))loL#JFt`iRq#L>QiXj00-Od>(#{6Hb zo0lX^|9jqAwp1^ zMPEwg1=(%HnU5fKq-KbF{GpYwiwLX%q&oJ3EplnNawK?WuFUV(lrAgE8+eO|!bKli z-J7?1(z~t~zg!VO%TEh_EvGb9_Kq^t-9Bb4gZ3!3BgL#<7HuCcEWkF&R;i!qFFVP$S>DpopmIi#jmIwq@QCCtinwU3!-z1^NrantdY9V_fi7yo{a zFsJSu{Aulpla0k4NAgNOMAiFbQ7VIdK3Rd?=wi0~FE;*6cAnU^AZ=AJ$4YUoFp&m1 z!~)^dhKX0IUORKM)7X)RKQpG4;AQ_-HA%~t?0rjcEqN$7$R(InDmfyujCQR+sg{ z-bw`t;S;*jC$3T!N6ReVW(0p7SaJ>O_cp^eTz$vs^I){y69NvKl}xa>`lDu+REwD!w5YMl7|EMp{fqi+4I_%7*EFcOn}PG4SmJYOJ>Cr!pwBo=;5&Toi`NKJIS zPI16yR0#GLx*+lVG#_Jm>(8SLrU^TZk(Zxe7uPsHAW=!?dQiP)u2SzXpSfdrZ4nmj zRsDhLNGvY1q?C>OJyX z&7On^33;j6%-&*v;h0bQM_A785qB|H2M1)Tg;M1& literal 0 HcmV?d00001 diff --git a/apps/api/src/environments/environment.prod.ts b/apps/api/src/environments/environment.prod.ts new file mode 100644 index 000000000..c9669790b --- /dev/null +++ b/apps/api/src/environments/environment.prod.ts @@ -0,0 +1,3 @@ +export const environment = { + production: true, +}; diff --git a/apps/api/src/environments/environment.ts b/apps/api/src/environments/environment.ts new file mode 100644 index 000000000..a20cfe557 --- /dev/null +++ b/apps/api/src/environments/environment.ts @@ -0,0 +1,3 @@ +export const environment = { + production: false, +}; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 000000000..6af87d6b4 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,21 @@ +/** + * This is not a production server yet! + * This is only a minimal backend to get started. + */ + +import * as express from 'express'; +import * as path from 'path'; + +const app = express(); + +app.use('/assets', express.static(path.join(__dirname, 'assets'))); + +app.get('/api', (req, res) => { + res.send({ message: 'Welcome to api!' }); +}); + +const port = process.env.port || 3333; +const server = app.listen(port, () => { + console.log(`Listening at http://localhost:${port}/api`); +}); +server.on('error', console.error); diff --git a/apps/api/src/prisma/migrations/20210924225358_initial/migration.sql b/apps/api/src/prisma/migrations/20210924225358_initial/migration.sql new file mode 100644 index 000000000..1442f53e1 --- /dev/null +++ b/apps/api/src/prisma/migrations/20210924225358_initial/migration.sql @@ -0,0 +1,114 @@ +-- CreateTable +CREATE TABLE "Article" ( + "id" SERIAL NOT NULL, + "slug" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "body" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "authorId" INTEGER NOT NULL, + + PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ArticleTags" ( + "articleId" INTEGER NOT NULL, + "tagId" INTEGER NOT NULL, + + PRIMARY KEY ("articleId","tagId") +); + +-- CreateTable +CREATE TABLE "Comment" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "body" TEXT NOT NULL, + "articleId" INTEGER NOT NULL, + "authorId" INTEGER NOT NULL, + + PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Tag" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + + PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "email" TEXT NOT NULL, + "username" TEXT NOT NULL, + "password" TEXT NOT NULL, + "image" TEXT DEFAULT E'https://realworld-temp-api.herokuapp.com/images/smiley-cyrus.jpeg', + "bio" TEXT, + "demo" BOOLEAN NOT NULL DEFAULT false, + + PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_UserFavorites" ( + "A" INTEGER NOT NULL, + "B" INTEGER NOT NULL +); + +-- CreateTable +CREATE TABLE "_UserFollows" ( + "A" INTEGER NOT NULL, + "B" INTEGER NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Article.slug_unique" ON "Article"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "User.email_unique" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User.username_unique" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "_UserFavorites_AB_unique" ON "_UserFavorites"("A", "B"); + +-- CreateIndex +CREATE INDEX "_UserFavorites_B_index" ON "_UserFavorites"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_UserFollows_AB_unique" ON "_UserFollows"("A", "B"); + +-- CreateIndex +CREATE INDEX "_UserFollows_B_index" ON "_UserFollows"("B"); + +-- AddForeignKey +ALTER TABLE "Article" ADD FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ArticleTags" ADD FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ArticleTags" ADD FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Comment" ADD FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Comment" ADD FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_UserFavorites" ADD FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_UserFavorites" ADD FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_UserFollows" ADD FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_UserFollows" ADD FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/src/prisma/migrations/20211001143221_implicit_tags/migration.sql b/apps/api/src/prisma/migrations/20211001143221_implicit_tags/migration.sql new file mode 100644 index 000000000..1bb691d29 --- /dev/null +++ b/apps/api/src/prisma/migrations/20211001143221_implicit_tags/migration.sql @@ -0,0 +1,36 @@ +/* + Warnings: + + - You are about to drop the `ArticleTags` table. If the table is not empty, all the data it contains will be lost. + - A unique constraint covering the columns `[name]` on the table `Tag` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropForeignKey +ALTER TABLE "ArticleTags" DROP CONSTRAINT "ArticleTags_articleId_fkey"; + +-- DropForeignKey +ALTER TABLE "ArticleTags" DROP CONSTRAINT "ArticleTags_tagId_fkey"; + +-- DropTable +DROP TABLE "ArticleTags"; + +-- CreateTable +CREATE TABLE "_ArticleToTag" ( + "A" INTEGER NOT NULL, + "B" INTEGER NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_ArticleToTag_AB_unique" ON "_ArticleToTag"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ArticleToTag_B_index" ON "_ArticleToTag"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "Tag.name_unique" ON "Tag"("name"); + +-- AddForeignKey +ALTER TABLE "_ArticleToTag" ADD FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ArticleToTag" ADD FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/src/prisma/migrations/20211105153605_api_url/migration.sql b/apps/api/src/prisma/migrations/20211105153605_api_url/migration.sql new file mode 100644 index 000000000..01134b25e --- /dev/null +++ b/apps/api/src/prisma/migrations/20211105153605_api_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "image" SET DEFAULT E'https://api.realworld.io/images/smiley-cyrus.jpeg'; diff --git a/apps/api/src/prisma/migrations/20211221184529_deprecated_preview/migration.sql b/apps/api/src/prisma/migrations/20211221184529_deprecated_preview/migration.sql new file mode 100644 index 000000000..74957c445 --- /dev/null +++ b/apps/api/src/prisma/migrations/20211221184529_deprecated_preview/migration.sql @@ -0,0 +1,11 @@ +-- RenameIndex +ALTER INDEX "Article.slug_unique" RENAME TO "Article_slug_key"; + +-- RenameIndex +ALTER INDEX "Tag.name_unique" RENAME TO "Tag_name_key"; + +-- RenameIndex +ALTER INDEX "User.email_unique" RENAME TO "User_email_key"; + +-- RenameIndex +ALTER INDEX "User.username_unique" RENAME TO "User_username_key"; diff --git a/apps/api/src/prisma/migrations/migration_lock.toml b/apps/api/src/prisma/migrations/migration_lock.toml new file mode 100644 index 000000000..fbffa92c2 --- /dev/null +++ b/apps/api/src/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/apps/api/src/prisma/prisma-client.ts b/apps/api/src/prisma/prisma-client.ts new file mode 100644 index 000000000..bd0bf1f80 --- /dev/null +++ b/apps/api/src/prisma/prisma-client.ts @@ -0,0 +1,23 @@ +import { PrismaClient } from '@prisma/client'; + +declare global { + namespace NodeJS { + interface Global {} + } +} + +// add prisma to the NodeJS global type +interface CustomNodeJsGlobal extends NodeJS.Global { + prisma: PrismaClient; +} + +// Prevent multiple instances of Prisma Client in development +declare const global: CustomNodeJsGlobal; + +const prisma = global.prisma || new PrismaClient(); + +if (process.env.NODE_ENV === 'development') { + global.prisma = prisma; +} + +export default prisma; diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma new file mode 100644 index 000000000..3d767b7cb --- /dev/null +++ b/apps/api/src/prisma/schema.prisma @@ -0,0 +1,56 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" + previewFeatures = [] +} + +model Article { + id Int @id @default(autoincrement()) + slug String @unique + title String + description String + body String + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + tagList Tag[] + author User @relation("UserArticles", fields: [authorId], onDelete: Cascade, references: [id]) + authorId Int + favoritedBy User[] @relation("UserFavorites") + comments Comment[] +} + +model Comment { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + body String + article Article @relation(fields: [articleId], references: [id], onDelete: Cascade) + articleId Int + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + authorId Int +} + +model Tag { + id Int @id @default(autoincrement()) + name String @unique + articles Article[] +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + username String @unique + password String + image String? @default("https://api.realworld.io/images/smiley-cyrus.jpeg") + bio String? + articles Article[] @relation("UserArticles") + favorites Article[] @relation("UserFavorites") + followedBy User[] @relation("UserFollows") + following User[] @relation("UserFollows") + comments Comment[] + demo Boolean @default(false) +} diff --git a/apps/api/src/prisma/seed.ts b/apps/api/src/prisma/seed.ts new file mode 100644 index 000000000..85c028e85 --- /dev/null +++ b/apps/api/src/prisma/seed.ts @@ -0,0 +1,64 @@ +import { + randEmail, + randFullName, + randLine, + randLines, + randParagraph, + randPassword, + randWord, +} from '@ngneat/falso'; +import { PrismaClient } from '@prisma/client'; +import { RegisteredUser } from '../app/models/registered-user.model'; +import { createUser } from '../app/services/auth.service'; +import { addComment, createArticle } from '../app/services/article.service'; + +const prisma = new PrismaClient(); + +export const generateUser = async (): Promise => + createUser({ + username: randFullName(), + email: randEmail(), + password: randPassword(), + image: 'https://api.realworld.io/images/demo-avatar.png', + demo: true, + }); + +export const generateArticle = async (username: string) => + createArticle( + { + title: randLine(), + description: randParagraph(), + body: randLines({ length: 10 }).join(' '), + tagList: randWord({ length: 4 }), + }, + username, + ); + +export const generateComment = async (username: string, slug: string) => + addComment(randParagraph(), slug, username); + +export const main = async () => { + const users = await Promise.all(Array.from({ length: 30 }, () => generateUser())); + users?.map(user => user); + + // eslint-disable-next-line no-restricted-syntax + for await (const user of users) { + const articles = await Promise.all( + Array.from({ length: 20 }, () => generateArticle(user.username)), + ); + + // eslint-disable-next-line no-restricted-syntax + for await (const article of articles) { + await Promise.all(users.map(userItem => generateComment(userItem.username, article.slug))); + } + } +}; + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async () => { + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/src/tests/prisma-mock.ts b/apps/api/src/tests/prisma-mock.ts new file mode 100644 index 000000000..fb854f161 --- /dev/null +++ b/apps/api/src/tests/prisma-mock.ts @@ -0,0 +1,17 @@ +import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended'; + +import prisma from '../prisma/prisma-client'; +import { PrismaClient } from '@prisma/client'; + +jest.mock('../prisma/prisma-client', () => ({ + __esModule: true, + default: mockDeep(), +})); + +const prismaMock = prisma as unknown as DeepMockProxy; + +beforeEach(() => { + mockReset(prismaMock); +}); + +export default prismaMock; diff --git a/apps/api/src/tests/services/article.service.test.ts b/apps/api/src/tests/services/article.service.test.ts new file mode 100644 index 000000000..7ed80f7c5 --- /dev/null +++ b/apps/api/src/tests/services/article.service.test.ts @@ -0,0 +1,138 @@ +import prismaMock from '../prisma-mock'; +import { + deleteComment, + favoriteArticle, + unfavoriteArticle, +} from '../../app/services/article.service'; + +describe('ArticleService', () => { + describe('deleteComment', () => { + test('should throw an error ', () => { + // Given + const id = 123; + const username = 'RealWorld'; + + // When + // @ts-ignore + prismaMock.comment.findFirst.mockResolvedValue(null); + + // Then + expect(deleteComment(id, username)).rejects.toThrowError(); + }); + }); + + describe('favoriteArticle', () => { + test('should return the favorited article', async () => { + // Given + const slug = 'How-to-train-your-dragon'; + const username = 'RealWorld'; + + const mockedUserResponse = { + id: 123, + username: 'RealWorld', + email: 'realworld@me', + password: '1234', + bio: null, + image: null, + token: '', + demo: false, + }; + + const mockedArticleResponse = { + id: 123, + slug: 'How-to-train-your-dragon', + title: 'How to train your dragon', + description: '', + body: '', + createdAt: new Date(), + updatedAt: new Date(), + authorId: 456, + tagList: [], + favoritedBy: [], + author: { + username: 'RealWorld', + bio: null, + image: null, + followedBy: [], + }, + }; + + // When + // @ts-ignore + prismaMock.user.findUnique.mockResolvedValue(mockedUserResponse); + // @ts-ignore + prismaMock.article.update.mockResolvedValue(mockedArticleResponse); + + // Then + await expect(favoriteArticle(slug, username)).resolves.toHaveProperty('favoritesCount'); + }); + + test('should throw an error if no user is found', async () => { + // Given + const slug = 'how-to-train-your-dragon'; + const username = 'RealWorld'; + + // When + prismaMock.user.findUnique.mockResolvedValue(null); + + // Then + await expect(favoriteArticle(slug, username)).rejects.toThrowError(); + }); + }); + describe('unfavoriteArticle', () => { + test('should return the unfavorited article', async () => { + // Given + const slug = 'How-to-train-your-dragon'; + const username = 'RealWorld'; + + const mockedUserResponse = { + id: 123, + username: 'RealWorld', + email: 'realworld@me', + password: '1234', + bio: null, + image: null, + token: '', + demo: false, + }; + + const mockedArticleResponse = { + id: 123, + slug: 'How-to-train-your-dragon', + title: 'How to train your dragon', + description: '', + body: '', + createdAt: new Date(), + updatedAt: new Date(), + authorId: 456, + tagList: [], + favoritedBy: [], + author: { + username: 'RealWorld', + bio: null, + image: null, + followedBy: [], + }, + }; + + // When + prismaMock.user.findUnique.mockResolvedValue(mockedUserResponse); + prismaMock.article.update.mockResolvedValue(mockedArticleResponse); + + // Then + await expect(unfavoriteArticle(slug, username)).resolves.toHaveProperty('favoritesCount'); + }); + + test('should throw an error if no user is found', async () => { + // Given + const slug = 'how-to-train-your-dragon'; + const username = 'RealWorld'; + + // When + prismaMock.user.findUnique.mockResolvedValue(null); + + // Then + await expect(unfavoriteArticle(slug, username)).rejects.toThrowError(); + }); + }); +}); diff --git a/apps/api/src/tests/services/auth.service.test.ts b/apps/api/src/tests/services/auth.service.test.ts new file mode 100644 index 000000000..d5e478f16 --- /dev/null +++ b/apps/api/src/tests/services/auth.service.test.ts @@ -0,0 +1,254 @@ +import * as bcrypt from 'bcryptjs'; +import { createUser, getCurrentUser, login, updateUser } from '../../app/services/auth.service'; +import prismaMock from '../prisma-mock'; + +describe('AuthService', () => { + describe('createUser', () => { + test('should create new user ', async () => { + // Given + const user = { + id: 123, + username: 'RealWorld', + email: 'realworld@me', + password: '1234', + }; + + const mockedResponse = { + id: 123, + username: 'RealWorld', + email: 'realworld@me', + password: '1234', + bio: null, + image: null, + token: '', + demo: false, + }; + + // When + // @ts-ignore + prismaMock.user.create.mockResolvedValue(mockedResponse); + + // Then + await expect(createUser(user)).resolves.toHaveProperty('token'); + }); + + test('should throw an error when creating new user with empty username ', async () => { + // Given + const user = { + id: 123, + username: ' ', + email: 'realworld@me', + password: '1234', + }; + + // Then + const error = String({ errors: { username: ["can't be blank"] } }); + await expect(createUser(user)).rejects.toThrow(error); + }); + + test('should throw an error when creating new user with empty email ', async () => { + // Given + const user = { + id: 123, + username: 'RealWorld', + email: ' ', + password: '1234', + }; + + // Then + const error = String({ errors: { email: ["can't be blank"] } }); + await expect(createUser(user)).rejects.toThrow(error); + }); + + test('should throw an error when creating new user with empty password ', async () => { + // Given + const user = { + id: 123, + username: 'RealWorld', + email: 'realworld@me', + password: ' ', + }; + + // Then + const error = String({ errors: { password: ["can't be blank"] } }); + await expect(createUser(user)).rejects.toThrow(error); + }); + + test('should throw an exception when creating a new user with already existing user on same username ', async () => { + // Given + const user = { + id: 123, + username: 'RealWorld', + email: 'realworld@me', + password: '1234', + }; + + const mockedExistingUser = { + id: 123, + username: 'RealWorld', + email: 'realworld@me', + password: '1234', + bio: null, + image: null, + token: '', + demo: false, + }; + + // When + prismaMock.user.findUnique.mockResolvedValue(mockedExistingUser); + + // Then + const error = { email: ['has already been taken'] }.toString(); + await expect(createUser(user)).rejects.toThrow(error); + }); + }); + + describe('login', () => { + test('should return a token', async () => { + // Given + const user = { + email: 'realworld@me', + password: '1234', + }; + + const hashedPassword = await bcrypt.hash(user.password, 10); + + const mockedResponse = { + id: 123, + username: 'RealWorld', + email: 'realworld@me', + password: hashedPassword, + bio: null, + image: null, + token: '', + demo: false, + }; + + // When + prismaMock.user.findUnique.mockResolvedValue(mockedResponse); + + // Then + await expect(login(user)).resolves.toHaveProperty('token'); + }); + + test('should throw an error when the email is empty', async () => { + // Given + const user = { + email: ' ', + password: '1234', + }; + + // Then + const error = String({ errors: { email: ["can't be blank"] } }); + await expect(login(user)).rejects.toThrow(error); + }); + + test('should throw an error when the password is empty', async () => { + // Given + const user = { + email: 'realworld@me', + password: ' ', + }; + + // Then + const error = String({ errors: { password: ["can't be blank"] } }); + await expect(login(user)).rejects.toThrow(error); + }); + + test('should throw an error when no user is found', async () => { + // Given + const user = { + email: 'realworld@me', + password: '1234', + }; + + // When + prismaMock.user.findUnique.mockResolvedValue(null); + + // Then + const error = String({ errors: { 'email or password': ['is invalid'] } }); + await expect(login(user)).rejects.toThrow(error); + }); + + test('should throw an error if the password is wrong', async () => { + // Given + const user = { + email: 'realworld@me', + password: '1234', + }; + + const hashedPassword = await bcrypt.hash('4321', 10); + + const mockedResponse = { + id: 123, + username: 'Gerome', + email: 'realworld@me', + password: hashedPassword, + bio: null, + image: null, + token: '', + demo: false, + }; + + // When + prismaMock.user.findUnique.mockResolvedValue(mockedResponse); + + // Then + const error = String({ errors: { 'email or password': ['is invalid'] } }); + await expect(login(user)).rejects.toThrow(error); + }); + }); + + describe('getCurrentUser', () => { + test('should return a token', async () => { + // Given + const username = 'RealWorld'; + + const mockedResponse = { + id: 123, + username: 'RealWorld', + email: 'realworld@me', + password: '1234', + bio: null, + image: null, + token: '', + demo: false, + }; + + // When + prismaMock.user.findUnique.mockResolvedValue(mockedResponse); + + // Then + await expect(getCurrentUser(username)).resolves.toHaveProperty('token'); + }); + }); + + describe('updateUser', () => { + test('should return a token', async () => { + // Given + const user = { + id: 123, + username: 'RealWorld', + email: 'realworld@me', + password: '1234', + }; + + const mockedResponse = { + id: 123, + username: 'RealWorld', + email: 'realworld@me', + password: '1234', + bio: null, + image: null, + token: '', + demo: false, + }; + + // When + prismaMock.user.update.mockResolvedValue(mockedResponse); + + // Then + await expect(updateUser(user, user.username)).resolves.toHaveProperty('token'); + }); + }); +}); diff --git a/apps/api/src/tests/services/profile.service.test.ts b/apps/api/src/tests/services/profile.service.test.ts new file mode 100644 index 000000000..8702399f6 --- /dev/null +++ b/apps/api/src/tests/services/profile.service.test.ts @@ -0,0 +1,147 @@ +import prismaMock from '../prisma-mock'; +import { followUser, getProfile, unfollowUser } from '../../app/services/profile.service'; + +describe('ProfileService', () => { + describe('getProfile', () => { + test('should return a following property', async () => { + // Given + const username = 'RealWorld'; + const usernameAuth = 'Gerome'; + + const mockedResponse = { + id: 123, + username: 'RealWorld', + email: 'realworld@me', + password: '1234', + bio: null, + image: null, + token: '', + demo: false, + followedBy: [], + }; + + // When + // @ts-ignore + prismaMock.user.findUnique.mockResolvedValue(mockedResponse); + + // Then + await expect(getProfile(username, usernameAuth)).resolves.toHaveProperty('following'); + }); + + test('should throw an error if no user is found', async () => { + // Given + const username = 'RealWorld'; + const usernameAuth = 'Gerome'; + + // When + prismaMock.user.findUnique.mockResolvedValue(null); + + // Then + await expect(getProfile(username, usernameAuth)).rejects.toThrowError(); + }); + }); + + describe('followUser', () => { + test('shoud return a following property', async () => { + // Given + const usernamePayload = 'AnotherUser'; + const usernameAuth = 'RealWorld'; + + const mockedAuthUser = { + id: 123, + username: 'RealWorld', + email: 'realworld@me', + password: '1234', + bio: null, + image: null, + token: '', + demo: false, + followedBy: [], + }; + + const mockedResponse = { + id: 123, + username: 'AnotherUser', + email: 'another@me', + password: '1234', + bio: null, + image: null, + token: '', + demo: false, + followedBy: [], + }; + + // When + prismaMock.user.findUnique.mockResolvedValue(mockedAuthUser); + prismaMock.user.update.mockResolvedValue(mockedResponse); + + // Then + await expect(followUser(usernamePayload, usernameAuth)).resolves.toHaveProperty('following'); + }); + + test('shoud throw an error if no user is found', async () => { + // Given + const usernamePayload = 'AnotherUser'; + const usernameAuth = 'RealWorld'; + + // When + prismaMock.user.findUnique.mockResolvedValue(null); + + // Then + await expect(followUser(usernamePayload, usernameAuth)).rejects.toThrowError(); + }); + }); + + describe('unfollowUser', () => { + test('shoud return a following property', async () => { + // Given + const usernamePayload = 'AnotherUser'; + const usernameAuth = 'RealWorld'; + + const mockedAuthUser = { + id: 123, + username: 'RealWorld', + email: 'realworld@me', + password: '1234', + bio: null, + image: null, + token: '', + demo: false, + followedBy: [], + }; + + const mockedResponse = { + id: 123, + username: 'AnotherUser', + email: 'another@me', + password: '1234', + bio: null, + image: null, + token: '', + demo: false, + followedBy: [], + }; + + // When + prismaMock.user.findUnique.mockResolvedValue(mockedAuthUser); + prismaMock.user.update.mockResolvedValue(mockedResponse); + + // Then + await expect(unfollowUser(usernamePayload, usernameAuth)).resolves.toHaveProperty( + 'following', + ); + }); + + test('shoud throw an error if no user is found', async () => { + // Given + const usernamePayload = 'AnotherUser'; + const usernameAuth = 'RealWorld'; + + // When + prismaMock.user.findUnique.mockResolvedValue(null); + + // Then + await expect(unfollowUser(usernamePayload, usernameAuth)).rejects.toThrowError(); + }); + }); +}); diff --git a/apps/api/src/tests/services/tag.service.test.ts b/apps/api/src/tests/services/tag.service.test.ts new file mode 100644 index 000000000..97bc54d7c --- /dev/null +++ b/apps/api/src/tests/services/tag.service.test.ts @@ -0,0 +1,6 @@ +describe('TagService', () => { + describe('getTags', () => { + // TODO : prismaMock.tag.groupBy.mockResolvedValue(mockedResponse) doesn't work + test.todo('should return a list of strings'); + }); +}); diff --git a/apps/api/src/tests/utils/profile.utils.test.ts b/apps/api/src/tests/utils/profile.utils.test.ts new file mode 100644 index 000000000..d9057544e --- /dev/null +++ b/apps/api/src/tests/utils/profile.utils.test.ts @@ -0,0 +1,79 @@ +import profileMapper from '../../app/utils/profile.utils'; + +describe('ProfileUtils', () => { + describe('profileMapper', () => { + test('should return a profile', () => { + // Given + const user = { + username: 'RealWorld', + bio: 'My happy life', + image: null, + followedBy: [], + }; + const username = 'RealWorld'; + + // When + const expected = { + username: 'RealWorld', + bio: 'My happy life', + image: null, + following: false, + }; + + // Then + expect(profileMapper(user, username)).toEqual(expected); + }); + + test('should return a profile followed by the user', () => { + // Given + const user = { + username: 'RealWorld', + bio: 'My happy life', + image: null, + followedBy: [ + { + username: 'RealWorld', + }, + ], + }; + const username = 'RealWorld'; + + // When + const expected = { + username: 'RealWorld', + bio: 'My happy life', + image: null, + following: true, + }; + + // Then + expect(profileMapper(user, username)).toEqual(expected); + }); + + test('should return a profile not followed by the user', () => { + // Given + const user = { + username: 'RealWorld', + bio: 'My happy life', + image: null, + followedBy: [ + { + username: 'NotRealWorld', + }, + ], + }; + const username = 'RealWorld'; + + // When + const expected = { + username: 'RealWorld', + bio: 'My happy life', + image: null, + following: false, + }; + + // Then + expect(profileMapper(user, username)).toEqual(expected); + }); + }); +}); diff --git a/apps/api/tsconfig.app.json b/apps/api/tsconfig.app.json new file mode 100644 index 000000000..efa8414ed --- /dev/null +++ b/apps/api/tsconfig.app.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["node", "express"] + }, + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 000000000..63dbe35fb --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/apps/api/tsconfig.spec.json b/apps/api/tsconfig.spec.json new file mode 100644 index 000000000..f6d8ffcc9 --- /dev/null +++ b/apps/api/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/apps/demo/project.json b/apps/demo/project.json index 56d1ee5db..21287cefe 100644 --- a/apps/demo/project.json +++ b/apps/demo/project.json @@ -38,7 +38,8 @@ "executor": "@nrwl/vite:dev-server", "defaultConfiguration": "development", "options": { - "buildTarget": "demo:build" + "buildTarget": "demo:build", + "proxyConfig": "apps/demo/proxy.conf.json" }, "configurations": { "development": { diff --git a/apps/demo/proxy.conf.json b/apps/demo/proxy.conf.json new file mode 100644 index 000000000..62a1e7b76 --- /dev/null +++ b/apps/demo/proxy.conf.json @@ -0,0 +1,6 @@ +{ + "/api": { + "target": "http://localhost:3333", + "secure": false + } +} diff --git a/apps/demo/src/app/app.spec.tsx b/apps/demo/src/app/app.spec.tsx index 4ef61a292..4119bff31 100644 --- a/apps/demo/src/app/app.spec.tsx +++ b/apps/demo/src/app/app.spec.tsx @@ -9,7 +9,7 @@ describe('App', () => { const { baseElement } = render( - + , ); expect(baseElement).toBeTruthy(); @@ -19,7 +19,7 @@ describe('App', () => { const { getByText } = render( - + , ); expect(getByText(/Welcome demo/gi)).toBeTruthy(); diff --git a/apps/demo/src/app/app.tsx b/apps/demo/src/app/app.tsx index 5b4d16d4a..fd1017e30 100644 --- a/apps/demo/src/app/app.tsx +++ b/apps/demo/src/app/app.tsx @@ -33,8 +33,7 @@ export function App() { path="/" element={
- This is the generated root route.{' '} - Click here for page 2. + This is the generated root route. Click here for page 2.
} /> diff --git a/apps/demo/src/app/nx-welcome.tsx b/apps/demo/src/app/nx-welcome.tsx index 3c1a440fe..a1322bd24 100644 --- a/apps/demo/src/app/nx-welcome.tsx +++ b/apps/demo/src/app/nx-welcome.tsx @@ -665,11 +665,7 @@ export function NxWelcome({ title }: { title: string }) {
- + Enable faster CI & better DX
-

- You can activate distributed tasks executions and caching by - running: -

+

You can activate distributed tasks executions and caching by running:

nx connect-to-nx-cloud
- + {' '} What is Nx Cloud?{' '} diff --git a/apps/demo/src/main.tsx b/apps/demo/src/main.tsx index 563fabfe0..eaffab661 100644 --- a/apps/demo/src/main.tsx +++ b/apps/demo/src/main.tsx @@ -4,13 +4,11 @@ import { BrowserRouter } from 'react-router-dom'; import App from './app/app'; -const root = ReactDOM.createRoot( - document.getElementById('root') as HTMLElement -); +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( - + , ); diff --git a/apps/documentation/docs/community/resources.md b/apps/documentation/docs/community/resources.md index 26c74f1ef..4d84766df 100644 --- a/apps/documentation/docs/community/resources.md +++ b/apps/documentation/docs/community/resources.md @@ -2,7 +2,6 @@ sidebar_position: 1 --- - # Resources # Community created resources @@ -10,15 +9,14 @@ sidebar_position: 1 Forks, tutorials, workshops, and other resources based on the RealWorld project: - [**RealWorld React/NodeJS E2E Tests**](https://github.com/anishkny/realworld-e2e-test) by [**Anish Karandikar**](https://github.com/anishkny) - - A repo showing how to wire [React](https://github.com/gothinkster/react-redux-realworld-example-app) frontend with [NodeJS](https://github.com/gothinkster/node-express-realworld-example-app) backend for a RealWorld fullstack - - Includes E2E integration tests that use [Chrome Puppeteer](https://github.com/GoogleChrome/puppeteer) and [Mocha](https://mochajs.org) and work with CI systems like [Travis CI](https://travis-ci.org/anishkny/realworld-e2e-test) and [CircleCI](https://circleci.com/gh/anishkny/realworld-e2e-test) - - Also demonstrates usage of [Greenkeeper](https://greenkeeper.io) for automatic dependency updates and [Snyk](https://snyk.io/) for vulnerability monitoring + - A repo showing how to wire [React](https://github.com/gothinkster/react-redux-realworld-example-app) frontend with [NodeJS](https://github.com/gothinkster/node-express-realworld-example-app) backend for a RealWorld fullstack + - Includes E2E integration tests that use [Chrome Puppeteer](https://github.com/GoogleChrome/puppeteer) and [Mocha](https://mochajs.org) and work with CI systems like [Travis CI](https://travis-ci.org/anishkny/realworld-e2e-test) and [CircleCI](https://circleci.com/gh/anishkny/realworld-e2e-test) + - Also demonstrates usage of [Greenkeeper](https://greenkeeper.io) for automatic dependency updates and [Snyk](https://snyk.io/) for vulnerability monitoring - Performance comparisons: - - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2020](https://medium.com/dailyjs/a-realworld-comparison-of-front-end-frameworks-2020-4e50655fe4c1) - - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2019](https://medium.freecodecamp.org/a-realworld-comparison-of-front-end-frameworks-with-benchmarks-2019-update-4be0d3c78075) - - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2018](https://medium.freecodecamp.org/a-real-world-comparison-of-front-end-frameworks-with-benchmarks-2018-update-e5760fb4a962) - - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2017](https://medium.freecodecamp.org/a-real-world-comparison-of-front-end-frameworks-with-benchmarks-e1cb62fd526c) - + - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2020](https://medium.com/dailyjs/a-realworld-comparison-of-front-end-frameworks-2020-4e50655fe4c1) + - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2019](https://medium.freecodecamp.org/a-realworld-comparison-of-front-end-frameworks-with-benchmarks-2019-update-4be0d3c78075) + - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2018](https://medium.freecodecamp.org/a-real-world-comparison-of-front-end-frameworks-with-benchmarks-2018-update-e5760fb4a962) + - [A Real-World Comparison of Front-End Frameworks with Benchmarks 2017](https://medium.freecodecamp.org/a-real-world-comparison-of-front-end-frameworks-with-benchmarks-e1cb62fd526c) :::tip Hello fellow writer, get in touch with us in [**GitHub Discussions**](https://github.com/gothinkster/realworld/discussions/categories/community) so we can add your RealWorld related content here. diff --git a/apps/documentation/docs/community/special-thanks.md b/apps/documentation/docs/community/special-thanks.md index e491f7ca0..8b976e87f 100644 --- a/apps/documentation/docs/community/special-thanks.md +++ b/apps/documentation/docs/community/special-thanks.md @@ -6,7 +6,7 @@ sidebar_position: 3 RealWorld would not be possible without the open source community's assistance in reviewing codebases, developing new app implementations, and a variety of other duties that help the project progress. We'd like to thank the following OSS leaders for their contributions to RealWorld: -- **Dan Abramov** (creator of Redux) for helping [spark the initial idea](https://twitter.com/dan_abramov/status/692009757775896577), [getting the Redux community involved](https://github.com/reactjs/redux/issues/1353), as well as graciously taking the time to provide feedback on the Redux codebase +- **Dan Abramov** (creator of Redux) for helping [spark the initial idea](https://twitter.com/dan_abramov/status/692009757775896577), [getting the Redux community involved](https://github.com/reactjs/redux/issues/1353), as well as graciously taking the time to provide feedback on the Redux codebase - **Max Lynch** (creator of Ionic) for taking the time to provide guidance in the early days of this project - **Addy Osmani** (creator of TodoMVC) for helping [spark the initial idea](https://twitter.com/addyosmani/status/762828483433144320) and his amazing work with TodoMVC - **TodoMVC** ([team & contributors](https://github.com/tastejs/todomvc#team)) for their exemplary & successful work; their project & org has been an invaluable analogy for us as we've built out RealWorld diff --git a/apps/documentation/docs/implementation-creation/expectations.md b/apps/documentation/docs/implementation-creation/expectations.md index 266dca579..9b21c2e5b 100644 --- a/apps/documentation/docs/implementation-creation/expectations.md +++ b/apps/documentation/docs/implementation-creation/expectations.md @@ -30,8 +30,8 @@ That said, we do _prefer_ that every repo includes excellent tests that are exem ## Other Expectations -* All the required features (see specs) should be implemented. -* You should publish your implementation on a dedicated GitHub repository with the "Issues" section open. -* You should provide a README that presents an overview of your implementation and explains how to run it locally. -* The library/framework you are using should have at least 300 GitHub stars. -* You should do your best to keep your implementation up to date. +- All the required features (see specs) should be implemented. +- You should publish your implementation on a dedicated GitHub repository with the "Issues" section open. +- You should provide a README that presents an overview of your implementation and explains how to run it locally. +- The library/framework you are using should have at least 300 GitHub stars. +- You should do your best to keep your implementation up to date. diff --git a/apps/documentation/docs/implementation-creation/features.md b/apps/documentation/docs/implementation-creation/features.md index c2f917993..3b272e933 100644 --- a/apps/documentation/docs/implementation-creation/features.md +++ b/apps/documentation/docs/implementation-creation/features.md @@ -7,9 +7,9 @@ sidebar_position: 3 **General functionality:** - Authenticate users via JWT (login/signup pages + logout button on settings page) -- CRU* users (sign up & settings page - no deleting required) +- CRU- users (sign up & settings page - no deleting required) - CRUD Articles -- CR*D Comments on articles (no updating required) +- CR-D Comments on articles (no updating required) - GET and display paginated lists of articles - Favorite articles - Follow other users diff --git a/apps/documentation/docs/implementation-creation/introduction.md b/apps/documentation/docs/implementation-creation/introduction.md index ffe7a6221..6a517fd80 100644 --- a/apps/documentation/docs/implementation-creation/introduction.md +++ b/apps/documentation/docs/implementation-creation/introduction.md @@ -7,15 +7,15 @@ sidebar_position: 1 **Conduit** is a social blogging site (i.e. a Medium.com clone). It uses a custom API for all requests, including authentication. Discover our [live demo](https://demo.realworld.io). - :::tip Check for [Discussions](https://github.com/gothinkster/realworld/discussions/categories/wip-implementations) about works in progress as we don't list duplicate projects. An opportunity to collaborate might awaits you already. ::: Otherwise: + 1. [fork our starter kit](https://github.com/gothinkster/realworld-starter-kit) -2. Read the followings sections: *expectations* and *features* for a better understanding of this project +2. Read the followings sections: _expectations_ and _features_ for a better understanding of this project 3. Read the frontend and/or the backend specs 4. Submit the new implementation on [CodebaseShow](https://codebase.show/projects/realworld) diff --git a/apps/documentation/docs/intro.mdx b/apps/documentation/docs/intro.mdx index 489fcad4f..75851ef17 100644 --- a/apps/documentation/docs/intro.mdx +++ b/apps/documentation/docs/intro.mdx @@ -6,15 +6,17 @@ import useBaseUrl from '@docusaurus/useBaseUrl'; # Introduction - + + + -> See how *the exact same* Medium.com clone (called [Conduit](https://demo.realworld.io)) is built using different [frontends](https://codebase.show/projects/realworld?category=frontend) and [backends](https://codebase.show/projects/realworld?category=backend). Yes, you can mix and match them, because **they all adhere to the same [API spec](specs/backend-specs/introduction)** ๐Ÿ˜ฎ๐Ÿ˜Ž +> See how _the exact same_ Medium.com clone (called [Conduit](https://demo.realworld.io)) is built using different [frontends](https://codebase.show/projects/realworld?category=frontend) and [backends](https://codebase.show/projects/realworld?category=backend). Yes, you can mix and match them, because **they all adhere to the same [API spec](specs/backend-specs/introduction)** ๐Ÿ˜ฎ๐Ÿ˜Ž While most "todo" demos provide an excellent cursory glance at a framework's capabilities, they typically don't convey the knowledge & perspective required to actually build _real_ applications with it. **RealWorld** solves this by allowing you to choose any frontend (React, Angular, & more) and any backend (Node, Django, & more) and see how they power a real world, beautifully designed fullstack app called [**Conduit**](https://demo.realworld.io). -*Read the [full blog post announcing RealWorld on Medium.](https://medium.com/@ericsimons/introducing-realworld-6016654d36b5)* +_Read the [full blog post announcing RealWorld on Medium.](https://medium.com/@ericsimons/introducing-realworld-6016654d36b5)_ Join us on [GitHub Discussions!](https://github.com/gothinkster/realworld/discussions) ๐ŸŽ‰ diff --git a/apps/documentation/docs/specs/backend-specs/endpoints.md b/apps/documentation/docs/specs/backend-specs/endpoints.md index 718750641..d3fd88758 100644 --- a/apps/documentation/docs/specs/backend-specs/endpoints.md +++ b/apps/documentation/docs/specs/backend-specs/endpoints.md @@ -10,12 +10,12 @@ You can read the authentication header from the headers of the request `Authorization: Token jwt.token.here` - ### Authentication: `POST /api/users/login` Example request body: + ```JSON { "user":{ @@ -29,12 +29,12 @@ No authentication required, returns a [User](/specs/backend-specs/api-response-f Required fields: `email`, `password` - ### Registration: `POST /api/users` Example request body: + ```JSON { "user":{ @@ -49,21 +49,18 @@ No authentication required, returns a [User](/specs/backend-specs/api-response-f Required fields: `email`, `username`, `password` - - ### Get Current User `GET /api/user` Authentication required, returns a [User](/specs/backend-specs/api-response-format.md#users-for-authentication) that's the current user - - ### Update User `PUT /api/user` Example request body: + ```JSON { "user":{ @@ -76,19 +73,14 @@ Example request body: Authentication required, returns the [User](/specs/backend-specs/api-response-format.md#users-for-authentication) - Accepted fields: `email`, `username`, `password`, `image`, `bio` - - ### Get Profile `GET /api/profiles/:username` Authentication optional, returns a [Profile](/specs/backend-specs/api-response-format.md#profile) - - ### Follow user `POST /api/profiles/:username/follow` @@ -97,8 +89,6 @@ Authentication required, returns a [Profile](/specs/backend-specs/api-response-f No additional parameters required - - ### Unfollow user `DELETE /api/profiles/:username/follow` @@ -107,8 +97,6 @@ Authentication required, returns a [Profile](/specs/backend-specs/api-response-f No additional parameters required - - ### List Articles `GET /api/articles` @@ -139,8 +127,6 @@ Offset/skip number of articles (default is 0): Authentication optional, will return [multiple articles](/specs/backend-specs/api-response-format.md#multiple-articles), ordered by most recent first - - ### Feed Articles `GET /api/articles/feed` @@ -149,7 +135,6 @@ Can also take `limit` and `offset` query parameters like [List Articles](/specs/ Authentication required, will return [multiple articles](/specs/backend-specs/api-response-format.md#multiple-articles) created by followed users, ordered by most recent first. - ### Get Article `GET /api/articles/:slug` @@ -179,8 +164,6 @@ Required fields: `title`, `description`, `body` Optional fields: `tagList` as an array of Strings - - ### Update Article `PUT /api/articles/:slug` @@ -201,15 +184,12 @@ Optional fields: `title`, `description`, `body` The `slug` also gets updated when the `title` is changed - ### Delete Article `DELETE /api/articles/:slug` Authentication required - - ### Add Comments to an Article `POST /api/articles/:slug/comments` @@ -228,24 +208,18 @@ Authentication required, returns the created [Comment](/specs/backend-specs/api- Required field: `body` - - ### Get Comments from an Article `GET /api/articles/:slug/comments` Authentication optional, returns [multiple comments](/specs/backend-specs/api-response-format.md#multiple-comments) - - ### Delete Comment `DELETE /api/articles/:slug/comments/:id` Authentication required - - ### Favorite Article `POST /api/articles/:slug/favorite` @@ -254,8 +228,6 @@ Authentication required, returns the [Article](/specs/backend-specs/api-response No additional parameters required - - ### Unfavorite Article `DELETE /api/articles/:slug/favorite` @@ -264,8 +236,6 @@ Authentication required, returns the [Article](/specs/backend-specs/api-response No additional parameters required - - ### Get Tags `GET /api/tags` diff --git a/apps/documentation/docs/specs/backend-specs/introduction.md b/apps/documentation/docs/specs/backend-specs/introduction.md index 217c57407..6b75d854f 100644 --- a/apps/documentation/docs/specs/backend-specs/introduction.md +++ b/apps/documentation/docs/specs/backend-specs/introduction.md @@ -4,7 +4,6 @@ sidebar_position: 1 # Introduction - All backend implementations need to adhere to our [API spec](https://github.com/gothinkster/realworld/tree/main/api). For your convenience, we have a [Postman collection](https://github.com/gothinkster/realworld/blob/main/api/Conduit.postman_collection.json) that you can use to test your API endpoints as you build your app. diff --git a/apps/documentation/docs/specs/backend-specs/postman.md b/apps/documentation/docs/specs/backend-specs/postman.md index ee7d16e35..0b9484062 100644 --- a/apps/documentation/docs/specs/backend-specs/postman.md +++ b/apps/documentation/docs/specs/backend-specs/postman.md @@ -6,8 +6,6 @@ sidebar_position: 6 For your convenience, we have a [Postman collection](https://github.com/gothinkster/realworld/blob/master/api/Conduit.postman_collection.json) that you can use to test your API endpoints as you build your app. - ## Running API tests locally To locally run the provided Postman collection against your backend, follow instructions [here](https://github.com/gothinkster/realworld/tree/main/api). - diff --git a/apps/documentation/docs/specs/frontend-specs/api.md b/apps/documentation/docs/specs/frontend-specs/api.md index f01b8f888..1148cc062 100644 --- a/apps/documentation/docs/specs/frontend-specs/api.md +++ b/apps/documentation/docs/specs/frontend-specs/api.md @@ -5,6 +5,7 @@ sidebar_position: 5 # API This project provides you different solutions to test your frontend implementation with an API by: + - [running our official backend implementation locally](#run-the-official-backend-implementation-locally) - [hosting your own API](#host-your-own-api) - [using the API deployed for the official demo](#demo-api) @@ -19,14 +20,14 @@ The Readme will provide you guidances to start the server locally. We encourage you to use this implementation's **main** branch for local tests as the **limited** one includes [limitations](#api-limitations) aimed to protect public-hosted APIs. ::: - ## Host your own API The official backend implementation includes a [**Deploy to Heroku** button](https://github.com/gothinkster/node-express-prisma-v1-official-app#deploy-to-heroku). This button provides you a quick and easy way to deploy the API on Heroku for your frontend implementation. -:::caution +:::caution The official backend implementation repository includes two branches: + - the **main** branch which adheres to the RealWorld backend specs - the **limited** branch which includes limitations for public-hosted APIs @@ -39,7 +40,6 @@ The **limited** branch will be more suitable if you plan to host your implementa This project provides you with a public hosted API to test your frontend implementations. Point your API requests to `https://api.realworld.io/api` and you're good to go! - ### API Usage The usage of the API is free and non-limited by any kind of key. @@ -60,17 +60,17 @@ Most of the requests require a valid token. You can retrieve a token by logging in or by registering. Log in : https://api.realworld.io/api-docs/#/User%20and%20Authentication/Login -Register: https://api.realworld.io/api-docs/#/User%20and%20Authentication/CreateUser +Register: https://api.realworld.io/api-docs/#/User%20and%20Authentication/CreateUser -* Click the `Try it out` button -* populate the body input with the related credentials -* Click `Execute` button -* Copy the token from the response body +- Click the `Try it out` button +- populate the body input with the related credentials +- Click `Execute` button +- Copy the token from the response body #### Register the token -* Click `Authorize` button on top of the Swagger documentation page -* Populate the field with `Token ` +- Click `Authorize` button on top of the Swagger documentation page +- Populate the field with `Token ` ## API Limitations @@ -79,5 +79,6 @@ To provide everyone a **safe** and **healthy** experience, the following limitat ::: The visibility of user content is limited : + - logged out users see only content created by demo accounts - logged in users see only their content and the content created by demo accounts diff --git a/apps/documentation/docs/specs/frontend-specs/routing.md b/apps/documentation/docs/specs/frontend-specs/routing.md index 2b1aa0b24..b8f8ffbeb 100644 --- a/apps/documentation/docs/specs/frontend-specs/routing.md +++ b/apps/documentation/docs/specs/frontend-specs/routing.md @@ -5,19 +5,19 @@ sidebar_position: 3 # Routing Guidelines - Home page (URL: /#/ ) - - List of tags - - List of articles pulled from either Feed, Global, or by Tag - - Pagination for list of articles + - List of tags + - List of articles pulled from either Feed, Global, or by Tag + - Pagination for list of articles - Sign in/Sign up pages (URL: /#/login, /#/register ) - - Uses JWT (store the token in localStorage) - - Authentication can be easily switched to session/cookie based + - Uses JWT (store the token in localStorage) + - Authentication can be easily switched to session/cookie based - Settings page (URL: /#/settings ) - Editor page to create/edit articles (URL: /#/editor, /#/editor/article-slug-here ) - Article page (URL: /#/article/article-slug-here ) - - Delete article button (only shown to article's author) - - Render markdown from server client side - - Comments section at bottom of page - - Delete comment button (only shown to comment's author) + - Delete article button (only shown to article's author) + - Render markdown from server client side + - Comments section at bottom of page + - Delete comment button (only shown to comment's author) - Profile page (URL: /#/profile/:username, /#/profile/:username/favorites ) - - Show basic user info - - List of articles populated from author's created articles or author's favorited articles + - Show basic user info + - List of articles populated from author's created articles or author's favorited articles diff --git a/apps/documentation/docs/specs/frontend-specs/styles.md b/apps/documentation/docs/specs/frontend-specs/styles.md index d00077ed6..1dcf3126c 100644 --- a/apps/documentation/docs/specs/frontend-specs/styles.md +++ b/apps/documentation/docs/specs/frontend-specs/styles.md @@ -4,13 +4,12 @@ sidebar_position: 2 # Styles -We created a custom Bootstrap 4 style & templates to ensure all frontends had consistent UI functionality. Our [starter kit](https://github.com/gothinkster/realworld-starter-kit) includes all the [templates & info required to get up and running](https://github.com/gothinkster/realworld-starter-kit/blob/master/FRONTEND_INSTRUCTIONS.md). - +We created a custom Bootstrap 4 style & templates to ensure all frontends had consistent UI functionality. Our [starter kit](https://github.com/gothinkster/realworld-starter-kit) includes all the [templates & info required to get up and running](https://github.com/gothinkster/realworld-starter-kit/blob/master/FRONTEND_INSTRUCTIONS.md). Instead of having the Bootstrap theme included locally, we recommend loading the precompiled theme from our CDN (our [header template](/specs/frontend-specs/templates.md#header) does this by default): ```html - + ``` Alternatively, if you want to make modifications to the theme, check out the [theme's repo](https://github.com/gothinkster/conduit-bootstrap-template). diff --git a/apps/documentation/docs/specs/frontend-specs/templates.md b/apps/documentation/docs/specs/frontend-specs/templates.md index 317110261..f97cb76f0 100644 --- a/apps/documentation/docs/specs/frontend-specs/templates.md +++ b/apps/documentation/docs/specs/frontend-specs/templates.md @@ -11,64 +11,63 @@ sidebar_position: 1 ```html - - + + Conduit - - + + - - - - - + + ``` ### Footer ```html -
-
- conduit - - An interactive learning project from Thinkster. Code & design licensed under MIT. - -
+
+ conduit + + An interactive learning project from Thinkster. Code & + design licensed under MIT. + +
- - - ``` ## Pages @@ -76,427 +75,389 @@ sidebar_position: 1 ### Home ```html -
- -