├── PART-2.md ├── src ├── frontend │ ├── Board.vue │ ├── types.ts │ ├── SelectProject.vue │ ├── App.spec.ts │ ├── DraggableTask.vue │ ├── Category.vue │ ├── App.vue │ └── store.ts ├── main.ts ├── rest │ ├── projects.ts │ └── index.ts ├── entity │ ├── User.ts │ ├── Project.ts │ ├── Category.ts │ └── Task.ts ├── viewModels │ ├── projects.ts │ └── __tests__ │ │ └── projects.spec.ts ├── graphql │ ├── index.ts │ ├── project.resolvers.ts │ ├── task.resolvers.ts │ └── __tests__ │ │ └── projects.resolver.spec.ts └── provideInject.spec.ts ├── bench.sh ├── index.d.ts ├── .gitignore ├── SS1.png ├── intro.md ├── README.md ├── jest.config.js ├── test └── factories │ ├── projects.ts │ └── categories.ts ├── tsconfig.json ├── COMPARISON.md ├── index.html ├── REST.md ├── ormconfig.json ├── GRAPH_QL.md ├── create_schema.sql ├── package.json ├── 20200615_Kanban_Board_with_Typesafe_GraphQL_Part_2.md ├── PART_4.md ├── PART_1.md ├── PART_3.md ├── 20200705_Kanban_Board_with_Typesafe_GraphQL_Part_3.md ├── 20200615_Kanban_Board_with_Typesafe_GraphQL_Part_1.md ├── PART_5.md └── PART_6.md /PART-2.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/frontend/Board.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bench.sh: -------------------------------------------------------------------------------- 1 | psql kanban -c "select * from projects;" > /dev/null 2 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | export default any 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | node_modules/ 4 | build/ 5 | tmp/ 6 | temp/ -------------------------------------------------------------------------------- /SS1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/graphql-rest-vue/HEAD/SS1.png -------------------------------------------------------------------------------- /intro.md: -------------------------------------------------------------------------------- 1 | ## Intro 2 | 3 | - Kanban board 4 | - TypeORM, postgres, existing DB 5 | - Rest -> GraphQL 6 | - DB logic separate to API endpoints 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## GraphQL Kanban Board 2 | 3 | A simple Kanban board build with: 4 | 5 | - Vue.js 3 6 | - TypeScript 7 | - TypeORM 8 | - Type GraphQL 9 | - Postgres 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | transform: { 4 | "^.+\\.vue$": "vue-jest" 5 | }, 6 | moduleFileExtensions: ['vue', 'js', 'json', 'jsx', 'ts', 'tsx', 'node'] 7 | } 8 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './frontend/App.vue' 3 | import { store } from './frontend/store' 4 | 5 | const app = createApp(App) 6 | app.provide('store', store) 7 | 8 | app.mount('#app') 9 | -------------------------------------------------------------------------------- /src/rest/projects.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import { projectViewModel } from "../viewModels/projects"; 3 | 4 | export const projects = async (req: Request, res: Response) => { 5 | const vm = await projectViewModel() 6 | res.json(vm) 7 | } 8 | -------------------------------------------------------------------------------- /src/rest/index.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express' 2 | import { createConnection } from 'typeorm' 3 | import { projects } from './projects' 4 | 5 | (async () => { 6 | await createConnection() 7 | const app = express() 8 | app.use('/projects', projects) 9 | app.listen(5000) 10 | })() 11 | -------------------------------------------------------------------------------- /src/entity/User.ts: -------------------------------------------------------------------------------- 1 | import {Entity, PrimaryGeneratedColumn, Column} from "typeorm"; 2 | 3 | @Entity() 4 | export class User { 5 | 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column() 10 | firstName: string; 11 | 12 | @Column() 13 | lastName: string; 14 | 15 | @Column() 16 | age: number; 17 | 18 | } 19 | -------------------------------------------------------------------------------- /test/factories/projects.ts: -------------------------------------------------------------------------------- 1 | import { getRepository, DeepPartial } from 'typeorm' 2 | import { Project } from '../../src/entity/Project' 3 | 4 | export const createProject = (attrs: DeepPartial): Promise => { 5 | const repo = getRepository(Project) 6 | return repo.save({ 7 | name: attrs.name || 'My new project' 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "es5", 5 | "es6", 6 | "DOM" 7 | ], 8 | "target": "es5", 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "outDir": "./build", 12 | "emitDecoratorMetadata": true, 13 | "experimentalDecorators": true, 14 | "sourceMap": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /COMPARISON.md: -------------------------------------------------------------------------------- 1 | ## Rest 2 | 3 | - divides resources by "type" 4 | - several requests are often needed 5 | - (more) simple to implement 6 | - mostly static 7 | - flexibility via query params, etc. 8 | 9 | ## GraphQL 10 | 11 | - a single request 12 | - many resources can be fetched in a single request, result is not pre-defined 13 | - (more) complex to implement. N+1 problem is common. 14 | - highly flexible 15 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Kanban 7 | 8 | 9 |
10 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /REST.md: -------------------------------------------------------------------------------- 1 | ## Rest API 2 | 3 | GET /projects/ 4 | 5 | ```json 6 | [ 7 | { 8 | "id": 1, 9 | "name": "Test Project" 10 | } 11 | ] 12 | ``` 13 | 14 | GET /projects/1/categories 15 | 16 | ```json 17 | [ 18 | { 19 | "id": 1, 20 | "name": "ready to develop" 21 | } 22 | ] 23 | ``` 24 | 25 | GET /projects/1/tasks 26 | 27 | ```json 28 | [ 29 | { 30 | "id": 1, 31 | "name": "Test Project", 32 | "category_id": 1 33 | } 34 | ] 35 | ``` 36 | -------------------------------------------------------------------------------- /test/factories/categories.ts: -------------------------------------------------------------------------------- 1 | 2 | import { getRepository, DeepPartial } from 'typeorm' 3 | import { Category } from '../../src/entity/Category' 4 | import { Project } from '../../src/entity/Project' 5 | 6 | export const createCategory = (attrs: DeepPartial, project: Project): Promise => { 7 | const repo = getRepository(Category) 8 | return repo.save({ 9 | name: attrs.name || 'My Category', 10 | projectId: project.id 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /ormconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "postgres", 3 | "host": "localhost", 4 | "port": 5432, 5 | "username": "", 6 | "password": "", 7 | "database": "kanban", 8 | "synchronize": false, 9 | "logging": false, 10 | "entities": [ 11 | "src/entity/**/*.ts" 12 | ], 13 | "migrations": [ 14 | "src/migration/**/*.ts" 15 | ], 16 | "subscribers": [ 17 | "src/subscriber/**/*.ts" 18 | ], 19 | "cli": { 20 | "entitiesDir": "src/entity", 21 | "migrationsDir": "src/migration", 22 | "subscribersDir": "src/subscriber" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/frontend/types.ts: -------------------------------------------------------------------------------- 1 | export interface SelectProject { 2 | id: string 3 | name: string 4 | } 5 | 6 | export interface Task { 7 | id: string 8 | name: string 9 | categoryId: string 10 | } 11 | 12 | export interface CurrentProject { 13 | id: string 14 | name: string 15 | categories: Category[] 16 | tasks: Record 17 | } 18 | 19 | export interface Category { 20 | id: string 21 | name: string 22 | } 23 | 24 | export interface FetchProject { 25 | id: string 26 | name: string 27 | categories: Category[] 28 | tasks: Array<{ 29 | id: string 30 | name: string 31 | category: Category 32 | }> 33 | } 34 | -------------------------------------------------------------------------------- /src/viewModels/projects.ts: -------------------------------------------------------------------------------- 1 | import { getRepository } from "typeorm" 2 | import { Project } from "../entity/Project" 3 | 4 | export interface RestProject { 5 | id: number 6 | name: string 7 | categories: Array<{ id: number, name: string }> 8 | } 9 | 10 | export const projectViewModel = async (): Promise => { 11 | const query = await getRepository(Project) 12 | .createQueryBuilder('project') 13 | .innerJoinAndSelect('project.categories', 'categories') 14 | .getMany() 15 | 16 | return query.map(x => ({ 17 | id: x.id, 18 | name: x.name, 19 | categories: x.categories.map(y => ({ id: y.id, name: y.name })) 20 | })) 21 | } 22 | -------------------------------------------------------------------------------- /GRAPH_QL.md: -------------------------------------------------------------------------------- 1 | ## GraphQL 2 | 3 | POST /graphql 4 | 5 | ```gql 6 | { 7 | projects { 8 | categories { 9 | id 10 | name 11 | } 12 | tasks: { 13 | id 14 | name 15 | category { 16 | id 17 | name 18 | } 19 | } 20 | } 21 | } 22 | ``` 23 | 24 | ```json 25 | { 26 | "projects": [ 27 | "categories": [ 28 | { 29 | "id": 1, 30 | "name": "Cat 1" 31 | } 32 | ], 33 | "tasks": [ 34 | { 35 | "id": 1, 36 | "name": "Task 1", 37 | "category": { 38 | "id": 1, 39 | "name": "Cat 1" 40 | } 41 | } 42 | ] 43 | ] 44 | } 45 | ``` 46 | -------------------------------------------------------------------------------- /src/graphql/index.ts: -------------------------------------------------------------------------------- 1 | import { createConnection } from "typeorm" 2 | import { buildSchema } from "type-graphql" 3 | import { ProjectsResolver } from "./project.resolvers" 4 | import * as express from 'express' 5 | import * as graphqlHTTP from 'express-graphql' 6 | import * as cors from 'cors' 7 | import { TaskResolver } from "./task.resolvers" 8 | 9 | (async() => { 10 | await createConnection() 11 | const schema = await buildSchema({ 12 | resolvers: [ProjectsResolver, TaskResolver] 13 | }) 14 | 15 | const app = express() 16 | app.use(cors()) 17 | app.use('/graphql', graphqlHTTP({ 18 | schema, 19 | graphiql: true 20 | })) 21 | 22 | app.listen(4000) 23 | })() 24 | -------------------------------------------------------------------------------- /src/graphql/project.resolvers.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Arg, Info, Query } from 'type-graphql' 2 | import { Project } from '../entity/Project'; 3 | import { getRepository } from 'typeorm'; 4 | 5 | @Resolver(of => Project) 6 | export class ProjectsResolver { 7 | 8 | @Query(returns => Project) 9 | async project(@Arg('id') id: number, @Info() info) { 10 | const project = await getRepository(Project).findOne(id) 11 | 12 | if (!project) { 13 | throw Error(`Project with id ${id} not found`) 14 | } 15 | 16 | return project 17 | } 18 | 19 | @Query(returns => [Project]) 20 | async projects(): Promise { 21 | return getRepository(Project).find() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/graphql/task.resolvers.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Arg, Mutation, InputType, Field, ID } from 'type-graphql' 2 | import { getRepository } from 'typeorm'; 3 | import { Task } from '../entity/Task' 4 | 5 | @InputType('UpdateTask') 6 | class UpdateTask { 7 | @Field(type => ID) 8 | id: number 9 | 10 | @Field(type => ID) 11 | categoryId: number 12 | } 13 | 14 | @Resolver(of => Task) 15 | export class TaskResolver { 16 | 17 | @Mutation(returns => Task) 18 | async updatingTask(@Arg('task') updateTask: UpdateTask): Promise { 19 | const { id, categoryId } = updateTask 20 | const repo = getRepository(Task) 21 | await repo.update({ id }, { categoryId }) 22 | return repo.findOne(id) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /create_schema.sql: -------------------------------------------------------------------------------- 1 | begin; 2 | 3 | create table projects ( 4 | id serial primary key, 5 | name text not null 6 | ); 7 | 8 | create table categories ( 9 | id serial primary key, 10 | name text not null, 11 | project_id integer not null 12 | ); 13 | 14 | alter table categories add foreign key (project_id) references projects(id) on delete cascade; 15 | 16 | create table tasks ( 17 | id serial primary key, 18 | name text not null, 19 | project_id integer not null, 20 | category_id integer not null 21 | ); 22 | 23 | alter table tasks add foreign key (project_id) references projects(id) on delete cascade; 24 | alter table tasks add foreign key (category_id) references categories(id) on delete cascade; 25 | 26 | commit; 27 | -------------------------------------------------------------------------------- /src/provideInject.spec.ts: -------------------------------------------------------------------------------- 1 | import { provide, inject, h } from 'vue' 2 | import { mount } from '@vue/test-utils' 3 | 4 | const A = { 5 | setup () { 6 | const color = inject('color') 7 | provide('color', 'blue') 8 | return () => [ 9 | h('div', { id: 'a' }, `Color is ${color}`), 10 | h(B) 11 | ] 12 | } 13 | } 14 | 15 | const B = { 16 | setup () { 17 | const color = inject('color') 18 | return () => h('div', { id: 'b' }, `Color is ${color}`) 19 | } 20 | } 21 | 22 | const App = { 23 | setup() { 24 | provide('color', 'red') 25 | return () => [ 26 | h(A) 27 | ] 28 | } 29 | } 30 | 31 | test('dep. injection', () => { 32 | const wrapper = mount(App) 33 | console.log(wrapper.html()) 34 | }) -------------------------------------------------------------------------------- /src/entity/Project.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm' 2 | import { Category } from './Category' 3 | import { ObjectType, ID, Field } from 'type-graphql' 4 | import { Task } from './Task' 5 | 6 | @ObjectType() 7 | @Entity({ name: 'projects' }) 8 | export class Project { 9 | @Field(type => ID) 10 | @PrimaryGeneratedColumn() 11 | id: number 12 | 13 | @Field() 14 | @Column() 15 | name: string 16 | 17 | @Field(type => [Category]) 18 | @OneToMany(type => Category, category => category.project, { 19 | eager: true 20 | }) 21 | categories: Category[] 22 | 23 | @Field(type => [Task]) 24 | @OneToMany(type => Task, task => task.project, { 25 | eager: true 26 | }) 27 | tasks: Task[] 28 | } 29 | -------------------------------------------------------------------------------- /src/entity/Category.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, OneToMany } from 'typeorm' 2 | import { Project } from './Project' 3 | import { ObjectType, ID, Field } from 'type-graphql' 4 | import { Task } from './Task' 5 | 6 | @ObjectType() 7 | @Entity({ name: 'categories' }) 8 | export class Category { 9 | @Field(type => ID) 10 | @PrimaryGeneratedColumn() 11 | id: number 12 | 13 | @Field() 14 | @Column() 15 | name: string 16 | 17 | @Column({ name: 'project_id' }) 18 | projectId: number 19 | 20 | @ManyToOne(type => Project, project => project.categories) 21 | @JoinColumn({ name: 'project_id' }) 22 | project: Project 23 | 24 | @Field(type => [Category]) 25 | @OneToMany(type => Task, task => task.category) 26 | tasks: Task[] 27 | } 28 | -------------------------------------------------------------------------------- /src/frontend/SelectProject.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | // prop: modelValue 11 | // emit update:modelValue 12 | 13 | 36 | -------------------------------------------------------------------------------- /src/entity/Task.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column, OneToMany, ManyToOne, JoinColumn } from 'typeorm' 2 | import { ObjectType, ID, Field } from 'type-graphql' 3 | import { Category } from './Category' 4 | import { Project } from './Project' 5 | 6 | @ObjectType() 7 | @Entity({ name: 'tasks' }) 8 | export class Task { 9 | @Field(type => ID) 10 | @PrimaryGeneratedColumn() 11 | id: number 12 | 13 | @Field() 14 | @Column() 15 | name: string 16 | 17 | @Field(type => Category) 18 | @JoinColumn({ name: 'category_id' }) 19 | @ManyToOne(type => Category, category => category.tasks, { 20 | eager: true 21 | }) 22 | category: Category 23 | 24 | @Field(type => Project) 25 | @JoinColumn({ name: 'project_id' }) 26 | @ManyToOne(type => Project, project => project.tasks) 27 | project: Project 28 | 29 | @Column({ name: 'project_id' }) 30 | projectId: number 31 | 32 | @Column({ name: 'category_id' }) 33 | categoryId: number 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rest-graphql-kanban", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "axios": "^0.19.2", 8 | "vue": "^3.0.0-beta.20" 9 | }, 10 | "devDependencies": { 11 | "@types/cors": "^2.8.6", 12 | "@types/express": "^4.17.6", 13 | "@types/jest": "^26.0.0", 14 | "@types/node": "^8.0.29", 15 | "@vue/test-utils": "^2.0.0-beta.0", 16 | "class-validator": "^0.12.2", 17 | "cors": "^2.8.5", 18 | "express": "^4.17.1", 19 | "express-graphql": "^0.9.0", 20 | "graphql": "^15.2.0", 21 | "jest": "^26.0.1", 22 | "mysql": "^2.14.1", 23 | "pg": "^8.2.1", 24 | "reflect-metadata": "^0.1.10", 25 | "ts-jest": "^26.1.3", 26 | "ts-node": "^8.10.2", 27 | "ts-node-dev": "^1.0.0-pre.50", 28 | "type-graphql": "^1.0.0-rc.3", 29 | "typeorm": "0.2.13", 30 | "typescript": "^3.9.7", 31 | "vite": "^1.0.0-beta.11", 32 | "vue-jest": "^5.0.0-alpha.1" 33 | }, 34 | "scripts": { 35 | "start": "ts-node src/index.ts" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/frontend/App.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount, flushPromises } from '@vue/test-utils' 2 | import App from './App.vue' 3 | import { Store } from './store' 4 | 5 | const mockProjectsResponse = { 6 | projects: [ 7 | { 8 | id: '1', 9 | name: 'Project' 10 | } 11 | ] 12 | } 13 | 14 | const mockProjectResponse = { 15 | project: { 16 | id: '1', 17 | name: 'Project', 18 | categories: [{ 19 | id: '1', 20 | name: 'My Category', 21 | }], 22 | tasks: [] 23 | } 24 | } 25 | 26 | let mockResponse 27 | 28 | beforeAll(() => { 29 | global['fetch'] = (url: string) => ({ 30 | json: () => ({ 31 | data: mockResponse 32 | }) 33 | }) 34 | }) 35 | 36 | test('App', async () => { 37 | mockResponse = mockProjectsResponse 38 | const store = new Store() 39 | const wrapper = mount(App, { 40 | global: { 41 | provide: { 42 | store: store 43 | } 44 | } 45 | }) 46 | 47 | await flushPromises() 48 | 49 | mockResponse = mockProjectResponse 50 | wrapper.find('select').setValue('1') 51 | await flushPromises() 52 | 53 | expect(wrapper.html()).toContain('My Category') 54 | }) -------------------------------------------------------------------------------- /src/frontend/DraggableTask.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 41 | 42 | 53 | -------------------------------------------------------------------------------- /src/viewModels/__tests__/projects.spec.ts: -------------------------------------------------------------------------------- 1 | import { projectViewModel, RestProject } from '../projects' 2 | import { createConnection, Connection, getRepository, DeepPartial } from 'typeorm' 3 | import { Project } from '../../entity/Project' 4 | import { createProject } from '../../../test/factories/projects' 5 | import { Category } from '../../entity/Category' 6 | import { createCategory } from '../../../test/factories/categories' 7 | 8 | let connection: Connection 9 | 10 | beforeAll(async () => { 11 | connection = await createConnection() 12 | const repo = getRepository(Project) 13 | await repo.remove(await repo.find()) 14 | }) 15 | 16 | afterAll(async () => { 17 | await connection.close() 18 | }) 19 | 20 | test('project view model', async () => { 21 | const project = await createProject({ name: 'Project' }) 22 | const category = await createCategory({ name: 'Category' }, project) 23 | 24 | const expected: RestProject[] = [ 25 | { 26 | id: project.id, 27 | name: 'Project', 28 | categories: [ 29 | { 30 | id: category.id, 31 | name: 'Category' 32 | } 33 | ] 34 | } 35 | ] 36 | 37 | const p = await getRepository(Project).findOne({ id: project.id }) 38 | console.log(p.categories) 39 | 40 | const actual = await projectViewModel() 41 | 42 | expect(actual).toEqual(expected) 43 | }) 44 | -------------------------------------------------------------------------------- /src/frontend/Category.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 53 | 54 | 64 | -------------------------------------------------------------------------------- /src/graphql/__tests__/projects.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { createConnection, Connection, getRepository, DeepPartial } from 'typeorm' 2 | import { Project } from '../../entity/Project' 3 | import { createProject } from '../../../test/factories/projects' 4 | import { Category } from '../../entity/Category' 5 | import { createCategory } from '../../../test/factories/categories' 6 | import { buildSchema } from 'type-graphql' 7 | import { ProjectsResolver } from '../project.resolvers' 8 | import { graphql } from 'graphql' 9 | 10 | let connection: Connection 11 | 12 | beforeAll(async () => { 13 | connection = await createConnection() 14 | const repo = getRepository(Project) 15 | await repo.remove(await repo.find()) 16 | }) 17 | 18 | afterAll(async () => { 19 | await connection.close() 20 | }) 21 | 22 | test('project resolvers', async () => { 23 | const project = await createProject({ name: 'Project' }) 24 | const category = await createCategory({ name: 'Category' }, project) 25 | 26 | const expected = { 27 | project: { 28 | id: project.id.toString(), 29 | name: 'Project', 30 | categories: [ 31 | { 32 | id: category.id.toString(), 33 | name: 'Category' 34 | } 35 | ] 36 | } 37 | } 38 | 39 | const schema = await buildSchema({ 40 | resolvers: [ProjectsResolver] 41 | }) 42 | 43 | const actual = await graphql({ 44 | schema, 45 | source: ` 46 | { 47 | project(id: ${project.id}) { 48 | id 49 | name 50 | categories { 51 | id 52 | name 53 | } 54 | } 55 | } 56 | ` 57 | }) 58 | 59 | expect(actual.data).toEqual(expected) 60 | }) 61 | -------------------------------------------------------------------------------- /src/frontend/App.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 62 | 63 | 69 | -------------------------------------------------------------------------------- /src/frontend/store.ts: -------------------------------------------------------------------------------- 1 | import { reactive, inject, provide } from 'vue' 2 | import { SelectProject, CurrentProject, FetchProject } from './types' 3 | 4 | interface State { 5 | projects: SelectProject[] 6 | currentProject?: CurrentProject 7 | count: number 8 | } 9 | 10 | function initialState(): State { 11 | return { 12 | projects: [], 13 | count: 0 14 | } 15 | } 16 | 17 | export class Store { 18 | protected state: State 19 | 20 | constructor(init: State = initialState()) { 21 | this.state = reactive(init) 22 | } 23 | 24 | increment() { 25 | this.state.count += 1 26 | } 27 | 28 | getState(): State { 29 | return this.state 30 | } 31 | 32 | async fetchProject(id: string) { 33 | const response = await window.fetch('http://localhost:4000/graphql', { 34 | method: 'POST', 35 | headers: { 36 | 'Content-Type': 'application/json' 37 | }, 38 | body: JSON.stringify({ 39 | query: ` 40 | { 41 | project(id: ${id}) { 42 | id 43 | name 44 | categories { 45 | id 46 | name 47 | } 48 | tasks { 49 | id 50 | name 51 | category { 52 | id 53 | } 54 | } 55 | } 56 | } 57 | ` 58 | }) 59 | }) 60 | const result: { data: { project: FetchProject } } = await response.json() 61 | this.state.currentProject = { 62 | id: result.data.project.id, 63 | name: result.data.project.name, 64 | categories: result.data.project.categories, 65 | tasks: result.data.project.tasks.reduce((acc, task) => { 66 | return { 67 | ...acc, 68 | [task.id]: { 69 | id: task.id, 70 | name: task.name, 71 | categoryId: task.category.id 72 | } 73 | } 74 | }, {}) 75 | } 76 | } 77 | 78 | async fetchProjects() { 79 | const response = await window.fetch('http://localhost:4000/graphql', { 80 | method: 'POST', 81 | headers: { 82 | 'Content-Type': 'application/json' 83 | }, 84 | body: JSON.stringify({ 85 | query: ` 86 | { 87 | projects { 88 | id 89 | name 90 | } 91 | }` 92 | }) 93 | }) 94 | const result: { data: { projects: SelectProject[] } } = await response.json() 95 | this.state.projects = result.data.projects 96 | } 97 | 98 | async updateTask(taskId: string, categoryId: string) { 99 | const response = await window.fetch('http://localhost:4000/graphql', { 100 | method: 'POST', 101 | headers: { 102 | 'Content-Type': 'application/json' 103 | }, 104 | body: JSON.stringify({ 105 | query: ` 106 | mutation { 107 | updatingTask(task: {id: ${taskId}, categoryId: ${categoryId}}) { 108 | category { 109 | id 110 | } 111 | } 112 | } 113 | ` 114 | }) 115 | }) 116 | const result: { data: { updatingTask: { category: { id: string } } } } = await response.json() 117 | store.getState().currentProject.tasks[taskId].categoryId = result.data.updatingTask.category.id 118 | } 119 | } 120 | 121 | export const store = new Store() 122 | 123 | export const useStore = (): Store => { 124 | return inject('store') 125 | } 126 | -------------------------------------------------------------------------------- /20200615_Kanban_Board_with_Typesafe_GraphQL_Part_2.md: -------------------------------------------------------------------------------- 1 | ## Add Categories 2 | 3 | Let's see how TypeORM handles relationships by adding categories to the view model. Update the definition for `RestProject`: 4 | 5 | ```ts 6 | interface RestProject { 7 | id: number 8 | name: string 9 | categories: Array<{ 10 | id: number 11 | name: string 12 | }> 13 | } 14 | ``` 15 | 16 | Then, update the test: 17 | 18 | ```ts 19 | import { createCategory } from '../../../test/factories/categories' 20 | 21 | // ... 22 | 23 | test('projectsViewModel', async () => { 24 | const project = await createProject({ name: 'Test Project' }) 25 | await createCategory({ name: 'Category' }, project) 26 | const expected: RestProject[] = [ 27 | { 28 | id: project.id, 29 | name: 'Test Project', 30 | categories: [ 31 | { 32 | id: 1, 33 | name: 'Category' 34 | } 35 | ] 36 | } 37 | ] 38 | 39 | const actual = await projectViewModel() 40 | 41 | expect(actual).toEqual(expected) 42 | }) 43 | ``` 44 | 45 | And add `tests/factories/categories.ts`: 46 | 47 | ```ts 48 | import { DeepPartial, getRepository } from 'typeorm' 49 | 50 | import { Category } from '../../src/entity/Category' 51 | import { Project } from '../../src/entity/Project' 52 | 53 | export const createCategory = ( 54 | category: DeepPartial, 55 | project: Project 56 | ) => { 57 | return getRepository(Category).save({ 58 | name: category.name, 59 | project_id: project.id 60 | }) 61 | } 62 | ``` 63 | 64 | We pass in the project as the second argument - `project_id` is a non-nullable column in the `categories` table, so we need to provide one. The test still won't pass yet - read on. 65 | 66 | ## TypeORM Relationships 67 | 68 | TypeORM has a really nice API for relationships. We want to express the one project -> many categories relationship, as well as the one category -> one project relationship. In other words, a 1..n (one to many) and a 1..1 (one to one) relationship. 69 | 70 | Update `src/entities/Project.ts`: 71 | 72 | ```ts 73 | import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm' 74 | import { Category } from './Category' 75 | 76 | @Entity({ name: 'projects' }) 77 | export class Project { 78 | 79 | // ... 80 | 81 | @OneToMany(type => Category, category => category.project) 82 | categories: Category 83 | } 84 | ``` 85 | 86 | All we need to do is add the property with the relevant decorators, and we will be able to access the categories with `project.categories`. Create `src/entities/Category.ts` and add the inverse: 87 | 88 | ```ts 89 | import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm' 90 | 91 | import { Project } from './Project' 92 | 93 | @Entity({ name: 'categories' }) 94 | export class Category { 95 | @PrimaryGeneratedColumn() 96 | id: number 97 | 98 | @Column() 99 | name: string 100 | 101 | @ManyToOne(type => Project, project => project.categories) 102 | @JoinColumn({ name: 'project_id' }) 103 | project: Project 104 | 105 | @Column() 106 | project_id: number 107 | } 108 | ``` 109 | 110 | Since we are not using the TypeORM default for the relationship (they use `projectId`), we need to specify the join column using the `JoinColumn` decorator. 111 | 112 | Finally, we can update the project view model and the test will pass: 113 | 114 | ```ts 115 | export const projectViewModel = async (): Promise => { 116 | const query = await getRepository(Project) 117 | .createQueryBuilder('project') 118 | .innerJoinAndSelect('project.categories', 'categories') 119 | .getMany() 120 | 121 | return query.map(x => ({ 122 | id: x.id, 123 | name: x.name, 124 | categories: x.categories.map(y => ({ id: y.id, name: y.name })) 125 | })) 126 | } 127 | ``` 128 | 129 | ## Adding the Controller and HTTP Server 130 | 131 | All the hard work is done, and we have 100% test coverage. Now we just need a way to expose it to the outside world. Add express, and in `src/rest` create `projects.ts` and `index.ts`. `projects.ts` will house the endpoint: 132 | 133 | ```ts 134 | import { Request, Response } from 'express' 135 | 136 | import { projectViewModel } from '../viewModels/projects' 137 | 138 | export const projects = async (req: Request, res: Response) => { 139 | const vm = await projectViewModel() 140 | res.json(vm) 141 | } 142 | ``` 143 | 144 | Simple stuff, not much to explain. Finally in `src/rest/index.ts` add a little express app (and note this is where we create the database connection): 145 | 146 | ```ts 147 | import * as express from 'express' 148 | import { createConnection } from 'typeorm' 149 | 150 | import { projects } from './projects' 151 | 152 | (async () => { 153 | await createConnection() 154 | const app = express() 155 | app.use('/projects', projects) 156 | app.listen(5000, () => console.log('Listening on port 5000')) 157 | })() 158 | ``` 159 | 160 | Run this however you like - I just like to use `ts-node` and run `yarn ts-node src/rest/index.ts`. You can curl it and see the following: 161 | 162 | ```sh 163 | $ curl http://localhost:5000/projects | json_pp 164 | 165 | [ 166 | { 167 | "categories" : [ 168 | { 169 | "id" : 1, 170 | "name" : "Ready to develop" 171 | } 172 | ], 173 | "id" : 1, 174 | "name" : "Test" 175 | } 176 | ] 177 | ``` 178 | 179 | If you go to `ormconfig.json` and set "logging: true", you can see the SQL that is executed: 180 | 181 | ```sh 182 | $ yarn ts-node src/rest/index.ts 183 | yarn run v1.22.4 184 | $ /Users/lachlan/code/dump/rest-graphql-kanban/node_modules/.bin/ts-node src/rest/index.ts 185 | Listening on port 5000 186 | 187 | query: SELECT "project"."id" AS "project_id", "project"."name" AS "project_name", "categories"."id" AS "categories_id", "categories"."name" AS "categories_name", "categories"."projectId" AS "categories_projectId" FROM "projects" "project" INNER JOIN "categories" "categories" ON "categories"."projectId"="project"."id" 188 | ``` 189 | 190 | You can see we get the projects and categories in a single query - this is important to remember, since we want to avoid the N+1 problem when we implement the GraphQL server! 191 | 192 | Implementing the `tasks` and `categories` view models and endpoints is no different to `projects`, so I will leave that as an exercise. You can find the full implementation in the [source code](https://github.com/lmiller1990/graphql-rest-vue). 193 | . 194 | 195 | ## Conclusion 196 | 197 | This post covered: 198 | 199 | - TypeORM 200 | - implementing a REST API 201 | - separating core logic via a view model layer to make it testable 202 | - creating factories to support tests 203 | 204 | The next posts will look at a GraphQL server, and the Vue front-end. 205 | -------------------------------------------------------------------------------- /PART_4.md: -------------------------------------------------------------------------------- 1 | The first three parts of this series focused on building the back-end - now we move onto the frontend! Note: if you are following along, I extended the `ProjectResolver` a little bit since the previous article, so check out the GitHub repository to get the latest changes. 2 | 3 | This article will focus on querying the API from the Vue app, building the select project dropdown, and a reactive store. As a reminder, the goal is a Kanban board like this: 4 | 5 | ![](https://raw.githubusercontent.com/lmiller1990/graphql-rest-vue/develop/SS1.png) 6 | 7 | ## Setting up Vite 8 | 9 | I will use [Vite](https://github.com/vitejs/vite), as opposed to the vue-cli, to develop the frontend. It's much faster and has TypeScript support out of the box. Install it with `yarn add vite --dev`. Since `vite` is designed for loading ES modules, and for frontend development, some of the existing dependencies will cause problems. Move *all* the existing dependencies to `devDependencies`. For more information on why, see the accompanying screencast. 10 | 11 | I created a new file at the root of the project, `index.html` with the following: 12 | 13 | ```html 14 | 15 | 16 | 17 | 18 | 19 | Kanban 20 | 21 | 22 |
23 | 25 | 26 | 27 | ``` 28 | 29 | Note that Vite can load TypeScript out of the box. Great! In `src/frontend/main.ts`, create a new Vue app: 30 | 31 | ```ts 32 | import { createApp } from 'vue' 33 | import App from './App.vue' 34 | 35 | const app = createApp(App) 36 | app.mount('#app') 37 | ``` 38 | 39 | `App.vue` is pretty simple, too: 40 | 41 | ```html 42 | 45 | 46 | 54 | ``` 55 | 56 | ## Loading Data 57 | 58 | The next thing is to load all the `projects`. I would normally use `axios` for this, but `axios` does not appear to have an ES build, so it won't work with Vite without some extra work. Instead, I will use `window.fetch`. The next question is how will we store the data? We could use the component local state, since this app is simple, but in my experience as apps grow, you need some kind of store. Let's make a simple one using Vue's new reactivity system. 59 | 60 | ## A Simple Store 61 | 62 | I will make a simple store. It will live in `src/frontend/store.ts`. I have also defined `SelectProject` interface, which will be used in the dropdown to select a project. `src/frontend/types.ts` looks like this: 63 | 64 | ```ts 65 | export interface SelectProject { 66 | id: string 67 | name: string 68 | } 69 | ``` 70 | 71 | The store is like this: 72 | 73 | ```ts 74 | import { reactive } from 'vue' 75 | import { SelectProject } from './types' 76 | 77 | interface State { 78 | projects: SelectProject[] 79 | } 80 | 81 | function initialState(): State { 82 | return { 83 | projects: [] 84 | } 85 | } 86 | 87 | class Store { 88 | protected state: State 89 | 90 | constructor(init: State = initialState()) { 91 | this.state = reactive(init) 92 | } 93 | 94 | getState(): State { 95 | return this.state 96 | } 97 | 98 | async fetchProjects() { 99 | // fetch posts... 100 | } 101 | } 102 | 103 | export const store = new Store() 104 | ``` 105 | 106 | The store is powered by Vue's new `reactive` function, which makes an object reactive. We also define the initial state to have a `projects` array, which will store the projects for the dropdown. How categories and tasks will be stored will be discussed later - for now we are just focusing on letting the user select a project. 107 | 108 | Another improvement that will come in the future is to use `provide` and `inject` instead of exporting the store instance directly from the store. Stay tuned! 109 | 110 | ## Adding CORS 111 | 112 | During development, we will have two servers: the graphql server and the Vite dev server. To allow cross origin requests, we need to enable CORS. I did this in `src/graphql/index.ts` using the `cors` package: 113 | 114 | ```ts 115 | // ... 116 | import * as express from 'express' 117 | import * as cors from 'cors' 118 | 119 | (async() => { 120 | // ... 121 | 122 | const app = express() 123 | app.use(cors()) 124 | // ... 125 | 126 | app.listen(4000) 127 | })() 128 | ``` 129 | 130 | ## Making a GraphQL Request with `fetch` 131 | 132 | You can use a library like `vue-apollo` to manage your GraphQL requests, but I'd like to keep things simple for this example. We will just use `fetch`. Update the store's `fetchProjects` function 133 | 134 | ```ts 135 | async fetchProjects() { 136 | const response = await window.fetch('http://localhost:4000/graphql', { 137 | method: 'POST', 138 | headers: { 139 | 'Content-Type': 'application/json' 140 | }, 141 | body: JSON.stringify({ 142 | query: ` 143 | { 144 | projects { 145 | id 146 | name 147 | } 148 | }` 149 | }) 150 | }) 151 | const result: { data: { projects: SelectProject[] } } = await response.json() 152 | this.state.projects = result.data.projects 153 | } 154 | ``` 155 | 156 | Unfortunately `fetch` does not have the nice generic types `axios` does, so we need to type the request manually. No big deal. 157 | 158 | ## The Select Project Dropdown 159 | 160 | Create a new component ``: 161 | 162 | ```html 163 | 170 | 171 | 183 | ``` 184 | 185 | And use it in `App.vue`: 186 | 187 | ```html 188 | 191 | 192 | 209 | ``` 210 | 211 | Importing the `store` instance is not ideal - the next article will show how to use dependency injection with `provide` and `inject`. 212 | 213 | ## Conclusion 214 | 215 | Although we just rendered a dropdown, which might not seem like much, we have set ourselves up for success by creating a store which will let our app scale, and are fetching the projects using `fetch` from our GraphQL API. The next step is allowing the user to select a project, which will load the categories and tasks, as well as making our store implementation more robust with `provide` and `inject`. 216 | -------------------------------------------------------------------------------- /PART_1.md: -------------------------------------------------------------------------------- 1 | Over the next few articles, I will be building a Kanban board app using GraphQL, Vue.js 3, postgres, Vite and some other technologies. Each article will focus on a different technology and some related concepts. The final product will look something like this: 2 | 3 | SS1 4 | 5 | The first article or two will focus on the how we present the data: REST vs GraphQL, and how this decision will impact our design. You can find the [source code here](https://github.com/lmiller1990/graphql-rest-vue). 6 | 7 | To really understand GraphQL and the problem it solves, you need to see the REST alternative, and its strengths and weaknesses. Furthermore, to get a good TypeScript experience with GraphQL, you need to use a good ORM. I recommend [TypeORM](https://typeorm.io/). We will first implement the Kanban board using REST, and then using GraphQL. This will let us compare and constrast the two. 8 | 9 | We will keep things modular and isolate our core logic, namely the construction of the SQL queries, so much of the logic can be shared between the REST and GraphQL servers. We will also learn about TypeORM along the way. 10 | 11 | ## The Database Schema 12 | 13 | The above mock-up has several "entities": 14 | 15 | - projects 16 | - categories (the columns) 17 | - tasks 18 | 19 | Projects have zero or more categories - a one to many relationship. Tasks, on the other hand, have one category - a one to one relationship. The database could look something like this (and this is the database schema I will use for the series): 20 | 21 | ```sql 22 | create table projects ( 23 | id serial primary key, 24 | name text not null 25 | ); 26 | 27 | create table categories ( 28 | id serial primary key, 29 | project_id integer not null, 30 | name text not null, 31 | foreign key (project_id) references projects(id), 32 | ); 33 | 34 | create table tasks ( 35 | id serial primary key, 36 | name text not null, 37 | project_id integer not null, 38 | category_id integer not null, 39 | foreign key (project_id) references projects(id), 40 | foreign key (category_id) references categories(id) 41 | ); 42 | ``` 43 | 44 | ## As a REST API 45 | 46 | A very generic REST API might have several endpoints with the following responses. Note, we could add nested `categories` and `tasks` to the `/projects` endpoint, however this would not be very generic - let's imagine the API is provided by a third party project management service, and we are building a kanban board on top of their APIs. 47 | 48 | The projects endpoint, `/projects`, might be something like this 49 | 50 | ```json 51 | [ 52 | { 53 | "id": 1, 54 | "name": "Test Project" 55 | } 56 | ] 57 | ``` 58 | 59 | You could get the categories on a project by project basis from `/projects/1/categories`: 60 | 61 | ```json 62 | [ 63 | { 64 | "id": 1, 65 | "name": "ready to develop" 66 | } 67 | ] 68 | ``` 69 | 70 | And finally, the tasks at `/projects/1/tasks`: 71 | 72 | ```json 73 | [ 74 | { 75 | "id": 1, 76 | "name": "Test Project", 77 | "category_id": 1 78 | } 79 | ] 80 | ``` 81 | 82 | The third part has kindly given us the `category_id` in the tasks response, rather than making us query `/projects/1/categories/2/tasks` etc. I rarely design REST APIs that go more than 2 or 3 resources deep, since it's far too tedious and rarely makes sense. 83 | 84 | To get the full dataset for our app, we need 3 requests. 85 | 86 | - `/projects` to get a list of projects for the dropdown. 87 | - `/projects/1/categories` to get the categories. 88 | - `/projects/1/tasks` to get the tasks. 89 | 90 | While three requests might not be idea, REST APIs are designed like this so developers can build whatever application they like - it's not specifically designed the minimize requests, but to be generically applicable to most use cases. 91 | 92 | For now, let's implement the above REST API using TypeORM. 93 | 94 | ## Setup 95 | 96 | Install the dependencies: `yarn add typeorm reflect-metadata @types/node pg`. Next, create a new typeorm project: `typeorm init --database pg`. Finally, create a new database with the following - I am calling my database `kanban`. 97 | 98 | ```sql 99 | create table projects ( 100 | id serial primary key, 101 | name text not null 102 | ); 103 | 104 | create table categories ( 105 | id serial primary key, 106 | name text not null 107 | ); 108 | 109 | create table tasks ( 110 | id serial primary key, 111 | name text not null, 112 | project_id integer not null, 113 | category_id integer not null, 114 | foreign key (project_id) references projects(id), 115 | foreign key (category_id) references categories(id) 116 | ); 117 | ``` 118 | 119 | ## TypeORM Crash Course 120 | 121 | Running the `typeorm init` created a `src/entity` directory. Let's create an entity for the `projects` table in `src/entity/projects.ts`: 122 | 123 | ```ts 124 | import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm' 125 | 126 | @Entity({ name: 'projects' }) 127 | export class Project { 128 | @PrimaryGeneratedColumn() 129 | id: number 130 | 131 | @Column() 132 | name: string 133 | } 134 | ``` 135 | 136 | The code is most self-explanatory. TypeORM uses a decorator-based API. This works well with GraphQL, which we will see later on. Now that we have a valid entity, update `ormconfig.json`, which was created when we ran `typeorm init`, and let's write our first TypeORM test. 137 | 138 | ## Testing the Project Entity 139 | 140 | This test alone won't be super valuable, but it will help us setup the plumbing for future tests. Since we want to keep our core logic modular and testable, we will be exposing data via controllers that are thin layers on top of **view models**. The view models will encapsulate any complexity behind the REST API, such as pagination, query params and optimizing the SQL. When we implement the GraphQL API, optimizing the SQL queries will be very important, since the N+1 problem becomes an issue very quickly when implementing GraphQL servers. 141 | 142 | Create `src/viewModels/projects.ts` and `src/viewModels/__tests__/projects.spec.ts`, and in the test file, add the following: 143 | 144 | ```ts 145 | import { createConnection, Connection } from 'typeorm' 146 | 147 | import { projectViewModel } from '../projects' 148 | import { createProject } from '../../../test/factories/projects' 149 | 150 | let connection: Connection 151 | 152 | beforeAll(async () => { 153 | connection = await createConnection() 154 | await connection.synchronize(true) 155 | }) 156 | 157 | afterAll(async () => { 158 | connection.close() 159 | }) 160 | 161 | test('projectViewModel', async () => { 162 | const project = await createProject({ name: 'Test' }) 163 | const vm = await projectViewModel() 164 | 165 | expect(vm).toEqual([ 166 | { 167 | id: project.id, 168 | name: 'Test' 169 | } 170 | ]) 171 | }) 172 | ``` 173 | 174 | Before we write the missing code to make this test pass, let's look at each part. 175 | 176 | - you need to call `createConnection` before interacting with your database via TypeORM, so we do this in the `beforeAll` hook (and close the connection in `afterAll`). 177 | - by calling `connection.synchronize(true)`, all the data will be dropped before the test, giving us a clean slate. The `true` argument has this effect (confusingly enough. I wish it was `synchronize({ dropData: true })` or something more descriptive. 178 | - we will create some **factories** to make writing tests easy - this is what `createProject` is, and why it's imported from `tests/factories`. This will let use quickly create test data. 179 | 180 | ## Creating a Project factory 181 | 182 | There are many ways to handle factory data (also known as *fixtures*, sometimes). I like to keep things simple. the `createProject` function takes a `DeepPartial`, so we can easily specify project fields when creating the test data to fit the test we are writing. 183 | 184 | ```ts 185 | import { getRepository, DeepPartial } from 'typeorm' 186 | import { Project } from '../../src/entity/Project' 187 | 188 | export const createProject = async (attrs: DeepPartial = {}): Promise => { 189 | return getRepository(Project).save({ 190 | name: attrs.name || 'Test project' 191 | }) 192 | } 193 | ``` 194 | 195 | ## Implementing the Projects View Model 196 | 197 | Now we can write the core business logic that will present the projects when the REST endpoint is called. Again, we are starting simple: 198 | 199 | ```ts 200 | import { getRepository } from 'typeorm' 201 | 202 | import { Project } from '../entity/Project' 203 | 204 | export const projectViewModel = async (): Promise => { 205 | return getRepository(Project) 206 | .createQueryBuilder('projects') 207 | .getMany() 208 | } 209 | ``` 210 | 211 | We could just have done `getRepository(Project).find()` - but this will not work when we need to do some joins. 212 | 213 | This is enough to get the test to pass when we run it when `yarn jest`. 214 | 215 | Implementing the `tasks` and `categories` view models and, so I will leave that as an exercise. You can find the full implementation in the [source code](https://github.com/lmiller1990/graphql-rest-vue). 216 | . 217 | 218 | The next article will explore how to implement relationships in TypeORM, for example `project.categories` and `category.tasks`, and add a HTTP endpoint with Express to expose our data. Then we will move on to GraphQL and the Vue.js front-end. 219 | 220 | ## Conclusion 221 | 222 | This post covered: 223 | 224 | - TypeORM 225 | - implementing the ViewModel architecture 226 | - separating core logic via a view model layer to make it testable 227 | - creating factories to support tests 228 | -------------------------------------------------------------------------------- /PART_3.md: -------------------------------------------------------------------------------- 1 | In the previous two articles, we looked at how to use TypeORM and Express to create a REST API in a modular, testable fashion. This lay the groundwork for our real goal: a GraphQL server. 2 | 3 | To build a GraphQL server and get a great TypeScript experience, we need a few libraries. 4 | 5 | You can find the source code for this article [here](https://github.com/lmiller1990/graphql-rest-vue). 6 | 7 | - [`express-graphql`](https://github.com/graphql/express-graphql) and `graphql`. `graphql` is a **JavaScript** implementation of GraphQL. `express-graphql` just wraps it nicely for us. 8 | - [`type-graphql`](https://github.com/MichalLytek/type-graphql). This will give us some decorators we can use to bridge the gap from GraphQL schema and our ORM (TypeORM in this case). 9 | 10 | I don't normally like to use too many libraries, but this is the best combination of libraries I've found to work with GraphQL and TypeScript. 11 | 12 | The goal will to be have a single endpoint, from which we can query for projects, tasks and categories: 13 | 14 | ```json 15 | { 16 | projects(id: 1) { 17 | tasks { 18 | id 19 | name 20 | category { 21 | id 22 | } 23 | } 24 | } 25 | } 26 | ``` 27 | 28 | Let's get started! 29 | 30 | ## Decorators type-graphql 31 | 32 | One of the nice things about `type-graphql` is it also uses a decorator based API, which fits well with TypeORM. The first thing we need to do is specify which classes and fields are going to be exposed via our Graph API. For now, let's just update `Project` and `Category`: 33 | 34 | ```ts 35 | import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm' 36 | import { Field, ID, ObjectType } from 'type-graphql' 37 | import { Category } from './Category' 38 | 39 | @ObjectType() 40 | @Entity({ name: 'projects' }) 41 | export class Project { 42 | @Field(type => ID) 43 | @PrimaryGeneratedColumn() 44 | id: number 45 | 46 | @Field() 47 | @Column() 48 | name: string 49 | 50 | @Field(type => [Category]) 51 | @OneToMany(type => Category, category => category.project) 52 | categories: Category[] 53 | } 54 | ``` 55 | 56 | I imported `Field`, `ID`, and `ObjectType` from `type-graphql` and applied them in the same way I applied the TypeORM decorators. They are written in a similar fashion to the TypeORM decorators - specifically, they take a callback which has one argument, usually named `type`, which specfies the type. Instead of `Category[]`, to specify an array we write the `[Category]` syntax. `ObjectType` is a bit of an ambiguous name; `GraphQLObject` would probably be more clear. Naming is tough, I guess? `Category` looks similar: 57 | 58 | ```ts 59 | import { ObjectType, Field, ID } from 'type-graphql' 60 | import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm' 61 | import { Project } from './Project' 62 | 63 | @ObjectType() 64 | @Entity({ name: 'categories' }) 65 | export class Category { 66 | @Field(type => ID) 67 | @PrimaryGeneratedColumn() 68 | id: number 69 | 70 | @Field() 71 | @Column() 72 | name: string 73 | 74 | @Column({ name: 'project_id' }) 75 | projectId: number 76 | 77 | @ManyToOne(type => Project, project => project.categories) 78 | @JoinColumn({ name: 'project_id' }) 79 | project: Project 80 | } 81 | ``` 82 | 83 | ## The GraphQL Endpoint 84 | 85 | Before we work on the **resolvers**, which is analagous to the View Model from the REST endpoint we made, let's create the GraphQL HTTP endpoint. I made a file called `src/graphql/index.ts`. There is a bit going on here - see below for an explanation. 86 | 87 | ```ts 88 | import 'reflect-metadata' 89 | import { createConnection, useContainer } from 'typeorm' 90 | import * as graphqlHTTP from 'express-graphql' 91 | import * as express from 'express' 92 | import { buildSchema } from 'type-graphql' 93 | 94 | import { ProjectResolver } from './project.resolvers' 95 | 96 | (async () => { 97 | const connection = await createConnection() 98 | const schema = await buildSchema({ 99 | resolvers: [ProjectResolver], 100 | container: Container 101 | }) 102 | const app = express() 103 | app.use('/graphql', graphqlHTTP({ 104 | schema, 105 | graphiql: true 106 | })) 107 | app.listen(4000) 108 | })() 109 | ``` 110 | 111 | The only part here actually required for a GraphQL server is `buildSchema` and the express app. The last thing we need before we see some GraphQL goodness is the `ProjectResolver`. 112 | 113 | ## Creating the ProjectResolver 114 | 115 | Resolvers in GraphQL are what takes in the query from the client, figures out what to load, and returns what the client asked for. In our case, they will run some SQL queries - same as the View Model from the REST API. Let's see some code, and talk about it: 116 | 117 | ```ts 118 | import { Query, Resolver, Arg } from 'type-graphql' 119 | import { InjectRepository } from 'typeorm-typedi-extensions' 120 | import { Repository } from 'typeorm' 121 | import { Project } from '../entity/Project' 122 | 123 | @Resolver(of => Project) 124 | export class ProjectResolver { 125 | 126 | @Query(returns => [Project]) 127 | async projects(@Arg('id') id: number) { 128 | const project = await this.repo.findOne(id) 129 | if (!project) { 130 | throw Error(`No project found for id ${id}`) 131 | } 132 | return project 133 | } 134 | } 135 | ``` 136 | 137 | This is enough to get us up and running. Start your GraphQL server - I like to use `ts-node` in development. I run it in watch mode with `yarn ts-node-dev src/graphql/index.ts`. I can run a query by visiting `http://localhost:4000/graphql`: 138 | 139 | SS1 140 | 141 | Fun stuff! But we just tested by hand - let's automate this a bit. 142 | 143 | ## Writing a Resolver Test 144 | 145 | The previous article covers most of this snippet, so let's see the test first. You might try and do something like this we with REST endpoint: 146 | 147 | ```ts 148 | test('project resolver', async () => { 149 | const project = await createProject({ name: 'Project' }) 150 | const category = await createCategory({ name: 'Category' }, project) 151 | 152 | const expected = { 153 | id: project.id, 154 | name: 'Project', 155 | categories: [ 156 | { 157 | id: category.id, 158 | name: 'Category' 159 | } 160 | ] 161 | } 162 | const resolver = new ProjectResolver(repo) 163 | const actual = await resolver.project(id: project.id) 164 | 165 | expect(actual).toEqual(expected) 166 | }) 167 | ``` 168 | 169 | This won't work out too well for a number of reasons. Firstly, we are not loading the categories eagerly - so this would be failing. Even if we did, though, it is not as simple as just creating a new `ProjectResolver` and passing in the arguments - since we are using `type-graphql` decorators, to test the resolver as it behaves in production we need to create new `graphql` instance, similar to what we do in `src/graphql/index.ts`. Before doing this, however, we need a few prerequisites: 170 | 171 | - create a database connection 172 | - create a graphql instance 173 | 174 | Update the test to use a `graphql` instance, and query it like we did in the GraphiQL UI. It's a lot of code - this is closer to an end to end, or integration test, than a unit test. That's fine - not everything has to be super granular or modular. This way, we get more coverage, and we are testing in a similar manner to production. 175 | 176 | ```ts 177 | import { createConnection, Connection, getRepository, Repository } from 'typeorm' 178 | import { Container } from 'typedi' 179 | import { graphql } from 'graphql' 180 | import { buildSchema } from 'type-graphql' 181 | 182 | import { Project } from '../../entity/Project' 183 | import { createProject } from '../../../test/factories/projects' 184 | import { createCategory } from '../../../test/factories/categories' 185 | import { ProjectResolver } from '../project.resolvers' 186 | 187 | let connection: Connection 188 | let repo: Repository 189 | 190 | beforeAll(async () => { 191 | connection = await createConnection() 192 | repo = getRepository(Project) 193 | await repo.remove(await repo.find()) 194 | }) 195 | 196 | afterAll(async () => { 197 | await connection.close() 198 | }) 199 | 200 | test('project resolver', async () => { 201 | const project = await createProject({ name: 'Project' }) 202 | const category = await createCategory({ name: 'Category' }, project) 203 | 204 | const expected = { 205 | project: { 206 | id: project.id.toString(), 207 | name: 'Project', 208 | categories: [ 209 | { 210 | id: category.id.toString(), 211 | name: 'Category' 212 | } 213 | ] 214 | } 215 | } 216 | 217 | const schema = await buildSchema({ 218 | resolvers: [ProjectResolver], 219 | container: Container 220 | }) 221 | 222 | const actual = await graphql({ 223 | schema, 224 | source: ` 225 | { 226 | project(id: ${project.id}) { 227 | id 228 | name 229 | categories { 230 | id 231 | name 232 | } 233 | } 234 | } 235 | ` 236 | }) 237 | 238 | expect(actual.data).toEqual(expected) 239 | }) 240 | ``` 241 | 242 | I discuss more about query optimization in the accompanying screencast. Check it out! 243 | 244 | ## Conclusion 245 | 246 | This article covered a lot of content: 247 | 248 | - setting up a GraphQL server using `type-graphql`, TypeORM and some utils 249 | - Jest `setupFiles` 250 | - Querying a GraphQL endpoint 251 | 252 | In the next article and screencast we will start building the front-end using Vue.js 3, powered by our GraphQL endpoint. 253 | 254 | -------------------------------------------------------------------------------- /20200705_Kanban_Board_with_Typesafe_GraphQL_Part_3.md: -------------------------------------------------------------------------------- 1 | In the previous two articles, we looked at how to use TypeORM and Express to create a REST API in a modular, testable fashion. This lay the groundwork for our real goal: a GraphQL server. 2 | 3 | To build a GraphQL server and get a great TypeScript experience, we need a few libraries. 4 | 5 | You can find the source code for this article [here](https://github.com/lmiller1990/graphql-rest-vue). 6 | 7 | - [`express-graphql`](https://github.com/graphql/express-graphql) and `graphql`. `graphql` is a **JavaScript** implementation of GraphQL. `express-graphql` just wraps it nicely for us. 8 | - [`type-graphql`](https://github.com/MichalLytek/type-graphql). This will give us some decorators we can use to bridge the gap from GraphQL schema and our ORM (TypeORM in this case). 9 | 10 | I don't normally like to use too many libraries, but this is the best combination of libraries I've found to work with GraphQL and TypeScript. 11 | 12 | The goal will to be have a single endpoint, from which we can query for projects, tasks and categories: 13 | 14 | ```json 15 | { 16 | projects(id: 1) { 17 | tasks { 18 | id 19 | name 20 | category { 21 | id 22 | } 23 | } 24 | } 25 | } 26 | ``` 27 | 28 | Let's get started! 29 | 30 | ## Decorators type-graphql 31 | 32 | One of the nice things about `type-graphql` is it also uses a decorator based API, which fits well with TypeORM. The first thing we need to do is specify which classes and fields are going to be exposed via our Graph API. For now, let's just update `Project` and `Category`: 33 | 34 | ```ts 35 | import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm' 36 | import { Field, ID, ObjectType } from 'type-graphql' 37 | import { Category } from './Category' 38 | 39 | @ObjectType() 40 | @Entity({ name: 'projects' }) 41 | export class Project { 42 | @Field(type => ID) 43 | @PrimaryGeneratedColumn() 44 | id: number 45 | 46 | @Field() 47 | @Column() 48 | name: string 49 | 50 | @Field(type => [Category]) 51 | @OneToMany(type => Category, category => category.project) 52 | categories: Category[] 53 | } 54 | ``` 55 | 56 | I imported `Field`, `ID`, and `ObjectType` from `type-graphql` and applied them in the same way I applied the TypeORM decorators. They are written in a similar fashion to the TypeORM decorators - specifically, they take a callback which has one argument, usually named `type`, which specfies the type. Instead of `Category[]`, to specify an array we write the `[Category]` syntax. `ObjectType` is a bit of an ambiguous name; `GraphQLObject` would probably be more clear. Naming is tough, I guess? `Category` looks similar: 57 | 58 | ```ts 59 | import { ObjectType, Field, ID } from 'type-graphql' 60 | import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm' 61 | import { Project } from './Project' 62 | 63 | @ObjectType() 64 | @Entity({ name: 'categories' }) 65 | export class Category { 66 | @Field(type => ID) 67 | @PrimaryGeneratedColumn() 68 | id: number 69 | 70 | @Field() 71 | @Column() 72 | name: string 73 | 74 | @Column({ name: 'project_id' }) 75 | projectId: number 76 | 77 | @ManyToOne(type => Project, project => project.categories) 78 | @JoinColumn({ name: 'project_id' }) 79 | project: Project 80 | } 81 | ``` 82 | 83 | ## The GraphQL Endpoint 84 | 85 | Before we work on the **resolvers**, which is analagous to the View Model from the REST endpoint we made, let's create the GraphQL HTTP endpoint. I made a file called `src/graphql/index.ts`. There is a bit going on here - see below for an explanation. 86 | 87 | ```ts 88 | import 'reflect-metadata' 89 | import { createConnection, useContainer } from 'typeorm' 90 | import * as graphqlHTTP from 'express-graphql' 91 | import * as express from 'express' 92 | import { buildSchema } from 'type-graphql' 93 | 94 | import { ProjectResolver } from './project.resolvers' 95 | 96 | (async () => { 97 | const connection = await createConnection() 98 | const schema = await buildSchema({ 99 | resolvers: [ProjectResolver], 100 | container: Container 101 | }) 102 | const app = express() 103 | app.use('/graphql', graphqlHTTP({ 104 | schema, 105 | graphiql: true 106 | })) 107 | app.listen(4000) 108 | })() 109 | ``` 110 | 111 | The only part here actually required for a GraphQL server is `buildSchema` and the express app. The last thing we need before we see some GraphQL goodness is the `ProjectResolver`. 112 | 113 | ## Creating the ProjectResolver 114 | 115 | Resolvers in GraphQL are what takes in the query from the client, figures out what to load, and returns what the client asked for. In our case, they will run some SQL queries - same as the View Model from the REST API. Let's see some code, and talk about it: 116 | 117 | ```ts 118 | import { Query, Resolver, Arg } from 'type-graphql' 119 | import { InjectRepository } from 'typeorm-typedi-extensions' 120 | import { Repository } from 'typeorm' 121 | import { Project } from '../entity/Project' 122 | 123 | @Resolver(of => Project) 124 | export class ProjectResolver { 125 | 126 | @Query(returns => [Project]) 127 | async projects(@Arg('id') id: number) { 128 | const project = await this.repo.findOne(id) 129 | if (!project) { 130 | throw Error(`No project found for id ${id}`) 131 | } 132 | return project 133 | } 134 | } 135 | ``` 136 | 137 | This is enough to get us up and running. Start your GraphQL server - I like to use `ts-node` in development. I run it in watch mode with `yarn ts-node-dev src/graphql/index.ts`. I can run a query by visiting `http://localhost:4000/graphql`: 138 | 139 | SS1 140 | 141 | Fun stuff! But we just tested by hand - let's automate this a bit. 142 | 143 | ## Writing a Resolver Test 144 | 145 | The previous article covers most of this snippet, so let's see the test first. You might try and do something like this we with REST endpoint: 146 | 147 | ```ts 148 | test('project resolver', async () => { 149 | const project = await createProject({ name: 'Project' }) 150 | const category = await createCategory({ name: 'Category' }, project) 151 | 152 | const expected = { 153 | id: project.id, 154 | name: 'Project', 155 | categories: [ 156 | { 157 | id: category.id, 158 | name: 'Category' 159 | } 160 | ] 161 | } 162 | const resolver = new ProjectResolver(repo) 163 | const actual = await resolver.project(id: project.id) 164 | 165 | expect(actual).toEqual(expected) 166 | }) 167 | ``` 168 | 169 | This won't work out too well for a number of reasons. Firstly, we are not loading the categories eagerly - so this would be failing. Even if we did, though, it is not as simple as just creating a new `ProjectResolver` and passing in the arguments - since we are using `type-graphql` decorators, to test the resolver as it behaves in production we need to create new `graphql` instance, similar to what we do in `src/graphql/index.ts`. Before doing this, however, we need a few prerequisites: 170 | 171 | - create a database connection 172 | - create a graphql instance 173 | 174 | Update the test to use a `graphql` instance, and query it like we did in the GraphiQL UI. It's a lot of code - this is closer to an end to end, or integration test, than a unit test. That's fine - not everything has to be super granular or modular. This way, we get more coverage, and we are testing in a similar manner to production. 175 | 176 | ```ts 177 | import { createConnection, Connection, getRepository, Repository } from 'typeorm' 178 | import { Container } from 'typedi' 179 | import { graphql } from 'graphql' 180 | import { buildSchema } from 'type-graphql' 181 | 182 | import { Project } from '../../entity/Project' 183 | import { createProject } from '../../../test/factories/projects' 184 | import { createCategory } from '../../../test/factories/categories' 185 | import { ProjectResolver } from '../project.resolvers' 186 | 187 | let connection: Connection 188 | let repo: Repository 189 | 190 | beforeAll(async () => { 191 | connection = await createConnection() 192 | repo = getRepository(Project) 193 | await repo.remove(await repo.find()) 194 | }) 195 | 196 | afterAll(async () => { 197 | await connection.close() 198 | }) 199 | 200 | test('project resolver', async () => { 201 | const project = await createProject({ name: 'Project' }) 202 | const category = await createCategory({ name: 'Category' }, project) 203 | 204 | const expected = { 205 | project: { 206 | id: project.id.toString(), 207 | name: 'Project', 208 | categories: [ 209 | { 210 | id: category.id.toString(), 211 | name: 'Category' 212 | } 213 | ] 214 | } 215 | } 216 | 217 | const schema = await buildSchema({ 218 | resolvers: [ProjectResolver], 219 | container: Container 220 | }) 221 | 222 | const actual = await graphql({ 223 | schema, 224 | source: ` 225 | { 226 | project(id: ${project.id}) { 227 | id 228 | name 229 | categories { 230 | id 231 | name 232 | } 233 | } 234 | } 235 | ` 236 | }) 237 | 238 | expect(actual.data).toEqual(expected) 239 | }) 240 | ``` 241 | 242 | I discuss more about query optimization in the accompanying screencast. Check it out! 243 | 244 | ## Conclusion 245 | 246 | This article covered a lot of content: 247 | 248 | - setting up a GraphQL server using `type-graphql`, TypeORM and some utils 249 | - Jest `setupFiles` 250 | - Querying a GraphQL endpoint 251 | 252 | In the next article and screencast we will start building the front-end using Vue.js 3, powered by our GraphQL endpoint. 253 | 254 | -------------------------------------------------------------------------------- /20200615_Kanban_Board_with_Typesafe_GraphQL_Part_1.md: -------------------------------------------------------------------------------- 1 | Over the next few articles, I will be building a Kanban board app using GraphQL, Vue.js 3, postgres, Vite and some other technologies. 2 | 3 | Each article will focus on a different technology and some related concepts. The final product will look something like this: 4 | 5 | ![](https://raw.githubusercontent.com/lmiller1990/graphql-rest-vue/develop/SS1.png) 6 | 7 | The first article or two will focus on the how we present the data: REST vs GraphQL, and how this decision will impact our design. You can find the [source code here](https://github.com/lmiller1990/graphql-rest-vue). 8 | 9 | To really understand GraphQL and the problem it solves, you need to see the REST alternative, and its strengths and weaknesses. Furthermore, to get a good TypeScript experience with GraphQL, you need to use a good ORM. I recommend [TypeORM](https://typeorm.io/). We will first implement the Kanban board using REST, and then using GraphQL. This will let us compare and constrast the two. 10 | 11 | We will keep things modular and isolate our core logic, namely the construction of the SQL queries, so much of the logic can be shared between the REST and GraphQL servers. We will also learn about TypeORM along the way. 12 | 13 | ## The Database Schema 14 | 15 | The above mock-up has several "entities": 16 | 17 | - projects 18 | - categories (the columns) 19 | - tasks 20 | 21 | Projects have zero or more categories - a one to many relationship. Tasks, on the other hand, have one category - a one to one relationship. The database could look something like this (and this is the database schema I will use for the series): 22 | 23 | ```sql 24 | create table projects ( 25 | id serial primary key, 26 | name text not null 27 | ); 28 | 29 | create table categories ( 30 | id serial primary key, 31 | name text not null, 32 | project_id integer not null, 33 | foreign key (project_id) references projects(id) on delete cascade 34 | ); 35 | 36 | create table tasks ( 37 | id serial primary key, 38 | name text not null, 39 | project_id integer not null, 40 | category_id integer not null, 41 | foreign key (project_id) references projects(id) on delete cascade, 42 | foreign key (category_id) references categories(id) on delete cascade 43 | ); 44 | ``` 45 | 46 | ## As a REST API 47 | 48 | A very generic REST API might have several endpoints with the following responses. Note, we could add nested `categories` and `tasks` to the `/projects` endpoint, however this would not be very generic - let's imagine the API is provided by a third party project management service, and we are building a kanban board on top of their APIs. 49 | 50 | The projects endpoint, `/projects`, might be something like this 51 | 52 | ```json 53 | [ 54 | { 55 | "id": 1, 56 | "name": "Test Project" 57 | } 58 | ] 59 | ``` 60 | 61 | You could get the categories on a project by project basis from `/projects/1/categories`: 62 | 63 | ```json 64 | [ 65 | { 66 | "id": 1, 67 | "name": "ready to develop" 68 | } 69 | ] 70 | ``` 71 | 72 | And finally, the tasks at `/projects/1/tasks`: 73 | 74 | ```json 75 | [ 76 | { 77 | "id": 1, 78 | "name": "Test Project", 79 | "category_id": 1 80 | } 81 | ] 82 | ``` 83 | 84 | The third part has kindly given us the `category_id` in the tasks response, rather than making us query `/projects/1/categories/2/tasks` etc. I rarely design REST APIs that go more than 2 or 3 resources deep, since it's far too tedious and rarely makes sense. 85 | 86 | To get the full dataset for our app, we need 3 requests. 87 | 88 | - `/projects` to get a list of projects for the dropdown. 89 | - `/projects/1/categories` to get the categories. 90 | - `/projects/1/tasks` to get the tasks. 91 | 92 | While three requests might not be idea, REST APIs are designed like this so developers can build whatever application they like - it's not specifically designed the minimize requests, but to be generically applicable to most use cases. 93 | 94 | For now, let's implement the above REST API using TypeORM. 95 | 96 | ## Setup 97 | 98 | Install the dependencies: `yarn add typeorm reflect-metadata @types/node pg`. Next, create a new typeorm project: `typeorm init --database pg`. Finally, create a new database with the following - I am calling my database `kanban`. 99 | 100 | ```sql 101 | create table projects ( 102 | id serial primary key, 103 | name text not null 104 | ); 105 | 106 | create table categories ( 107 | id serial primary key, 108 | name text not null, 109 | project_id integer not null, 110 | foreign key (project_id) references projects(id) on delete cascade 111 | ); 112 | 113 | create table tasks ( 114 | id serial primary key, 115 | name text not null, 116 | project_id integer not null, 117 | category_id integer not null, 118 | foreign key (project_id) references projects(id) on delete cascade, 119 | foreign key (category_id) references categories(id) on delete cascade 120 | ); 121 | ``` 122 | 123 | ## TypeORM Crash Course 124 | 125 | Running the `typeorm init` created a `src/entity` directory. Let's create an entity for the `projects` table in `src/entity/projects.ts`: 126 | 127 | ```ts 128 | import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm' 129 | 130 | @Entity({ name: 'projects' }) 131 | export class Project { 132 | @PrimaryGeneratedColumn() 133 | id: number 134 | 135 | @Column() 136 | name: string 137 | } 138 | ``` 139 | 140 | The code is most self-explanatory. TypeORM uses a decorator-based API. This works well with GraphQL, which we will see later on. Now that we have a valid entity, update `ormconfig.json`, which was created when we ran `typeorm init`, and let's write our first TypeORM test. 141 | 142 | ## Testing the Project Entity 143 | 144 | This test alone won't be super valuable, but it will help us setup the plumbing for future tests. Since we want to keep our core logic modular and testable, we will be exposing data via controllers that are thin layers on top of **view models**. The view models will encapsulate any complexity behind the REST API, such as pagination, query params and optimizing the SQL. When we implement the GraphQL API, optimizing the SQL queries will be very important, since the N+1 problem becomes an issue very quickly when implementing GraphQL servers. 145 | 146 | Create `src/viewModels/projects.ts` and `src/viewModels/__tests__/projects.spec.ts`, and in the test file, add the following: 147 | 148 | ```ts 149 | import { createConnection, Connection } from 'typeorm' 150 | 151 | import { projectViewModel } from '../projects' 152 | import { createProject } from '../../../test/factories/projects' 153 | 154 | let connection: Connection 155 | 156 | beforeAll(async () => { 157 | connection = await createConnection() 158 | const repo = getRepository(Project) 159 | await repo.remove(await repo.find()) 160 | }) 161 | 162 | afterAll(async () => { 163 | connection.close() 164 | }) 165 | 166 | test('projectViewModel', async () => { 167 | const project = await createProject({ name: 'Test' }) 168 | const vm = await projectViewModel() 169 | 170 | expect(vm).toEqual([ 171 | { 172 | id: project.id, 173 | name: 'Test' 174 | } 175 | ]) 176 | }) 177 | ``` 178 | 179 | Before we write the missing code to make this test pass, let's look at each part. 180 | 181 | - you need to call `createConnection` before interacting with your database via TypeORM, so we do this in the `beforeAll` hook (and close the connection in `afterAll`). 182 | - we will create some **factories** to make writing tests easy - this is what `createProject` is, and why it's imported from `tests/factories`. This will let use quickly create test data. 183 | - we delete all the projects before each test to ensure a fresh database is used 184 | 185 | ## Creating a Project factory 186 | 187 | There are many ways to handle factory data (also known as *fixtures*, sometimes). I like to keep things simple. the `createProject` function takes a `DeepPartial`, so we can easily specify project fields when creating the test data to fit the test we are writing. 188 | 189 | ```ts 190 | import { getRepository, DeepPartial } from 'typeorm' 191 | import { Project } from '../../src/entity/Project' 192 | 193 | export const createProject = async (attrs: DeepPartial = {}): Promise => { 194 | return getRepository(Project).save({ 195 | name: attrs.name || 'Test project' 196 | }) 197 | } 198 | ``` 199 | 200 | ## Implementing the Projects View Model 201 | 202 | Now we can write the core business logic that will present the projects when the REST endpoint is called. Again, we are starting simple: 203 | 204 | ```ts 205 | import { getRepository } from 'typeorm' 206 | 207 | import { Project } from '../entity/Project' 208 | 209 | export const projectViewModel = async (): Promise => { 210 | return getRepository(Project) 211 | .createQueryBuilder('projects') 212 | .getMany() 213 | } 214 | ``` 215 | 216 | We could just have done `getRepository(Project).find()` - but this will not work when we need to do some joins. 217 | 218 | This is enough to get the test to pass when we run it when `yarn jest`. 219 | 220 | Implementing the `tasks` and `categories` view models and, so I will leave that as an exercise. You can find the full implementation in the [source code](https://github.com/lmiller1990/graphql-rest-vue). 221 | . 222 | 223 | The next article will explore how to implement relationships in TypeORM, for example `project.categories` and `category.tasks`, and add a HTTP endpoint with Express to expose our data. Then we will move on to GraphQL and the Vue.js front-end. 224 | 225 | ## Conclusion 226 | 227 | This post covered: 228 | 229 | - TypeORM 230 | - implementing the ViewModel architecture 231 | - separating core logic via a view model layer to make it testable 232 | - creating factories to support tests 233 | -------------------------------------------------------------------------------- /PART_5.md: -------------------------------------------------------------------------------- 1 | In this article, we continue building our the front-end of kanban board. We previously added the ability to select a project; now we need to fetch the relevant categories and tasks, store the data somewhere in our store, and render a column for each category! 2 | 3 | The first thing we need to do is update our `` component to use `v-model`, so we know when a project was selected. Vue.js 3 changes how `v-model` works slightly, but the idea is the same. The main different is the value is received is a `modelValue` prop, instead of `value` and you need to emit a `update:modelValue` event, instead of an `input` event. 4 | 5 | ```html 6 | 14 | 15 | 40 | ``` 41 | 42 | The changes I've made are highlighted. Now we can use the component with `v-model` in `App.vue`. I will also use `watch` to fetch the relevant project data whenever the selected project changes (we will implement the `fetchProject` function next): 43 | 44 | ```html 45 | 48 | 49 | 73 | ``` 74 | 75 | ## Fetching the Project Data 76 | 77 | Now we need to fetch the categories and tasks. We have already got an endpoint for that, so we just need to decide how we will store the data. If this project was going to grow much larger, I would probably create a separate for for `tasks`. Some kanban boards, like Jira, allow you to move tasks between projects, so nesting the tasks under a project might not be ideal. I'd also consider organizing my flux store with something like [flux-entities](https://github.com/lmiller1990/flux-entities). For this series, however, we will keep things simple and as such just nest the categories and tasks under a single `currentProject` field in the store. This will give us some duplication: we will have the current project `id` and `name` in both `projects` and `currentProjects`, however I think it is acceptable for a small application such as this one. 78 | 79 | Define some new types in `types.ts`: 80 | 81 | ```ts 82 | interface Task { 83 | id: string 84 | name: string 85 | categoryId: string 86 | } 87 | 88 | interface Category { 89 | id: string 90 | name: string 91 | } 92 | 93 | interface CurrentProject { 94 | id: string 95 | name: string 96 | categories: Category[] 97 | tasks: Record 98 | } 99 | 100 | interface FetchProject { 101 | project: { 102 | id: string 103 | name: string 104 | categories: Array<{ 105 | id: string 106 | name: string 107 | }> 108 | tasks: Array<{ 109 | id: string 110 | name: string 111 | }> 112 | } 113 | } 114 | ``` 115 | 116 | Notice `categories` is an array and `tasks` is an object. I see the need to access a single task frequently, such as when we implement drag and drop, or perhaps we want to show a modal when a task is clicked: this these cases, I don't want to loop over and array to find my task, which is an `O(n)` operation (not that it matters in this tiny app), as opposed to an `O(1)` operation, which we get using an object. Basically, I see myself doing `tasksMap[id]` more often than I see myself looping over all the tasks, so a key value map (using an object) makes sense here. 117 | 118 | I could use a similar approach for `categories` as well; that said, I think an array will be more simple here, so I am going with that. I would likely use key value map (the `Record` type in TypeScript) if I saw the app growing more complex, for the same reason; I think I'll be looking up a single category more often than looping over them all. 119 | 120 | I also added a `FetchProject` interface. This will be the shape of the response from the graphql endpoint. 121 | 122 | We can now implement `fetchProject`: 123 | 124 | ```ts 125 | interface State { 126 | projects: SelectProject[] 127 | currentProject?: CurrentProject 128 | } 129 | 130 | // ... 131 | 132 | async fetchProject(id: string) { 133 | const response = await window.fetch('http://localhost:4000/graphql', { 134 | method: 'POST', 135 | headers: { 136 | 'Content-Type': 'application/json' 137 | }, 138 | body: JSON.stringify({ 139 | query: ` 140 | { 141 | project(id: ${id}) { 142 | id 143 | name 144 | categories { 145 | id 146 | name 147 | } 148 | tasks { 149 | id 150 | name 151 | } 152 | } 153 | }` 154 | }) 155 | }) 156 | const result: { data: FetchProject } = await response.json() 157 | this.state.currentProject = { 158 | id: result.data.project.id, 159 | name: result.data.project.name, 160 | categories: result.data.project.categories.map(x => ({ id: x.id, name: x.name })), 161 | tasks: result.data.project.tasks.reduce((acc, curr) => acc[curr.id] = curr, {}) 162 | } 163 | } 164 | ``` 165 | 166 | I updated `State` to have an optional `currentProject` field. If it is `undefined`, we assume the project has not been fetched yet. `fetchProject` is very similar to the `fetchProjects` function from the previous article; there is a lot of duplication here, which could easily be refactored away. This would be a good exercise. We do some simple manipulating of the response to make it fit the `CurrentProject` interface. 167 | 168 | Now to render the categories! Add a `Category.vue` component: 169 | 170 | ```html 171 | 176 | 177 | 189 | 190 | 199 | ``` 200 | 201 | Nothing much to see here. This is a UI component; it's output is entirely based on its inputs (the `props` in this case) so it will be very easy to test. Update `App.vue` to use the `Category.vue` component: 202 | 203 | ```html 204 | 210 | 211 | 238 | 239 | 244 | ``` 245 | 246 | And that's enough to get our columns rendering, with the title of the category in each one! The last thing we need to do is render the tasks, which is easy, and implement drag and drop. Drag and drop is also relatively straight-forward. That will be the focus on the next article! We are in the home stretch. 247 | 248 | The app is getting fairly complex now, so it's a good time to add a test. There is a fair bit going on here. The important parts are: 249 | 250 | - Jest and jsdom do not have `window.fetch`: we are able to mock it by adding `fetch` to the `global` object 251 | - We can use a `mockResponse` variable and change it during the test to simulate different responses from the graphql endpoint 252 | - Both of our data fetching functions `fetchProject` and `fetchProjects` are marked as `async`; we need to use `flush-promises` to force those promises to resolve before the test continues 253 | - `flush-promises` is now exported from Vue Test Utils as of `2.0.0-beta.0`! Convinient. 254 | 255 | I go into more depth regarding this test in the accompanying screencast. Check it out! 256 | 257 | ```ts 258 | import { mount, flushPromises } from '@vue/test-utils' 259 | import App from '../App.vue' 260 | 261 | const projectsResponse = { 262 | projects: [{ 263 | id: '1', 264 | name: 'Project 1', 265 | }] 266 | } 267 | 268 | const projectResponse = { 269 | project: { 270 | id: '1', 271 | name: 'Project', 272 | categories: [ 273 | { id: '1', name: 'Category 1' } 274 | ], 275 | tasks: [] 276 | } 277 | } 278 | 279 | let mockResponse 280 | 281 | describe('App', () => { 282 | beforeAll(() => { 283 | global['fetch'] = (url: string) => ({ 284 | json: () => ({ 285 | data: mockResponse 286 | }) 287 | }) 288 | }) 289 | 290 | afterAll(() => { 291 | delete global['fetch'] 292 | }) 293 | 294 | it('renders categories', async () => { 295 | mockResponse = projectsResponse 296 | const wrapper = mount(App) 297 | await flushPromises() 298 | 299 | mockResponse = projectResponse 300 | await wrapper.find('[data-testid="select-project"]').setValue('1') 301 | await flushPromises() 302 | 303 | expect(wrapper.html()).toContain('Category 1') 304 | }) 305 | }) 306 | ``` 307 | 308 | We could write some more tests for `Category` and `SelectProject`: I would do this if they got more complex. For now, I am happy to test everything in a single test, which gives me more coverage and is closer to what a user will be experiencing when they use the application. There are not really any edge cases for `SelectProject` or `Category`, since they are so simple, so I am confident to test the as part of the system in `App.vue`, as opposed to in isolation. The purpose of tests isn't to test everything edge case, and every component in isolation, but to be confident in your application. 309 | 310 | You could actually use the real graphql server in this test, if you liked: you would need to figure out a way to use `setValue` without knowing hardcoding the project (you could just create it in `beforeAll` using the `createProject` function we defined for our back-end tests, and grab it from there, though). This might be a good exercise. 311 | ## Conclusion 312 | 313 | This article focused on rendering the columns for the kanban board. We covered 314 | 315 | - new `modelValue` and `update:modelValue` syntax to use `v-model` with a component 316 | - defined some additional types, forcing us to consider the data structures for our store 317 | -------------------------------------------------------------------------------- /PART_6.md: -------------------------------------------------------------------------------- 1 | In the sixth and final part of this series, we will implement drag and drop, as well as our first GraphQL mutation (as opposed to a query) to update data, rather than just fetching it. 2 | 3 | NOTE: if you are following along, I made some small changes to the app since part 5. Specifically, each task only belongs to 1 category, but I had set the relationship like this: `@OneToMany(type => Task, task => task.categories)`. I have since updated it to be `@OneToMany(type => Task, task => task.category)`, which is more semantically accurate. I had to update the relevant query in the flux store, as well as the test mock response. The actual behavior remains the same. I also updated the `create_schema.sql` script slightly. Again, find the final version in the source code on GitHub. 4 | 5 | You can find the final source code here. 6 | 7 | ## Rendering Tasks 8 | 9 | We are rendering categories already, but no tasks. We do have those saved in the flux store, though, so let's start by grabbing the correct tasks for each category. I will handle this in `App.vue`: 10 | 11 | ```html 12 | 23 | 24 | 69 | 70 | 76 | ``` 77 | 78 | I had to import the `Category` interface as `ICategory` since I also named my component `Category`. We also need to update `Category.vue` to render the tasks: I will do this by adding another component, `DraggableTask.vue` (which will be draggable in the near future!) 79 | 80 | `Category.vue`: 81 | 82 | ```html 83 | 94 | 95 | 112 | 113 | 123 | ``` 124 | 125 | And `DraggableTask.vue`: 126 | 127 | 128 | ```html 129 | 136 | 137 | 148 | 149 | 160 | ``` 161 | 162 | Finally, our kanban board is starting to take shape: 163 | 164 | SS1 165 | 166 | ## Drag and Drop 167 | 168 | Implementing drag and drop is somewhat of a rite of passage for any front-end developer. Of course we could use a library, but in my experience, libraries are either too featureful and complex, or not featureful enough, or hard to modified to your liking. Since we only need a very simple implementation, we will just roll our own. Plus, it's a great way to learn. Once we have drag and drop working, we will add the back-end code to persist the change in category. 169 | 170 | First, we need to make the `DraggableTask` draggable, and specify what happens when we start and stop dragging the element: 171 | 172 | ```html 173 | 184 | 185 | 213 | ``` 214 | 215 | Once you set an element to `draggable="true"`, it will be draggable in the browser. Because we need some way to track which task is getting dragged and where it is dropped, we set that data with `dataTransfer` as a stringified JSON object. 216 | 217 | We are not actually using the `dragging` ref, but you could bind to this (for example with `:class` or `:style`) to visually indicate a task is in the dragging state (for example we could make the other tasks a bit more opace). This would probably be a better UX, however for the purpose of this article we will not be doing this - the goal is just to illustrate how to build the actual kanban board. 218 | 219 | The next thing we need to do is specify what happens when the task is dropped. Update `Category.vue`: 220 | 221 | ```html 222 | 238 | 239 | 272 | ``` 273 | 274 | You need to specify both `@dragover.prevent` and `@drop.prevent` - see what happens if you don't. We also add an event handler in `@drop.prevent` to handle updating the DOM. We do this in a very manual manner, as opposed to using Vue's virtual DOM to update the DOM. Simple is best! We only want to let the user drop on a category element, so we do a check to ensure the `data-dropdone` attribute is present. Then we grab the DOM element and insert it into the category it was dropped on. 275 | 276 | We did it - you can now drag and drop tasks between categories. They won't be persisted though - we need a new resolver, a `TaskResolver`, and a GraphQL mutation to do this. 277 | 278 | ## Adding a TaskResolver 279 | 280 | The `TaskResolver` we are going to make (in `src/graphql/task.resolvers.ts`) is very similar to the `ProjectResolver`, so we won't go into too much detail. The main difference is we are now specifying the payload using an `InputType` decorator. To keep things simple, we will only support updating the `categoryId` for a task. 281 | 282 | ```ts 283 | import { Resolver, Arg, Mutation, InputType, Field, ID } from 'type-graphql' 284 | import { getRepository } from 'typeorm' 285 | import { Task } from '../entity/Task' 286 | 287 | @InputType('UpdateTask') 288 | class UpdateTask { 289 | @Field(type => ID) 290 | id: number 291 | 292 | @Field(type => ID) 293 | categoryId: number 294 | } 295 | 296 | @Resolver(of => Task) 297 | export class TaskResolver { 298 | 299 | @Mutation(returns => Task) 300 | async updatingTask(@Arg('task') updateTask: UpdateTask): Promise { 301 | const { id, categoryId } = updateTask 302 | const repo = getRepository(Task) 303 | await repo.update({ id }, { categoryId }) 304 | return repo.findOne(id) 305 | } 306 | } 307 | ``` 308 | 309 | Pretty straight forward. We receive a payload with a `id` (for the task) and a `categoryId` and update the relevant column using `update`. Then we returned the newly updated task. 310 | 311 | Don't forget to add this to the root of our GraphQL server: 312 | 313 | ```ts 314 | import { TaskResolver } from "./task.resolvers" 315 | 316 | // ... 317 | 318 | (async() => { 319 | await createConnection() 320 | const schema = await buildSchema({ 321 | resolvers: [ProjectsResolver, TaskResolver] 322 | }) 323 | 324 | // ... 325 | })() 326 | ``` 327 | 328 | We can now create a function in the store to make the request: 329 | 330 | ```ts 331 | class Store { 332 | // ... 333 | async updateTask(taskId: string, categoryId: string) { 334 | const response = await window.fetch('http://localhost:4000/graphql', { 335 | method: 'POST', 336 | headers: { 337 | 'Content-Type': 'application/json' 338 | }, 339 | body: JSON.stringify({ 340 | query: ` 341 | mutation { 342 | updatingTask(task: {id: ${taskId}, categoryId: ${categoryId}}) { 343 | category { 344 | id 345 | } 346 | } 347 | } 348 | ` 349 | }) 350 | }) 351 | const result: { data: { updatingTask: { category: { id: string } } } } = await response.json() 352 | store.getState().currentProject.tasks[taskId].categoryId = result.data.updatingTask.category.id 353 | } 354 | } 355 | ``` 356 | 357 | We can see the benefit of saving the `tasks` as a non-nested entity - we can access and update the task just by referencing `tasks[taskId].categoryId`. If we had made `tasks` a nested array on `categories`, we would need to iterate the tasks on the old category, remove it, then add it to the new category. A lot of extra code and not nearly as performant, not to mention more code and more complexity leads to more bugs. 358 | 359 | This brings us to the end of this series. We did not write a test for the `TaskResolver`, nor the drag and drop. Writing a `TaskResolver` test is fairly trivial, and a good exercise. While you can test drag and drop with Vue Test Utils or Testing Library, I much prefer to test this kind of thing either with Cypress (so you can visual confirm it "looks" correct - drag and drop really needs to look good, not just "work", to be useful) or even just test it by hand. I may look at some strategies for testing this kind of interaction in a future article if there is interest! 360 | 361 | ## Conclusion 362 | 363 | The final installment in this series looked at: 364 | 365 | - Implementing drag and drop. 366 | - Using a GraphQL mutation. 367 | - Further emphasised the importance of choosing a the right data structure - we saw how making `tasks` a key-value map made it trivial to update the category. 368 | 369 | As of next week, I will return to the traditional format of self contained articles and screencasts. If you have any suggestions or requests, please let me know. 370 | 371 | The final source code for this project can be found here. 372 | --------------------------------------------------------------------------------