├── .env.example ├── .gitignore ├── .gitlab-ci.yml ├── .npmrc ├── .vscode ├── launch.json └── settings.json ├── Dockerfile ├── Readme.md ├── config ├── db.js └── index.ts ├── db.docker-compose.yml ├── db ├── index.ts ├── migrations │ ├── 20190530000728-add-user.js │ └── 20190530010448-add-todo.js └── models │ ├── Todo.ts │ ├── User.ts │ └── index.ts ├── docker-compose.yml ├── index.ts ├── lib ├── cache.ts ├── error.ts ├── index.ts ├── logger.ts ├── redis.ts ├── sentry.ts └── session.ts ├── middlewares ├── auth.ts ├── context.ts └── index.ts ├── package.json ├── postgres-initdb.sh ├── scripts └── createSchema.ts ├── src ├── directives │ ├── auth.ts │ ├── findOption.ts │ ├── index.ts │ ├── relation.ts │ └── sql.ts ├── index.ts ├── plugin │ └── httpStatus.ts ├── resolvers │ ├── Mutation.ts │ ├── Query.ts │ ├── Todo.ts │ └── User.ts ├── scalars │ └── index.ts └── utils.ts ├── tsconfig.json ├── type.ts └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | REDIS_HOST=localhost 2 | 3 | DB_DATABASE=todos 4 | DB_DIALECT=postgres 5 | DB_HOST=localhost 6 | DB_PORT=5432 7 | DB_USERNAME=postgres 8 | 9 | SALT="hello,world" 10 | JWT_SECRET=shanyue 11 | SENTRY_DSN="https://14f249998e234949820a1e24949bdddb@sentry.io/1489452" 12 | 13 | NODE_ENV=development 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | db/_schemas 4 | logs 5 | .env 6 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - pre 3 | - deploy 4 | 5 | audit: 6 | stage: pre 7 | image: node:10-alpine 8 | script: 9 | - npm audit 10 | tags: 11 | - docker 12 | 13 | deploy: 14 | only: 15 | - master 16 | script: 17 | - docker-compose up --build -d 18 | tags: 19 | - shell 20 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Attach", 11 | "port": 9930, 12 | "restart": true 13 | }, 14 | { 15 | "type": "node", 16 | "request": "launch", 17 | "name": "Launch Typescript", 18 | "runtimeArgs": [ 19 | "-r", 20 | "ts-node/register" 21 | ], 22 | "args": [ 23 | "${workspaceFolder}/index.ts" 24 | ] 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.tabSize": 2, 4 | "editor.formatOnSave": false, 5 | "editor.rulers": [100], 6 | "editor.wordWrapColumn": 100, 7 | "files.trimTrailingWhitespace": true, 8 | "files.insertFinalNewline": true 9 | } 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10-alpine 2 | 3 | ENV NODE_ENV development 4 | ENV PORT 80 5 | WORKDIR /code 6 | 7 | ADD package.json package-lock.json /code/ 8 | 9 | RUN npm install 10 | 11 | ADD . /code 12 | 13 | RUN npm run config && npm run migrate 14 | 15 | USER node 16 | EXPOSE 80 17 | CMD npm start 18 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # GraphQL Starter 2 | 3 | 使用 [apollo-server](https://github.com/apollographql/apollo-server) 帮助你快速开发 [GraphQL](https://github.com/graphql/graphql-js)。 4 | 5 | ## 准备条件 6 | 7 | + `docker`,你需要使用它先启动 redis 与 postgres 8 | + `redis`/`postgres`,如果你有数据库,则可以使用现成的数据库,而无需 `docker` 部署及启动 9 | 10 | ## 快速开始 11 | 12 | 如果没有现成的数据库,需要准备数据库 db/redis 的环境 (使用 docker,你需要有 docker 环境),使用命令 `npm run env:db`。 13 | 14 | ``` bash 15 | $ git clone git@github.com:shfshanyue/apollo-server-starter.git 16 | $ cd apollo-server-starter 17 | 18 | # 如果没有现成的数据库,准备数据库 db/redis 的环境 (使用 docker) 19 | # 该命令通过 docker-compose -f db.compose.yml up,搭建本地数据库 20 | # redis6: https://hub.docker.com/_/redis 21 | # postgres14: https://hub.docker.com/_/postgres 22 | $ npm run env:db 23 | 24 | # 配置环境变量 25 | $ cp .env.example .env 26 | 27 | # 迁移数据库 28 | $ npm run migrate 29 | 30 | # 开始开发 31 | $ npm run dev 32 | ``` 33 | 34 | ## 技术栈 35 | 36 | + [GraphQL.js](https://github.com/graphql/graphql-js), [apollo-server](https://github.com/apollographql/apollo-server), [koa](https://github.com/koajs/koa), [DataLoader](https://github.com/graphql/dataloader) —- API 层 37 | + `PostgresSQL`, `Redis`, [ioredis](https://github.com/luin/ioredis), [sequelize](https://github.com/sequelize/sequelize), [lru-cache](https://github.com/isaacs/node-lru-cache) -- 存储 38 | + [TypeScript](https://github.com/zhongsp/TypeScript), [sequelize-typescript](https://github.com/RobinBuschmann/sequelize-typescript) -- ts 支持 39 | + `Joi`, `consul`, [node-consul](https://github.com/silas/node-consul#readme), [winston](https://github.com/winstonjs/winston), [sentry](https://github.com/getsentry/sentry-javascript) -- 校验,配置,日志与报警 40 | + `Docker`, `docker-compose`, `gitlab-CI`, `traefik`, `kubernetes` -- 部署 41 | 42 | ## 目录结构 43 | 44 | ``` bash 45 | . 46 | ├── config # 配置文件 47 | │ ├── db.js # 数据库配置文件,主要被 sequelize-cli 使用 48 | │ └── index.ts # 关于本项目的配置,包括数据库,redis,主要从环境变量中读取 49 | ├── db # 关于 db 50 | │ ├── index.ts # 关于数据库的配置以及日志 (sequelize) 51 | │ ├── migrations/ # 关于数据库的迁移脚本 52 | │ └── models/ # 关于数据库的 Model (typescript-sequelize) 53 | ├── lib # 关于 lib 54 | │ ├── error.ts # 异常的结构化与自定义异常 55 | │ ├── logger.ts # 关于日志的配置 (winston) 56 | │ ├── redis.ts # 关于 redis 的配置以及日志 (ioredis) 57 | │ ├── sentry.ts # 关于 sentry 的配置以及初始化 58 | │ └── session.ts # 关于 CLS 的控制 59 | ├── logs # 日志,自动生成 60 | │ ├── api.log # graphql 的日志 61 | │ ├── common.log # 通用日志 62 | │ ├── db.log # 关于数据库 `SQL` 的日志 63 | │ └── redis.log # 关于 redis 执行语句的日志 64 | ├── middlewares # KOA 中间件 65 | │ ├── auth.ts # 认证,解析出 user 66 | │ ├── context.ts # requestId,以及一些列上下文打进日志以及 Sentry 67 | │ └── index.ts # 导出所有中间件 68 | ├── scripts # 脚本 69 | │ └── createSchema.ts # 自动生成 graphql schema 与数据库 schema 的脚本 70 | ├── src # 关于 graphql 的一系列 71 | │ ├── index.ts # graphql typeDefs & resolvers 72 | │ ├── directives/ # graphql directives 73 | │ ├── resolvers/ # graphql resolvers (Mutation & Query) 74 | │ ├── scalars/ # graphql scalars 75 | │ └── utils.ts # graphql 的辅助函数 76 | ├── .env.example # 数据库与redis的配置,以及一些敏感数据 77 | ├── Dockerfile # Dockerfile 78 | ├── db.docker-compose.yml # 数据库环境准备 79 | ├── docker-compose.yml # docker-compose 80 | ├── package-lock.json # pakcage-lock.json 81 | ├── package.json # package.json 82 | ├── tsconfig.json # 关于 ts 的配置 83 | ├── index.ts # 服务入口 84 | └── type.ts # typescript 支持 85 | ``` 86 | 87 | ### 关于数据库的操作 88 | 89 | ``` bash 90 | npm run migrate:new # 生成新的迁移文件 91 | npm run migrate # 执行迁移文件 92 | npm run migrate:undo # 撤销执行的迁移文件 93 | ``` 94 | 95 | ### 自动生成 resolve 与 数据库 model 96 | 97 | ``` bash 98 | $ npm run schema hello # 生成 Hello.ts 99 | ``` 100 | 101 | ### 查看日志 102 | 103 | ``` bash 104 | # 查看数据库的日志 105 | $ npm run log:db 106 | 107 | # 查看 graphql 的日志 108 | $ LOG=query npm run log 109 | ``` 110 | 111 | ## 开发指南 112 | 113 | ### 单文件管理 `typeDef` 与 `resolver` 114 | 115 | 如下,在单文件中定义 `ObjectType` 与其在 `Query` 以及 `Mutation` 中对应的查询。并把 `typeDef` 与 `resolver` 集中管理。 116 | 117 | ``` typescript 118 | // src/resolvers/Todo.ts 119 | const typeDef = gql` 120 | type Todo @sql { 121 | id: ID! 122 | } 123 | 124 | extend type Query { 125 | todos: [Todo!] 126 | } 127 | 128 | extend type Mutation { 129 | createTodo: TODO! 130 | } 131 | ` 132 | 133 | const resolver: IResolverObject = { 134 | Todo: { 135 | user () {} 136 | }, 137 | Query: { 138 | todos () {} 139 | }, 140 | Mutation: { 141 | createTodo () {} 142 | } 143 | } 144 | ``` 145 | 146 | ### 按需取数据库字段 147 | 148 | 使用 `@findOption` 可以按需查询,并注入到 `resolver` 函数中的 `info.attributes` 字段 149 | 150 | ``` gql 151 | type Query { 152 | users: [User!] @findOption 153 | } 154 | 155 | query USERS { 156 | users { 157 | id 158 | name 159 | } 160 | } 161 | ``` 162 | 163 | ``` typescript 164 | function users ({}, {}, { models }, { attributes }: any) { 165 | return models.User.findAll({ 166 | attributes 167 | }) 168 | } 169 | ``` 170 | 171 | ### 分页 172 | 173 | 对列表添加 `page` 以及 `pageSize` 参数来进行分页 174 | 175 | ``` graphql 176 | type User { 177 | id: ID! 178 | todos ( 179 | page: Int = 1 180 | pageSize: Int = 10 181 | ): [Todo!] @findOption 182 | } 183 | 184 | query TODOS { 185 | todos (page: 1, pageSize: 10) { 186 | id 187 | name 188 | } 189 | } 190 | ``` 191 | 192 | 193 | ### 数据库层解决 N+1 查询问题 194 | 195 | 使用 [dataloader-sequelize](https://github.com/mickhansen/dataloader-sequelize) 解决数据库查询的 batch 问题 196 | 197 | 当使用以下查询时,会出现 N+1 查询问题 198 | 199 | ``` gql 200 | { 201 | users (page: 1, pageSize: 3) { 202 | id 203 | todos { 204 | id 205 | name 206 | } 207 | } 208 | } 209 | ``` 210 | 211 | 如果不做优化,生成的 `SQL` 如下 212 | 213 | ``` sql 214 | select id from users limit 3 215 | 216 | select id, name from todo where user_id = 1 217 | select id, name from todo where user_id = 2 218 | select id, name from todo where user_id = 3 219 | ``` 220 | 221 | 而使用 `dataloader` 解决 N+1 问题后,会大大减少 `SQL` 语句的条数,生成的 `SQL` 如下 222 | 223 | ``` sql 224 | select id from users limit 3 225 | 226 | select id, name, user_id from todo where user_id in (1, 2, 3) 227 | ``` 228 | 229 | > 注意 Batch 请求后需要返回 `user_id` 字段,为了重新分组 230 | 231 | ### N+1 Query 优化后问题 232 | 233 | 当有如下所示多级分页查询时,N+1 优化失效,所以应避免多级分页操作 234 | 235 | > 此处只能在客户端避免多层分页查询,而当有恶意查询时会加大服务器压力。可以使用以下的 Hash Query 避免此类问题,同时也在生产环境禁掉 `introspection` 236 | 237 | ``` gql 238 | { 239 | users (page: 1, pageSize: 3) { 240 | id 241 | todos (page: 1, pageSize: 3) { 242 | id 243 | name 244 | } 245 | } 246 | } 247 | ``` 248 | 249 | ``` sql 250 | select id from users limit 3 251 | 252 | select id, name from todo where user_id = 1 limit 3 253 | select id, name from todo where user_id = 2 limit 3 254 | select id, name from todo where user_id = 3 limit 3 255 | ``` 256 | 257 | ### 使用 DataLoader 解决 N+1 查询问题 258 | 259 | ### 使用 ID/Hash 代替 Query 260 | 261 | **TODO** 262 | **需要客户端配合** 263 | 264 | 当 `Query` 越来越大时,http 所传输的请求体积越来越大,严重影响应用的性能,此时可以把 `Query` 映射成 `hash`。 265 | 266 | 当请求体变小时,此时可以替代使用 `GET` 请求,方便缓存。 267 | 268 | **我发现掘金的 GraphQL Query 已由 ID 替代** 269 | 270 | ### 使用 `consul` 管理配置 271 | 272 | `project` 代表本项目在 `consul` 中对应的 `key`。项目将会拉取该 `key` 对应的配置并与本地的 `config/project.ts` 做 `Object.assign` 操作。 273 | `dependencies` 代表本项目所依赖的配置,如数据库,缓存以及用户服务等的配置,项目将会在 `consul` 上拉取依赖配置。 274 | 275 | 项目最终生成的配置为 `AppConfig` 标识。 276 | 277 | ``` typescript 278 | // config/consul.ts 279 | export const project = 'todo' 280 | export const dependencies = ['redis', 'pg'] 281 | ``` 282 | 283 | ### 用户认证 284 | 285 | 使用 `@auth` 指令表示该资源受限,需要用户登录,`roles` 表示只有特定角色才能访问受限资源 286 | 287 | ``` graphql 288 | directive @auth( 289 | # USER, ADMIN 可以自定义 290 | roles: [String] 291 | ) on FIELD_DEFINITION 292 | 293 | type Query { 294 | authInfo: Int @auth 295 | } 296 | ``` 297 | 298 | 以下是相关代码 299 | 300 | ``` typescript 301 | // src/directives/auth.ts 302 | function visitFieldDefinition (field: GraphQLField) { 303 | const { resolve = defaultFieldResolver } = field 304 | const { roles } = this.args 305 | // const roles: UserRole[] = ['USER', 'ADMIN'] 306 | field.resolve = async (root, args, ctx, info) => { 307 | if (!ctx.user) { 308 | throw new AuthenticationError('Unauthorized') 309 | } 310 | if (roles && !roles.includes(ctx.user.role)) { 311 | throw new ForbiddenError('Forbidden') 312 | } 313 | return resolve.call(this, root, args, ctx, info) 314 | } 315 | } 316 | ``` 317 | 318 | ### jwt 与白名单 319 | 320 | ### jwt 与 token 更新 321 | 322 | 当用户认证成功时,检查其 token 有效期,如果剩余一半时间,则生成新的 token 并赋值到响应头中。 323 | 324 | ### 用户角色验证 325 | 326 | ### 日志 327 | 328 | 为 `graphql`,`sql`,`redis` 以及一些重要信息(如 user) 添加日志,并设置标签 329 | 330 | ``` typescript 331 | // lib/logger.ts 332 | export const apiLogger = createLogger('api') 333 | export const dbLogger = createLogger('db') 334 | export const redisLogger = createLogger('redis') 335 | export const logger = createLogger('common') 336 | ``` 337 | 338 | ### 为日志添加 requestId (sessionId) 339 | 340 | 为日志添加 `requestId` 方便追踪 bug 以及检测性能问题 341 | 342 | ``` typescript 343 | // lib/logger.ts 344 | const requestId = format((info) => { 345 | info.requestId = session.get('requestId') 346 | return info 347 | }) 348 | ``` 349 | 350 | ### 结构化异常信息 351 | 352 | 结构化 API 异常信息,其中 `extensions.code` 代表异常错误码,方便调试以及前端使用。`extensions.exception` 代表原始异常,堆栈以及详细信息。注意在生产环境需要屏蔽掉 `extensions.exception` 353 | 354 | ``` bash 355 | $ curl 'https://todo.xiange.tech/graphql' -H 'Content-Type: application/json' --data-binary '{"query":"{\n dbError\n}"}' 356 | { 357 | "errors": [ 358 | { 359 | "message": "column User.a does not exist", 360 | "locations": [ 361 | { 362 | "line": 2, 363 | "column": 3 364 | } 365 | ], 366 | "path": [ 367 | "dbError" 368 | ], 369 | "extensions": { 370 | "code": "SequelizeDatabaseError", 371 | "exception": { 372 | "name": "SequelizeDatabaseError", 373 | "original": { 374 | "name": "error", 375 | "length": 104, 376 | "severity": "ERROR", 377 | "code": "42703", 378 | "position": "57", 379 | "file": "parse_relation.c", 380 | "line": "3293", 381 | "routine": "errorMissingColumn", 382 | "sql": "SELECT count(*) AS \"count\" FROM \"users\" AS \"User\" WHERE \"User\".\"a\" = 3;" 383 | }, 384 | "sql": "SELECT count(*) AS \"count\" FROM \"users\" AS \"User\" WHERE \"User\".\"a\" = 3;", 385 | "stacktrace": [ 386 | "SequelizeDatabaseError: column User.a does not exist", 387 | " at Query.formatError (/code/node_modules/sequelize/lib/dialects/postgres/query.js:354:16)", 388 | ] 389 | } 390 | } 391 | } 392 | ], 393 | "data": { 394 | "dbError": null 395 | } 396 | } 397 | ``` 398 | 399 | ### 在生产环境屏蔽掉异常堆栈以及详细信息 400 | 401 | 避免把原始异常以及堆栈信息暴露在生产环境 402 | 403 | ``` typescript 404 | { 405 | "errors": [ 406 | { 407 | "message": "column User.a does not exist", 408 | "locations": [ 409 | { 410 | "line": 2, 411 | "column": 3 412 | } 413 | ], 414 | "path": [ 415 | "dbError" 416 | ], 417 | "extensions": { 418 | "code": "SequelizeDatabaseError" 419 | } 420 | } 421 | ], 422 | "data": { 423 | "dbError": null 424 | } 425 | } 426 | ``` 427 | 428 | ### 异常报警 429 | 430 | 根据异常的 `code` 对异常进行严重等级分类,并上报监控系统。这里监控系统采用的 `sentry` 431 | 432 | ``` typescript 433 | // lib/error.ts:formatError 434 | let code: string = _.get(error, 'extensions.code', 'Error') 435 | let info: any 436 | let level = Severity.Error 437 | 438 | if (isAxiosError(originalError)) { 439 | code = `Request${originalError.code}` 440 | } else if (isJoiValidationError(originalError)) { 441 | code = 'JoiValidationError' 442 | info = originalError.details 443 | } else if (isSequelizeError(originalError)) { 444 | code = originalError.name 445 | if (isUniqueConstraintError(originalError)) { 446 | info = originalError.fields 447 | level = Severity.Warning 448 | } 449 | } else if (isApolloError(originalError)){ 450 | level = originalError.level || Severity.Warning 451 | } else if (isError(originalError)) { 452 | code = _.get(originalError, 'code', originalError.name) 453 | level = Severity.Fatal 454 | } 455 | 456 | Sentry.withScope(scope => { 457 | scope.setTag('code', code) 458 | scope.setLevel(level) 459 | scope.setExtras(formatError) 460 | Sentry.captureException(originalError || error) 461 | }) 462 | ``` 463 | 464 | ### 健康检查 465 | 466 | 在 `k8s` 上根据健康检查监控应用状态,当应用发生异常时可以及时响应并解决 467 | 468 | ``` bash 469 | $ curl http://todo.xiange.tech/.well-known/apollo/server-health 470 | {"status":"pass"} 471 | ``` 472 | 473 | ## filebeat & ELK 474 | 475 | 通过 `filebeat` 把日志文件发送到 `elk` 日志系统,方便日后分析以及辅助 debug 476 | 477 | ### 监控 478 | 479 | 在日志系统中监控 SQL 慢查询以及耗时 API 的日志,并实时邮件通知 (可以考虑钉钉) 480 | 481 | ### 参数校验 482 | 483 | 使用 [Joi](https://github.com/hapijs/joi) 做参数校验 484 | 485 | ``` javascript 486 | function createUser ({}, { name, email, password }, { models, utils }) { 487 | Joi.assert(email, Joi.string().email()) 488 | } 489 | 490 | function createTodo ({}, { todo }, { models, utils }) { 491 | Joi.validate(todo, Joi.object().keys({ 492 | name: Joi.string().min(1), 493 | })) 494 | } 495 | ``` 496 | 497 | ### 服务端渲染 498 | 499 | ### npm scripts 500 | 501 | + `npm start` 502 | + `npm test` 503 | + `npm run dev` 504 | 505 | ### 使用 CI 加强代码质量 506 | -------------------------------------------------------------------------------- /config/db.js: -------------------------------------------------------------------------------- 1 | // 被 sequelize-cli 使用 2 | require('dotenv').config() 3 | 4 | module.exports = { 5 | database: process.env.DB_DATABASE, 6 | host: process.env.DB_HOST, 7 | port: Number(process.env.DB_PORT), 8 | dialect: process.env.DB_DIALECT, 9 | username: process.env.DB_USERNAME 10 | } 11 | -------------------------------------------------------------------------------- /config/index.ts: -------------------------------------------------------------------------------- 1 | import { SequelizeOptions } from 'sequelize-typescript' 2 | import { RedisOptions } from 'ioredis' 3 | import { config as env } from 'dotenv' 4 | 5 | env() 6 | 7 | interface AppConfig { 8 | salt: string; 9 | jwtSecret: string; 10 | sentryDSN: string; 11 | db: SequelizeOptions; 12 | redis: RedisOptions; 13 | }; 14 | 15 | const config: AppConfig = { 16 | redis: { 17 | host: process.env.REDIS_HOST, 18 | password: process.env.REDIS_PASSWORD 19 | }, 20 | db: { 21 | database: process.env.DB_DATABASE, 22 | password: process.env.DB_PASSWORD, 23 | host: process.env.DB_HOST, 24 | port: Number(process.env.DB_PORT), 25 | dialect: process.env.DB_DIALECT as any, 26 | username: process.env.DB_USERNAME 27 | }, 28 | salt: process.env.SALT!, 29 | jwtSecret: process.env.JWT_SECRET!, 30 | sentryDSN: process.env.SENTRY_DSN! 31 | } 32 | 33 | export default config 34 | -------------------------------------------------------------------------------- /db.docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | db: 5 | image: postgres:14-alpine 6 | read_only: true 7 | tmpfs: 8 | - /tmp 9 | - /var/run/postgresql 10 | volumes: 11 | - db:/var/lib/postgresql/data 12 | # - ./postgres-initdb.sh:/docker-entrypoint-initdb.d/init-user-db.sh 13 | ports: 14 | - "5432:5432" 15 | environment: 16 | POSTGRES_HOST_AUTH_METHOD: trust 17 | 18 | redis: 19 | image: redis:6-alpine 20 | read_only: true 21 | volumes: 22 | - redis:/data 23 | user: redis 24 | ports: 25 | - "6379:6379" 26 | 27 | volumes: 28 | db: 29 | redis: 30 | -------------------------------------------------------------------------------- /db/index.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize } from 'sequelize-typescript' 2 | import _ from 'lodash' 3 | import { dbLogger, cache, session } from '../lib' 4 | import config from '../config' 5 | 6 | import * as models from './models' 7 | 8 | // Sequelize.useCLS(session) 9 | // const { createContext, EXPECTED_OPTIONS_KEY } = require('dataloader-sequelize') 10 | 11 | console.log(config.db) 12 | 13 | const sequelize = new Sequelize({ 14 | ...config.db, 15 | define: { 16 | timestamps: false, 17 | underscored: true 18 | }, 19 | logging (sql, timing) { 20 | dbLogger.info(sql, _.isObject(timing) ? timing : { timing }) 21 | } 22 | }) 23 | 24 | sequelize.addModels(Object.values(models)) 25 | 26 | const contextOption = {} 27 | // const contextOption = { 28 | // get [EXPECTED_OPTIONS_KEY] () { 29 | // const key = 'SequelizeContext' 30 | // if (cache.get(key)) { 31 | // return cache.get(key) 32 | // } 33 | // const context = createContext(sequelize) 34 | // cache.set(key, context) 35 | // return context 36 | // } 37 | // } 38 | 39 | export default sequelize 40 | export { models, contextOption } 41 | -------------------------------------------------------------------------------- /db/migrations/20190530000728-add-user.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up (queryInterface, Sequelize) { 3 | const sql = ` 4 | create type user_role as enum ('USER', 'ADMIN'); 5 | 6 | create table users ( 7 | id serial primary key, 8 | name varchar(255) not null check (char_length(name) > 0), 9 | role user_role default 'USER', 10 | password varchar(255) not null, 11 | create_time timestamptz default now(), 12 | email varchar(255) not null unique 13 | ); 14 | ` 15 | return queryInterface.sequelize.query(sql, { raw: true }) 16 | }, 17 | 18 | down (queryInterface, Sequelize) { 19 | return queryInterface.dropTable('users') 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /db/migrations/20190530010448-add-todo.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up (queryInterface, Sequelize) { 3 | const sql = ` 4 | create type todo_status as enum ('UNDO', 'DONE'); 5 | 6 | create table todo ( 7 | id serial primary key, 8 | name varchar(255) not null check(char_length(name) > 0), 9 | status todo_status default 'UNDO', 10 | create_time timestamptz default now(), 11 | user_id int not null references users(id) 12 | ); 13 | ` 14 | return queryInterface.sequelize.query(sql) 15 | }, 16 | 17 | down (queryInterface, Sequelize) { 18 | return queryInterface.dropTable('todo') 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /db/models/Todo.ts: -------------------------------------------------------------------------------- 1 | import { Table, Column, Model, AutoIncrement, PrimaryKey, BelongsTo, ForeignKey } from 'sequelize-typescript' 2 | import { ENUM } from 'sequelize' 3 | import { User } from './User' 4 | 5 | @Table({ 6 | tableName: 'todo' 7 | }) 8 | export class Todo extends Model { 9 | @AutoIncrement 10 | @PrimaryKey 11 | @Column 12 | id: number; 13 | 14 | @Column 15 | name: string; 16 | 17 | @Column 18 | createTime: Date; 19 | 20 | @Column(ENUM('UNDO', 'DONE')) 21 | status: 'UNDO' | 'DONE'; 22 | 23 | @ForeignKey(() => User) 24 | @Column 25 | userId: number; 26 | 27 | @BelongsTo(() => User) 28 | user: User 29 | } 30 | -------------------------------------------------------------------------------- /db/models/User.ts: -------------------------------------------------------------------------------- 1 | import { Table, Column, Model, AutoIncrement, PrimaryKey, HasMany } from 'sequelize-typescript' 2 | import { ENUM } from 'sequelize' 3 | import { Todo } from './Todo'; 4 | 5 | @Table({ 6 | tableName: 'users' 7 | }) 8 | export class User extends Model { 9 | @AutoIncrement 10 | @PrimaryKey 11 | @Column 12 | id: number; 13 | 14 | @Column 15 | name: string; 16 | 17 | @Column 18 | email: string; 19 | 20 | @Column 21 | password: string; 22 | 23 | @Column 24 | createTime: Date; 25 | 26 | @Column(ENUM('USER', 'ADMIN')) 27 | role: 'USER' | 'ADMIN'; 28 | 29 | @HasMany(() => Todo) 30 | todos: Todo[] 31 | } 32 | -------------------------------------------------------------------------------- /db/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './User' 2 | export * from './Todo' 3 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | todo: 4 | build: . 5 | restart: always 6 | labels: 7 | - "traefik.frontend.rule=Host:todo.xiange.tech" 8 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from 'apollo-server-koa' 2 | import Koa from 'koa' 3 | import _ from 'lodash' 4 | // import responseCachePlugin from 'apollo-server-plugin-response-cache' 5 | import { RedisCache } from 'apollo-server-cache-redis' 6 | import { formatError, Exception, responseLogger, queryLogger, session, redis } from './lib' 7 | import { typeDefs, resolvers } from './src' 8 | // import directives from './src/directives' 9 | import * as utils from './src/utils' 10 | import sequelize, { models, contextOption } from './db' 11 | import config from './config' 12 | import { AppContext, KoaContext } from './type' 13 | import { auth, context } from './middlewares' 14 | // import httpStatusPlugin from './src/plugin/httpStatus' 15 | 16 | const cache = new RedisCache({ 17 | host: config.redis.host, 18 | password: config.redis.password 19 | }) 20 | 21 | const server = new ApolloServer({ 22 | typeDefs, 23 | resolvers, 24 | context ({ ctx }: { ctx: KoaContext }): AppContext { 25 | const body = ctx.request.body || {} 26 | queryLogger.info(body.query, { 27 | operationName: body.operationName, 28 | query: body.query, 29 | variables: body.variables, 30 | ip: ctx.request.ip, 31 | user: ctx.user 32 | }) 33 | return { 34 | sequelize, 35 | contextOption, 36 | models, 37 | config, 38 | utils, 39 | redis, 40 | Exception, 41 | user: ctx.user 42 | } 43 | }, 44 | formatError, 45 | formatResponse (response: any) { 46 | responseLogger.info('Response', { 47 | data: response.data, 48 | duration: _.get(response, 'extensions.tracing.duration', 0) / 1000000 49 | }) 50 | return null 51 | }, 52 | // cacheControl: { 53 | // defaultMaxAge: 5 54 | // }, 55 | // prefix with fqc 56 | cache, 57 | // prefix with apq 58 | persistedQueries: { 59 | cache 60 | }, 61 | // plugins: [responseCachePlugin() as any, httpStatusPlugin], 62 | // schemaDirectives: directives, 63 | rootValue: {}, 64 | // playground: true, 65 | // tracing: true, 66 | }) 67 | 68 | const app = new Koa() 69 | app.use(async ({}, next) => { 70 | // integreted with requestId 71 | await session.runPromise(() => { 72 | return next() 73 | }) 74 | }) 75 | app.use(auth) 76 | app.use(context) 77 | server.start().then(() => { 78 | server.applyMiddleware({ 79 | app, 80 | onHealthCheck: async () => true, 81 | bodyParserConfig: true, 82 | }) 83 | }) 84 | 85 | const port = process.env.PORT || 4000 86 | app.listen({ port }, () => 87 | console.log(`🚀 Server ready at http://localhost:${port}/graphql`), 88 | ) 89 | -------------------------------------------------------------------------------- /lib/cache.ts: -------------------------------------------------------------------------------- 1 | import LRU from 'lru-cache' 2 | 3 | export const cache = new LRU({ 4 | max: 500, 5 | maxAge: 10 * 1000 6 | }) 7 | -------------------------------------------------------------------------------- /lib/error.ts: -------------------------------------------------------------------------------- 1 | import { ApolloError } from 'apollo-server-koa' 2 | import { GraphQLError, GraphQLFormattedError } from 'graphql' 3 | import { BaseError, UniqueConstraintError } from 'sequelize' 4 | import _ from 'lodash' 5 | import { AxiosError } from 'axios' 6 | import Joi from 'joi' 7 | import { Sentry } from './sentry' 8 | import { Severity } from '@sentry/node' 9 | 10 | const isProduction = process.env.NODE_ENV === 'production' 11 | 12 | function isAxiosError (error: any): error is AxiosError { 13 | return error.isAxiosError 14 | } 15 | 16 | function isJoiValidationError (error: any): error is Joi.ValidationError { 17 | return error.isJoi 18 | } 19 | 20 | function isSequelizeError (error: any): error is BaseError { 21 | return error instanceof BaseError 22 | } 23 | 24 | function isUniqueConstraintError (error: any): error is UniqueConstraintError { 25 | return error instanceof UniqueConstraintError 26 | } 27 | 28 | function isApolloError (error: any): error is ApolloError { 29 | return error instanceof ApolloError 30 | } 31 | 32 | function isError (error: any): error is Error { 33 | return error instanceof Error 34 | } 35 | 36 | const enum APP_ERROR_CODE { 37 | // 如果 code 为 BadRequest, 则客户端可以直接展示 message 38 | BadRequest 39 | } 40 | 41 | export class Exception extends ApolloError { 42 | constructor (message: string, code: keyof typeof APP_ERROR_CODE = 'BadRequest', properties: Record = {}) { 43 | super(message, `App${code}`, { 44 | level: Severity.Warning, 45 | ...properties 46 | }) 47 | } 48 | } 49 | 50 | export function formatError (error: GraphQLError): GraphQLFormattedError { 51 | let code: string = _.get(error, 'extensions.code', 'Error') 52 | let info: any 53 | let level = Severity.Error 54 | 55 | const originalError: any = error.originalError 56 | if (isAxiosError(originalError)) { 57 | code = `Request${originalError.code}` 58 | } else if (isJoiValidationError(originalError)) { 59 | code = 'JoiValidationError' 60 | info = originalError.details 61 | } else if (isSequelizeError(originalError)) { 62 | code = originalError.name 63 | if (isUniqueConstraintError(originalError)) { 64 | info = originalError.fields 65 | level = Severity.Warning 66 | } 67 | } else if (isApolloError(originalError)){ 68 | level = originalError.level || Severity.Warning 69 | } else if (isError(originalError)) { 70 | code = _.get(originalError, 'code', originalError.name) 71 | level = Severity.Fatal 72 | } 73 | 74 | const formatError = { 75 | ...error, 76 | extensions: { 77 | ..._.get(error, 'extensions', {}), 78 | code, 79 | info 80 | } 81 | } 82 | Sentry.withScope(scope => { 83 | scope.setTag('code', code) 84 | scope.setLevel(level) 85 | scope.setExtras(formatError) 86 | Sentry.captureException(originalError || error) 87 | }) 88 | if (!isProduction) { 89 | // if in dev, print formatError 90 | console.error(formatError) 91 | } 92 | return { 93 | ...error, 94 | extensions: isProduction ? { code, info } : formatError.extensions 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error' 2 | export * from './logger' 3 | export * from './redis' 4 | export * from './sentry' 5 | export * from './session' 6 | export * from './cache' 7 | -------------------------------------------------------------------------------- /lib/logger.ts: -------------------------------------------------------------------------------- 1 | import winston, { format } from 'winston' 2 | import os from 'os' 3 | import { session } from './session' 4 | 5 | const requestId = format((info) => { 6 | info.requestId = session.get('requestId') 7 | return info 8 | }) 9 | 10 | function createLogger (label: string) { 11 | return winston.createLogger({ 12 | defaultMeta: { 13 | serverName: os.hostname(), 14 | label 15 | }, 16 | format: format.combine( 17 | format.timestamp(), 18 | requestId(), 19 | format.json() 20 | ), 21 | transports: [ 22 | new winston.transports.File({ 23 | dirname: './logs', 24 | filename: `${label}.log`, 25 | }) 26 | ] 27 | }) 28 | } 29 | 30 | export const queryLogger = createLogger('query') 31 | export const responseLogger = createLogger('response') 32 | export const dbLogger = createLogger('db') 33 | export const redisLogger = createLogger('redis') 34 | export const logger = createLogger('common') 35 | -------------------------------------------------------------------------------- /lib/redis.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis' 2 | import { redisLogger } from './logger' 3 | import config from '../config' 4 | 5 | const redis = new Redis(config.redis) 6 | 7 | const { sendCommand } = Redis.prototype 8 | Redis.prototype.sendCommand = async function (...options: any[]) { 9 | const response = await sendCommand.call(this, ...options); 10 | redisLogger.info(options[0].name, { 11 | ...options[0], 12 | response 13 | }) 14 | return response 15 | } 16 | 17 | export { redis } 18 | -------------------------------------------------------------------------------- /lib/sentry.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node' 2 | import config from '../config' 3 | 4 | const debug = process.env.NODE_ENV !== 'production' 5 | 6 | Sentry.init({ 7 | dsn: config.sentryDSN, 8 | debug, 9 | environment: process.env.NODE_ENV || 'development', 10 | // beforeSend (event) { 11 | // console.log(event) 12 | // return null 13 | // } 14 | }) 15 | 16 | export { Sentry } 17 | -------------------------------------------------------------------------------- /lib/session.ts: -------------------------------------------------------------------------------- 1 | import { createNamespace } from 'cls-hooked' 2 | 3 | const session = createNamespace('hello, world') 4 | 5 | export { session } -------------------------------------------------------------------------------- /middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import { UserContext, KoaContext } from '../type' 3 | import config from '../config' 4 | 5 | function getUser (token: string): UserContext | undefined { 6 | try { 7 | const who = jwt.verify(token, config.jwtSecret) 8 | return who as UserContext 9 | } catch (e) { 10 | return 11 | } 12 | } 13 | 14 | export async function auth (ctx: KoaContext, next: any) { 15 | const token = ctx.header['authorization'] || '' 16 | ctx.user = getUser(token) 17 | await next() 18 | } 19 | -------------------------------------------------------------------------------- /middlewares/context.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { v4 as uuid } from 'uuid' 3 | import { Sentry } from '../lib/sentry' 4 | import { session } from '../lib/session' 5 | import { KoaContext } from '../type' 6 | 7 | // Sentry Context && Tracing 8 | export async function context (ctx: KoaContext, next: any) { 9 | Sentry.configureScope(scope => { 10 | scope.clear() 11 | ctx.user && scope.setUser({ 12 | ...ctx.user, 13 | token: ctx.header['authorization'] 14 | } as any) 15 | scope.addEventProcessor(event => Sentry.Handlers.parseRequest(event, ctx.request as any)) 16 | const requestId = ctx.header['x-request-id'] || uuid() 17 | ctx.requestId = requestId as any 18 | ctx.res.setHeader('X-Request-ID', requestId) 19 | session.set('requestId', requestId) 20 | scope.setTags(_.pickBy({ 21 | requestId, 22 | query: _.get(ctx.request.body, 'operationName') 23 | })) 24 | }) 25 | await next() 26 | } 27 | -------------------------------------------------------------------------------- /middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth' 2 | export * from './context' -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apollo-server-starter", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "start": "ts-node index.ts", 9 | "dev": "nodemon index.ts", 10 | "ncu": "ncu", 11 | "env:db": "docker-compose -f db.compose.yml up", 12 | "debug": "nodemon --exec 'node --inspect=0.0.0.0:9930 --require ts-node/register index.ts'", 13 | "config": "ts-node scripts/pullConfig", 14 | "schema": "ts-node scripts/createSchema", 15 | "migrate": "sequelize db:migrate --config ./config/db.js --migrations-path ./db/migrations", 16 | "migrate:new": "sequelize migration:generate --config ./config/db.js --migrations-path ./db/migrations --name $name", 17 | "migrate:undo": "sequelize db:migrate:undo --config ./config/db.js --migrations-path ./db/migrations", 18 | "seed": "sequelize db:seed:all --config ./config/db.js --seeders-path ./db/seeders", 19 | "seed:new": "sequelize seed:generate --config ./config/db.js --seeders-path ./db/seeders --name $name", 20 | "seed:undo": "sequelize db:seed:undo --config ./config/db.js --seeders-path ./db/seeders --name $name", 21 | "log:db": "tail -f logs/db.log | jq '. | {message, requestId}'", 22 | "log": "tail -f logs/$LOG.log | jq '. | {message, requestId}'" 23 | }, 24 | "keywords": [], 25 | "author": "", 26 | "license": "ISC", 27 | "dependencies": { 28 | "@graphql-tools/utils": "^8.6.1", 29 | "@sentry/node": "^6.17.9", 30 | "@sideway/address": "^4.1.3", 31 | "@sideway/formula": "^3.0.0", 32 | "@sideway/pinpoint": "^2.0.0", 33 | "@types/bluebird": "^3.5.36", 34 | "@types/cls-hooked": "^4.3.3", 35 | "@types/consul": "^0.40.0", 36 | "@types/graphql-iso-date": "^3.4.0", 37 | "@types/graphql-type-json": "^0.3.2", 38 | "@types/hapi__joi": "^17.1.8", 39 | "@types/ioredis": "^4.28.8", 40 | "@types/jsonwebtoken": "^8.5.8", 41 | "@types/koa": "^2.13.4", 42 | "@types/lodash": "^4.14.178", 43 | "@types/lru-cache": "^5.1.1", 44 | "@types/node": "^17.0.18", 45 | "@types/rimraf": "^3.0.2", 46 | "@types/sequelize": "^4.28.11", 47 | "@types/uuid": "^8.3.4", 48 | "@types/validator": "^13.7.1", 49 | "apollo-server-cache-redis": "^3.3.1", 50 | "apollo-server-koa": "^3.6.3", 51 | "apollo-server-plugin-response-cache": "^3.5.1", 52 | "axios": "^0.26.0", 53 | "bluebird": "^3.7.2", 54 | "cls-hooked": "^4.2.2", 55 | "consul": "^0.40.0", 56 | "dataloader-sequelize": "^2.3.3", 57 | "dotenv": "^16.0.0", 58 | "graphql": "^16.3.0", 59 | "graphql-fields": "^2.0.3", 60 | "graphql-iso-date": "^3.6.1", 61 | "graphql-tag": "^2.12.6", 62 | "graphql-type-json": "^0.3.2", 63 | "ioredis": "^4.28.5", 64 | "joi": "^17.6.0", 65 | "jsonwebtoken": "^8.5.1", 66 | "koa": "^2.13.4", 67 | "lodash": "^4.17.21", 68 | "lru-cache": "^7.3.1", 69 | "moment": "^2.29.1", 70 | "nodemon": "^2.0.15", 71 | "npm-check-updates": "^12.3.0", 72 | "pg": "^8.7.3", 73 | "pg-hstore": "^2.3.4", 74 | "reflect-metadata": "^0.1.13", 75 | "rimraf": "^3.0.2", 76 | "sequelize": "^6.16.1", 77 | "sequelize-cli": "^6.4.1", 78 | "sequelize-typescript": "^2.1.3", 79 | "ts-node": "^10.5.0", 80 | "uuid": "^8.3.2", 81 | "validator": "^13.7.0", 82 | "winston": "^3.6.0" 83 | }, 84 | "devDependencies": { 85 | "typescript": "^4.5.5" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /postgres-initdb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | psql --variable=ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL 4 | CREATE DATABASE "todos"; 5 | EOSQL 6 | 7 | psql --variable=ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname=dev <<-EOSQL 8 | CREATE EXTENSION "uuid-ossp"; 9 | CREATE EXTENSION "hstore"; 10 | EOSQL 11 | -------------------------------------------------------------------------------- /scripts/createSchema.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import _ from 'lodash' 3 | 4 | const dbModelContent = `import { Table, Column, Model, AutoIncrement, PrimaryKey, BelongsTo, ForeignKey } from 'sequelize-typescript' 5 | import { ENUM } from 'sequelize' 6 | import { User } from './User' 7 | 8 | // import this in ./index.ts 9 | // import this in ./index.ts 10 | // import this in ./index.ts 11 | @Table({ 12 | tableName: 'todo' 13 | }) 14 | export class Todo extends Model { 15 | @AutoIncrement 16 | @PrimaryKey 17 | @Column 18 | id: number; 19 | 20 | @Column 21 | name: string; 22 | 23 | @Column 24 | createTime: Date; 25 | 26 | @Column(ENUM('UNDO', 'DONE')) 27 | status: 'UNDO' | 'DONE'; 28 | 29 | @ForeignKey(() => User) 30 | @Column 31 | userId: number; 32 | 33 | @BelongsTo(() => User) 34 | user: User 35 | } 36 | ` 37 | 38 | const gqlSchemaContent = `import { SequelizeResolverObject } from './../../type' 39 | import { gql } from 'apollo-server-koa' 40 | import { contextOption } from '../../db' 41 | 42 | const typeDef = gql\` 43 | enum TodoStatus { 44 | DONE 45 | UNDO 46 | } 47 | 48 | type Todo @sql { 49 | id: ID! 50 | name: String! 51 | status: TodoStatus! 52 | createTime: DateTime! 53 | user: User! @sql(dep: ["userId"]) @relation 54 | } 55 | 56 | extend type Query { 57 | todos: [Todo!] @findOption 58 | todo ( 59 | id: ID 60 | ): Todo @findOption 61 | } 62 | \` 63 | 64 | const resolver: SequelizeResolverObject = { 65 | Todo: { 66 | user (todo, {}, {}, { attributes }: any) { 67 | return todo.$get('user', { 68 | attributes, 69 | ...contextOption 70 | }) 71 | } 72 | }, 73 | Query: { 74 | todo ({}, { id }, { models }, { attributes }: any) { 75 | return models.Todo.findByPk(id, { 76 | attributes, 77 | ...contextOption 78 | }) 79 | } 80 | } 81 | } 82 | 83 | export { 84 | typeDef, 85 | resolver 86 | } 87 | ` 88 | 89 | if (!process.argv[2]) { 90 | console.error('ERROR: You must provide a SCHEMA NAME') 91 | process.exit(1) 92 | } 93 | 94 | const name = _.upperFirst(process.argv[2]) 95 | const modelPath = `${__dirname}/../db/models/${name}.ts` 96 | const schemaPath = `${__dirname}/../src/resolvers/${name}.ts` 97 | 98 | if (fs.existsSync(modelPath) || fs.existsSync(schemaPath)) { 99 | console.warn('WARN: current file is exist') 100 | } 101 | 102 | fs.writeFileSync(`${__dirname}/../db/models/${name}.ts`, dbModelContent.replace(/Todo/g, name).replace(/todo/g, _.lowerCase(name))) 103 | fs.writeFileSync(`${__dirname}/../src/resolvers/${name}.ts`, gqlSchemaContent.replace(/Todo/g, name).replace(/todo/g, _.lowerCase(name))) 104 | 105 | console.log(`Creating ${modelPath} 106 | Creating ${schemaPath}`) 107 | -------------------------------------------------------------------------------- /src/directives/auth.ts: -------------------------------------------------------------------------------- 1 | import { SchemaDirectiveVisitor } from "graphql-tools" 2 | import { GraphQLField, defaultFieldResolver } from "graphql" 3 | import { AuthenticationError, ForbiddenError } from 'apollo-server-koa' 4 | import { AppContext, UserRole } from "../../type" 5 | 6 | class AuthDirective extends SchemaDirectiveVisitor { 7 | public readonly args: { 8 | // role 为 undefined,代表所有角色的用户都有权限访问 9 | roles?: UserRole[] 10 | } 11 | 12 | visitFieldDefinition (field: GraphQLField) { 13 | const { resolve = defaultFieldResolver } = field 14 | const { roles } = this.args 15 | // const roles: UserRole[] = ['USER', 'ADMIN'] 16 | field.resolve = async (root, args, ctx, info) => { 17 | if (!ctx.user) { 18 | throw new AuthenticationError('Unauthorized') 19 | } 20 | if (roles && !roles.includes(ctx.user.role)) { 21 | throw new ForbiddenError('Forbidden') 22 | } 23 | return resolve.call(this, root, args, ctx, info) 24 | } 25 | } 26 | } 27 | 28 | export default AuthDirective -------------------------------------------------------------------------------- /src/directives/findOption.ts: -------------------------------------------------------------------------------- 1 | import { SchemaDirectiveVisitor } from 'graphql-tools' 2 | import { GraphQLField, defaultFieldResolver } from 'graphql' 3 | import { AppContext } from './../../type' 4 | 5 | class FindOptionDirective extends SchemaDirectiveVisitor { 6 | visitFieldDefinition (field: GraphQLField & { col?: string, dep?: string[] }) { 7 | const { resolve = defaultFieldResolver } = field 8 | 9 | field.resolve = async (root, args, ctx, info) => { 10 | const { utils, models } = ctx 11 | const attributes = utils.getModelAttrs(info, models) 12 | const { page, pageSize } = args 13 | const { limit, offset } = utils.parsePage(page, pageSize) 14 | return resolve.call(this, root, args, ctx, { 15 | ...info, 16 | attributes, 17 | limit, 18 | offset 19 | }) 20 | } 21 | } 22 | } 23 | 24 | export default FindOptionDirective 25 | -------------------------------------------------------------------------------- /src/directives/index.ts: -------------------------------------------------------------------------------- 1 | import SqlDirective from './sql' 2 | import FindOptionDirective from './findOption' 3 | import AuthDirective from './auth' 4 | import RelationDirective from './relation' 5 | 6 | export default { 7 | sql: SqlDirective, 8 | findOption: FindOptionDirective, 9 | auth: AuthDirective, 10 | relation: RelationDirective 11 | } -------------------------------------------------------------------------------- /src/directives/relation.ts: -------------------------------------------------------------------------------- 1 | import { SchemaDirectiveVisitor } from 'graphql-tools' 2 | import { GraphQLField } from 'graphql' 3 | import { AppContext } from './../../type' 4 | 5 | import _ from 'lodash' 6 | import { Model } from 'sequelize-typescript' 7 | 8 | class RelationDirective extends SchemaDirectiveVisitor { 9 | visitFieldDefinition (field: GraphQLField & { col?: string, dep?: string[] }) { 10 | const { order } = this.args 11 | const name = field.name 12 | 13 | field.resolve = async (root, args, ctx, info) => { 14 | if (_.get(root, name)) { 15 | return _.get(root, name) 16 | } 17 | const { models, utils, contextOption } = ctx 18 | 19 | const sourceModel = root.constructor as typeof Model 20 | const association = sourceModel.associations[name] 21 | 22 | // 如果是 1:m 需要1的外键 23 | let foreignKey 24 | if (association.associationType === 'HasMany') { 25 | foreignKey = association.foreignKey 26 | } 27 | 28 | const attrs = utils.getModelAttrs(info, models) 29 | 30 | // 添加外键,方便 dataloader 31 | const attributes = attrs && foreignKey ? _.uniq([...attrs, foreignKey]) : attrs 32 | const { page, pageSize } = args 33 | const { limit, offset } = utils.parsePage(page, pageSize) 34 | return root.$get(name as any, { 35 | attributes, 36 | order, 37 | limit, 38 | offset, 39 | ...contextOption 40 | }) 41 | } 42 | } 43 | } 44 | 45 | export default RelationDirective 46 | 47 | -------------------------------------------------------------------------------- /src/directives/sql.ts: -------------------------------------------------------------------------------- 1 | import { SchemaDirectiveVisitor } from 'graphql-tools' 2 | import { 3 | defaultFieldResolver, 4 | GraphQLField, 5 | GraphQLObjectType 6 | } from 'graphql' 7 | 8 | class SqlDirective extends SchemaDirectiveVisitor { 9 | public readonly args: { 10 | table?: string, 11 | col?: string, 12 | dep?: [string] 13 | } 14 | 15 | public visitObject(object: GraphQLObjectType & { table?: string }) { 16 | object.table = this.args.table || object.name 17 | } 18 | 19 | public visitFieldDefinition(field: GraphQLField & { col?: string, dep?: string[] }) { 20 | const { col, dep = [] } = this.args 21 | const { resolve = defaultFieldResolver } = field 22 | 23 | field.dep = col ? [...dep, col] : dep 24 | 25 | field.resolve = async (root, ...args) => { 26 | if (col) { 27 | return root[col] 28 | } 29 | return resolve.call(this, root, ...args) 30 | } 31 | } 32 | } 33 | 34 | export default SqlDirective 35 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fs from 'fs' 3 | import _ from 'lodash' 4 | import { DocumentNode } from 'graphql' 5 | import { IResolvers } from '@graphql-tools/utils' 6 | 7 | import { typeDef as scalarTypeDefs, resolver as scalarResovers } from './scalars' 8 | 9 | interface Resolver { 10 | typeDefs: DocumentNode[], 11 | resolvers: IResolvers 12 | } 13 | 14 | const initResolver: Resolver = { 15 | typeDefs: [scalarTypeDefs], 16 | resolvers: { 17 | ...scalarResovers as any 18 | } 19 | } 20 | 21 | const schemaFiles: string[] = fs.readdirSync(path.resolve(__dirname, 'resolvers')) 22 | const { typeDefs, resolvers } = schemaFiles.reduce(({ typeDefs, resolvers }, file) => { 23 | const { resolver, typeDef } = require(`./resolvers/${file}`) 24 | return { 25 | typeDefs: [...typeDefs, typeDef], 26 | resolvers: _.merge(resolvers, resolver) 27 | } 28 | }, initResolver) 29 | 30 | export { 31 | typeDefs, 32 | resolvers 33 | } 34 | -------------------------------------------------------------------------------- /src/plugin/httpStatus.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloServerPlugin, 3 | GraphQLRequestListener, 4 | } from 'apollo-server-plugin-base'; 5 | import _ from 'lodash' 6 | 7 | const httpStatusPlugin: ApolloServerPlugin = { 8 | requestDidStart(): GraphQLRequestListener { 9 | return { 10 | didEncounterErrors({ response, errors }) { 11 | const errorMap = _.keyBy(errors, 'extensions.code') 12 | if (errorMap['UNAUTHENTICATED']) { 13 | response!.http!.status = 401 14 | return 15 | } 16 | if (errorMap['FORBIDDEN']) { 17 | response!.http!.status = 403 18 | return 19 | } 20 | response!.http!.status = 400 21 | } 22 | } 23 | } 24 | } 25 | 26 | export default httpStatusPlugin 27 | -------------------------------------------------------------------------------- /src/resolvers/Mutation.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-koa' 2 | import { AppResolvers } from './../../type' 3 | 4 | const typeDef = gql` 5 | type Mutation { 6 | hello: String 7 | } 8 | ` 9 | 10 | const resolver: AppResolvers = { 11 | Mutation: { 12 | hello () { 13 | return 'hello, world' 14 | } 15 | } 16 | } 17 | 18 | export { 19 | typeDef, 20 | resolver 21 | } 22 | -------------------------------------------------------------------------------- /src/resolvers/Query.ts: -------------------------------------------------------------------------------- 1 | import { gql, ForbiddenError } from 'apollo-server-koa' 2 | import axios from 'axios' 3 | import fs from 'fs' 4 | import { AppContext } from './../../type' 5 | import { IExecutableSchemaDefinition } from '@graphql-tools/schema' 6 | 7 | const typeDef = gql` 8 | directive @sql( 9 | table: String 10 | dep: [String] 11 | col: String 12 | ) on FIELD_DEFINITION | OBJECT 13 | 14 | directive @findOption on FIELD_DEFINITION 15 | directive @relation on FIELD_DEFINITION 16 | 17 | directive @auth( 18 | roles: [String] 19 | ) on FIELD_DEFINITION 20 | 21 | type Query { 22 | hello: String 23 | 24 | graphqlError: Int! 25 | reqError: Int 26 | dbError: Int 27 | readError: Int 28 | exception: Int 29 | typeError: Int 30 | cache: Int 31 | 32 | authInfo: Int @auth 33 | forbidden: Int 34 | } 35 | ` 36 | 37 | const resolver: IExecutableSchemaDefinition['resolvers'] = { 38 | Query: { 39 | hello () { 40 | return 'hello, world' 41 | }, 42 | cache ({}, {}, { redis }) { 43 | return redis.getset('hello', 10086) 44 | }, 45 | typeError () { 46 | const o: any = undefined 47 | return o.a 48 | }, 49 | graphqlError () {}, 50 | reqError () { 51 | return axios.get('http://localhost:8080', { 52 | params: { 53 | a: 3, 54 | b: 4 55 | } 56 | }) 57 | }, 58 | readError () { 59 | fs.readFileSync('/does/not/exist'); 60 | }, 61 | exception ({}, {}, { Exception }) { 62 | return new Exception('Exception', undefined, { a: 3 }) 63 | }, 64 | dbError ({}, {}, { models }) { 65 | return models.User.count({ 66 | where: { 67 | // a: 3 68 | } 69 | }) 70 | }, 71 | authInfo () { 72 | return 10 73 | }, 74 | forbidden () { 75 | throw new ForbiddenError('Forbidden test') 76 | } 77 | } 78 | } 79 | 80 | export { 81 | typeDef, 82 | resolver 83 | } 84 | -------------------------------------------------------------------------------- /src/resolvers/Todo.ts: -------------------------------------------------------------------------------- 1 | import { AppResolvers } from './../../type' 2 | import { gql } from 'apollo-server-koa' 3 | 4 | const typeDef = gql` 5 | enum TodoStatus { 6 | DONE 7 | UNDO 8 | } 9 | 10 | type Todo @sql { 11 | id: ID! 12 | name: String! 13 | status: TodoStatus! 14 | createTime: DateTime! 15 | user: User! @sql(dep: ["userId"]) @relation 16 | } 17 | 18 | extend type Query { 19 | todos: [Todo!] @findOption 20 | todo ( 21 | id: ID 22 | ): Todo @findOption 23 | } 24 | ` 25 | 26 | const resolver: AppResolvers = { 27 | Todo: { 28 | user (todo, {}, { contextOption }, { attributes }: any) { 29 | return todo.$get('user', { 30 | attributes, 31 | ...contextOption 32 | }) 33 | } 34 | }, 35 | Query: { 36 | todo ({}, { id }, { models, contextOption }, { attributes }: any) { 37 | return models.Todo.findByPk(id, { 38 | attributes, 39 | ...contextOption 40 | }) 41 | }, 42 | todos ({}, {}, { models, contextOption }, { attributes }: any) { 43 | return models.Todo.findAll({ 44 | attributes, 45 | ...contextOption 46 | }) 47 | } 48 | } 49 | } 50 | 51 | export { 52 | typeDef, 53 | resolver 54 | } 55 | -------------------------------------------------------------------------------- /src/resolvers/User.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-koa' 2 | import * as Joi from 'joi' 3 | import jwt from 'jsonwebtoken' 4 | import _ from 'lodash' 5 | import { SequelizeResolverObject } from './../../type' 6 | 7 | const typeDef = gql` 8 | type User @sql { 9 | id: ID! 10 | name: String! 11 | email: String! 12 | createTime: DateTime! 13 | todos ( 14 | page: Int = 1 15 | pageSize: Int = 10 16 | ): [Todo!] @relation 17 | } 18 | 19 | extend type Query { 20 | users ( 21 | page: Int = 1 22 | pageSize: Int = 10 23 | ): [User!] @findOption 24 | } 25 | 26 | extend type Mutation { 27 | # 用户注册 28 | createUser( 29 | name: String! 30 | email: String! 31 | password: String! 32 | ): User! 33 | 34 | # 用户登录,如果返回 null,代表登录失败 35 | createUserToken( 36 | email: String! 37 | password: String! 38 | ): String 39 | 40 | updateUserToken: String! @auth 41 | } 42 | ` 43 | 44 | const resolver: SequelizeResolverObject = { 45 | Query: { 46 | users ({}, {}, { models }, { attributes, limit, offset }: any) { 47 | return models.User.findAll({ 48 | attributes, 49 | limit, 50 | offset 51 | }) 52 | } 53 | }, 54 | Mutation: { 55 | createUser ({}, { name, email, password }, { models, utils }) { 56 | // 有的格式前端需要做预校验,不需要返回 machine readable 的 fields 57 | Joi.assert(email, Joi.string().email()) 58 | return models.User.create({ 59 | name, 60 | email, 61 | password: utils.hash(password) 62 | } as any) 63 | }, 64 | async createUserToken ({}, { email, password }, { models, utils, config }) { 65 | const user = await models.User.findOne({ 66 | where: { 67 | email, 68 | password: utils.hash(password) 69 | }, 70 | attributes: ['id', 'role'], 71 | raw: true 72 | }) 73 | if (!user) { 74 | return 75 | } 76 | return jwt.sign(user, config.jwtSecret, { expiresIn: '7d' }) 77 | }, 78 | async updateUserToken ({}, {}, { user, config }) { 79 | return jwt.sign(_.pick(user, ['id', 'role']), config.jwtSecret, { expiresIn: '7d' }) 80 | } 81 | } 82 | } 83 | 84 | export { 85 | typeDef, 86 | resolver 87 | } 88 | -------------------------------------------------------------------------------- /src/scalars/index.ts: -------------------------------------------------------------------------------- 1 | import GraphQLJSON from 'graphql-type-json' 2 | import { GraphQLDate, GraphQLTime, GraphQLDateTime } from 'graphql-iso-date' 3 | import { gql } from 'apollo-server-koa' 4 | 5 | 6 | const typeDef = gql` 7 | scalar JSON 8 | scalar DateTime 9 | scalar Date 10 | scalar Time 11 | ` 12 | 13 | const resolver: any = { 14 | JSON: GraphQLJSON, 15 | DateTime: GraphQLDateTime, 16 | Date: GraphQLDate, 17 | Time: GraphQLTime 18 | } 19 | 20 | export { 21 | typeDef, 22 | resolver 23 | } 24 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import crypto from 'crypto' 3 | import { 4 | GraphQLResolveInfo, 5 | GraphQLWrappingType, 6 | GraphQLObjectType, 7 | isObjectType, 8 | GraphQLType, 9 | isWrappingType, 10 | GraphQLField, 11 | GraphQLFieldMap 12 | } from "graphql" 13 | import { Model } from 'sequelize' 14 | import { Dictionary } from './../type' 15 | import config from '../config' 16 | 17 | const graphqlFields = require('graphql-fields') 18 | 19 | function getObjectType (type: GraphQLWrappingType): GraphQLObjectType { 20 | while (!isObjectType(type)) { 21 | type = type.ofType as any 22 | } 23 | return type as GraphQLObjectType 24 | } 25 | 26 | export function getModelAttrs (info: GraphQLResolveInfo, models: Dictionary, field?: string): string[] | undefined { 27 | let type: GraphQLType = isWrappingType(info.returnType) ? getObjectType(info.returnType) : info.returnType 28 | 29 | if (field) { 30 | } 31 | 32 | // TODO interface 33 | if (!isObjectType(type) || !_.get(type, 'table')) { 34 | return 35 | } 36 | 37 | const requestFields: string[] = Object.keys(graphqlFields(info, {}, { excludedFields: ['__typename'] })) 38 | const objectFieldMap: GraphQLFieldMap = type.getFields() 39 | 40 | const model = models[_.get(type, 'table')] 41 | // 该 Object 对应数据库 Model 的所有字段 42 | const modelAttributeMap = model.rawAttributes 43 | 44 | const attrs = _.flatMap(requestFields, (field => { 45 | const { dep }: GraphQLField & { dep?: string[] } = _.get(objectFieldMap, field) 46 | if (!dep) { 47 | // 如果没有使用 sql directive 对数据库进行标记,则查看该字段是否在数据库的列中存在 48 | if (modelAttributeMap[field]) { 49 | return field 50 | } 51 | return [] 52 | } 53 | return dep 54 | })) 55 | 56 | return _.uniq(attrs) 57 | } 58 | /** 59 | * @param {number=1} page 60 | * @param {number|undefined=undefined} pageSize 61 | * @returns 62 | */ 63 | 64 | export function parsePage (page: number | undefined, pageSize: number | undefined): { 65 | limit: number | undefined, 66 | offset: number | undefined 67 | } { 68 | if (page === 0) { 69 | return { 70 | limit: undefined, 71 | offset: undefined 72 | } 73 | } 74 | if (!pageSize) { 75 | pageSize = !page ? undefined : 10 76 | } 77 | if (!page) { 78 | page = 1 79 | } 80 | return { 81 | limit: pageSize, 82 | offset: pageSize ? (page - 1) * pageSize : undefined 83 | } 84 | } 85 | 86 | export function hash (str: string): string { 87 | return crypto.createHash('md5').update(`${str}-${config.salt}`, 'utf8').digest('hex') 88 | } 89 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "target": "es2020", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "sourceMap": true, 9 | "removeComments": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "strictPropertyInitialization": false, 15 | "noUnusedParameters": true, 16 | "resolveJsonModule": true, 17 | // "noUnusedLocals": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "lib": ["es2017", "esnext.asynciterable"], 20 | "experimentalDecorators": true, 21 | "emitDecoratorMetadata": true, 22 | "types": ["node"], 23 | "outDir": "dist" 24 | }, 25 | "exclude": [ 26 | "node_modules", 27 | "dist" 28 | ], 29 | "include": [ 30 | "lib", 31 | "config", 32 | "config/config.json", 33 | "db", 34 | "lib", 35 | "middlewares", 36 | "scripts", 37 | "src", 38 | "index.ts", 39 | "type.ts" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /type.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize, SequelizeOptions } from 'sequelize-typescript' 2 | import type { ParameterizedContext } from 'koa' 3 | import type { IExecutableSchemaDefinition } from '@graphql-tools/schema' 4 | import { TypeSource, IResolvers, IResolverValidationOptions, GraphQLParseOptions, PruneSchemaOptions } from '@graphql-tools/utils'; 5 | import { Redis, RedisOptions } from 'ioredis' 6 | import * as utils from './src/utils' 7 | import { Exception } from './lib/error' 8 | import { models, contextOption } from './db' 9 | 10 | export type WithRequired = T & Required>; 11 | 12 | export interface Dictionary { 13 | [index: string]: T; 14 | } 15 | 16 | export interface NumericDictionary { 17 | [index: number]: T; 18 | } 19 | 20 | export type Many = T | Readonly; 21 | 22 | export type Models = typeof models; 23 | 24 | export interface ProjectConfig { 25 | salt: string; 26 | jwtSecret: string; 27 | sentryDSN: string; 28 | [key: string]: any; 29 | }; 30 | 31 | export interface ConsulConfig { 32 | db: SequelizeOptions; 33 | redis: RedisOptions; 34 | }; 35 | 36 | // type of config/config.json 37 | export type AppConfig = ConsulConfig & ProjectConfig; 38 | 39 | export type UserRole = 'ADMIN' | 'USER'; 40 | 41 | export interface UserContext { 42 | id: number; 43 | role: UserRole; 44 | exp: number; 45 | }; 46 | 47 | export interface AppContext { 48 | sequelize: Sequelize; 49 | contextOption: typeof contextOption; 50 | models: Models; 51 | config: AppConfig; 52 | utils: typeof utils; 53 | redis: Redis; 54 | user?: UserContext; 55 | Exception: typeof Exception; 56 | }; 57 | 58 | export type AppResolvers = IExecutableSchemaDefinition['resolvers'] 59 | 60 | export interface KoaContext extends ParameterizedContext { 61 | user?: UserContext; 62 | requestId: string; 63 | }; 64 | 65 | type ResolverModel = IResolvers> 66 | 67 | export type SequelizeResolverObject = { 68 | // [key in keyof typeof models]?: IResolverObject 69 | User?: ResolverModel; 70 | Todo?: ResolverModel; 71 | } & IResolvers> 72 | --------------------------------------------------------------------------------