准备工作

开始搭建

设置

$ npm i -g @nestjs/cli #安装cli命令行
$ nest new centurion #初始化centurion项目文件夹


如果安装过程过慢或者意外终端可以进入文件夹后执行

npm install # 安装依赖
git init .  # 初始化git

添加.gitignore文件来忽略不需要提交的文件

node_modules
.vscode
dist

启动应用

npm run start

创建配置

我们手动创建配置文件夹和两个配置文件,如果有其他配置文件可以继续创建

mkdir conf
touch dev.env
touch prod.env
npm i --save dotenv # 安装env解析库

配置内容举例 dev.env

CLIENT_ID=bd873d6*****757512f2d89
CLIENT_SECRET=faae6e50266****57f5d342ceada1e
SCOPE=write_products,read_product_listings,write_orders,write_script_tags,write_fulfillments,write_shipping,write_checkouts,write_price_rules,read_shopify_payments_payouts
REDIRECT_URI=https://ebf2c350.ngrok.io/codes/redirect
ACCESS_MODE=
DB_HOST=***
DB_PORT=3306
DB_USERNAME=***
DB_PASSWORD=***
DB_DATABASE=***

然后再创建配置服务

mkdir src/config
touch src/config/config.module.ts
touch src/config/config.service.ts

首先创建 config.module.ts

import { Module, Global } from '@nestjs/common';
import { ConfigService } from './config.service';

@Module({
  providers: [
    {
      provide: ConfigService,
      useValue: new ConfigService(`conf/${process.env.NODE_ENV}.env`),  //注意这里与原文档不同,因为配置文件保存在conf文件夹中
    },
  ],
  exports: [ConfigService],
})
export class ConfigModule {}

接着创建config.service.ts (后续可以优化加入数据类型校验)

import { Injectable } from '@nestjs/common';
import * as dotenv from 'dotenv';
import * as fs from 'fs';

@Injectable()
export class ConfigService {
  private readonly envConfig: { [key: string]: string };

  constructor(filePath: string) {
    this.envConfig = dotenv.parse(fs.readFileSync(filePath))
  }

  get(key: string): string {
    return this.envConfig[key];
  }
}

配置的使用我们在数据库相关章节再介绍。

数据库(如果项目不涉及数据库可跳过)

数据库我们使用的mysql,选用typeorm作为ORM

安装orm

npm install --save @nestjs/typeorm typeorm mysql

设置数据库连接

编辑 app.module.ts 文件,其中涉及了配置服务的使用

import { Module } from '@nestjs/common';
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from './config/config.module';
import { ConfigService } from './config/config.service';

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => ({
        type: 'mysql' as 'mysql',
        host: configService.get('DB_HOST'),
        port: parseInt(configService.get('DB_PORT')),
        username: configService.get('DB_USERNAME'),
        password: configService.get('DB_PASSWORD'),
        database: configService.get('DB_DATABASE'),
        entities: [__dirname + '/**/*.entity{.ts,.js}'],
      }),
    })
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

创建实体entity

业务层面我们在src文件夹下需要创建一个oauth的数据库表,那么我们就应该先建立好oauth的文件夹存储整个oauth这个模块(module),然后在这个文件夹中先创建oauth.entity.ts

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

@Entity('codes')
export class Codes {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 256 })
  shopName: string;

  @Column({ length: 32 })
  nonce: string;

  @Column({ length: 128 })
  authorizeCode: string;

  @Column({ length: 128 })
  accessToken: string;

  @Column({
    type: 'datetime'
  })
  createTime: Date;
}

通过typeorm命令行创建migration

  1. 可以参考typeorm的文档typeorm/migrations.md at master · typeorm/typeorm · GitHub
  2. 安装typeorm cli
npm install -g ts-node  # 全局安装tsnode

在package.json文件中添加如下命令

"script" {
    ...
    "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js"    
}

在根目录下增加orm的配置文件ormconfig.json,这个时候大家就会有疑问了,之前不是在app.module里面加载了数据库信息,这里怎么又要有一个配置文件。这里解释一下: 这个配置文件是专门用于typeorm的cli进行命令行操作的时候用到的,有个不方便的地方就是切换环境的时候,后续再想办法优化。

{
    "type": "mysql",
    "host": "host",
    "port": 30005,
    "username": "username",
    "password": "password",
    "database": "database",
    "entities": [
        "src/**/**.entity.ts"
    ],
    "migrations": ["src/migration/*.ts"],
    "cli": {
        "migrationsDir": "src/migration",
        "entitiesDir": "src/**/**.entity.ts"
    },
    "synchronize": true
  }
  1. 创建初始化migration
npm run typeorm migration:generate -- -n InitMigration

