fastify를 사용하는 데모 API

소개



나는 배울 새로운 기술과 새로운 좋은 것들을 찾고 있었고 그것이 내가 fastify에 대해 알게 된 방법입니다.
언뜻 보기에는 Express와 상당히 유사한 프레임워크이지만, 더 나은benchmarks 기능도 있고 몇 가지 꽤 좋은 기능도 있으므로 사용해 보기로 하고 이 게시물을 작성하게 된 이유입니다.

이 게시물은 업데이트되었으며 코드는 이제 fastify 3.x에서 작동합니다.

이 게시물에 대한 모든 코드는 here 입니다.

시작하자



프로젝트 구조:

./migrations
./src
    /plugins
    /routes
        /api
            /persons
                /index.js
                /schemas.js
            /products
                /index.js
                /schemas.js
        /docs
            /index.js
    /services
        /persons.js
        /products.js
    /utils
        /functions.js
    /app.js
    /environment.js
    /start.js
./knexfile.js

./이전:
이 경우에는 knex 쿼리 빌더를 사용하고 있는데, 저에게는 꽤 괜찮고 마이그레이션 및 시드 작업이 쉽습니다. 제품(20200124230315_create_Person_table.js) 테이블에 대한 마이그레이션 파일이 하나만 있고 다음과 같습니다.

const up = knex => {
  return knex.schema.hasTable('Person').then(exists => {
    if (!exists) {
      return knex.schema.createTable('Person', table => {
        table.increments('id');
        table.string('name', 100).notNullable();
        table.string('lastName', 100).defaultTo(null);
        table.string('document', 15).notNullable();
        table.string('genre', 1).notNullable();
        table.integer('phone').unsigned().notNullable();
        table.timestamps(true, true);

        table.unique('document');
      });
    }
  });
};

const down = knex => {
  return knex.schema.dropTable('Person');
};

module.exports = {
  up,
  down
};

Note: For a quick start about the migrations or seeds, you can see this gist.



