diff --git a/starters/express-apollo-prisma/.editorconfig b/starters/express-apollo-prisma/.editorconfig index adbfc82d0..3628054c6 100644 --- a/starters/express-apollo-prisma/.editorconfig +++ b/starters/express-apollo-prisma/.editorconfig @@ -14,3 +14,6 @@ indent_size = 2 [*.md] max_line_length = off trim_trailing_whitespace = false + +[{package.json, eslintrc.json}] +indent_style = space diff --git a/starters/express-apollo-prisma/.env.example b/starters/express-apollo-prisma/.env.example index c43efbab2..80277c1b7 100644 --- a/starters/express-apollo-prisma/.env.example +++ b/starters/express-apollo-prisma/.env.example @@ -1,24 +1,22 @@ +# Application PORT=4001 -DATABASE_URL="mysql://root:root@localhost:3307/testdb" -REDIS_URL="redis://:redi$pass@localhost:6380" +CORS_ALLOWED_ORIGINS= # optional. Default value: '*'. Sample value: CORS_ALLOWED_ORIGINS=https://starter.dev,http://127.0.0.1 + +# MySQL +DB_USER=demo +DB_PASSWORD=demopass +DB_DATABASE=demodb +DB_PORT=3306 +DB_URL="mysql://root:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_DATABASE}" + +# Redis +REDIS_PASSWORD='redi$pass' # important! Quotes are required when using special characters +REDIS_HOST=localhost +REDIS_PORT=6379 REDIS_CACHE_TTL_SECONDS=1800 -# For docker-compose.yaml -# mysql -DOCKER_MYSQLDB_ROOT_PASSWORD=root -DOCKER_MYSQLDB_DATABASE=testdb -DOCKER_MYSQLDB_PORT_LOCAL=3307 -DOCKER_MYSQLDB_PORT_CONTAINER=3306 -# redis -DOCKER_REDIS_PASSWORD='redi$pass' # important! single quotes required -DOCKER_REDIS_HOST=localhost -DOCKER_REDIS_PORT_LOCAL=6380 -DOCKER_REDIS_PORT_CONTAINER=6379 -# rabbitmq + +# RabbitMQ AMQP_URL=amqp://localhost:5673 -AMQP_QUEUE_JOB="jobs" -DOCKER_RABBIT_MQ_PORT_CLIENT_API_LOCAL=5673 -DOCKER_RABBIT_MQ_PORT_CLIENT_API_CONTAINER=5672 -DOCKER_RABBIT_MQ_PORT_ADMIN_API_LOCAL=15673 -DOCKER_RABBIT_MQ_PORT_ADMIN_API_CONTAINER=15672 -# cors -CORS_ALLOWED_ORIGINS= # optional. Default value: '*'. Sample value: CORS_ALLOWED_ORIGINS=https://starter.dev,http://127.0.0.1 +AMQP_QUEUE_JOB=jobs +RABBIT_MQ_PORT_CLIENT=5673 +RABBIT_MQ_PORT_ADMIN=15673 diff --git a/starters/express-apollo-prisma/README.md b/starters/express-apollo-prisma/README.md index fee204e89..99782c8e7 100644 --- a/starters/express-apollo-prisma/README.md +++ b/starters/express-apollo-prisma/README.md @@ -28,10 +28,12 @@ This starter kit features Express, Apollo Server and Prisma. - [Express](#express) - [Apollo Server](#apollo-server) - [ORM](#orm) + - [How to use kit with MongoDB](#how-to-use-kit-with-mongodb) - [Queueing](#queueing) - [Caching](#caching) - [Testing](#testing) - [Deployment](#deployment) + - [Build a Production Scale app with Express-Apollo-Prisma kit](#build-a-production-scale-app-with-express-apollo-prisma-kit) ## Overview @@ -106,27 +108,20 @@ git clone https://github.com/thisdot/starter.dev.git ## Environment Variables - `PORT` - The port exposed to connect with the application. -- `DATABASE_URL` - The database connection URL. -- `REDIS_URL` - The Redis connection URL. +- `DB_URL` - Connector for Prisma to run the migrations (our example will build the correct URL from the other variables) +- `DB_USER` - User to use on the MySQL server +- `DB_PASS` - Password for both the user and root user +- `DB_DATABASE` - Name of the database in MySQL +- `DB_PORT` - Which port to run the MySQL server on +- `REDIS_USER` - User to use on the Redis server (can be left blank) +- `REDIS_PASSWORD` - Password to authenticate Redis +- `REDIS_HOST` - Host Redis is running on +- `REDIS_PORT` - Which port to run the Redis server on - `REDIS_CACHE_TTL_SECONDS` - The remaining time(seconds) to live of a key that has a timeout. -- `DOCKER_MYSQLDB_ROOT_PASSWORD` - The MySQL root user password. -- `DOCKER_MYSQLDB_DATABASE` - The MySQL database name. -- `DOCKER_MYSQLDB_PORT_LOCAL` - The MySQL Docker host's TCP port. -- `DOCKER_MYSQLDB_PORT_CONTAINER` - The MySQL Docker container's TCP port. -- `DOCKER_REDIS_PASSWORD` - The Redis password. -- `DOCKER_REDIS_HOST` - The Redis host IP. -- `DOCKER_REDIS_PORT_LOCAL` - The Redis Docker host's TCP port. -- `DOCKER_REDIS_PORT_CONTAINER` - The Redis Docker container's TCP port. - `AMQP_URL` - The RabbitMQ connection URL. - `AMQP_QUEUE_JOB` - The RabbitMQ channel queue name. - `CORS_ALLOWED_ORIGINS` - (Optional) Comma separated Allowed Origins. Default value: '\*'. (See [CORS Cross-Origin Resource Sharing](#cors-cross-origin-resource-sharing)) -We map TCP port `DOCKER_MYSQLDB_PORT_CONTAINER` in the container to port `DOCKER_MYSQLDB_PORT_LOCAL` on the Docker host. -We also map TCP port `DOCKER_REDIS_PORT_LOCAL` in the container to port `DOCKER_REDIS_PORT_CONTAINER` on the Docker host. - -To ensure proper connection to our resources -For more information on Docker container networks: https://docs.docker.com/config/containers/container-networking/ - ### Database and Redis To start up your API in development mode with an active database connection, please follow the following steps: @@ -239,7 +234,7 @@ There is a `technologies` query in `technology.typedefs.ts` file. The query uses type Query { ... "Returns a list of Technologies" - technologies(limit: Int = 5, offset: Int = 0): TechnologyCollectionPage! + technologies(limit: Int = 5, offset: Int = 0): TechnologyCollection! } ... ``` @@ -298,7 +293,7 @@ The data sources are located in `src/graphql/data-sources`. The data sources of ### ORM -The kit uses Prisma as a TypeScript ORM for proper data fetch and mutation from the source. It is configured with the following environment variable: `DATABASE_URL="mysql://root:root@localhost:3307/testdb"`. +The kit uses Prisma as a TypeScript ORM for proper data fetch and mutation from the source. It is configured with the `DB_URL` environment variable. We use Prisma for the following: @@ -307,6 +302,55 @@ We use Prisma for the following: Learn more about [Prisma](https://www.prisma.io/docs/concepts/overview/prisma-in-your-stack/is-prisma-an-orm). +#### How to use kit with MongoDB + +1. Set up a MongoDB account via the following tutorial: [Create MongoDB Account](https://www.mongodb.com/docs/guides/atlas/account/). +2. Set up MongoDB cluster. [Create Cluster](https://www.mongodb.com/docs/guides/atlas/cluster/) +3. Set up MongoDB User. [Create User](https://www.mongodb.com/docs/guides/atlas/db-user/) +4. Get the [MongoDB Connection URI](https://www.mongodb.com/docs/guides/atlas/connection-string/). +5. Replace the ``, `` and `` to your `DB_URL` in your `.env` with the username, password and database you created. + ``` + DB_URL="mongodb+srv://:@app.random.mongodb.net/database?retryWrites=true&w=majority" + ``` +6. Replace the `DB_USER`, `DB_PASSWORD` and `DB_DATABASE` in your `.env` with the username, password and database you created. +7. Edit the datasource in `prisma/schema.prisma`. + + ```prisma + datasource db { + provider = "mongodb" + url = env("DATABASE_URL") + } + ``` + +8. Edit the `PRISMA_CONFIG` in `src/config.ts` to: + + ```ts + export const PRISMA_CONFIG: Prisma.PrismaClientOptions = { + datasources: { + db: { + url: `mongodb+srv://${DB_USER}:${DB_PASSWORD}@app.random.mongodb.net/${DB_DATABASE}?retryWrites=true&w=majority`, + }, + }, + }; + ``` + +9. Finally update your `src/healthcheck/datasource-healthcheck.ts` to check the mongodb connection. + + ```ts + import { PrismaClient } from '@prisma/client'; + + export const getDataSourceHealth = async (prismaClient?: PrismaClient) => { + try { + const prismaClientPingResult = await prismaClient?.$runCommandRaw({ + ping: 1, + }); + return prismaClientPingResult?.ok == 1; + } catch { + return false; + } + }; + ``` + ### Queueing The kit provides an implementation of queueing using RabbitMQ, an open-source message broker that allows multiple applications or services to communicate with each other through queues. It's a powerful tool for handling tasks asynchronously and distributing workloads across multiple machines. @@ -364,3 +408,7 @@ npm run infrastructure:up 3. Deploy your application to your chosen provider or service using their deployment tools or services. You can use the start script to start your application in production mode. You may also need to configure any necessary proxy or routing rules to direct incoming traffic to your application. 4. Monitor your application for any issues or errors and adjust your deployment as needed. This may involve configuring load balancers, auto-scaling, or other performance optimization features, depending on your chosen provider or service. + +## Build a Production Scale app with Express-Apollo-Prisma kit + +Learn how to build a Production Scale app with Express-Apollo-Prisma kit in this [article](https://www.thisdot.co/blog/building-a-production-scale-app-with-the-express-apollo-prisma-starter-kit). We will cover what's included in the kit, how to set it up, and how to use the provided tools to create a scalable web application. We will also discuss how to extend the starter kit to add features like authentication. Finally, we will look at how to use the provided tools to ensure that your application is well-maintained and efficient diff --git a/starters/express-apollo-prisma/docker-compose.yaml b/starters/express-apollo-prisma/docker-compose.yaml index fbadf971f..05f4dc1a6 100644 --- a/starters/express-apollo-prisma/docker-compose.yaml +++ b/starters/express-apollo-prisma/docker-compose.yaml @@ -2,45 +2,36 @@ version: '3.8' services: mysql: - container_name: mysql image: mysql:8.0 restart: unless-stopped environment: - - MYSQL_ROOT_PASSWORD=$DOCKER_MYSQLDB_ROOT_PASSWORD - - MYSQL_DATABASE=$DOCKER_MYSQLDB_DATABASE + - MYSQL_PASSWORD=${DB_PASSWORD:-demopass} + - MYSQL_USER=${DB_USER:-demo} + - MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-demopass} + - MYSQL_DATABASE=${DB_DATABASE:-demodb} ports: - - $DOCKER_MYSQLDB_PORT_LOCAL:$DOCKER_MYSQLDB_PORT_CONTAINER + - '${DB_PORT:-3306}:3306' volumes: - db:/var/lib/mysql + redis: - container_name: redis - image: 'redis:7.0-alpine' + image: redis:7.0-alpine restart: unless-stopped - env_file: ./.env command: - --loglevel warning - - --requirepass "$DOCKER_REDIS_PASSWORD" + - --requirepass ${REDIS_PASSWORD:-redi$$pass} ports: - - $DOCKER_REDIS_PORT_LOCAL:$DOCKER_REDIS_PORT_CONTAINER - environment: - - REDIS_REPLICATION_MODE=master - depends_on: - - mysql + - '${REDIS_PORT:-6379}:6379' + rabbitmq: image: rabbitmq:3.8-management-alpine - container_name: 'rabbitmq' restart: unless-stopped ports: - - $DOCKER_RABBIT_MQ_PORT_CLIENT_API_LOCAL:$DOCKER_RABBIT_MQ_PORT_CLIENT_API_CONTAINER - - $DOCKER_RABBIT_MQ_PORT_ADMIN_API_LOCAL:$DOCKER_RABBIT_MQ_PORT_ADMIN_API_CONTAINER + - '${RABBIT_MQ_PORT_CLIENT:-5672}:5672' + - '${RABBIT_MQ_PORT_ADMIN:-15672}:15672' volumes: - queue:/var/lib/rabbitmq/ - - ./.docker-conf/rabbitmq/log/:/var/log/rabbitmq - networks: - - rabbitmq_nodejs -networks: - rabbitmq_nodejs: - driver: bridge + volumes: db: queue: diff --git a/starters/express-apollo-prisma/jest.config.ts b/starters/express-apollo-prisma/jest.config.ts index 5ad206909..2c4cc7da8 100644 --- a/starters/express-apollo-prisma/jest.config.ts +++ b/starters/express-apollo-prisma/jest.config.ts @@ -31,6 +31,8 @@ export default { '/graphql/schema/generated', 'index.ts', '/mocks', + '/config.ts', + '/queue/worker.ts', '\\.(d.ts)$', ], diff --git a/starters/express-apollo-prisma/package.json b/starters/express-apollo-prisma/package.json index 64e5837e6..f79489164 100644 --- a/starters/express-apollo-prisma/package.json +++ b/starters/express-apollo-prisma/package.json @@ -13,10 +13,9 @@ "scripts": { "codegen": "graphql-codegen && npm run format:codegen", "prepare": "npm run prisma:generate && npm run codegen", - "test": "npm run prepare && jest", + "test": "jest", "start": "npm run prepare && prisma migrate dev && nodemon", - "dev:test": "jest", - "dev:start": "nodemon", + "dev": "nodemon", "build": "npm run prepare && tsc --project tsconfig.build.json", "lint": "eslint \"src/**/*.ts\"", "lint:fix": "npm run lint --fix", @@ -31,7 +30,7 @@ "infrastructure:up": "docker compose up -d", "infrastructure:pause": "docker compose stop", "infrastructure:down": "docker compose down --remove-orphans --volumes", - "queue:run": "ts-node queue/worker.ts" + "queue:run": "ts-node src/queue/worker.ts" }, "author": "", "devDependencies": { diff --git a/starters/express-apollo-prisma/prisma/schema.prisma b/starters/express-apollo-prisma/prisma/schema.prisma index dbb5003cd..6b33bcace 100644 --- a/starters/express-apollo-prisma/prisma/schema.prisma +++ b/starters/express-apollo-prisma/prisma/schema.prisma @@ -7,7 +7,7 @@ generator client { datasource db { provider = "mysql" - url = env("DATABASE_URL") + url = env("DB_URL") } model TechnologyEntity { diff --git a/starters/express-apollo-prisma/prisma/seed.ts b/starters/express-apollo-prisma/prisma/seed.ts index bbc511202..84128d7a4 100644 --- a/starters/express-apollo-prisma/prisma/seed.ts +++ b/starters/express-apollo-prisma/prisma/seed.ts @@ -1,83 +1,82 @@ -import { PrismaClient, Prisma } from '@prisma/client' -import * as dotenv from 'dotenv'; +import {PrismaClient, Prisma} from '@prisma/client' +import {PRISMA_CONFIG} from "../src/config"; -dotenv.config(); - -const prisma = new PrismaClient() +const prisma = new PrismaClient(PRISMA_CONFIG) const RECORDS: Prisma.TechnologyEntityCreateInput[] = [ - { - displayName: 'Node.js', - description: 'JavaScript runtime built on Chrome V8 JavaScript engine', - url: 'https://nodejs.org/' - }, - { - displayName: 'TypeScript', - description: 'Strongly typed programming language that builds on JavaScript, giving you better tooling at any scale', - url: 'https://www.typescriptlang.org/' - }, - { - displayName: 'Nodemon', - description: 'Simple monitor utility for use during development of a Node.js app, that will monitor for any changes in your source and automatically restart your server', - url: 'https://nodemon.io/' - }, - { - displayName: 'Express', - description: 'Fast, unopinionated, minimalist web framework for Node.js', - url: 'https://expressjs.com/' - }, - { - displayName: 'Apollo GrapQL', - description: 'The GraphQL developer platform', - url: 'https://www.apollographql.com/' - }, - { - displayName: 'Prisma', - description: 'Next-generation Node.js and TypeScript ORM', - url: 'https://www.prisma.io/' - }, - { - displayName: 'Redis', - description: 'The open source, in-memory data store used by millions of developers as a database, cache, streaming engine, and message broker', - url: 'https://redis.io/' - }, - { - displayName: 'RabbitMQ', - description: 'Open source message broker', - url: 'https://www.rabbitmq.com/' - }, - { - displayName: 'Jest', - description: 'JavaScript Testing Framework with a focus on simplicity', - url: 'https://jestjs.io/' - }, - { - displayName: 'Docker', - description: 'Platform designed to help developers build, share, and run modern applications', - url: 'https://www.docker.com/' - }, - { - displayName: 'Prettier', - description: 'Opinionated Code Formatter', - url: 'https://prettier.io/' - } + { + displayName: 'Node.js', + description: 'JavaScript runtime built on Chrome V8 JavaScript engine', + url: 'https://nodejs.org/' + }, + { + displayName: 'TypeScript', + description: 'Strongly typed programming language that builds on JavaScript, giving you better tooling at any scale', + url: 'https://www.typescriptlang.org/' + }, + { + displayName: 'Nodemon', + description: 'Simple monitor utility for use during development of a Node.js app, that will monitor for any changes in your source and automatically restart your server', + url: 'https://nodemon.io/' + }, + { + displayName: 'Express', + description: 'Fast, unopinionated, minimalist web framework for Node.js', + url: 'https://expressjs.com/' + }, + { + displayName: 'Apollo GrapQL', + description: 'The GraphQL developer platform', + url: 'https://www.apollographql.com/' + }, + { + displayName: 'Prisma', + description: 'Next-generation Node.js and TypeScript ORM', + url: 'https://www.prisma.io/' + }, + { + displayName: 'Redis', + description: 'The open source, in-memory data store used by millions of developers as a database, cache, streaming engine, and message broker', + url: 'https://redis.io/' + }, + { + displayName: 'RabbitMQ', + description: 'Open source message broker', + url: 'https://www.rabbitmq.com/' + }, + { + displayName: 'Jest', + description: 'JavaScript Testing Framework with a focus on simplicity', + url: 'https://jestjs.io/' + }, + { + displayName: 'Docker', + description: 'Platform designed to help developers build, share, and run modern applications', + url: 'https://www.docker.com/' + }, + { + displayName: 'Prettier', + description: 'Opinionated Code Formatter', + url: 'https://prettier.io/' + } ] async function main() { - const promises = RECORDS.map(record => prisma.technologyEntity.upsert({ - where: { displayName: record.displayName }, - update: {}, - create: record, - })); - const results = await Promise.all(promises); - console.log(`Seeding completed successfully:`, results) + const promises = RECORDS.map(record => prisma.technologyEntity.upsert({ + where: {displayName: record.displayName}, + update: {}, + create: record, + })); + const results = await Promise.all(promises); + console.log(`Seeding completed successfully:`, results) } + main() - .then(async () => { - await prisma.$disconnect() - }) - .catch(async (e) => { - console.error(e) - await prisma.$disconnect() - process.exit(1) - }) + .then(async () => { + await prisma.$disconnect() + }) + .catch(async (e) => { + console.error(e) + await prisma.$disconnect() + process.exit(1) + }) diff --git a/starters/express-apollo-prisma/src/cache/cache-api-wrapper-factory.spec.ts b/starters/express-apollo-prisma/src/cache/cache-api-wrapper-factory.spec.ts index 0dd0e1859..d7de9bd63 100644 --- a/starters/express-apollo-prisma/src/cache/cache-api-wrapper-factory.spec.ts +++ b/starters/express-apollo-prisma/src/cache/cache-api-wrapper-factory.spec.ts @@ -4,6 +4,15 @@ import { CacheAPIWrapper } from './cache-api-wrapper'; import { createCacheAPIWrapperAsync } from './cache-api-wrapper-factory'; import { connectRedisClient } from './redis'; import { RedisCacheAPIWrapper } from './redis-cache-api-wrapper'; +import { + REDIS_URL as MOCK_REDIS_URL, + REDIS_CACHE_TTL_SECONDS as MOCK_REDIS_CACHE_TTL_SECONDS, +} from '../config'; + +jest.mock('../config', () => ({ + REDIS_CACHE_TTL_SECONDS: 123, + REDIS_URL: 'MOCK_REDIS_URL', +})); jest.mock('./redis/connect-redis-client', () => ({ connectRedisClient: jest.fn(), @@ -22,115 +31,68 @@ type CacheAPIWrapperType = CacheAPIWrapper< const MOCK_REDIS_CACHE_API_WRAPPER_CONSTRUCTOR = RedisCacheAPIWrapper as unknown as jest.Mock; -const MOCK_REDIS_URL = 'MOCK_REDIS_URL'; -const MOCK_REDIS_CACHE_TTL_SECONDS_NUMBER = 123; -const MOCK_REDIS_CACHE_TTL_SECONDS = String(MOCK_REDIS_CACHE_TTL_SECONDS_NUMBER); -const MOCK_REDIS_CACHE_TTL_SECONDS_INVALID = 'MOCK_REDIS_CACHE_TTL_SECONDS_INVALID'; - const MOCK_CACHE_KEY_PREFIX = 'MOCK_CACHE_KEY_PREFIX'; const MOCK_REDIS_CLIENT = createMockRedisClient(); const MOCK_CACHE_API_WRAPPER = createMockCacheApiWrapper(); describe('.createCacheAPIWrapperAsync', () => { - let originalEnv: NodeJS.ProcessEnv; - beforeAll(() => { - originalEnv = process.env; - }); - afterAll(() => { - process.env = originalEnv; - }); - describe('when called', () => { - describe('and evironment variable REDIS_URL set', () => { - beforeAll(() => { - process.env = { REDIS_URL: MOCK_REDIS_URL }; + describe('and Redis available', () => { + let result: CacheAPIWrapperType | null; + + beforeAll(async () => { + MOCK_CONNECT_REDIS_CLIENT.mockResolvedValue(MOCK_REDIS_CLIENT); + MOCK_REDIS_CACHE_API_WRAPPER_CONSTRUCTOR.mockReturnValue(MOCK_CACHE_API_WRAPPER); + + result = await createCacheAPIWrapperAsync(MOCK_CACHE_KEY_PREFIX); + }); + + afterAll(() => { + MOCK_CONNECT_REDIS_CLIENT.mockReset(); + MOCK_REDIS_CACHE_API_WRAPPER_CONSTRUCTOR.mockReset(); + }); + + it('calls .connectRedisClient once with expected argument', async () => { + expect(MOCK_CONNECT_REDIS_CLIENT).toHaveBeenCalledTimes(1); + expect(MOCK_CONNECT_REDIS_CLIENT).toHaveBeenCalledWith(MOCK_REDIS_URL); }); - describe('and evironment variable REDIS_CACHE_TTL_SECONDS is valid', () => { - describe('and Redis available', () => { - let result: CacheAPIWrapperType | null; - - beforeAll(async () => { - process.env = { ...process.env, REDIS_CACHE_TTL_SECONDS: MOCK_REDIS_CACHE_TTL_SECONDS }; - MOCK_CONNECT_REDIS_CLIENT.mockResolvedValue(MOCK_REDIS_CLIENT); - MOCK_REDIS_CACHE_API_WRAPPER_CONSTRUCTOR.mockReturnValue(MOCK_CACHE_API_WRAPPER); - - result = await createCacheAPIWrapperAsync(MOCK_CACHE_KEY_PREFIX); - }); - - afterAll(() => { - MOCK_CONNECT_REDIS_CLIENT.mockReset(); - MOCK_REDIS_CACHE_API_WRAPPER_CONSTRUCTOR.mockReset(); - }); - - it('calls .connectRedisClient once with expected argument', async () => { - expect(MOCK_CONNECT_REDIS_CLIENT).toHaveBeenCalledTimes(1); - expect(MOCK_CONNECT_REDIS_CLIENT).toHaveBeenCalledWith(MOCK_REDIS_URL); - }); - - it('calls RedisCacheAPIWrapper constructor with expected arguments', async () => { - expect(MOCK_REDIS_CACHE_API_WRAPPER_CONSTRUCTOR).toHaveBeenCalledTimes(1); - expect(MOCK_REDIS_CACHE_API_WRAPPER_CONSTRUCTOR).toHaveBeenCalledWith( - MOCK_REDIS_CLIENT, - MOCK_CACHE_KEY_PREFIX, - MOCK_REDIS_CACHE_TTL_SECONDS_NUMBER - ); - }); - - it('returns expected result', () => { - expect(result).toBe(MOCK_CACHE_API_WRAPPER); - }); - }); - - describe('and Redis unavailable', () => { - let result: CacheAPIWrapperType | null; - - beforeAll(async () => { - process.env = { ...process.env, REDIS_CACHE_TTL_SECONDS: MOCK_REDIS_CACHE_TTL_SECONDS }; - MOCK_CONNECT_REDIS_CLIENT.mockResolvedValue(undefined); - MOCK_REDIS_CACHE_API_WRAPPER_CONSTRUCTOR.mockReturnValue(MOCK_CACHE_API_WRAPPER); - - result = await createCacheAPIWrapperAsync(MOCK_CACHE_KEY_PREFIX); - }); - - afterAll(() => { - MOCK_CONNECT_REDIS_CLIENT.mockReset(); - MOCK_REDIS_CACHE_API_WRAPPER_CONSTRUCTOR.mockReset(); - }); - - it('calls .connectRedisClient once with expected argument', async () => { - expect(MOCK_CONNECT_REDIS_CLIENT).toHaveBeenCalledTimes(1); - expect(MOCK_CONNECT_REDIS_CLIENT).toHaveBeenCalledWith(MOCK_REDIS_URL); - }); - - it('returns expected result', () => { - expect(result).toBe(null); - }); - }); + + it('calls RedisCacheAPIWrapper constructor with expected arguments', async () => { + expect(MOCK_REDIS_CACHE_API_WRAPPER_CONSTRUCTOR).toHaveBeenCalledTimes(1); + expect(MOCK_REDIS_CACHE_API_WRAPPER_CONSTRUCTOR).toHaveBeenCalledWith( + MOCK_REDIS_CLIENT, + MOCK_CACHE_KEY_PREFIX, + MOCK_REDIS_CACHE_TTL_SECONDS + ); }); - describe('and evironment variable REDIS_CACHE_TTL_SECONDS is invalid', () => { - beforeAll(async () => { - process.env = { - ...process.env, - REDIS_CACHE_TTL_SECONDS: MOCK_REDIS_CACHE_TTL_SECONDS_INVALID, - }; - }); - - it('throws expected error', async () => { - await expect(createCacheAPIWrapperAsync).rejects.toThrowError( - '[Invalid environment] Invalid variable: REDIS_CACHE_TTL_SECONDS. Should be a number' - ); - }); + + it('returns expected result', () => { + expect(result).toBe(MOCK_CACHE_API_WRAPPER); }); }); - describe('and evironment variable REDIS_URL not set', () => { - beforeAll(() => { - process.env = {}; + describe('and Redis unavailable', () => { + let result: CacheAPIWrapperType | null; + + beforeAll(async () => { + MOCK_CONNECT_REDIS_CLIENT.mockResolvedValue(undefined); + MOCK_REDIS_CACHE_API_WRAPPER_CONSTRUCTOR.mockReturnValue(MOCK_CACHE_API_WRAPPER); + + result = await createCacheAPIWrapperAsync(MOCK_CACHE_KEY_PREFIX); }); - it('throws expected error', async () => { - await expect(createCacheAPIWrapperAsync).rejects.toThrowError( - '[Invalid environment] Variable not found: REDIS_URL' - ); + + afterAll(() => { + MOCK_CONNECT_REDIS_CLIENT.mockReset(); + MOCK_REDIS_CACHE_API_WRAPPER_CONSTRUCTOR.mockReset(); + }); + + it('calls .connectRedisClient once with expected argument', async () => { + expect(MOCK_CONNECT_REDIS_CLIENT).toHaveBeenCalledTimes(1); + expect(MOCK_CONNECT_REDIS_CLIENT).toHaveBeenCalledWith(MOCK_REDIS_URL); + }); + + it('returns expected result', () => { + expect(result).toBe(null); }); }); }); diff --git a/starters/express-apollo-prisma/src/cache/cache-api-wrapper-factory.ts b/starters/express-apollo-prisma/src/cache/cache-api-wrapper-factory.ts index c134483f9..011e3c404 100644 --- a/starters/express-apollo-prisma/src/cache/cache-api-wrapper-factory.ts +++ b/starters/express-apollo-prisma/src/cache/cache-api-wrapper-factory.ts @@ -1,6 +1,7 @@ import { CacheAPIWrapper } from './cache-api-wrapper'; import { RedisCacheAPIWrapper } from './redis-cache-api-wrapper'; import { connectRedisClient } from './redis/connect-redis-client'; +import { REDIS_CACHE_TTL_SECONDS, REDIS_URL } from '../config'; export const createCacheAPIWrapperAsync = async < TEntity extends { [k: string]: number | string | null }, @@ -8,18 +9,6 @@ export const createCacheAPIWrapperAsync = async < >( cacheKeyPrefix: string ): Promise | null> => { - const REDIS_URL = process.env.REDIS_URL; - if (!REDIS_URL) { - throw new Error('[Invalid environment] Variable not found: REDIS_URL'); - } - - const REDIS_CACHE_TTL_SECONDS_STRING = process.env.REDIS_CACHE_TTL_SECONDS; - const REDIS_CACHE_TTL_SECONDS = Number(REDIS_CACHE_TTL_SECONDS_STRING); - if (REDIS_CACHE_TTL_SECONDS_STRING && isNaN(REDIS_CACHE_TTL_SECONDS)) { - throw new Error( - '[Invalid environment] Invalid variable: REDIS_CACHE_TTL_SECONDS. Should be a number' - ); - } const redisClient = await connectRedisClient(REDIS_URL); return redisClient ? new RedisCacheAPIWrapper(redisClient, cacheKeyPrefix, REDIS_CACHE_TTL_SECONDS) diff --git a/starters/express-apollo-prisma/src/config.ts b/starters/express-apollo-prisma/src/config.ts new file mode 100644 index 000000000..a34ff5088 --- /dev/null +++ b/starters/express-apollo-prisma/src/config.ts @@ -0,0 +1,45 @@ +import * as dotenv from 'dotenv'; +import { Prisma } from '@prisma/client'; +dotenv.config(); + +// Application port to listen on +export const PORT = process.env.PORT ? Number(process.env.PORT) : 4001; + +export let CORS_ALLOWED_ORIGINS: string[] | undefined; +if (process.env.CORS_ALLOWED_ORIGINS && process.env.CORS_ALLOWED_ORIGINS !== '*') { + CORS_ALLOWED_ORIGINS = process.env.CORS_ALLOWED_ORIGINS.split(','); +} + +// Build Redis URL based on the separate values +export const REDIS_URL = [ + 'redis://', + process.env.REDIS_USER || '', + ':', + process.env.REDIS_PASSWORD || 'redi$pass', + '@', + process.env.REDIS_HOST || 'localhost', + ':', + process.env.REDIS_PORT || '6379', +].join(''); +export const REDIS_CACHE_TTL_SECONDS = process.env.REDIS_CACHE_TTL_SECONDS + ? Number(process.env.REDIS_CACHE_TTL_SECONDS) + : 3600; + +// Database configuration +export const DB_PORT = process.env.DB_PORT ? Number(process.env.DB_PORT) : 3306; +export const DB_DATABASE = process.env.DB_DATABASE || 'demodb'; +export const DB_PASSWORD = process.env.DB_PASSWORD || 'demopass'; +export const DB_USER = process.env.DB_USER || 'demo'; + +export const PRISMA_CONFIG: Prisma.PrismaClientOptions = { + datasources: { + db: { + url: `mysql://${DB_USER}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_DATABASE}`, + }, + }, +}; + +// Queue +export const RABBIT_MQ_PORT_CLIENT = process.env.RABBIT_MQ_PORT_CLIENT || '5673'; +export const AMQP_URL = process.env.AMQP_URL || 'amqp://localhost:' + RABBIT_MQ_PORT_CLIENT; +export const AMQP_QUEUE_JOB = process.env.AMQP_QUEUE_JOB || 'jobs'; diff --git a/starters/express-apollo-prisma/src/graphql/data-sources/technology-data-source.spec.ts b/starters/express-apollo-prisma/src/graphql/data-sources/technology-data-source.spec.ts index deee7bb98..34396bc28 100644 --- a/starters/express-apollo-prisma/src/graphql/data-sources/technology-data-source.spec.ts +++ b/starters/express-apollo-prisma/src/graphql/data-sources/technology-data-source.spec.ts @@ -1,4 +1,4 @@ -import { TechnologyDataSource, TechnologyEntityCollectionPage } from './technology-data-source'; +import { TechnologyDataSource, TechnologyEntityCollection } from './technology-data-source'; import { PrismaClient, TechnologyEntity } from '@prisma/client'; import { DeepMockProxy } from 'jest-mock-extended'; import { createMockPrismaClient } from '../../mocks/prisma-client'; @@ -34,6 +34,12 @@ describe('TechnologyDataSource', () => { }); }); + const testMockTechnologyCacheSet = () => + it('calls CacheAPIWrapper.set method once with valid arguments', () => { + expect(MOCK_CACHE_API_WRAPPER.cache).toHaveBeenCalledTimes(1); + expect(MOCK_CACHE_API_WRAPPER.cache).toHaveBeenCalledWith(MOCK_TECHNOLOGY, 'id'); + }); + const GENERAL_CASES: [string, TechnologyDataSource, boolean][] = [ [ 'when instance created with PrismaClient (required) only', @@ -47,12 +53,6 @@ describe('TechnologyDataSource', () => { ], ]; - const testMockTechnologyCacheSet = () => - it('calls CacheAPIWrapper.set method once with valid arguments', () => { - expect(MOCK_CACHE_API_WRAPPER.cache).toHaveBeenCalledTimes(1); - expect(MOCK_CACHE_API_WRAPPER.cache).toHaveBeenCalledWith(MOCK_TECHNOLOGY, 'id'); - }); - describe('#createTechnology', () => { describe.each(GENERAL_CASES)('%s', (_statement, instance, cacheEnabled) => { const EXPECTED_RESULT_CREATE = MOCK_TECHNOLOGY; @@ -308,51 +308,181 @@ describe('TechnologyDataSource', () => { describe('#getTechnologies', () => { describe.each(GENERAL_CASES)('%s', (_statement, instance) => { - const MOCK_LIMIT = 1; - const MOCK_OFFSET = 2; - const MOCK_RESULT_TOTAL_COUNT = 3; - const MOCK_TECHNOLOGIES: TechnologyEntity[] = [MOCK_TECHNOLOGY]; - - const EXPECTED_RESULT: TechnologyEntityCollectionPage = { - totalCount: MOCK_RESULT_TOTAL_COUNT, - items: MOCK_TECHNOLOGIES, - }; - - let result: TechnologyEntityCollectionPage; - - beforeAll(async () => { - MOCK_PRISMA_CLIENT.$transaction.mockResolvedValue([ - MOCK_RESULT_TOTAL_COUNT, - [MOCK_TECHNOLOGY], - ]); - result = await instance.getTechnologies(MOCK_LIMIT, MOCK_OFFSET); - }); + const MOCK_TOTAL_COUNT = 3; + + const MOCK_TECHNOLOGY_NODES = [ + { + node: { + description: 'MOCK_DESCRIPTION_1', + displayName: 'MOCK_DISPLAY_NAME_1', + id: 1, + url: 'MOCK_URL_1', + }, + cursor: 1, + }, + { + node: { + description: 'MOCK_DESCRIPTION_2', + displayName: 'MOCK_DISPLAY_NAME_2', + id: 2, + url: 'MOCK_URL_2', + }, + cursor: 2, + }, + { + node: { + description: 'MOCK_DESCRIPTION_3', + displayName: 'MOCK_DISPLAY_NAME_3', + id: 3, + url: 'MOCK_URL_3', + }, + cursor: 3, + }, + ]; + + const MOCK_TECHNOLOGY_ENTITIES = [ + { + description: 'MOCK_DESCRIPTION_1', + displayName: 'MOCK_DISPLAY_NAME_1', + id: 1, + url: 'MOCK_URL_1', + }, + { + description: 'MOCK_DESCRIPTION_2', + displayName: 'MOCK_DISPLAY_NAME_2', + id: 2, + url: 'MOCK_URL_2', + }, + { + description: 'MOCK_DESCRIPTION_3', + displayName: 'MOCK_DISPLAY_NAME_3', + id: 3, + url: 'MOCK_URL_3', + }, + ]; + + const PAGINATION_CASES: [ + string, + number, + number | undefined, + number, + TechnologyEntity[], + TechnologyEntityCollection + ][] = [ + [ + `and 'after' input is defined and items array is empty`, + 1, + 2, + 0, + [], + { + totalCount: MOCK_TOTAL_COUNT, + edges: [], + pageInfo: { + hasPreviousPage: false, + hasNextPage: false, + startCursor: undefined, + endCursor: undefined, + }, + }, + ], + [ + `and 'after' input is defined and items array is not empty`, + 1, + 1, + 1, + [MOCK_TECHNOLOGY_ENTITIES[2]], + { + totalCount: MOCK_TOTAL_COUNT, + edges: [MOCK_TECHNOLOGY_NODES[2]], + pageInfo: { + hasPreviousPage: true, + hasNextPage: true, + startCursor: 3, + endCursor: 3, + }, + }, + ], + [ + `and 'after' input is undefined and items array is empty`, + 1, + undefined, + 0, + [], + { + totalCount: MOCK_TOTAL_COUNT, + edges: [], + pageInfo: { + hasPreviousPage: false, + hasNextPage: false, + startCursor: undefined, + endCursor: undefined, + }, + }, + ], + [ + `and 'after' input is undefined and items array is not empty`, + 2, + undefined, + 1, + [MOCK_TECHNOLOGY_ENTITIES[0], MOCK_TECHNOLOGY_ENTITIES[1]], + { + totalCount: MOCK_TOTAL_COUNT, + edges: [MOCK_TECHNOLOGY_NODES[0], MOCK_TECHNOLOGY_NODES[1]], + pageInfo: { + hasPreviousPage: true, + hasNextPage: true, + startCursor: 1, + endCursor: 2, + }, + }, + ], + ]; + + describe.each(PAGINATION_CASES)( + '%s', + ( + _inner_statement, + MOCK_FIRST, + MOCK_AFTER, + MOCK_RESOLVED_COUNT, + MOCK_DB_DATA, + EXPECTED_RESULT + ) => { + const MOCK_ORDER_BY = { id: 'asc' }; + + let result: TechnologyEntityCollection; - afterAll(() => { - MOCK_PRISMA_CLIENT.$transaction.mockReset(); - MOCK_PRISMA_CLIENT.technologyEntity.count.mockReset(); - MOCK_PRISMA_CLIENT.technologyEntity.findMany.mockReset(); - }); + beforeAll(async () => { + MOCK_PRISMA_CLIENT.$transaction.mockResolvedValue([MOCK_TOTAL_COUNT, MOCK_DB_DATA]); + MOCK_PRISMA_CLIENT.technologyEntity.count.mockResolvedValue(MOCK_RESOLVED_COUNT); + result = await instance.getTechnologies(MOCK_FIRST, MOCK_AFTER); + }); - it('calls PrismaClient count method once', () => { - expect(MOCK_PRISMA_CLIENT.technologyEntity.count).toHaveBeenCalledTimes(1); - }); + afterAll(() => { + MOCK_PRISMA_CLIENT.$transaction.mockReset(); + MOCK_PRISMA_CLIENT.technologyEntity.count.mockReset(); + MOCK_PRISMA_CLIENT.technologyEntity.findMany.mockReset(); + }); - it('calls PrismaClient findMany method once with expected argument', () => { - expect(MOCK_PRISMA_CLIENT.technologyEntity.findMany).toHaveBeenCalledTimes(1); - expect(MOCK_PRISMA_CLIENT.technologyEntity.findMany).toHaveBeenCalledWith({ - take: MOCK_LIMIT, - skip: MOCK_OFFSET, - }); - }); + it('calls PrismaClient count method once', () => { + expect(MOCK_PRISMA_CLIENT.technologyEntity.count).toHaveBeenCalledTimes(3); + }); - it('calls PrismaClient.$transaction method once', () => { - expect(MOCK_PRISMA_CLIENT.technologyEntity.count).toHaveBeenCalledTimes(1); - }); + it('calls PrismaClient findMany method once with expected argument', () => { + expect(MOCK_PRISMA_CLIENT.technologyEntity.findMany).toHaveBeenCalledTimes(1); + expect(MOCK_PRISMA_CLIENT.technologyEntity.findMany).toHaveBeenCalledWith({ + take: MOCK_FIRST, + orderBy: MOCK_ORDER_BY, + where: MOCK_AFTER ? { id: { gt: MOCK_AFTER } } : {}, + }); + }); - it('returns expected result', () => { - expect(result).toEqual(EXPECTED_RESULT); - }); + it('returns expected result', () => { + expect(result).toEqual(EXPECTED_RESULT); + }); + } + ); }); }); }); diff --git a/starters/express-apollo-prisma/src/graphql/data-sources/technology-data-source.ts b/starters/express-apollo-prisma/src/graphql/data-sources/technology-data-source.ts index 714084b65..ae3bbcad6 100644 --- a/starters/express-apollo-prisma/src/graphql/data-sources/technology-data-source.ts +++ b/starters/express-apollo-prisma/src/graphql/data-sources/technology-data-source.ts @@ -1,11 +1,25 @@ import { PrismaClient, Prisma, TechnologyEntity } from '@prisma/client'; import { CacheAPIWrapper } from '../../cache'; +import { InputMaybe } from '../schema/generated/types'; type TechnologyEntityId = TechnologyEntity['id']; -export type TechnologyEntityCollectionPage = { +export type TechnologyEdge = { + cursor: TechnologyEntityId; + node: TechnologyEntity; +}; + +export type PageInformation = { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor?: TechnologyEntityId; + endCursor?: TechnologyEntityId; +}; + +export type TechnologyEntityCollection = { totalCount: number; - items: TechnologyEntity[]; + pageInfo: PageInformation; + edges: TechnologyEdge[]; }; export class TechnologyDataSource { @@ -30,17 +44,48 @@ export class TechnologyDataSource { return entity; } - async getTechnologies(limit: number, offset: number): Promise { + async getTechnologies( + first: number, // the number of items to return in a page + after?: InputMaybe | undefined // the cursor to start the next page from + ): Promise { + const where: Prisma.TechnologyEntityWhereInput = after ? { id: { gt: after } } : {}; + const [totalCount, items] = await this.prismaClient.$transaction([ this.prismaClient.technologyEntity.count(), this.prismaClient.technologyEntity.findMany({ - take: limit, - skip: offset, + where, + take: first, + orderBy: { id: 'asc' }, }), ]); + + const startCursor = items.length > 0 ? items[0].id : undefined; + const endCursor = items.length > 0 ? items[items.length - 1].id : undefined; + + const hasNextPage = + (await this.prismaClient.technologyEntity.count({ + where: { id: { gt: endCursor } }, + })) > 0; + + const hasPreviousPage = + (await this.prismaClient.technologyEntity.count({ + where: { id: { lt: items[0] ? items[0].id : undefined } }, + })) > 0; + + const edges = items.map((node) => ({ + cursor: node.id, + node, + })); + return { totalCount, - items, + edges, + pageInfo: { + hasNextPage, + hasPreviousPage, + startCursor, + endCursor, + }, }; } diff --git a/starters/express-apollo-prisma/src/graphql/mappers/technology.spec.ts b/starters/express-apollo-prisma/src/graphql/mappers/technology.spec.ts index 23234b979..80b463c2d 100644 --- a/starters/express-apollo-prisma/src/graphql/mappers/technology.spec.ts +++ b/starters/express-apollo-prisma/src/graphql/mappers/technology.spec.ts @@ -1,8 +1,9 @@ -import { mapTechnology, mapTechnologyCollectionPage } from './technology'; +import { mapTechnology, mapTechnologyCollection } from './technology'; import { TechnologyEntity } from '@prisma/client'; -import { Technology, TechnologyCollectionPage } from '../schema/generated/types'; -import { createMockTechnologyEntityCollectionPage } from '../../mocks/technology-entity'; -import { createMockTechnology } from '../../mocks/technology'; +import { Technology, TechnologyCollection } from '../schema/generated/types'; +import { createMockTechnologyEntityCollection } from '../../mocks/technology-entity'; +import { createMockTechnologyCollectionResult } from '../../mocks/technology'; +import { PageInformation } from '../data-sources'; jest.mock('./technology', () => { const originalModule = jest.requireActual('./technology'); @@ -14,12 +15,6 @@ jest.mock('./technology', () => { }; }); -const SPY_MAP_TECHNOLOGY = mapTechnology as unknown as jest.SpyInstance< - Technology, - [entity: TechnologyEntity], - unknown ->; - describe('.mapTechnology', () => { describe('when called', () => { it('returns expected result', () => { @@ -44,23 +39,33 @@ describe('.mapTechnology', () => { }); }); -describe('.mapTechnologyCollectionPage', () => { +describe('.mapTechnologyCollection', () => { describe('when called with arguments', () => { - const MOCK_TECHNOLOGY_ENTITY_COLLECTION_PAGE = createMockTechnologyEntityCollectionPage(3, 11); - const MOCK_TECHNOLOGY = createMockTechnology(); - const EXPECTED_RESULT: TechnologyCollectionPage = { - totalCount: 11, - items: Array(3).fill(MOCK_TECHNOLOGY), + const MOCK_TOTAL_COUNT = 11; + const MOCK_FIRST_INPUT = 3; + const MOCK_PAGE_INFO: PageInformation = { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 1, + endCursor: 3, }; - let result: TechnologyCollectionPage; - beforeAll(() => { - SPY_MAP_TECHNOLOGY.mockReturnValue(MOCK_TECHNOLOGY); - result = mapTechnologyCollectionPage(MOCK_TECHNOLOGY_ENTITY_COLLECTION_PAGE); - }); + const MOCK_TECHNOLOGY_ENTITY_COLLECTION_PAGE = createMockTechnologyEntityCollection( + MOCK_FIRST_INPUT, + MOCK_TOTAL_COUNT, + MOCK_PAGE_INFO + ); - afterAll(() => { - SPY_MAP_TECHNOLOGY.mockRestore(); + const EXPECTED_RESULT = createMockTechnologyCollectionResult( + MOCK_TOTAL_COUNT, + MOCK_FIRST_INPUT, + MOCK_PAGE_INFO + ); + + let result: TechnologyCollection; + + beforeAll(() => { + result = mapTechnologyCollection(MOCK_TECHNOLOGY_ENTITY_COLLECTION_PAGE); }); it('returns expected result', () => { diff --git a/starters/express-apollo-prisma/src/graphql/mappers/technology.ts b/starters/express-apollo-prisma/src/graphql/mappers/technology.ts index 3e753753c..3b9c7dac3 100644 --- a/starters/express-apollo-prisma/src/graphql/mappers/technology.ts +++ b/starters/express-apollo-prisma/src/graphql/mappers/technology.ts @@ -1,6 +1,6 @@ import { TechnologyEntity } from '@prisma/client'; -import { TechnologyEntityCollectionPage } from '../data-sources'; -import { Technology, TechnologyCollectionPage } from '../schema/generated/types'; +import { TechnologyEntityCollection } from '../data-sources'; +import { Technology, TechnologyCollection } from '../schema/generated/types'; export const mapTechnology = (entity: TechnologyEntity): Technology => ({ __typename: 'Technology', @@ -10,11 +10,16 @@ export const mapTechnology = (entity: TechnologyEntity): Technology => ({ url: entity.url, }); -export const mapTechnologyCollectionPage = ( - entityCollectionPage: TechnologyEntityCollectionPage -): TechnologyCollectionPage => { +export const mapTechnologyCollection = ( + entityCollectionPage: TechnologyEntityCollection +): TechnologyCollection => { return { totalCount: entityCollectionPage.totalCount, - items: entityCollectionPage.items.map(mapTechnology), + edges: entityCollectionPage.edges.map((entity) => ({ + __typename: 'TechnologyEdge', + node: mapTechnology(entity.node), + cursor: entity.cursor, + })), + pageInfo: entityCollectionPage.pageInfo, }; }; diff --git a/starters/express-apollo-prisma/src/graphql/schema/generated/graphql.schema.json b/starters/express-apollo-prisma/src/graphql/schema/generated/graphql.schema.json index 38b9be633..7147379ae 100644 --- a/starters/express-apollo-prisma/src/graphql/schema/generated/graphql.schema.json +++ b/starters/express-apollo-prisma/src/graphql/schema/generated/graphql.schema.json @@ -211,6 +211,73 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "PageInformation", + "description": "Pagination Information", + "fields": [ + { + "name": "endCursor", + "description": "Last cursor in page", + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasNextPage", + "description": "Shows if there is a page after", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasPreviousPage", + "description": "Shows if there is a page before", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startCursor", + "description": "First cursor in page", + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Query", @@ -221,26 +288,26 @@ "description": "Returns a list of Technologies", "args": [ { - "name": "limit", + "name": "after", "description": null, "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "defaultValue": "5", + "defaultValue": null, "isDeprecated": false, "deprecationReason": null }, { - "name": "offset", + "name": "first", "description": null, "type": { "kind": "SCALAR", "name": "Int", "ofType": null }, - "defaultValue": "0", + "defaultValue": "5", "isDeprecated": false, "deprecationReason": null } @@ -250,7 +317,7 @@ "name": null, "ofType": { "kind": "OBJECT", - "name": "TechnologyCollectionPage", + "name": "TechnologyCollection", "ofType": null } }, @@ -371,11 +438,11 @@ }, { "kind": "OBJECT", - "name": "TechnologyCollectionPage", - "description": "A page of technology items", + "name": "TechnologyCollection", + "description": "A collection of technologies", "fields": [ { - "name": "items", + "name": "edges", "description": "A list of records of the requested page", "args": [], "type": { @@ -386,7 +453,7 @@ "name": null, "ofType": { "kind": "OBJECT", - "name": "Technology", + "name": "TechnologyEdge", "ofType": null } } @@ -394,6 +461,22 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "pageInfo", + "description": "Pagination Information", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInformation", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "totalCount", "description": "Identifies the total count of technology records in data source", @@ -416,6 +499,49 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "TechnologyEdge", + "description": "Pagination Technology Node", + "fields": [ + { + "name": "cursor", + "description": "Current Cursor for Entity Node", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "Technology Entity Node", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Technology", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "UpdateTechnology", diff --git a/starters/express-apollo-prisma/src/graphql/schema/generated/schema.graphql b/starters/express-apollo-prisma/src/graphql/schema/generated/schema.graphql index 4a6e7be60..8f92d2adc 100644 --- a/starters/express-apollo-prisma/src/graphql/schema/generated/schema.graphql +++ b/starters/express-apollo-prisma/src/graphql/schema/generated/schema.graphql @@ -24,12 +24,26 @@ type Mutation { updateTechnology(id: ID!, input: UpdateTechnology!): Technology! } +""" +Pagination Information +""" +type PageInformation { + "Last cursor in page" + endCursor: Int + "Shows if there is a page after" + hasNextPage: Boolean! + "Shows if there is a page before" + hasPreviousPage: Boolean! + "First cursor in page" + startCursor: Int +} + """ Technology queries """ type Query { "Returns a list of Technologies" - technologies(limit: Int = 5, offset: Int = 0): TechnologyCollectionPage! + technologies(after: Int, first: Int = 5): TechnologyCollection! "Returns a single Technology by ID" technology(id: ID!): Technology } @@ -49,15 +63,27 @@ type Technology { } """ -A page of technology items +A collection of technologies """ -type TechnologyCollectionPage { +type TechnologyCollection { "A list of records of the requested page" - items: [Technology]! + edges: [TechnologyEdge]! + "Pagination Information" + pageInfo: PageInformation! "Identifies the total count of technology records in data source" totalCount: Int! } +""" +Pagination Technology Node +""" +type TechnologyEdge { + "Current Cursor for Entity Node" + cursor: Int! + "Technology Entity Node" + node: Technology! +} + input UpdateTechnology { "A brief description of the Technology" description: String diff --git a/starters/express-apollo-prisma/src/graphql/schema/generated/types/index.ts b/starters/express-apollo-prisma/src/graphql/schema/generated/types/index.ts index 62e558e47..6d2b3fc80 100644 --- a/starters/express-apollo-prisma/src/graphql/schema/generated/types/index.ts +++ b/starters/express-apollo-prisma/src/graphql/schema/generated/types/index.ts @@ -50,19 +50,32 @@ export type MutationupdateTechnologyArgs = { input: UpdateTechnology; }; +/** Pagination Information */ +export type PageInformation = { + __typename?: 'PageInformation'; + /** Last cursor in page */ + endCursor?: Maybe; + /** Shows if there is a page after */ + hasNextPage: Scalars['Boolean']; + /** Shows if there is a page before */ + hasPreviousPage: Scalars['Boolean']; + /** First cursor in page */ + startCursor?: Maybe; +}; + /** Technology queries */ export type Query = { __typename?: 'Query'; /** Returns a list of Technologies */ - technologies: TechnologyCollectionPage; + technologies: TechnologyCollection; /** Returns a single Technology by ID */ technology?: Maybe; }; /** Technology queries */ export type QuerytechnologiesArgs = { - limit?: InputMaybe; - offset?: InputMaybe; + after?: InputMaybe; + first?: InputMaybe; }; /** Technology queries */ @@ -83,15 +96,26 @@ export type Technology = { url?: Maybe; }; -/** A page of technology items */ -export type TechnologyCollectionPage = { - __typename?: 'TechnologyCollectionPage'; +/** A collection of technologies */ +export type TechnologyCollection = { + __typename?: 'TechnologyCollection'; /** A list of records of the requested page */ - items: Array>; + edges: Array>; + /** Pagination Information */ + pageInfo: PageInformation; /** Identifies the total count of technology records in data source */ totalCount: Scalars['Int']; }; +/** Pagination Technology Node */ +export type TechnologyEdge = { + __typename?: 'TechnologyEdge'; + /** Current Cursor for Entity Node */ + cursor: Scalars['Int']; + /** Technology Entity Node */ + node: Technology; +}; + export type UpdateTechnology = { /** A brief description of the Technology */ description?: InputMaybe; @@ -190,10 +214,12 @@ export type ResolversTypes = { ID: ResolverTypeWrapper; Int: ResolverTypeWrapper; Mutation: ResolverTypeWrapper<{}>; + PageInformation: ResolverTypeWrapper; Query: ResolverTypeWrapper<{}>; String: ResolverTypeWrapper; Technology: ResolverTypeWrapper; - TechnologyCollectionPage: ResolverTypeWrapper; + TechnologyCollection: ResolverTypeWrapper; + TechnologyEdge: ResolverTypeWrapper; UpdateTechnology: UpdateTechnology; }; @@ -204,10 +230,12 @@ export type ResolversParentTypes = { ID: Scalars['ID']; Int: Scalars['Int']; Mutation: {}; + PageInformation: PageInformation; Query: {}; String: Scalars['String']; Technology: Technology; - TechnologyCollectionPage: TechnologyCollectionPage; + TechnologyCollection: TechnologyCollection; + TechnologyEdge: TechnologyEdge; UpdateTechnology: UpdateTechnology; }; @@ -235,15 +263,26 @@ export type MutationResolvers< >; }; +export type PageInformationResolvers< + ContextType = any, + ParentType extends ResolversParentTypes['PageInformation'] = ResolversParentTypes['PageInformation'] +> = { + endCursor?: Resolver, ParentType, ContextType>; + hasNextPage?: Resolver; + hasPreviousPage?: Resolver; + startCursor?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type QueryResolvers< ContextType = any, ParentType extends ResolversParentTypes['Query'] = ResolversParentTypes['Query'] > = { technologies?: Resolver< - ResolversTypes['TechnologyCollectionPage'], + ResolversTypes['TechnologyCollection'], ParentType, ContextType, - RequireFields + RequireFields >; technology?: Resolver< Maybe, @@ -264,18 +303,30 @@ export type TechnologyResolvers< __isTypeOf?: IsTypeOfResolverFn; }; -export type TechnologyCollectionPageResolvers< +export type TechnologyCollectionResolvers< ContextType = any, - ParentType extends ResolversParentTypes['TechnologyCollectionPage'] = ResolversParentTypes['TechnologyCollectionPage'] + ParentType extends ResolversParentTypes['TechnologyCollection'] = ResolversParentTypes['TechnologyCollection'] > = { - items?: Resolver>, ParentType, ContextType>; + edges?: Resolver>, ParentType, ContextType>; + pageInfo?: Resolver; totalCount?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; +export type TechnologyEdgeResolvers< + ContextType = any, + ParentType extends ResolversParentTypes['TechnologyEdge'] = ResolversParentTypes['TechnologyEdge'] +> = { + cursor?: Resolver; + node?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type Resolvers = { Mutation?: MutationResolvers; + PageInformation?: PageInformationResolvers; Query?: QueryResolvers; Technology?: TechnologyResolvers; - TechnologyCollectionPage?: TechnologyCollectionPageResolvers; + TechnologyCollection?: TechnologyCollectionResolvers; + TechnologyEdge?: TechnologyEdgeResolvers; }; diff --git a/starters/express-apollo-prisma/src/graphql/schema/technology/technology.resolvers.spec.ts b/starters/express-apollo-prisma/src/graphql/schema/technology/technology.resolvers.spec.ts index 22e0c752b..94c7ae7f0 100644 --- a/starters/express-apollo-prisma/src/graphql/schema/technology/technology.resolvers.spec.ts +++ b/starters/express-apollo-prisma/src/graphql/schema/technology/technology.resolvers.spec.ts @@ -6,13 +6,13 @@ import { CreateTechnology, UpdateTechnology, QuerytechnologiesArgs, - TechnologyCollectionPage, + TechnologyCollection, } from '../generated/types'; import assert from 'assert'; import { testServerExecuteOperation } from '../../../mocks/graphql-server'; import { createMockTechnologyDataSource, - createMockTechnologyEntityCollectionPage, + createMockTechnologyEntityCollection, } from '../../../mocks/technology-entity'; import { GraphQLResponse } from '@apollo/server'; @@ -20,7 +20,8 @@ import { ApolloServerErrorCode } from '@apollo/server/errors'; import { ServerContext } from '../../server-context'; import { TechnologyEntity } from '@prisma/client'; -import { mapTechnology, mapTechnologyCollectionPage } from '../../mappers'; +import { mapTechnology, mapTechnologyCollection } from '../../mappers'; +import { PageInformation } from '../../data-sources'; type QueryTechnology = Pick; type QueryTechnologies = Pick; @@ -53,13 +54,22 @@ const MOCK_QUERY_TECHNOLOGY = gql` const MOCK_QUERY_TECHNOLOGIES_PAGINATION_DEFAULT = gql` query TechnologiesQueryPaginationArgumentsDefualt { technologies { - items { - description - displayName - id - url - } totalCount + edges { + node { + id + displayName + description + url + } + cursor + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } } } `; @@ -67,21 +77,30 @@ const MOCK_QUERY_TECHNOLOGIES_PAGINATION_DEFAULT = gql` const MOCK_VARIABLES_TECHNOLOGIES_PAGINATION_DEFAULT: QuerytechnologiesArgs = {}; const MOCK_QUERY_TECHNOLOGIES_PAGINATION_CUSTOM = gql` - query TechnologiesQueryPaginationArgumentsCustom($limit: Int, $offset: Int) { - technologies(limit: $limit, offset: $offset) { - items { - description - displayName - id - url - } + query Technologies($first: Int!, $after: Int) { + technologies(first: $first, after: $after) { totalCount + edges { + node { + id + displayName + description + url + } + cursor + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } } } `; const MOCK_VARIABLES_TECHNOLOGIES_PAGINATION_CUSTOM: QuerytechnologiesArgs = { - limit: 10, - offset: 20, + first: 1, + after: 3, }; const MOCK_TECHNOLOGY_DATASOURCE = createMockTechnologyDataSource(); @@ -145,11 +164,11 @@ jest.mock('../../mappers/technology', () => ({ description: 'MOCK_TECHNOLOGY_DESCRIPTION', url: 'MOCK_TECHNOLOGY_URL', }), - mapTechnologyCollectionPage: jest.fn(), + mapTechnologyCollection: jest.fn(), })); const MOCK_MAP_TECHNOLOGY = mapTechnology as jest.Mock; -const MOCK_MAP_TECHNOLOGY_COLLECTION_PAGE = mapTechnologyCollectionPage as jest.MockedFn< - typeof mapTechnologyCollectionPage +const MOCK_MAP_TECHNOLOGY_COLLECTION = mapTechnologyCollection as jest.MockedFn< + typeof mapTechnologyCollection >; describe('technologyResolvers', () => { @@ -277,16 +296,27 @@ describe('technologyResolvers', () => { describe('.technologies', () => { describe('when called', () => { - const MOCK_RESULT_TECHNOLOGY_COLLECTION_PAGE: TechnologyCollectionPage = { + const MOCK_RESULT_PAGE_INFO: PageInformation = { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 1, + endCursor: 3, + }; + + const MOCK_RESULT_TECHNOLOGY_COLLECTION: TechnologyCollection = { totalCount: 987, - items: [ + edges: [ { - displayName: 'MOCK_DISPLAY_NAME_RESULT', - description: 'MOCK_DESCRIPTION_RESULT', - id: 'MOCK_ID_RESULT', - url: 'MOCK_URL_RESULT', + node: { + displayName: 'MOCK_DISPLAY_NAME_RESULT', + description: 'MOCK_DESCRIPTION_RESULT', + id: 'MOCK_ID_RESULT', + url: 'MOCK_URL_RESULT', + }, + cursor: 1, }, ], + pageInfo: MOCK_RESULT_PAGE_INFO, }; describe.each([ @@ -294,17 +324,17 @@ describe('technologyResolvers', () => { 'with default pagination arguments', MOCK_QUERY_TECHNOLOGIES_PAGINATION_DEFAULT, MOCK_VARIABLES_TECHNOLOGIES_PAGINATION_DEFAULT, - createMockTechnologyEntityCollectionPage(5, 30), + createMockTechnologyEntityCollection(5, 30, MOCK_RESULT_PAGE_INFO), 5, - 0, + undefined, ], [ 'with custom pagination arguments', MOCK_QUERY_TECHNOLOGIES_PAGINATION_CUSTOM, MOCK_VARIABLES_TECHNOLOGIES_PAGINATION_CUSTOM, - createMockTechnologyEntityCollectionPage(10, 50), - Number(MOCK_VARIABLES_TECHNOLOGIES_PAGINATION_CUSTOM.limit), - Number(MOCK_VARIABLES_TECHNOLOGIES_PAGINATION_CUSTOM.offset), + createMockTechnologyEntityCollection(10, 50, MOCK_RESULT_PAGE_INFO), + Number(MOCK_VARIABLES_TECHNOLOGIES_PAGINATION_CUSTOM.first), + Number(MOCK_VARIABLES_TECHNOLOGIES_PAGINATION_CUSTOM.after), ], ])( '%s', @@ -313,16 +343,14 @@ describe('technologyResolvers', () => { mockQuery, mockVariables, mockCollectionPage, - expectedLimit, - expectedOffset + expectedFirst, + expectedAfter ) => { let response: GraphQLResponse; beforeAll(async () => { MOCK_TECHNOLOGY_DATASOURCE.getTechnologies.mockResolvedValue(mockCollectionPage); - MOCK_MAP_TECHNOLOGY_COLLECTION_PAGE.mockReturnValue( - MOCK_RESULT_TECHNOLOGY_COLLECTION_PAGE - ); + MOCK_MAP_TECHNOLOGY_COLLECTION.mockReturnValue(MOCK_RESULT_TECHNOLOGY_COLLECTION); response = await testServerExecuteOperation( { query: mockQuery, @@ -334,28 +362,29 @@ describe('technologyResolvers', () => { afterAll(() => { MOCK_TECHNOLOGY_DATASOURCE.getTechnologies.mockReset(); - MOCK_MAP_TECHNOLOGY_COLLECTION_PAGE.mockReset(); + MOCK_MAP_TECHNOLOGY_COLLECTION.mockReset(); }); it('calls TechnologyDataSource getTechnologies method once', () => { expect(MOCK_TECHNOLOGY_DATASOURCE.getTechnologies).toHaveBeenCalledTimes(1); expect(MOCK_TECHNOLOGY_DATASOURCE.getTechnologies).toHaveBeenCalledWith( - expectedLimit, - expectedOffset + expectedFirst, + expectedAfter ); }); it('calls mapTechnology mapper function for each technology entity', () => { - expect(MOCK_MAP_TECHNOLOGY_COLLECTION_PAGE).toHaveBeenCalledTimes(1); - expect(MOCK_MAP_TECHNOLOGY_COLLECTION_PAGE).toHaveBeenCalledWith(mockCollectionPage); + expect(MOCK_MAP_TECHNOLOGY_COLLECTION).toHaveBeenCalledTimes(1); + expect(MOCK_MAP_TECHNOLOGY_COLLECTION).toHaveBeenCalledWith(mockCollectionPage); }); it('returns expected result', async () => { expect(response.body.kind).toEqual('single'); assert(response.body.kind === 'single'); expect(response.body.singleResult.errors).toBeUndefined(); + expect(response.body.singleResult.data).toEqual({ - technologies: MOCK_RESULT_TECHNOLOGY_COLLECTION_PAGE, + technologies: MOCK_RESULT_TECHNOLOGY_COLLECTION, }); }); } diff --git a/starters/express-apollo-prisma/src/graphql/schema/technology/technology.resolvers.ts b/starters/express-apollo-prisma/src/graphql/schema/technology/technology.resolvers.ts index f1fb79b68..d9e8bfe95 100644 --- a/starters/express-apollo-prisma/src/graphql/schema/technology/technology.resolvers.ts +++ b/starters/express-apollo-prisma/src/graphql/schema/technology/technology.resolvers.ts @@ -2,7 +2,7 @@ import { ServerContext } from '../../server-context/server-context'; import { Resolvers, UpdateTechnology } from '../generated/types'; import { ApolloServerErrorCode } from '@apollo/server/errors'; import { GraphQLError } from 'graphql'; -import { mapTechnology, mapTechnologyCollectionPage } from '../../mappers'; +import { mapTechnology, mapTechnologyCollection } from '../../mappers'; const parseTechnologyId = (id: string): number => { const idNumber = Number(id); @@ -35,9 +35,9 @@ export const technologyResolvers: Resolvers = { } return mapTechnology(entity); }, - technologies: async (_parent, { limit, offset }, { dataSources: { technologyDataSource } }) => { - const collectionPage = await technologyDataSource.getTechnologies(limit, offset); - return mapTechnologyCollectionPage(collectionPage); + technologies: async (_parent, { first, after }, { dataSources: { technologyDataSource } }) => { + const collectionPage = await technologyDataSource.getTechnologies(first, after); + return mapTechnologyCollection(collectionPage); }, }, Mutation: { diff --git a/starters/express-apollo-prisma/src/graphql/schema/technology/technology.typedefs.ts b/starters/express-apollo-prisma/src/graphql/schema/technology/technology.typedefs.ts index a962cc26e..6260676b8 100644 --- a/starters/express-apollo-prisma/src/graphql/schema/technology/technology.typedefs.ts +++ b/starters/express-apollo-prisma/src/graphql/schema/technology/technology.typedefs.ts @@ -16,13 +16,39 @@ export const technologyTypeDefs = gql` } """ - A page of technology items + Pagination Technology Node """ - type TechnologyCollectionPage { + type TechnologyEdge { + "Current Cursor for Entity Node" + cursor: Int! + "Technology Entity Node" + node: Technology! + } + + """ + Pagination Information + """ + type PageInformation { + "Shows if there is a page after" + hasNextPage: Boolean! + "Shows if there is a page before" + hasPreviousPage: Boolean! + "First cursor in page" + startCursor: Int + "Last cursor in page" + endCursor: Int + } + + """ + A collection of technologies + """ + type TechnologyCollection { "Identifies the total count of technology records in data source" totalCount: Int! "A list of records of the requested page" - items: [Technology]! + edges: [TechnologyEdge]! + "Pagination Information" + pageInfo: PageInformation! } """ @@ -32,7 +58,7 @@ export const technologyTypeDefs = gql` "Returns a single Technology by ID" technology(id: ID!): Technology "Returns a list of Technologies" - technologies(limit: Int = 5, offset: Int = 0): TechnologyCollectionPage! + technologies(first: Int = 5, after: Int): TechnologyCollection! } input CreateTechnology { diff --git a/starters/express-apollo-prisma/src/graphql/server-context/server-context-middleware-options.ts b/starters/express-apollo-prisma/src/graphql/server-context/server-context-middleware-options.ts index 88c0a5389..2262451a1 100644 --- a/starters/express-apollo-prisma/src/graphql/server-context/server-context-middleware-options.ts +++ b/starters/express-apollo-prisma/src/graphql/server-context/server-context-middleware-options.ts @@ -4,11 +4,12 @@ import { WithRequired } from '@apollo/utils.withrequired'; import { TechnologyDataSource } from '../data-sources'; import { PrismaClient, TechnologyEntity } from '@prisma/client'; import { createCacheAPIWrapperAsync } from '../../cache'; +import { PRISMA_CONFIG } from '../../config'; export const createServerContextMiddlewareOptionsAsync = async (): Promise< WithRequired, 'context'> > => { - const prismaClient = new PrismaClient(); + const prismaClient = new PrismaClient(PRISMA_CONFIG); const technologyCacheAPIWrapper = await createCacheAPIWrapperAsync( 'technology' ); diff --git a/starters/express-apollo-prisma/src/main.ts b/starters/express-apollo-prisma/src/main.ts index deeee0087..2b1c473b1 100644 --- a/starters/express-apollo-prisma/src/main.ts +++ b/starters/express-apollo-prisma/src/main.ts @@ -4,28 +4,11 @@ import http from 'http'; import cors from 'cors'; import bodyParser from 'body-parser'; import { graphqlServer, createGraphqlServerMiddlewareAsync } from './graphql'; -import * as dotenv from 'dotenv'; import { connectRedisClient } from './cache/redis'; import { createHealthcheckHandler } from './healthcheck'; import { jobGeneratorHandler } from './queue/job-generator-handler'; import { PrismaClient } from '@prisma/client'; - -dotenv.config(); - -const PORT = Number(process.env.PORT); -if (isNaN(PORT)) { - throw new Error(`[Invalid environment] Variable not found: PORT`); -} - -const REDIS_URL = process.env.REDIS_URL; -if (!REDIS_URL) { - throw new Error(`[Invalid environment] Variable not found: REDIS_URL`); -} - -let CORS_ALLOWED_ORIGINS: string[] | undefined; -if (process.env.CORS_ALLOWED_ORIGINS && process.env.CORS_ALLOWED_ORIGINS !== '*') { - CORS_ALLOWED_ORIGINS = process.env.CORS_ALLOWED_ORIGINS.split(','); -} +import { CORS_ALLOWED_ORIGINS, PORT, PRISMA_CONFIG, REDIS_URL } from './config'; (async () => { // Required logic for integrating with Express @@ -54,7 +37,7 @@ if (process.env.CORS_ALLOWED_ORIGINS && process.env.CORS_ALLOWED_ORIGINS !== '*' // Set up server-related Express middleware app.use('/graphql', await createGraphqlServerMiddlewareAsync()); - const prismaClient = new PrismaClient(); + const prismaClient = new PrismaClient(PRISMA_CONFIG); app.use('/health', createHealthcheckHandler({ redisClient, prismaClient })); app.post('/example-job', jobGeneratorHandler); diff --git a/starters/express-apollo-prisma/src/mocks/technology-entity.ts b/starters/express-apollo-prisma/src/mocks/technology-entity.ts index 576c5e63d..0cf2faf08 100644 --- a/starters/express-apollo-prisma/src/mocks/technology-entity.ts +++ b/starters/express-apollo-prisma/src/mocks/technology-entity.ts @@ -1,14 +1,20 @@ import { TechnologyEntity } from '@prisma/client'; import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; -import { TechnologyDataSource, TechnologyEntityCollectionPage } from '../graphql/data-sources'; +import { + PageInformation, + TechnologyDataSource, + TechnologyEntityCollection, + TechnologyEdge, +} from '../graphql/data-sources'; export const createMockTechnologyDataSource = (): DeepMockProxy => mockDeep(); -let technologyEntityIdCount = 0; +let technologyEntityIdCount = 1; +let alternateTechnologyEntityIdCount = 1; -const createMockTechnologyEntity = (): TechnologyEntity => { - const id = technologyEntityIdCount++; +const createMockTechnologyEntity = (idCount?: number): TechnologyEntity => { + const id = idCount ? idCount++ : technologyEntityIdCount++; return { description: `MOCK_DESCRIPTION_${id}`, displayName: `MOCK_DISPLAY_NAME_${id}`, @@ -17,10 +23,36 @@ const createMockTechnologyEntity = (): TechnologyEntity => { }; }; -export const createMockTechnologyEntityCollectionPage = ( - itemsCount: number, - totalCount: number -): TechnologyEntityCollectionPage => ({ +export const createMockTechnologyEntityCollection = ( + edgesCount: number, + totalCount: number, + pageInfo: PageInformation +): TechnologyEntityCollection => ({ totalCount, - items: Array(itemsCount).fill(null).map(createMockTechnologyEntity), + pageInfo, + edges: Array(edgesCount) + .fill(null) + .map(() => { + const technology = createMockTechnologyEntity(); + return { + node: technology, + cursor: technology.id, + }; + }), }); + +export const createMockTechnologyEntities = (totalCount: number): TechnologyEntity[] => { + return Array(totalCount).fill(null).map(createMockTechnologyEntity); +}; + +export const createMockTechnologyEdges = (totalCount: number): TechnologyEdge[] => { + return Array(totalCount) + .fill(null) + .map(() => { + const technology = createMockTechnologyEntity(alternateTechnologyEntityIdCount++); + return { + node: technology, + cursor: technology.id, + }; + }); +}; diff --git a/starters/express-apollo-prisma/src/mocks/technology.ts b/starters/express-apollo-prisma/src/mocks/technology.ts index 1ff922295..2fe63cf2d 100644 --- a/starters/express-apollo-prisma/src/mocks/technology.ts +++ b/starters/express-apollo-prisma/src/mocks/technology.ts @@ -1,12 +1,35 @@ -import { Technology } from '../graphql/schema/generated/types'; +import { PageInformation } from '../graphql/data-sources'; +import { Technology, TechnologyCollection } from '../graphql/schema/generated/types'; -let technologyIdCount = 0; +let technologyIdCount = 1; export const createMockTechnology = (): Technology => { - const id = `MOCK_ID_${technologyIdCount++}`; + const id = `${technologyIdCount++}`; return { + __typename: 'Technology', description: `MOCK_DESCRIPTION_${id}`, displayName: `MOCK_DISPLAY_NAME_${id}`, id, url: `MOCK_URL_${id}`, }; }; + +export const createMockTechnologyCollectionResult = ( + totalCount: number, + first: number, + pageInfo: PageInformation +): TechnologyCollection => { + return { + totalCount: totalCount, + pageInfo, + edges: Array(first) + .fill(null) + .map(() => { + const MOCK_TECHNOLOGY = createMockTechnology(); + return { + __typename: 'TechnologyEdge', + node: MOCK_TECHNOLOGY, + cursor: Number(MOCK_TECHNOLOGY.id), + }; + }), + }; +}; diff --git a/starters/express-apollo-prisma/src/queue/job-generator.spec.ts b/starters/express-apollo-prisma/src/queue/job-generator.spec.ts index dc4aa830d..febfe9245 100644 --- a/starters/express-apollo-prisma/src/queue/job-generator.spec.ts +++ b/starters/express-apollo-prisma/src/queue/job-generator.spec.ts @@ -1,10 +1,14 @@ import { connect, Replies } from 'amqplib'; import { createMockChannel, createMockConnection } from '../mocks/amqplib'; import { generateJob } from './job-generator'; +import { AMQP_QUEUE_JOB as MOCK_AMQP_QUEUE_JOB, AMQP_URL as MOCK_AMQP_URL } from '../config'; + +jest.mock('../config', () => ({ + AMQP_QUEUE_JOB: 'MOCK_ENV_AMQP_QUEUE_JOB', + AMQP_URL: 'MOCK_AMQP_URL', +})); const MOCK_MESSAGE = 'MOCK_MESSAGE'; -const MOCK_ENV_AMQP_URL = 'MOCK_AMQP_URL'; -const MOCK_ENV_AMQP_QUEUE_JOB = 'MOCK_ENV_AMQP_QUEUE_JOB'; const MOCK_AMQP_CONNECTION = createMockConnection(); const MOCK_AMPQ_CHANNEL = createMockChannel(); @@ -23,52 +27,16 @@ const MOCK_AMQP_CONNECT = connect as jest.MockedFn; const SPY_CONSOLE_WARN = jest.spyOn(console, 'warn'); describe('.generateJob', () => { - const OROGINAL_ENV = process.env; beforeAll(() => { SPY_CONSOLE_WARN.mockImplementation(jest.fn()); }); afterAll(() => { SPY_CONSOLE_WARN.mockRestore(); - process.env = OROGINAL_ENV; }); describe('when called with message', () => { - describe('and environment variable AMQP_URL not set', () => { - const REPRODUCER_FN = async () => { - await generateJob(MOCK_MESSAGE); - }; - - beforeAll(async () => { - process.env = {}; - }); - - it('throws expected error', async () => { - await expect(REPRODUCER_FN).rejects.toThrowError( - '[Invalid environment] Variable not found: AMQP_URL' - ); - }); - }); - - describe('and environment variable AMQP_QUEUE_JOB not set', () => { - const REPRODUCER_FN = async () => { - await generateJob(MOCK_MESSAGE); - }; - - beforeAll(() => { - process.env = { - AMQP_URL: MOCK_ENV_AMQP_URL, - }; - }); - - it('throws expected error', async () => { - await expect(REPRODUCER_FN).rejects.toThrowError( - '[Invalid environment] Variable not found: AMQP_QUEUE_JOB' - ); - }); - }); - - describe('and environment variables set', () => { + describe('and correctly configured', () => { const MOCK_INSTANCE_ERROR = new Error(); type ExpectedFlow = { @@ -175,10 +143,6 @@ describe('.generateJob', () => { let result: boolean; beforeAll(async () => { - process.env = { - AMQP_URL: MOCK_ENV_AMQP_URL, - AMQP_QUEUE_JOB: MOCK_ENV_AMQP_QUEUE_JOB, - }; mockAwaitedResultValue(MOCK_AMQP_CONNECT, mockAMPQConnectResult); mockAwaitedResultValue( MOCK_AMQP_CONNECTION.createChannel, @@ -202,7 +166,7 @@ describe('.generateJob', () => { it('calls amqplib.connect method once with expected argument', () => { expect(MOCK_AMQP_CONNECT).toHaveBeenCalledTimes(1); - expect(MOCK_AMQP_CONNECT).toHaveBeenCalledWith(MOCK_ENV_AMQP_URL); + expect(MOCK_AMQP_CONNECT).toHaveBeenCalledWith(MOCK_AMQP_URL); }); expectedFlow.createsChannel && @@ -213,7 +177,7 @@ describe('.generateJob', () => { expectedFlow.assertsQueue && it('calls Channel.assertQueue method once with expected argument', () => { expect(MOCK_AMPQ_CHANNEL.assertQueue).toHaveBeenCalledTimes(1); - expect(MOCK_AMPQ_CHANNEL.assertQueue).toHaveBeenCalledWith(MOCK_ENV_AMQP_QUEUE_JOB); + expect(MOCK_AMPQ_CHANNEL.assertQueue).toHaveBeenCalledWith(MOCK_AMQP_QUEUE_JOB); }); expectedFlow.sendsMessageToQueue && @@ -221,7 +185,7 @@ describe('.generateJob', () => { expect(MOCK_AMPQ_CHANNEL.sendToQueue).toHaveBeenCalledTimes(1); const expectedBuffer = Buffer.from(MOCK_MESSAGE); expect(MOCK_AMPQ_CHANNEL.sendToQueue).toHaveBeenCalledWith( - MOCK_ENV_AMQP_QUEUE_JOB, + MOCK_AMQP_QUEUE_JOB, expectedBuffer, { persistent: true, diff --git a/starters/express-apollo-prisma/src/queue/job-generator.ts b/starters/express-apollo-prisma/src/queue/job-generator.ts index 681b63b59..97654b19f 100644 --- a/starters/express-apollo-prisma/src/queue/job-generator.ts +++ b/starters/express-apollo-prisma/src/queue/job-generator.ts @@ -1,16 +1,8 @@ import { Connection, Channel, connect } from 'amqplib'; +import { AMQP_QUEUE_JOB, AMQP_URL } from '../config'; export const generateJob = async (message: string): Promise => { let success: boolean; - const AMQP_URL = process.env.AMQP_URL; - if (!AMQP_URL) { - throw new Error('[Invalid environment] Variable not found: AMQP_URL'); - } - - const AMQP_QUEUE_JOB = process.env.AMQP_QUEUE_JOB; - if (!AMQP_QUEUE_JOB) { - throw new Error('[Invalid environment] Variable not found: AMQP_QUEUE_JOB'); - } let connection: Connection | undefined; let channel: Channel | undefined; diff --git a/starters/express-apollo-prisma/queue/worker.ts b/starters/express-apollo-prisma/src/queue/worker.ts similarity index 66% rename from starters/express-apollo-prisma/queue/worker.ts rename to starters/express-apollo-prisma/src/queue/worker.ts index 02ebddab4..41963ccd7 100644 --- a/starters/express-apollo-prisma/queue/worker.ts +++ b/starters/express-apollo-prisma/src/queue/worker.ts @@ -1,19 +1,7 @@ import amqplib from 'amqplib'; -import * as dotenv from 'dotenv'; - -dotenv.config(); +import { AMQP_QUEUE_JOB, AMQP_URL } from '../config'; (async () => { - const AMQP_URL = process.env.AMQP_URL; - if (!AMQP_URL) { - throw new Error(`[Invalid environment] Variable not found: AMQP_URL`); - } - - const AMQP_QUEUE_JOB = process.env.AMQP_QUEUE_JOB; - if (!AMQP_QUEUE_JOB) { - throw new Error(`[Invalid environment] Variable not found: AMQP_QUEUE_JOB`); - } - const connection = await amqplib.connect(AMQP_URL); const channel = await connection.createChannel();