会在src/migration文件夹下生成一个{timstamp}-InitMigration.ts的文件,内容如下:

import {MigrationInterface, QueryRunner} from "typeorm";

export class InitMigration1550915407802 implements MigrationInterface {

    public async up(queryRunner: QueryRunner): Promise<any> {
        await queryRunner.query("CREATE TABLE `oauth` (`id` int NOT NULL AUTO_INCREMENT, `shopName` varchar(256) NOT NULL, `nonce` varchar(32) NOT NULL, `authorizeCode` varchar(128) NOT NULL, `accessToken` varchar(128) NOT NULL, `createTime` datetime NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB");
    }

    public async down(queryRunner: QueryRunner): Promise<any> {
        await queryRunner.query("DROP TABLE `oauth`");
    }

}

里面包含了创建该数据库表的内容。
4. 执行migration在数据库中建表

npm run typeorm migration:run

执行成功后,我们就可以在数据库中看到该表,接下来就可以写逻辑了

业务逻辑API

在上节的数据库中我们创建了oauth的文件夹,如果要写业务逻辑,我们就都放在这个文件夹中:

创建service

在文件夹中创建oauth.service.ts文件, 并编辑内容如下:

import { Injectable, HttpService } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Oauth } from './oauth.entity';
import { map } from 'rxjs/operators';
import * as crypto from 'crypto'
import * as querystring from 'querystring'
import { ConfigService } from '../config/config.service';

@Injectable()
export class OauthService {
  constructor(
    @InjectRepository(Oauth)
    private readonly oauthRepository: Repository<Oauth>,
    private readonly http: HttpService,
    private readonly config: ConfigService
  ) {
  }
    getAccessToken(hostname: string, authorizationCode: string) {
      let url = 'https://' + hostname + '/admin/oauth/access_token'
      let postData = {
          client_id: this.config.get('CLIENT_ID'),
          client_secret: this.config.get('CLIENT_SECRET'),
          code: authorizationCode
      }

      return this.http.post(
          url,
          postData
      )
      .pipe(
          map(response => response.data)
      ).toPromise();
  }

  checkHMACDigest(query: Object) : boolean {
      let hmac = query['hmac']
      delete query['hmac']
      let queryString = querystring.stringify(query)
      return hmac == crypto.createHmac('SHA256', this.config.get('CLIENT_SECRET')).update(queryString).digest('hex')
  }

  checkHostname(hostname: string) : boolean {
      if (!hostname.endsWith('myshopify.com')) {
          return false
      }

      if (!RegExp('^[-.a-z0-9]*$').test(hostname)) {
          return false
      }

      return true
  }

  getRedirectUrl(shopName: string, nonce: string) : string {
    return 'https://' + shopName +
            '.myshopify.com/admin/oauth/authorize?client_id=' + this.config.get('CLIENT_ID') + 
            '&scope=' + this.config.get('SCOPE') + 
            '&redirect_uri=' + this.config.get('REDIRECT_URI') + 
            '&state=' + nonce + 
            '&grant_options[]=' + this.config.get('ACCESS_MODE')
  }

  async findAll(): Promise<Oauth[]> {
    return await this.oauthRepository.find();
  }

  async findByShopName(shopName: string): Promise<Oauth> {
    return await this.oauthRepository.findOne({shopName: shopName});
  }

  async updateByShopName(shopName: string, accessToken: string, ac: string) {
    let oauth = await this.oauthRepository.findOne({shopName: shopName});
    oauth.accessToken = accessToken
    oauth.authorizeCode = ac
    this.oauthRepository.save(oauth);
  }

  async updateNonceByShopName(shopName: string, nonce: string) {
    let oauth = await this.oauthRepository.findOne({shopName: shopName});
    oauth.nonce = nonce
    this.oauthRepository.save(oauth);
  }

  async create(oauth: Oauth) {
    return await this.oauthRepository.save(oauth)
  }
}

我们可以看到其中既有用到数据查询更新,也有用到通过HTTP Module请求其他服务,这些都是在service中完成的。

创建 controller

在文件夹中创建oauth.controller.ts文件,可以通过nest的命令行来完成

nest g controller oauth

会创建oauth.controller.ts 和 oauth.controller.spec.ts(测试用,目前可忽略)。这里有个问题是通过命令行创建controller时会覆写app.module.ts所以要提前做好备份,或者不用命令行来创建controller。
controller里的装饰器就体现了访问理由,例如第一个方法的路由就是/oauth/install
接下来编辑oauth.controller.ts:

import { Controller, Get, Query, Res, UseFilters, HttpException } from '@nestjs/common';
import { OauthService } from './oauth.service'
import { Logger } from '@nestjs/common';
import { HttpExceptionFilter } from '../filters/api-exception.filter';
import { Oauth } from '../oauth/oauth.entity';

