Compare commits

..

11 Commits

31 changed files with 10096 additions and 14903 deletions

View File

@ -1,13 +1,15 @@
{ {
"cSpell.words": [ "cSpell.words": [
"Repos",
"boardcat", "boardcat",
"execa",
"gitea", "gitea",
"lpush", "lpush",
"lrange", "lrange",
"metatype", "metatype",
"pmessage", "pmessage",
"psubscribe", "psubscribe",
"QLJSON",
"Repos",
"rpop", "rpop",
"rpush" "rpush"
] ]

View File

@ -14,5 +14,6 @@ db:
port: 6379 port: 6379
password: password:
prefix: blog prefix: blog
workspaces: etcd:
root: '/Users/ivanli/Projects/fennec/workspaces' hosts:
- 'http://192.168.31.194:2379'

View File

@ -0,0 +1,20 @@
version: "3.9"
services:
postgres:
image: 'postgres:14'
restart: always
environment:
- POSTGRES_HOST_AUTH_METHOD=trust
ports:
- '${PG_PORT}:5432'
redis:
image: 'redis:6'
restart: always
ports:
- '${REDIS_PORT}:6379'
networks:
default:
name: 'blog-dev'
driver: bridge

61
docker/init-dev.mjs Normal file
View File

@ -0,0 +1,61 @@
import execa from 'execa';
import { URL } from 'url';
import { findFreePorts } from 'find-free-ports';
import YAML from 'js-yaml';
import { readFile, writeFile } from 'fs/promises';
const [PG_PORT, REDIS_PORT] = await findFreePorts(2);
await execa(
'docker-compose',
[
'-f',
new URL('./docker-compose.dev.yml', import.meta.url).pathname,
'up',
'-d',
],
{
env: {
PG_PORT,
REDIS_PORT,
},
stdout: process.stdout,
stderr: process.stderr,
},
);
console.log(`✅ Postgres is running on port ${PG_PORT}`);
console.log(`✅ Redis is running on port ${REDIS_PORT}`);
const config = await YAML.load(
await readFile(
new URL('../config.yml.example', import.meta.url).pathname,
'utf-8',
),
);
config.db.postgres = {
host: 'localhost',
port: PG_PORT,
database: 'postgres',
username: 'postgres',
password: '',
};
config.db.redis = {
host: 'localhost',
port: REDIS_PORT,
};
const configOutputPath = new URL('../config.yml', import.meta.url).pathname;
await writeFile(configOutputPath, YAML.dump(config), 'utf-8');
console.log(`✅ Config file is written to ${configOutputPath}`);
await execa.command('npm run typeorm -- migration:run', {
cwd: new URL('../', import.meta.url).pathname,
stdout: process.stdout,
stderr: process.stderr,
});
console.log(`✅ Database Initiated!`);

View File

@ -1,7 +1,7 @@
module.exports = { module.exports = {
apps: [ apps: [
{ {
name: 'fennec-be', name: 'blog-be',
script: 'npm', script: 'npm',
args: 'run start:prod', args: 'run start:prod',
watch: false, watch: false,

View File

@ -0,0 +1,20 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class articleAndTagMigration1635661602570 implements MigrationInterface {
name = 'articleAndTagMigration1635661602570'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "article" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, "title" character varying NOT NULL, "content" text NOT NULL, "publishedAt" TIMESTAMP, "tags" character varying array NOT NULL, CONSTRAINT "PK_40808690eb7b915046558c0f81b" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_f330baf6be412e8dd60ff7f78e" ON "article" ("publishedAt") `);
await queryRunner.query(`CREATE TABLE "tag" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP, "name" character varying(100) NOT NULL, CONSTRAINT "PK_8e4052373c579afc1471f526760" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_6a9775008add570dc3e5a0bab7" ON "tag" ("name") `);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."IDX_6a9775008add570dc3e5a0bab7"`);
await queryRunner.query(`DROP TABLE "tag"`);
await queryRunner.query(`DROP INDEX "public"."IDX_f330baf6be412e8dd60ff7f78e"`);
await queryRunner.query(`DROP TABLE "article"`);
}
}

22
ormconfig.js Normal file
View File

@ -0,0 +1,22 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const yaml = require('js-yaml');
const fs = require('fs');
const path = require('path');
const config = yaml.load(
fs.readFileSync(path.join(__dirname, './config.yml'), 'utf8'),
);
module.exports = {
type: 'postgres',
host: config.db.postgres.host,
port: config.db.postgres.port,
username: config.db.postgres.username,
password: config.db.postgres.password,
database: config.db.postgres.database,
migrations: ['migrations/**/*.ts'],
entities: ['src/**/*.entity.ts'],
cli: {
migrationsDir: 'migrations',
},
};

24405
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "fennec-be", "name": "blog-be",
"version": "0.0.1", "version": "0.1.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@ -13,64 +13,74 @@
"start:dev": "DEBUG=fennec:* nest start --watch", "start:dev": "DEBUG=fennec:* nest start --watch",
"start:debug": "DEBUG=fennec:* nest start --debug --watch", "start:debug": "DEBUG=fennec:* nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"init:dev": "node docker/init-dev.mjs",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js"
}, },
"dependencies": { "dependencies": {
"@nestjs/bull": "^0.3.1", "@fennec/configuration": "^0.0.2",
"@nestjs/common": "^7.6.15", "@nestjs-lib/auth": "^0.2.3",
"@nestjs/config": "^0.6.2", "@nestjs-lib/etcd3": "^0.0.1",
"@nestjs/core": "^7.6.15", "@nestjs/common": "^8.1.1",
"@nestjs/graphql": "^7.9.8", "@nestjs/config": "^1.0.3",
"@nestjs/platform-express": "^7.6.15", "@nestjs/core": "^8.1.1",
"@nestjs/typeorm": "^7.1.5", "@nestjs/graphql": "^9.1.1",
"apollo-server-express": "^2.19.2", "@nestjs/platform-express": "^8.1.1",
"bcrypt": "^5.0.0", "@nestjs/typeorm": "^8.0.2",
"apollo-server-express": "^3.4.0",
"bcrypt": "^5.0.1",
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
"bull": "^3.20.1", "class-transformer": "^0.4.0",
"class-transformer": "^0.3.2",
"class-validator": "^0.13.1", "class-validator": "^0.13.1",
"debug": "^4.3.1", "debug": "^4.3.2",
"graphql": "^15.5.0", "graphql": "^15.6.1",
"graphql-tools": "^7.0.2", "graphql-tools": "^8.2.0",
"ioredis": "^4.25.0", "graphql-type-json": "^0.3.2",
"js-yaml": "^4.0.0", "highlight.js": "^11.3.1",
"nestjs-redis": "^1.2.8", "ioredis": "^4.28.0",
"observable-to-async-generator": "^1.0.1-rc", "js-yaml": "^4.1.0",
"pg": "^8.5.1", "marked": "^3.0.7",
"nestjs-redis": "^1.3.3",
"observable-to-async-generator": "^1.0.2",
"pg": "^8.7.1",
"ramda": "^0.27.1", "ramda": "^0.27.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs": "^6.6.7", "rxjs": "^7.4.0",
"simple-git": "^2.35.0", "simple-git": "^2.47.0",
"typeorm": "^0.2.30" "typeorm": "^0.2.38"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^7.5.7", "@nestjs/cli": "^8.1.4",
"@nestjs/schematics": "^7.3.1", "@nestjs/schematics": "^8.0.4",
"@nestjs/testing": "^7.6.15", "@nestjs/testing": "^8.1.1",
"@types/express": "^4.17.8", "@types/express": "^4.17.13",
"@types/jest": "^26.0.22", "@types/highlight.js": "^10.1.0",
"@types/node": "^14.14.41", "@types/jest": "^27.0.2",
"@types/marked": "^3.0.2",
"@types/node": "^16.11.2",
"@types/supertest": "^2.0.11", "@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^4.22.0", "@typescript-eslint/eslint-plugin": "^5.1.0",
"@typescript-eslint/parser": "^4.22.0", "@typescript-eslint/parser": "^5.1.0",
"apollo-server-testing": "^2.23.0", "apollo-server-testing": "^2.23.0",
"eslint": "^7.24.0", "eslint": "^8.0.1",
"eslint-config-prettier": "7.2.0", "eslint-config-prettier": "8.3.0",
"eslint-plugin-prettier": "^3.4.0", "eslint-plugin-prettier": "^4.0.0",
"jest": "^26.6.3", "execa": "^5.1.1",
"prettier": "^2.1.2", "find-free-ports": "^3.0.0",
"supertest": "^6.0.0", "jest": "^27.3.1",
"ts-jest": "^26.5.5", "prettier": "^2.4.1",
"ts-loader": "^8.1.0", "supertest": "^6.1.6",
"ts-node": "^9.0.0", "ts-jest": "^27.0.7",
"tsconfig-paths": "^3.9.0", "ts-loader": "^9.2.6",
"typescript": "^4.2.4" "ts-node": "^10.3.0",
"tsconfig-paths": "^3.11.0",
"typescript": "^4.4.4"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
@ -86,6 +96,9 @@
"collectCoverageFrom": [ "collectCoverageFrom": [
"**/*.(t|j)s" "**/*.(t|j)s"
], ],
"moduleNameMapper": {
"^jose/(.*)$": "<rootDir>/../node_modules/jose/dist/node/cjs/$1"
},
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node"
} }

View File

@ -6,19 +6,19 @@ import { AppController } from './app.controller';
import { AppResolver } from './app.resolver'; import { AppResolver } from './app.resolver';
import { AppService } from './app.service'; import { AppService } from './app.service';
import configuration from './commons/config/configuration'; import configuration from './commons/config/configuration';
import { RedisModule } from 'nestjs-redis';
import { ParseBodyMiddleware } from './commons/middleware/parse-body.middleware'; import { ParseBodyMiddleware } from './commons/middleware/parse-body.middleware';
import { BullModule } from '@nestjs/bull';
import { PubSubModule } from './commons/pub-sub/pub-sub.module';
import { ArticlesModule } from './articles/articles.module'; import { ArticlesModule } from './articles/articles.module';
import { EtcdModule } from '@nestjs-lib/etcd3';
import { CommonsModule } from './commons/commons.module';
import { TagsModule } from './tags/tags.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
load: [configuration], load: [configuration],
isGlobal: true,
}), }),
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({
type: 'postgres', type: 'postgres',
host: configService.get<string>('db.postgres.host'), host: configService.get<string>('db.postgres.host'),
@ -26,13 +26,12 @@ import { ArticlesModule } from './articles/articles.module';
username: configService.get<string>('db.postgres.username'), username: configService.get<string>('db.postgres.username'),
password: configService.get<string>('db.postgres.password'), password: configService.get<string>('db.postgres.password'),
database: configService.get<string>('db.postgres.database'), database: configService.get<string>('db.postgres.database'),
synchronize: true, synchronize: false,
autoLoadEntities: true, autoLoadEntities: true,
}), }),
inject: [ConfigService], inject: [ConfigService],
}), }),
GraphQLModule.forRootAsync({ GraphQLModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({
debug: configService.get<string>('env') !== 'prod', debug: configService.get<string>('env') !== 'prod',
playground: true, playground: true,
@ -41,39 +40,15 @@ import { ArticlesModule } from './articles/articles.module';
}), }),
inject: [ConfigService], inject: [ConfigService],
}), }),
BullModule.forRootAsync({ EtcdModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({
redis: { hosts: configService.get<string>('db.etcd.hosts', 'localhost:2379'),
host: configService.get<string>('db.redis.host', 'localhost'),
port: configService.get<number>('db.redis.port', undefined),
password: configService.get<string>('db.redis.password', undefined),
},
}),
inject: [ConfigService],
}),
PubSubModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
redis: {
host: configService.get<string>('db.redis.host', 'localhost'),
port: configService.get<number>('db.redis.port', undefined),
password: configService.get<string>('db.redis.password', undefined),
},
}),
inject: [ConfigService],
}),
RedisModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
host: configService.get<string>('db.redis.host', 'localhost'),
port: configService.get<number>('db.redis.port', 6379),
password: configService.get<string>('db.redis.password', ''),
keyPrefix: configService.get<string>('db.redis.prefix', 'blog') + ':',
}), }),
inject: [ConfigService], inject: [ConfigService],
}), }),
CommonsModule,
ArticlesModule, ArticlesModule,
TagsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService, AppResolver], providers: [AppService, AppResolver],

View File

@ -1,3 +1,4 @@
import { CommonsModule } from './../commons/commons.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ArticlesService } from './articles.service'; import { ArticlesService } from './articles.service';
import { ArticlesResolver } from './articles.resolver'; import { ArticlesResolver } from './articles.resolver';
@ -5,7 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Article } from './entities/article.entity'; import { Article } from './entities/article.entity';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Article])], imports: [TypeOrmModule.forFeature([Article]), CommonsModule],
providers: [ArticlesResolver, ArticlesService], providers: [ArticlesResolver, ArticlesService],
}) })
export class ArticlesModule {} export class ArticlesModule {}

View File

@ -1,3 +1,4 @@
import { JwtService } from '@nestjs-lib/auth';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ArticlesResolver } from './articles.resolver'; import { ArticlesResolver } from './articles.resolver';
import { ArticlesService } from './articles.service'; import { ArticlesService } from './articles.service';
@ -13,6 +14,10 @@ describe('ArticlesResolver', () => {
provide: ArticlesService, provide: ArticlesService,
useValue: {}, useValue: {},
}, },
{
provide: JwtService,
useValue: {},
},
], ],
}).compile(); }).compile();

View File

@ -1,13 +1,26 @@
import { Resolver, Query, Mutation, Args, Int } from '@nestjs/graphql'; import {
Resolver,
Query,
Mutation,
Args,
Int,
ResolveField,
Parent,
} from '@nestjs/graphql';
import { ArticlesService } from './articles.service'; import { ArticlesService } from './articles.service';
import { Article } from './entities/article.entity'; import { Article } from './entities/article.entity';
import { CreateArticleInput } from './dto/create-article.input'; import { CreateArticleInput } from './dto/create-article.input';
import { UpdateArticleInput } from './dto/update-article.input'; import { UpdateArticleInput } from './dto/update-article.input';
import * as marked from 'marked';
import highlight from 'highlight.js';
import { AccountRole, Roles } from '@nestjs-lib/auth';
import { ArticleHistory } from './models/article-history.model';
@Resolver(() => Article) @Resolver(() => Article)
export class ArticlesResolver { export class ArticlesResolver {
constructor(private readonly articlesService: ArticlesService) {} constructor(private readonly articlesService: ArticlesService) {}
@Roles(AccountRole.admin, AccountRole.super)
@Mutation(() => Article) @Mutation(() => Article)
createArticle( createArticle(
@Args('createArticleInput') createArticleInput: CreateArticleInput, @Args('createArticleInput') createArticleInput: CreateArticleInput,
@ -16,8 +29,8 @@ export class ArticlesResolver {
} }
@Query(() => [Article], { name: 'articles' }) @Query(() => [Article], { name: 'articles' })
findAll() { async findAll() {
return this.articlesService.findAll(); return await this.articlesService.findAll();
} }
@Query(() => Article, { name: 'article' }) @Query(() => Article, { name: 'article' })
@ -25,6 +38,7 @@ export class ArticlesResolver {
return this.articlesService.findOne(id); return this.articlesService.findOne(id);
} }
@Roles(AccountRole.admin, AccountRole.super)
@Mutation(() => Article) @Mutation(() => Article)
async updateArticle( async updateArticle(
@Args('updateArticleInput') updateArticleInput: UpdateArticleInput, @Args('updateArticleInput') updateArticleInput: UpdateArticleInput,
@ -33,8 +47,44 @@ export class ArticlesResolver {
return this.articlesService.update(article, updateArticleInput); return this.articlesService.update(article, updateArticleInput);
} }
@Roles(AccountRole.admin, AccountRole.super)
@Mutation(() => Int) @Mutation(() => Int)
removeArticle(@Args('id', { type: () => String }) id: string) { removeArticle(@Args('id', { type: () => String }) id: string) {
return this.articlesService.remove(id); return this.articlesService.remove(id);
} }
@ResolveField(() => String)
async html(@Parent() article: Article) {
const tokens = marked.lexer(article.content);
const index = tokens.findIndex((token) => ['heading'].includes(token.type));
if (index !== -1) {
tokens.splice(index, 1);
}
return marked.parser(tokens, {
gfm: true,
smartLists: true,
smartypants: true,
langPrefix: 'hljs language-',
highlight: (code, language) => {
return highlight.highlight(code, {
language: highlight.getLanguage(language) ? language : 'plaintext',
}).value;
},
});
}
@ResolveField(() => String, { nullable: true })
async description(@Parent() article: Article) {
const tokens = marked.lexer(article.content);
const token = tokens.find((token) =>
['blockquote', 'paragraph'].includes(token.type),
) as { text: string };
return token?.text;
}
@ResolveField(() => [ArticleHistory])
async histories(@Parent() article: Article) {
return article.histories;
}
} }

View File

@ -0,0 +1,7 @@
import { ObjectType, OmitType } from '@nestjs/graphql';
import { Article } from '../entities/article.entity';
@ObjectType()
export class ArticleListItemDto extends OmitType(Article, [
'content',
] as const) {}

View File

@ -1,4 +1,5 @@
import { ObjectType } from '@nestjs/graphql'; import { ArticleHistory } from './../models/article-history.model';
import { HideField, ObjectType } from '@nestjs/graphql';
import { Column, Entity, Index } from 'typeorm'; import { Column, Entity, Index } from 'typeorm';
import { AppBaseEntity } from '../../commons/entities/app-base-entity'; import { AppBaseEntity } from '../../commons/entities/app-base-entity';
@ -17,4 +18,8 @@ export class Article extends AppBaseEntity {
@Column({ type: 'varchar', array: true }) @Column({ type: 'varchar', array: true })
tags: string[]; tags: string[];
@HideField()
@Column({ type: 'jsonb', default: [] })
histories: ArticleHistory[];
} }

View File

@ -0,0 +1,15 @@
import { Column } from 'typeorm';
import { ObjectType, Field } from '@nestjs/graphql';
import { Article } from '../entities/article.entity';
@ObjectType()
export class ArticleHistory {
@Field(() => Object)
payload: Partial<Article>;
updatedAt: Date;
automatic: boolean;
published: boolean;
}

View File

@ -1,10 +1,12 @@
import { ObjectScalar } from './scalars/object.scalar';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PasswordConverter } from './services/password-converter'; import { PasswordConverter } from './services/password-converter';
import { PubSubModule } from './pub-sub/pub-sub.module'; import { PubSubModule } from './pub-sub/pub-sub.module';
import { AuthModule } from '@nestjs-lib/auth';
@Module({ @Module({
providers: [PasswordConverter], imports: [PubSubModule, AuthModule],
exports: [PasswordConverter], providers: [PasswordConverter, ObjectScalar],
imports: [PubSubModule], exports: [PasswordConverter, AuthModule],
}) })
export class CommonsModule {} export class CommonsModule {}

View File

@ -1,9 +1,9 @@
import { readFileSync } from 'fs'; import { readConfiguration } from '@fennec/configuration';
import * as yaml from 'js-yaml';
import { join } from 'path';
export default () => { export default () => {
return yaml.load( return readConfiguration({
readFileSync(join(__dirname, '../../../config.yml'), 'utf8'), etcd: {
) as unknown; hosts: '192.168.31.2:2379',
},
});
}; };

View File

@ -12,7 +12,7 @@ export class AppBaseEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@CreateDateColumn() @CreateDateColumn({ select: false })
createdAt: Date; createdAt: Date;
@UpdateDateColumn({ select: false }) @UpdateDateColumn({ select: false })

View File

@ -4,7 +4,7 @@ import { PubSub } from './pub-sub';
debug.enable('app:pubsub:*'); debug.enable('app:pubsub:*');
describe('PubSub', () => { describe.skip('PubSub', () => {
let instance: PubSub; let instance: PubSub;
beforeEach(async () => { beforeEach(async () => {

View File

@ -0,0 +1,13 @@
import { Scalar, CustomScalar } from '@nestjs/graphql';
import { GraphQLJSONObject } from 'graphql-type-json';
@Scalar('Object', (type) => Object)
export class ObjectScalar implements CustomScalar<number, Object> {
description = GraphQLJSONObject.description;
parseValue = GraphQLJSONObject.parseValue;
serialize = GraphQLJSONObject.serialize;
parseLiteral = GraphQLJSONObject.parseLiteral;
}

View File

@ -1,3 +1,4 @@
import { ServiceRegister } from '@fennec/configuration';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
@ -15,6 +16,11 @@ async function bootstrap() {
}), }),
); );
app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(configService.get<number>('http.port')); const server = await app.listen(configService.get<number>('http.port', 0));
const port = server.address().port;
const register = new ServiceRegister({ etcd: { hosts: 'http://rpi:2379' } });
register.register('blog/api', `http://localhost:${port}`);
register.register('admin.blog/api', `http://localhost:${port}`);
register.register('api.blog', `http://localhost:${port}`);
} }
bootstrap(); bootstrap();

View File

@ -0,0 +1,9 @@
import { InputType } from '@nestjs/graphql';
import { IsString, Length } from 'class-validator';
@InputType()
export class CreateTagInput {
@IsString()
@Length(1, 100)
name: string;
}

View File

@ -0,0 +1,9 @@
import { CreateTagInput } from './create-tag.input';
import { InputType, PartialType } from '@nestjs/graphql';
import { IsUUID } from 'class-validator';
@InputType()
export class UpdateTagInput extends PartialType(CreateTagInput) {
@IsUUID()
id: string;
}

View File

@ -0,0 +1,11 @@
import { AppBaseEntity } from './../../commons/entities/app-base-entity';
import { ObjectType } from '@nestjs/graphql';
import { Column, Index, Entity } from 'typeorm';
@Entity()
@ObjectType()
export class Tag extends AppBaseEntity {
@Index({ unique: true })
@Column({ length: 100 })
name: string;
}

12
src/tags/tags.module.ts Normal file
View File

@ -0,0 +1,12 @@
import { Tag } from './entities/tag.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CommonsModule } from './../commons/commons.module';
import { Module } from '@nestjs/common';
import { TagsService } from './tags.service';
import { TagsResolver } from './tags.resolver';
@Module({
imports: [CommonsModule, TypeOrmModule.forFeature([Tag])],
providers: [TagsResolver, TagsService],
})
export class TagsModule {}

View File

@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TagsResolver } from './tags.resolver';
import { TagsService } from './tags.service';
describe('TagsResolver', () => {
let resolver: TagsResolver;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TagsResolver,
{
provide: TagsService,
useValue: {},
},
],
}).compile();
resolver = module.get<TagsResolver>(TagsResolver);
});
it('should be defined', () => {
expect(resolver).toBeDefined();
});
});

36
src/tags/tags.resolver.ts Normal file
View File

@ -0,0 +1,36 @@
import { Resolver, Query, Mutation, Args } from '@nestjs/graphql';
import { TagsService } from './tags.service';
import { Tag } from './entities/tag.entity';
import { CreateTagInput } from './dto/create-tag.input';
import { UpdateTagInput } from './dto/update-tag.input';
@Resolver(() => Tag)
export class TagsResolver {
constructor(private readonly tagsService: TagsService) {}
@Mutation(() => Tag)
createTag(@Args('createTagInput') createTagInput: CreateTagInput) {
return this.tagsService.create(createTagInput);
}
@Query(() => [Tag], { name: 'tags' })
findAll() {
return this.tagsService.findAll();
}
@Query(() => Tag, { name: 'tag' })
findOne(@Args('id', { type: () => String }) id: string) {
return this.tagsService.findOne(id);
}
@Mutation(() => Tag)
async updateTag(@Args('updateTagInput') updateTagInput: UpdateTagInput) {
const tag = await this.tagsService.findOne(updateTagInput.id);
return this.tagsService.update(tag, updateTagInput);
}
@Mutation(() => Tag)
removeTag(@Args('id', { type: () => String }) id: string) {
return this.tagsService.remove(id);
}
}

View File

@ -0,0 +1,27 @@
import { Tag } from './entities/tag.entity';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Test, TestingModule } from '@nestjs/testing';
import { TagsService } from './tags.service';
import { Repository } from 'typeorm';
describe('TagsService', () => {
let service: TagsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TagsService,
{
provide: getRepositoryToken(Tag),
useValue: new Repository(),
},
],
}).compile();
service = module.get<TagsService>(TagsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

45
src/tags/tags.service.ts Normal file
View File

@ -0,0 +1,45 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseDbService } from './../commons/services/base-db.service';
import { Injectable } from '@nestjs/common';
import { CreateTagInput } from './dto/create-tag.input';
import { UpdateTagInput } from './dto/update-tag.input';
import { Tag } from './entities/tag.entity';
@Injectable()
export class TagsService extends BaseDbService<Tag> {
readonly uniqueFields: Array<keyof Tag> = ['name'];
constructor(@InjectRepository(Tag) readonly repository: Repository<Tag>) {
super(repository);
}
/**
* create or recover a tag
*/
async create(createTagInput: CreateTagInput): Promise<Tag> {
const old = await this.repository.findOne({ name: createTagInput.name });
return this.repository.save(
old
? this.repository.merge(old, createTagInput)
: this.repository.create(createTagInput),
);
}
async findAll() {
return await this.repository.find({
order: { createdAt: 'DESC' },
});
}
async update(tag: Tag, updateTagInput: UpdateTagInput) {
await this.isDuplicateEntityForUpdate(tag.id, updateTagInput);
return await this.repository.save(
this.repository.merge(tag, updateTagInput),
);
}
async remove(id: string) {
await this.canRemove([id]);
return await this.repository.softDelete({ id }).then((d) => d.affected);
}
}

View File

@ -6,10 +6,12 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"lib": ["es2020"],
"target": "es2017", "target": "es2017",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"baseUrl": "./", "baseUrl": "./",
"incremental": true "incremental": true,
"skipLibCheck": true
} }
} }