From be039eca4b99a39d4016d2b38783631ad9e1a6fe Mon Sep 17 00:00:00 2001 From: Jed Watson Date: Fri, 24 Sep 2021 17:35:13 +1000 Subject: [PATCH] REST API example (#6634) * Adding REST API example * Add smoke test for rest-api example * Add changeset * Update select field config --- .changeset/gentle-colts-behave.md | 6 + .github/workflows/tests.yml | 1 + examples/rest-api/CHANGELOG.md | 1 + examples/rest-api/README.md | 17 + examples/rest-api/keystone.ts | 42 +++ examples/rest-api/package.json | 23 ++ examples/rest-api/routes/tasks.ts | 42 +++ examples/rest-api/schema.graphql | 333 ++++++++++++++++++++ examples/rest-api/schema.prisma | 31 ++ examples/rest-api/schema.ts | 32 ++ examples/rest-api/seed-data/data.ts | 72 +++++ examples/rest-api/seed-data/index.ts | 66 ++++ tests/examples-smoke-tests/rest-api.test.ts | 29 ++ 13 files changed, 695 insertions(+) create mode 100644 .changeset/gentle-colts-behave.md create mode 100644 examples/rest-api/CHANGELOG.md create mode 100644 examples/rest-api/README.md create mode 100644 examples/rest-api/keystone.ts create mode 100644 examples/rest-api/package.json create mode 100644 examples/rest-api/routes/tasks.ts create mode 100644 examples/rest-api/schema.graphql create mode 100644 examples/rest-api/schema.prisma create mode 100644 examples/rest-api/schema.ts create mode 100644 examples/rest-api/seed-data/data.ts create mode 100644 examples/rest-api/seed-data/index.ts create mode 100644 tests/examples-smoke-tests/rest-api.test.ts diff --git a/.changeset/gentle-colts-behave.md b/.changeset/gentle-colts-behave.md new file mode 100644 index 00000000000..3e9efd9aab8 --- /dev/null +++ b/.changeset/gentle-colts-behave.md @@ -0,0 +1,6 @@ +--- +"@keystone-next/example-rest-api": major +"@keystonejs/examples-smoke-tests": patch +--- + +REST API example diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4b9ae0f3e2c..8a779da73e1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -255,6 +255,7 @@ jobs: 'default-values.test.ts', 'extend-graphql-schema.test.ts', 'json.test.ts', + 'rest-api.test.ts', 'roles.test.ts', 'task-manager.test.ts', 'testing.test.ts', diff --git a/examples/rest-api/CHANGELOG.md b/examples/rest-api/CHANGELOG.md new file mode 100644 index 00000000000..095f37cc9c6 --- /dev/null +++ b/examples/rest-api/CHANGELOG.md @@ -0,0 +1 @@ +# @keystone-next/example-rest-api diff --git a/examples/rest-api/README.md b/examples/rest-api/README.md new file mode 100644 index 00000000000..8eb79490b22 --- /dev/null +++ b/examples/rest-api/README.md @@ -0,0 +1,17 @@ +## Feature Example - Creating REST API endpoints + +This project demonstrates how to create REST endpoints by extending Keystone's express app and using the Query API to execute queries against the schema. + +## Instructions + +To run this project, clone the Keystone repository locally then navigate to this directory and run: + +```shell +yarn dev +``` + +This will start the Admin UI at [localhost:3000](http://localhost:3000). + +You can use the Admin UI to create items in your database. + +To run the seed data script, pass the `--seed-data` flag when starting the app. diff --git a/examples/rest-api/keystone.ts b/examples/rest-api/keystone.ts new file mode 100644 index 00000000000..2be4ced4b29 --- /dev/null +++ b/examples/rest-api/keystone.ts @@ -0,0 +1,42 @@ +import { config } from '@keystone-next/keystone'; +import { lists } from './schema'; +import { insertSeedData } from './seed-data'; +import { getTasks } from './routes/tasks'; + +/* + A quick note on types: normally if you're adding custom properties to your + express request you'd extend the global Express namespace, but we're not + doing that here because we're in the keystone monorepo; so we're casting + the request and keystone context with `as` instead to keep this local. +*/ + +export default config({ + db: { + provider: 'sqlite', + url: process.env.DATABASE_URL || 'file:./keystone-example.db', + async onConnect(context) { + if (process.argv.includes('--seed-data')) { + await insertSeedData(context); + } + }, + }, + server: { + /* + This is the main part of this example. Here we include a function that + takes the express app Keystone created, and does two things: + - Adds a middleware function that will run on requests matching our REST + API routes, to get a keystone context on `req`. This means we don't + need to put our route handlers in a closure and repeat it for each. + - Adds a GET handler for tasks, which will query for tasks in the + Keystone schema and return the results as JSON + */ + extendExpressApp: (app, createContext) => { + app.use('/rest', async (req, res, next) => { + (req as any).context = await createContext(req, res); + next(); + }); + app.get('/rest/tasks', getTasks); + }, + }, + lists, +}); diff --git a/examples/rest-api/package.json b/examples/rest-api/package.json new file mode 100644 index 00000000000..abaedb6ee49 --- /dev/null +++ b/examples/rest-api/package.json @@ -0,0 +1,23 @@ +{ + "name": "@keystone-next/example-rest-api", + "version": "0.0.0", + "private": true, + "license": "MIT", + "scripts": { + "dev": "keystone-next dev", + "start": "keystone-next start", + "build": "keystone-next build", + "seed-data": "keystone-next --seed-data" + }, + "dependencies": { + "@keystone-next/keystone": "^25.0.0", + "express": "^4.17.1" + }, + "devDependencies": { + "typescript": "^4.4.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + }, + "repository": "https://github.com/keystonejs/keystone/tree/master/examples/rest-api" +} diff --git a/examples/rest-api/routes/tasks.ts b/examples/rest-api/routes/tasks.ts new file mode 100644 index 00000000000..5a4cbbfd4f8 --- /dev/null +++ b/examples/rest-api/routes/tasks.ts @@ -0,0 +1,42 @@ +import type { Request, Response } from 'express'; +import type { KeystoneContext } from '@keystone-next/keystone/types'; + +/* + This example route handler gets all the tasks in the database and returns + them as JSON data, emulating what you'd normally do in a REST API. + + More sophisticated API routes might accept query params to select fields, + map more params to `where` arguments, add pagination support, etc. + + We're also demonstrating how you can query related data through the schema. +*/ + +export async function getTasks(req: Request, res: Response) { + // This was added by the context middleware in ../keystone.ts + const context = (req as any).context as KeystoneContext; + // Let's map the `complete` query param to a where filter + let isComplete; + if (req.query.complete === 'true') { + isComplete = { equals: true }; + } else if (req.query.complete === 'false') { + isComplete = { equals: false }; + } + // Now we can use it to query the Keystone Schema + const users = await context.query.Task.findMany({ + where: { + isComplete, + }, + query: ` + id + label + priority + isComplete + assignedTo { + id + name + } + `, + }); + // And return the result as JSON + res.json(users); +} diff --git a/examples/rest-api/schema.graphql b/examples/rest-api/schema.graphql new file mode 100644 index 00000000000..546de8db159 --- /dev/null +++ b/examples/rest-api/schema.graphql @@ -0,0 +1,333 @@ +# This file is automatically generated by Keystone, do not modify it manually. +# Modify your Keystone config when you want to change this. + +type Task { + id: ID! + label: String + priority: TaskPriorityType + isComplete: Boolean + assignedTo: Person + finishBy: String +} + +enum TaskPriorityType { + low + medium + high +} + +input TaskWhereUniqueInput { + id: ID +} + +input TaskWhereInput { + AND: [TaskWhereInput!] + OR: [TaskWhereInput!] + NOT: [TaskWhereInput!] + id: IDFilter + label: StringNullableFilter + priority: TaskPriorityTypeNullableFilter + isComplete: BooleanFilter + assignedTo: PersonWhereInput + finishBy: DateTimeNullableFilter +} + +input IDFilter { + equals: ID + in: [ID!] + notIn: [ID!] + lt: ID + lte: ID + gt: ID + gte: ID + not: IDFilter +} + +input StringNullableFilter { + equals: String + in: [String!] + notIn: [String!] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + not: NestedStringNullableFilter +} + +input NestedStringNullableFilter { + equals: String + in: [String!] + notIn: [String!] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + not: NestedStringNullableFilter +} + +input TaskPriorityTypeNullableFilter { + equals: TaskPriorityType + in: [TaskPriorityType!] + notIn: [TaskPriorityType!] + not: TaskPriorityTypeNullableFilter +} + +input BooleanFilter { + equals: Boolean + not: BooleanFilter +} + +input DateTimeNullableFilter { + equals: String + in: [String!] + notIn: [String!] + lt: String + lte: String + gt: String + gte: String + not: DateTimeNullableFilter +} + +input TaskOrderByInput { + id: OrderDirection + label: OrderDirection + priority: OrderDirection + isComplete: OrderDirection + finishBy: OrderDirection +} + +enum OrderDirection { + asc + desc +} + +input TaskUpdateInput { + label: String + priority: TaskPriorityType + isComplete: Boolean + assignedTo: PersonRelateToOneForUpdateInput + finishBy: String +} + +input PersonRelateToOneForUpdateInput { + create: PersonCreateInput + connect: PersonWhereUniqueInput + disconnect: Boolean +} + +input TaskUpdateArgs { + where: TaskWhereUniqueInput! + data: TaskUpdateInput! +} + +input TaskCreateInput { + label: String + priority: TaskPriorityType + isComplete: Boolean + assignedTo: PersonRelateToOneForCreateInput + finishBy: String +} + +input PersonRelateToOneForCreateInput { + create: PersonCreateInput + connect: PersonWhereUniqueInput +} + +type Person { + id: ID! + name: String + tasks( + where: TaskWhereInput! = {} + orderBy: [TaskOrderByInput!]! = [] + take: Int + skip: Int! = 0 + ): [Task!] + tasksCount(where: TaskWhereInput! = {}): Int +} + +input PersonWhereUniqueInput { + id: ID +} + +input PersonWhereInput { + AND: [PersonWhereInput!] + OR: [PersonWhereInput!] + NOT: [PersonWhereInput!] + id: IDFilter + name: StringNullableFilter + tasks: TaskManyRelationFilter +} + +input TaskManyRelationFilter { + every: TaskWhereInput + some: TaskWhereInput + none: TaskWhereInput +} + +input PersonOrderByInput { + id: OrderDirection + name: OrderDirection +} + +input PersonUpdateInput { + name: String + tasks: TaskRelateToManyForUpdateInput +} + +input TaskRelateToManyForUpdateInput { + disconnect: [TaskWhereUniqueInput!] + set: [TaskWhereUniqueInput!] + create: [TaskCreateInput!] + connect: [TaskWhereUniqueInput!] +} + +input PersonUpdateArgs { + where: PersonWhereUniqueInput! + data: PersonUpdateInput! +} + +input PersonCreateInput { + name: String + tasks: TaskRelateToManyForCreateInput +} + +input TaskRelateToManyForCreateInput { + create: [TaskCreateInput!] + connect: [TaskWhereUniqueInput!] +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON + @specifiedBy( + url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" + ) + +type Mutation { + createTask(data: TaskCreateInput!): Task + createTasks(data: [TaskCreateInput!]!): [Task] + updateTask(where: TaskWhereUniqueInput!, data: TaskUpdateInput!): Task + updateTasks(data: [TaskUpdateArgs!]!): [Task] + deleteTask(where: TaskWhereUniqueInput!): Task + deleteTasks(where: [TaskWhereUniqueInput!]!): [Task] + createPerson(data: PersonCreateInput!): Person + createPeople(data: [PersonCreateInput!]!): [Person] + updatePerson(where: PersonWhereUniqueInput!, data: PersonUpdateInput!): Person + updatePeople(data: [PersonUpdateArgs!]!): [Person] + deletePerson(where: PersonWhereUniqueInput!): Person + deletePeople(where: [PersonWhereUniqueInput!]!): [Person] +} + +type Query { + tasks( + where: TaskWhereInput! = {} + orderBy: [TaskOrderByInput!]! = [] + take: Int + skip: Int! = 0 + ): [Task!] + task(where: TaskWhereUniqueInput!): Task + tasksCount(where: TaskWhereInput! = {}): Int + people( + where: PersonWhereInput! = {} + orderBy: [PersonOrderByInput!]! = [] + take: Int + skip: Int! = 0 + ): [Person!] + person(where: PersonWhereUniqueInput!): Person + peopleCount(where: PersonWhereInput! = {}): Int + keystone: KeystoneMeta! +} + +type KeystoneMeta { + adminMeta: KeystoneAdminMeta! +} + +type KeystoneAdminMeta { + enableSignout: Boolean! + enableSessionItem: Boolean! + lists: [KeystoneAdminUIListMeta!]! + list(key: String!): KeystoneAdminUIListMeta +} + +type KeystoneAdminUIListMeta { + key: String! + itemQueryName: String! + listQueryName: String! + hideCreate: Boolean! + hideDelete: Boolean! + path: String! + label: String! + singular: String! + plural: String! + description: String + initialColumns: [String!]! + pageSize: Int! + labelField: String! + fields: [KeystoneAdminUIFieldMeta!]! + initialSort: KeystoneAdminUISort + isHidden: Boolean! +} + +type KeystoneAdminUIFieldMeta { + path: String! + label: String! + isOrderable: Boolean! + isFilterable: Boolean! + fieldMeta: JSON + viewsIndex: Int! + customViewsIndex: Int + createView: KeystoneAdminUIFieldMetaCreateView! + listView: KeystoneAdminUIFieldMetaListView! + itemView(id: ID): KeystoneAdminUIFieldMetaItemView + search: QueryMode +} + +type KeystoneAdminUIFieldMetaCreateView { + fieldMode: KeystoneAdminUIFieldMetaCreateViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaCreateViewFieldMode { + edit + hidden +} + +type KeystoneAdminUIFieldMetaListView { + fieldMode: KeystoneAdminUIFieldMetaListViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaListViewFieldMode { + read + hidden +} + +type KeystoneAdminUIFieldMetaItemView { + fieldMode: KeystoneAdminUIFieldMetaItemViewFieldMode +} + +enum KeystoneAdminUIFieldMetaItemViewFieldMode { + edit + read + hidden +} + +enum QueryMode { + default + insensitive +} + +type KeystoneAdminUISort { + field: String! + direction: KeystoneAdminUISortDirection! +} + +enum KeystoneAdminUISortDirection { + ASC + DESC +} diff --git a/examples/rest-api/schema.prisma b/examples/rest-api/schema.prisma new file mode 100644 index 00000000000..55c8804b04f --- /dev/null +++ b/examples/rest-api/schema.prisma @@ -0,0 +1,31 @@ +// This file is automatically generated by Keystone, do not modify it manually. +// Modify your Keystone config when you want to change this. + +datasource sqlite { + url = env("DATABASE_URL") + provider = "sqlite" +} + +generator client { + provider = "prisma-client-js" + output = "node_modules/.prisma/client" + engineType = "binary" +} + +model Task { + id String @id @default(cuid()) + label String? + priority String? + isComplete Boolean @default(false) + assignedTo Person? @relation("Task_assignedTo", fields: [assignedToId], references: [id]) + assignedToId String? @map("assignedTo") + finishBy DateTime? + + @@index([assignedToId]) +} + +model Person { + id String @id @default(cuid()) + name String? + tasks Task[] @relation("Task_assignedTo") +} \ No newline at end of file diff --git a/examples/rest-api/schema.ts b/examples/rest-api/schema.ts new file mode 100644 index 00000000000..b10f82a13e2 --- /dev/null +++ b/examples/rest-api/schema.ts @@ -0,0 +1,32 @@ +import { list } from '@keystone-next/keystone'; +import { checkbox, relationship, text, timestamp } from '@keystone-next/keystone/fields'; +import { select } from '@keystone-next/keystone/fields'; + +export const lists = { + Task: list({ + fields: { + label: text({ isRequired: true }), + priority: select({ + type: 'enum', + options: [ + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium' }, + { label: 'High', value: 'high' }, + ], + }), + isComplete: checkbox(), + assignedTo: relationship({ ref: 'Person.tasks', many: false }), + finishBy: timestamp(), + }, + defaultIsFilterable: true, + defaultIsOrderable: true, + }), + Person: list({ + fields: { + name: text({ isRequired: true }), + tasks: relationship({ ref: 'Task.assignedTo', many: true }), + }, + defaultIsFilterable: true, + defaultIsOrderable: true, + }), +}; diff --git a/examples/rest-api/seed-data/data.ts b/examples/rest-api/seed-data/data.ts new file mode 100644 index 00000000000..015886479c8 --- /dev/null +++ b/examples/rest-api/seed-data/data.ts @@ -0,0 +1,72 @@ +export const persons = [ + { name: 'Lucy Wroblewski' }, + { name: 'Ches Adebayor' }, + { name: 'Tiff Jayden' }, + { name: 'Henrique Urrea' }, +]; + +export const tasks = [ + { + label: 'Install Keystone in local dev πŸ§ͺ', + isComplete: true, + finishBy: '2021-01-01T02:30:00.000Z', + assignedTo: 'Lucy Wroblewski', + priority: 'high', + }, + { + label: 'Model the contentπŸ’‘', + isComplete: true, + finishBy: '2021-01-22T05:43:51.000Z', + assignedTo: 'Ches Adebayor', + priority: 'high', + }, + { + label: 'Architect the data schema πŸ”—', + isComplete: true, + finishBy: '2021-02-02T20:02:37.000Z', + assignedTo: 'Lucy Wroblewski', + priority: 'high', + }, + { + label: 'Design the UI πŸ’…πŸΌ', + isComplete: true, + finishBy: '2021-02-24T22:17:07.000Z', + assignedTo: 'Tiff Jayden', + priority: 'medium', + }, + { + label: 'Publish the content πŸ“', + isComplete: true, + finishBy: '2021-03-01T05:41:37.000Z', + assignedTo: 'Ches Adebayor', + priority: 'low', + }, + { + label: 'Query content over GraphQLπŸ”Ž', + isComplete: false, + finishBy: '2021-03-21T05:41:37.000Z', + assignedTo: 'Lucy Wroblewski', + priority: 'medium', + }, + { + label: 'Implement the UI design in code πŸ–Ό', + isComplete: false, + finishBy: '2021-03-23T05:41:37.000Z', + assignedTo: 'Henrique Urrea', + priority: 'medium', + }, + { + label: 'Deploy Keystone backend to the web ☁️', + isComplete: false, + finishBy: '2021-03-30T05:41:37.000Z', + assignedTo: 'Lucy Wroblewski', + priority: 'low', + }, + { + label: 'Launch project πŸš€', + isComplete: false, + finishBy: '2021-04-01T05:41:37.000Z', + assignedTo: 'Lucy Wroblewski', + priority: 'low', + }, +]; diff --git a/examples/rest-api/seed-data/index.ts b/examples/rest-api/seed-data/index.ts new file mode 100644 index 00000000000..7e3e3741f7c --- /dev/null +++ b/examples/rest-api/seed-data/index.ts @@ -0,0 +1,66 @@ +import { KeystoneContext } from '@keystone-next/keystone/types'; +import { persons, tasks } from './data'; + +type PersonProps = { + name: string; +}; + +type TaskProps = { + label: string; + isComplete: Boolean; + finishBy: string; + assignedTo: Object; + person?: Object; +}; + +export async function insertSeedData(context: KeystoneContext) { + console.log(`🌱 Inserting seed data`); + + const createPerson = async (personData: PersonProps) => { + let person = null; + try { + person = await context.query.Person.findOne({ + where: { name: personData.name }, + query: 'id', + }); + } catch (e) {} + if (!person) { + person = await context.query.Person.createOne({ + data: personData, + query: 'id', + }); + } + return person; + }; + + const createTask = async (taskData: TaskProps) => { + let persons; + try { + persons = await context.query.Person.findMany({ + where: { name: { equals: taskData.assignedTo } }, + query: 'id', + }); + } catch (e) { + persons = []; + } + taskData.assignedTo = { connect: { id: persons[0].id } }; + const task = await context.query.Task.createOne({ + data: taskData, + query: 'id', + }); + return task; + }; + + for (const person of persons) { + console.log(`πŸ‘© Adding person: ${person.name}`); + await createPerson(person); + } + for (const task of tasks) { + console.log(`πŸ”˜ Adding task: ${task.label}`); + await createTask(task); + } + + console.log(`βœ… Seed data inserted`); + console.log(`πŸ‘‹ Please start the process with \`yarn dev\` or \`npm run dev\``); + process.exit(); +} diff --git a/tests/examples-smoke-tests/rest-api.test.ts b/tests/examples-smoke-tests/rest-api.test.ts new file mode 100644 index 00000000000..2e31e3a7088 --- /dev/null +++ b/tests/examples-smoke-tests/rest-api.test.ts @@ -0,0 +1,29 @@ +import { Browser, Page } from 'playwright'; +import fetch from 'node-fetch'; +import { exampleProjectTests } from './utils'; + +exampleProjectTests('rest-api', browserType => { + let browser: Browser = undefined as any; + let page: Page = undefined as any; + beforeAll(async () => { + browser = await browserType.launch(); + page = await browser.newPage(); + await page.goto('http://localhost:3000'); + }); + test('Load list', async () => { + await Promise.all([page.waitForNavigation(), page.click('h3:has-text("People")')]); + await page.waitForSelector('button:has-text("Create Person")'); + }); + test('Get Tasks', async () => { + const tasks = await fetch('http://localhost:3000/rest/tasks', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }).then(res => res.json()); + expect(tasks).toEqual([]); + }); + afterAll(async () => { + await browser.close(); + }); +});