@Controller('oauth')
export class OauthController {
    constructor(
        private oauthService: OauthService,
    ){
    }

    @Get('install')
    async auth(
        @Query() query,
        @Res() res
    ) {
        Logger.log('收到install请求:' + query.hmac + '|' + query.shop + '|' + query.timestamp)
        let shopName = query.shop.split('.')[0]

        await this.oauthService.findByShopName(shopName).then((result) => {
            let nonce = new Date().getTime().toString()

            if (result && result.accessToken) {
                // 店铺已存在 更新nonce
                this.oauthService.updateNonceByShopName(shopName, nonce);
            } else {
                // 店铺oauth不存在 创建店铺oauth
                let oauth = new Oauth()
                oauth.shopName = shopName
                oauth.createTime = new Date()
                oauth.nonce = nonce
                this.oauthService.create(oauth)
            }
            const installRedirectUrl = this.oauthService.getRedirectUrl(shopName, nonce);
            res.redirect(installRedirectUrl);
        })
    }

    @Get('redirect')
    @UseFilters(new HttpExceptionFilter())
    async redirectUri(
        @Query() query
    ) {
        Logger.log('收到redirect请求:' + JSON.stringify(query))
        let hostname = query.shop
        let shopName = query.shop.split('.')[0]
        let authorizationCode = query.code
        let oauth: Oauth = null

        await this.oauthService.findByShopName(shopName).then((res) => {
            Logger.log('oauth:' + JSON.stringify(res))
            oauth = res
        })
        // 1.校验nonce
        if (query.state === oauth.nonce) {
            Logger.log('nonce校验成功')
        } else {
            Logger.error('nonce校验失败')
            throw new HttpException('nonce校验失败', 100001)
        }

        // 2.校验hmac
        let hmacCheck = this.oauthService.checkHMACDigest(query)
        if (false === hmacCheck) {
            Logger.error('hmac校验失败')
            throw new HttpException('hmac校验失败', 100002)
        } else {
            Logger.log('hmac校验成功')
        }
        
        // 3.校验hostname
        let hostnameCheck = this.oauthService.checkHostname(hostname)
        if (false === hostnameCheck) {
            Logger.error('hostname校验失败')
            throw new HttpException('hostname校验失败', 100003)
        } else {
            Logger.log('hostname校验成功')
        }

        await this.oauthService.getAccessToken(hostname, authorizationCode).then((res) => {
            Logger.log('收到access token:' + JSON.stringify(res));
            this.oauthService.updateByShopName(shopName, res.access_token, authorizationCode);
        }, (err) => {
            Logger.error('error:' + err)
            throw new HttpException('获取access token失败', 100004)
        })

        return 'success'
    }
}

创建module

sevice和controller都创建之后,我们要创建module,然后把oauth这个module添加到app.module中去。oauth.module.ts内容如下:

import { Module, HttpModule } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { OauthService } from './oauth.service'
import { OauthController } from './oauth.controller'
import { Oauth } from './oauth.entity'
import { ConfigModule } from '../config/config.module';

@Module({
  imports: [TypeOrmModule.forFeature([Oauth]), HttpModule, ConfigModule],
  providers: [OauthService],
  controllers: [OauthController],
})

export class OauthModule {}

然后在app.module中添加oauth如下所以:

import { Module, HttpModule } from '@nestjs/common';
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { OauthController } from './oauth/oauth.controller';
import { OauthModule } from './oauth/oauth.module'
import { OauthService } from './oauth/oauth.service';
import { ConfigModule } from './config/config.module';
import { ConfigService } from './config/config.service';

@Module({
  imports: [
    HttpModule,
    OauthModule,
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => ({
        type: 'mysql' as 'mysql',
        host: configService.get('DB_HOST'),
        port: parseInt(configService.get('DB_PORT')),
        username: configService.get('DB_USERNAME'),
        password: configService.get('DB_PASSWORD'),
        database: configService.get('DB_DATABASE'),
        entities: [__dirname + '/**/*.entity{.ts,.js}'],
      }),
    })
  ],
  controllers: [AppController, OauthController],
  providers: [AppService, OauthService],
})
export class AppModule {}

切换环境

如果我们要通过命令行来启动dev或者production环境,那么我们先要编辑package.json文件,修改对应的script如下:

script : {
	...
  "start:dev": "export NODE_ENV=dev && nodemon",
  "start:prod": "export NODE_ENV=prod && ts-node dist/main.js",
}

这通过 npm run start:dev就会加载conf/dev.env配置文件并启动服务,
npm run start:prod就会加载conf/prod.env配置文件,并编译之后启动服务。

后续优化,未完待续。。。