./src/플러그인:
fastify의 중요한 측면은 플러그인 개념입니다. 코드 기능을 캡슐화한 다음 fastify 앱 개체에서 사용하는 것이 좋습니다. knex 연결용 플러그인과 mongo 연결용 플러그인이 두 개 있습니다.

  • ./src/plugins/knex-db-connector.js:

  • const fastifyPlugin = require('fastify-plugin');
    const knex = require('knex');
    
    const {
      DB_SQL_CLIENT,
      DB_SQL_HOST,
      DB_SQL_USER,
      DB_SQL_PASSWORD,
      DB_SQL_NAME,
      DB_SQL_PORT
    } = require('../environment');
    
    const knexConnector = async (server, options = {}) => {
      const db = knex({
        client: DB_SQL_CLIENT,
        connection: {
          host: DB_SQL_HOST,
          user: DB_SQL_USER,
          password: DB_SQL_PASSWORD,
          database: DB_SQL_NAME,
          port: DB_SQL_PORT,
          ...options.connection
        },
        ...options
      });
      server.decorate('knex', db);
    };
    
    // Wrapping a plugin function with fastify-plugin exposes the decorators,
    // hooks, and middlewares declared inside the plugin to the parent scope.
    module.exports = fastifyPlugin(knexConnector);
    

  • ./src/plugins/mongo-db-connector.js:

  • const {
      DB_NOSQL_USER,
      DB_NOSQL_PASSWORD,
      DB_NOSQL_HOST,
      DB_NOSQL_NAME
    } = require('../environment');
    
    const MONGO_URL = `mongodb+srv://${DB_NOSQL_USER}:${DB_NOSQL_PASSWORD}@${DB_NOSQL_HOST}/${DB_NOSQL_NAME}?retryWrites=true&w=majority`;
    
    const mongoConnector = app => {
      app.register(require('fastify-mongodb'), {
        // force to close the mongodb connection when app stopped
        // the default value is false
        forceClose: true,
        url: MONGO_URL
      });
    };
    
    module.exports = mongoConnector;
    

    Note: In this case I'm using fastify-mongodb to help me with the connection. At this moment it's throwing a warning about some deprecated methods, that's a problem that will have a solution on comming updates from fastify-mongodb.



    ./src/경로:
    Express에서와 마찬가지로 fastify의 경로에는 요청 개체 및 응답 개체(fastify에 대한 응답)가 있는 경로 처리기가 있지만 fastify에서 경로는 JSON Schema을 사용하여 유효성 검사 및 직렬화와 같은 다른 기능을 가질 수 있습니다.

  • ./src/routes/api/index.js:

  • const oas = require('fastify-swagger');
    
    const apiRoutes = async (app, options) => {
      app.register(oas, require('../docs'));
      app.register(require('./persons'), { prefix: 'persons' });
      app.register(require('./products'), { prefix: 'products' });
      app.get('/', async (request, reply) => {
        return { hello: 'world' };
      });
    };
    
    module.exports = apiRoutes;
    

    Note: As you can see I have a GET route ‘/’ and it has a route handler, also I’m using as plugins the other routes for persons and products endpoints, I describe them below, and finally I’m using fastify-swagger, don’t worry I’m going to explain that.



  • ./src/routes/api/persons/index.js:

  • const { PersonService } = require('../../../services/persons');
    const { createSchema, getAllSchema, getOneSchema, updateSchema, deleteSchema } = require('./schemas');
    
    const personRoutes = async (app, options) => {
      const personService = new PersonService(app);
    
      // create
      app.post('/', { schema: createSchema }, async (request, reply) => {
        const { body } = request;
    
        const created = await personService.create({ person: body });
    
        return created;
      });
    
      // get all
      app.get('/', { schema: getAllSchema }, async (request, reply) => {
        app.log.info('request.query', request.query);
        const persons = await personService.getAll({});
        return persons;
      });
    
      // get one
      app.get('/:personId', { schema: getOneSchema }, async (request, reply) => {
        const { params: { personId } } = request;
    
        app.log.info('personId', personId);
    
        const person = await personService.getOne({ id: personId });
        return person;
      });
    
      // update
      app.patch('/:personId', { schema: updateSchema }, async (request, reply) => {
        const { params: { personId } } = request;
    
        const { body } = request;
    
        app.log.info('personId', personId);
        app.log.info('body', body);
    
        const updated = await personService.update({ id: personId, person: body });
    
        return updated;
      });
    
      // delete
      app.delete('/:personId', { schema: deleteSchema }, async (request, reply) => {
        const { params: { personId } } = request;
    
        app.log.info('personId', personId);
    
        const deleted = await personService.delete({ id: personId });
        return deleted;
      });
    };
    
    module.exports = personRoutes;
    

    Note: Here I have all the endpoints related to persons, I’m using the schema for each endpoint and also I’m using the PersonService, I'll explain that.



  • ./src/routes/api/persons/schemas.js:

  •    const personProperties = {
      id: { type: 'number' },
      name: { type: 'string' },
      lastName: { type: 'string', nullable: true },
      document: { type: 'string' },
      genre: {
        type: 'string',
        enum: ['M', 'F']
      },
      phone: { type: 'number', maximum: 9999999999 },
      created_at: { type: 'string' },
      updated_at: { type: 'string' }
    };
    
    const tags = ['person'];
    
    const paramsJsonSchema = {
      type: 'object',
      properties: {
        personId: { type: 'number' }
      },
      required: ['personId']
    };
    
    const queryStringJsonSchema = {
      type: 'object',
      properties: {
        filter: { type: 'string' }
      },
      required: ['filter']
    };
    
    const bodyCreateJsonSchema = {
      type: 'object',
      properties: personProperties,
      required: ['name', 'document', 'genre', 'phone']
    };
    
    const bodyUpdateJsonSchema = {
      type: 'object',
      properties: personProperties
    };
    
    const getAllSchema = {
      tags,
      querystring: queryStringJsonSchema,
      response: {
        200: {
          type: 'array',
          items: {
            type: 'object',
            properties: personProperties
          }
        }
      }
    };
    
    const getOneSchema = {
      tags,
      params: paramsJsonSchema,
      querystring: queryStringJsonSchema,
      response: {
        200: {
          type: 'object',
          properties: personProperties
        }
      }
    };
    
    const createSchema = {
      tags,
      body: bodyCreateJsonSchema,
      response: {
        201: {
          type: 'object',
          properties: personProperties
        }
      }
    };
    
    const updateSchema = {
      tags,
      params: paramsJsonSchema,
      body: bodyUpdateJsonSchema,
      response: {
        200: {
          type: 'object',
          properties: personProperties
        }
      }
    };
    
    const deleteSchema = {
      tags,
      params: paramsJsonSchema,
      response: {
        200: {
          type: 'object',
          properties: personProperties
        }
      }
    };
    
    module.exports = {
      getAllSchema,
      getOneSchema,
      createSchema,
      updateSchema,
      deleteSchema
    };
    

    Note: Here are all the schemas for each endpoint, using these schemas I can have some importans validations, for example, a body attribute can't be a string if was defined as number, the maximun length of a string attribute, etc.



  • ./src/routes/api/products/index.js:

  • const { ProductService } = require('../../../services/products');
    const {
      createSchema,
      getAllSchema,
      getOneSchema,
      updateSchema,
      deleteSchema
    } = require('./schemas');
    
    const productRoutes = async (app, options) => {
      const productService = new ProductService(app);
    
      // create
      app.post('/', { schema: createSchema }, async (request, reply) => {
        const { body } = request;
    
        const insertedId = await productService.create({ product: body });
        app.log.info('insertedId', insertedId);
        return { _id: insertedId };
      });
    
      // get all
      app.get('/', { schema: getAllSchema }, async (request, reply) => {
        app.log.info('request.query', request.query);
        const products = await productService.getAll({ filter: {} });
        return products;
      });
    
      // get one
      app.get('/:productId', { schema: getOneSchema }, async (request, reply) => {
        const { params: { productId } } = request;
    
        app.log.info('productId', productId);
    
        const product = await productService.getOne({ id: productId });
    
        return product;
      });
    
      // update
      app.patch('/:productId', { schema: updateSchema }, async (request, reply) => {
        const { params: { productId } } = request;
    
        const { body } = request;
    
        app.log.info('productId', productId);
        app.log.info('body', body);
    
        const updated = await productService.update({ id: productId, product: body });
    
        return updated;
      });
    
      // delete
      app.delete('/:productId', { schema: deleteSchema }, async (request, reply) => {
        const { params: { productId } } = request;
    
        app.log.info('productId', productId);
    
        const deleted = await productService.delete({ id: productId });
    
        return deleted;
      });
    };
    
    module.exports = productRoutes;
    

  • ./src/routes/api/products/schemas.js:

  • const productProperties = {
      _id: { type: 'string' },
      name: { type: 'string' },
      description: { type: 'string' },
      image: { type: 'string', nullable: true },
      price: { type: 'number', maximum: 9999999999 }
    };
    
    const tags = ['product'];
    
    const paramsJsonSchema = {
      type: 'object',
      properties: {
        productId: { type: 'string' }
      },
      required: ['productId']
    };
    
    const queryStringJsonSchema = {
      type: 'object',
      properties: {
        filter: { type: 'string' }
      },
      required: ['filter']
    };
    
    const bodyCreateJsonSchema = {
      type: 'object',
      properties: productProperties,
      required: ['name', 'description', 'price']
    };
    
    const bodyUpdateJsonSchema = {
      type: 'object',
      properties: productProperties
    };
    
    const getAllSchema = {
      tags,
      querystring: queryStringJsonSchema,
      response: {
        200: {
          type: 'array',
          items: {
            type: 'object',
            properties: productProperties
          }
        }
      }
    };
    
    const getOneSchema = {
      tags,
      params: paramsJsonSchema,
      querystring: queryStringJsonSchema,
      response: {
        200: {
          type: 'object',
          properties: productProperties
        }
      }
    };
    
    const createSchema = {
      tags,
      body: bodyCreateJsonSchema,
      response: {
        201: {
          type: 'object',
          properties: productProperties
        }
      }
    };
    
    const updateSchema = {
      tags,
      params: paramsJsonSchema,
      body: bodyUpdateJsonSchema,
      response: {
        200: {
          type: 'object',
          properties: productProperties
        }
      }
    };
    
    const deleteSchema = {
      tags,
      params: paramsJsonSchema,
      response: {
        200: {
          type: 'object',
          properties: productProperties
        }
      }
    };
    
    module.exports = {
      getAllSchema,
      getOneSchema,
      createSchema,
      updateSchema,
      deleteSchema
    };
    

  • ./src/routes/docs/index.js:

  • const { APP_PORT } = require('../../environment');
    
    module.exports = {
      routePrefix: '/documentation',
      exposeRoute: true,
      swagger: {
        info: {
          title: 'fastify demo api',
          description: 'docs',
          version: '0.1.0'
        },
        externalDocs: {
          url: 'https://swagger.io',
          description: 'Find more info here'
        },
        servers: [
          { url: `http://localhost:${APP_PORT}`, description: 'local development' },
          { url: 'https://dev.your-site.com', description: 'development' },
          { url: 'https://sta.your-site.com', description: 'staging' },
          { url: 'https://pro.your-site.com', description: 'production' }
        ],
        schemes: ['http'],
        consumes: ['application/json'],
        produces: ['application/json'],
        tags: [
          { name: 'person', description: 'Person related end-points' },
          { name: 'product', description: 'Product related end-points' }
        ]
      }
    };
    

    Note: Is the turn to talk about fastify-swagger, It's a module that helps us creating the API documentation, using this “configuration file” and the schemas used before, for each endpoint.



    ./src/서비스:
    이 폴더에는 끝점에서 필요한 모든 논리를 해결하기 위해 담당하는 "클래스"가 포함되어 있습니다.

  • ./src/services/persons.js:

  • const { isEmptyObject } = require('../utils/functions');
    
    class PersonService {
      /**
       * Creates an instance of PersonService.
       * @param {object} app fastify app
       * @memberof PersonService
       */
      constructor (app) {
        if (!app.ready) throw new Error(`can't get .ready from fastify app.`);
        this.app = app;
    
        const { knex } = this.app;
    
        if (!knex) {
          throw new Error('cant get .knex from fastify app.');
        }
      }
    
      /**
       * function to create one
       *
       * @param { {person: object} } { person }
       * @returns {Promise<number>} created id
       * @memberof PersonService
       */
      async create ({ person }) {
        const err = new Error();
        if (!person) {
          err.statusCode = 400;
          err.message = 'person is needed.';
          throw err;
        }
    
        const { knex } = this.app;
    
        const id = (await knex('Person').insert(person))[0];
    
        const createdPerson = await this.getOne({ id });
    
        return createdPerson;
      }
    
      /**
       * function to get all
       *
       * @param { filter: object } { filter = {} }
       * @returns {Promise<{ id: number }>[]} array
       * @memberof PersonService
       */
      async getAll ({ filter = {} }) {
        const { knex } = this.app;
    
        const persons = await knex.select('*').from('Person').where(filter);
    
        return persons;
      }
    
      /**
       * function to get one
       *
       * @param { { id: number } } { id }
       * @returns {Promise<{id: number}>} object
       * @memberof PersonService
       */
      async getOne ({ id }) {
        const err = new Error();
    
        if (!id) {
          err.message = 'id is needed';
          err.statusCode = 400;
          throw err;
        }
    
        const { knex } = this.app;
    
        const data = await knex.select('*').from('Person').where({ id });
    
        if (!data.length) {
          err.statusCode = 412;
          err.message = `can't get the person ${id}.`;
          throw err;
        }
    
        const [person] = data;
        return person;
      }
    
      /**
       * function to update one
       *
       * @param { { id: number, person: object } } { id, person = {} }
       * @returns {Promise<{ id: number }>} updated
       * @memberof PersonService
       */
      async update ({ id, person = {} }) {
        const personBefore = await this.getOne({ id });
    
        if (isEmptyObject(person)) {
          return personBefore;
        }
    
        const { knex } = this.app;
        await knex('Person')
          .update(person)
          .where({ id: personBefore.id });
    
        const personAfter = await this.getOne({ id });
    
        return personAfter;
      }
    
      /**
       * function to delete one
       *
       * @param { { id: number } } { id }
       * @returns {Promise<object>} deleted
       * @memberof PersonService
       */
      async delete ({ id }) {
        const personBefore = await this.getOne({ id });
    
        const { knex } = this.app;
        await knex('Person').where({ id }).delete();
    
        delete personBefore.id;
    
        return personBefore;
      }
    }
    
    module.exports = {
      PersonService
    };
    

    Note: As you noticed, this is the service that I’m using in the person routes, by constructor I'm passing the fastify app and then, from the fastify app I get the knex connector to keep in touch with the database.



  • ./src/services/products.js:

  • const { ObjectId } = require('mongodb');
    
    class ProductService {
      /**
       * Creates an instance of ProductService.
       * @param {object} app fastify app
       * @memberof ProductService
       */
      constructor (app) {
        if (!app.ready) throw new Error(`can't get .ready from fastify app.`);
        this.app = app;
        const { mongo } = this.app;
    
        if (!mongo) {
          throw new Error('cant get .mongo from fastify app.');
        }
    
        const db = mongo.db;
        const collection = db.collection('Product');
        this.collection = collection;
      }
    
      /**
       * function to create one
       *
       * @param {{ product: object }} { product }
       * @returns {Promise<{ id: number }>} created
       * @memberof ProductService
       */
      async create ({ product }) {
        const { insertedId } = (await this.collection.insertOne(product));
    
        const created = await this.getOne({ id: insertedId });
    
        return created;
      }
    
      /**
       * function to get all
       *
       * @param {{ filter: object }} { filter = {} }
       * @returns {Promise<{ id: number }> []} array
       * @memberof ProductService
       */
      async getAll ({ filter = {} }) {
        const products = await this.collection.find(filter).toArray();
    
        return products;
      }
    
      /**
       * function to get one
       *
       * @param {{ id: number }} { id }
       * @returns {Promise<{ id: number }>}
       * @memberof ProductService
       */
      async getOne ({ id }) {
        const err = new Error();
    
        if (!id) {
          err.statusCode = 400;
          err.message = 'id is needed.';
          throw err;
        }
    
        const product = await this.collection.findOne({ _id: ObjectId(id) });
    
        if (!product) {
          err.statusCode = 400;
          err.message = `can't get the product ${id}.`;
          throw err;
        }
    
        return product;
      }
    
      /**
       * function to update one
       *
       * @param {{ id: number, product: object }} { id, product }
       * @returns {Promise<{ id: number }>} updated
       * @memberof ProductService
       */
      async update ({ id, product }) {
        await this.getOne({ id });
    
        const { upsertedId } = (await this.collection.updateOne(
          {
            _id: ObjectId(id)
          },
          {
            $set: product
          },
          {
            upsert: true
          }
        ));
    
        const after = await this.getOne({ upsertedId });
    
        return after;
      }
    
      /**
       * function to delete one
       *
       * @param {{ id: number }} { id }
       * @returns {Promise<object>} deleted
       * @memberof ProductService
       */
      async delete ({ id }) {
        const before = await this.getOne({ id });
    
        await this.collection.deleteOne({ _id: ObjectId(id) });
    
        delete before._id;
    
        return before;
      }
    }
    
    module.exports = {
      ProductService
    };
    

    Note: In the same way, as in the PersonService I’m passing the fastify app by the constructor and then I use it to get the mongo connector.



    ./src/environment.js:
    이것은 컨텍스트(로컬, 개발, 스테이징, 프로덕션)에 따라 환경 변수를 처리하는 데 사용하는 파일이며 다음과 같습니다.

    const dotenv = require('dotenv');
    const path = require('path');
    
    dotenv.config({ path: path.resolve(__dirname, '../.env') });
    
    let envPath;
    
    // validate the NODE_ENV
    const NODE_ENV = process.env.NODE_ENV;
    switch (NODE_ENV) {
    case 'development':
      envPath = path.resolve(__dirname, '../.env.development');
      break;
    case 'staging':
      envPath = path.resolve(__dirname, '../.env.staging');
      break;
    case 'production':
      envPath = path.resolve(__dirname, '../.env.production');
      break;
    default:
      envPath = path.resolve(__dirname, '../.env.local');
      break;
    };
    
    dotenv.config({ path: envPath });
    
    const enviroment = {
      /* GENERAL */
      NODE_ENV,
      TIME_ZONE: process.env.TIME_ZONE,
      APP_PORT: process.env.APP_PORT || 8080,
      /* DATABASE INFORMATION */
      DB_NOSQL_HOST: process.env.DB_NOSQL_HOST,
      DB_NOSQL_USER: process.env.DB_NOSQL_USER,
      DB_NOSQL_PASSWORD: process.env.DB_NOSQL_PASSWORD,
      DB_NOSQL_NAME: process.env.DB_NOSQL_NAME,
      DB_NOSQL_PORT: process.env.DB_NOSQL_PORT,
      DB_SQL_CLIENT: process.env.DB_SQL_CLIENT,
      DB_SQL_HOST: process.env.DB_SQL_HOST,
      DB_SQL_USER: process.env.DB_SQL_USER,
      DB_SQL_PASSWORD: process.env.DB_SQL_PASSWORD,
      DB_SQL_NAME: process.env.DB_SQL_NAME,
      DB_SQL_PORT: process.env.DB_SQL_PORT
    };
    
    module.exports = enviroment;
    

    ./src/app.js:
    이제 우리는 이 API 데모에서 가장 중요한 파일에 있습니다. 이 파일에는 프로젝트의 구성/설정이 있습니다.

    const Fastify = require('fastify');
    const cors = require('cors');
    
    // order to register / load
    // 1. plugins (from the Fastify ecosystem)
    // 2. your plugins (your custom plugins)
    // 3. decorators
    // 4. hooks and middlewares
    // 5. your services
    
    const build = async () => {
      const fastify = Fastify({
        bodyLimit: 1048576 * 2,
        logger: { prettyPrint: true }
      });
    
      // plugins
      await require('./plugins/mongo-db-connector')(fastify);
    
      await fastify.register(require('fastify-express'));
      await fastify.register(require('./plugins/knex-db-connector'), {});
      await fastify.register(require('./routes/api'), { prefix: 'api' });
    
      // hooks
      fastify.addHook('onClose', (instance, done) => {
        const { knex } = instance;
        knex.destroy(() => instance.log.info('knex pool destroyed.'));
      });
    
      // middlewares
      fastify.use(cors());
    
      return fastify;
    };
    
    // implement inversion of control to make the code testable
    module.exports = {
      build
    };
    

    ./src/start.js:
    이것은 서버를 시작하는 데 사용하는 파일입니다.

     const { build } = require('./app');
    
    const { APP_PORT } = require('./environment');
    
    build()
      .then(app => {
        // run the server!
        app.listen(APP_PORT, (err, address) => {
          if (err) {
            app.log.error(err);
            process.exit(1);
          }
    
          app.log.info(`server listening on ${address}`);
    
          process.on('SIGINT', () => app.close());
          process.on('SIGTERM', () => app.close());
        });
      });
    
    

    프로젝트가 콘솔에서 다음과 같이 표시됩니다.


    이것이 swagger UI 문서의 모습입니다.


    끝내자



    그게 다야, 질문이나 피드백이 있으면 누군가가 새로운 것을 배우는 데 도움이 되기를 바랍니다. 읽어주셔서 감사합니다.
    다른 사람들을 돕기 위해 더 많은 포스트 출판물을 만들고 싶습니다.

    좋은 웹페이지 즐겨찾기