Compare commits

...

14 Commits

Author SHA1 Message Date
e413163993 bak. 2021-12-01 19:15:16 +08:00
4b8bb47800 feat: add docker compose, use @fennec/configuration. 2021-10-31 14:53:11 +08:00
ece8ccf27a chore: 更新依赖并支持从 etcd 读取配置。 2021-10-28 22:07:29 +08:00
Ivan Li
6cf4d1b748 feat: tags module. 2021-07-24 10:34:10 +08:00
Ivan
6f6018501b chore: 更新 auth 依赖。 2021-07-22 10:51:57 +08:00
Ivan Li
6de2f85b24 fix: 测试用例因 jose、jest 兼容性问题无法通过的问题。 2021-07-18 11:25:17 +08:00
Ivan Li
d0f6b8f9a6 feat: 添加鉴权相关功能。 2021-07-17 17:32:42 +08:00
Ivan Li
51d5ac6ee6 feat: add highlight for article html. 2021-07-03 12:40:52 +08:00
Ivan
76862b738d feat: markdown to html 2021-07-02 15:16:27 +08:00
Ivan Li
2b7e344931 test: 跳过 pubsub测试 2021-05-03 17:14:24 +08:00
Ivan Li
c4a0f3e405 fix: wrong project name in pm2 ecosystem config. 2021-05-02 22:10:49 +08:00
Ivan Li
7700100a7d build: add pm2 ecosystem config. 2021-05-02 21:11:34 +08:00
Ivan Li
9120332051 fix(articles): 修复返回值不匹配的问题。 2021-05-01 17:17:43 +08:00
Ivan Li
fba867e0b5 backup 2021-04-24 14:47:45 +08:00
47 changed files with 18237 additions and 8128 deletions

25
.eslintrc.js Normal file
View File

@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'prettier/@typescript-eslint',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
/config.yml

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

16
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,16 @@
{
"cSpell.words": [
"boardcat",
"execa",
"gitea",
"lpush",
"lrange",
"metatype",
"pmessage",
"psubscribe",
"QLJSON",
"Repos",
"rpop",
"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!`);

14
ecosystem.config.js Normal file
View File

@ -0,0 +1,14 @@
module.exports = {
apps: [
{
name: 'blog-be',
script: 'npm',
args: 'run start:prod',
watch: false,
ignore_watch: ['node_modules'],
log_date_format: 'MM-DD HH:mm:ss.SSS Z',
env: {},
max_restarts: 5,
},
],
};

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',
},
};

25860
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,67 +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.5.1", "@nestjs-lib/auth": "^0.2.3",
"@nestjs/config": "^0.6.2", "@nestjs-lib/etcd3": "^0.0.1",
"@nestjs/core": "^7.5.1", "@nestjs/common": "^8.1.1",
"@nestjs/graphql": "^7.9.8", "@nestjs/config": "^1.0.3",
"@nestjs/platform-express": "^7.5.1", "@nestjs/core": "^8.1.1",
"@nestjs/typeorm": "^7.1.5", "@nestjs/graphql": "^9.1.1",
"@types/bull": "^3.15.0", "@nestjs/platform-express": "^8.1.1",
"apollo-server-express": "^2.19.2", "@nestjs/typeorm": "^8.0.2",
"bcrypt": "^5.0.0", "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.3", "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.1.3", "@nestjs/schematics": "^8.0.4",
"@nestjs/testing": "^7.5.1", "@nestjs/testing": "^8.1.1",
"@types/body-parser": "^1.19.0", "@types/express": "^4.17.13",
"@types/debug": "^4.1.5", "@types/highlight.js": "^10.1.0",
"@types/express": "^4.17.8", "@types/jest": "^27.0.2",
"@types/ioredis": "^4.22.3", "@types/marked": "^3.0.2",
"@types/jest": "^26.0.15", "@types/node": "^16.11.2",
"@types/node": "^14.14.6", "@types/supertest": "^2.0.11",
"@types/supertest": "^2.0.10", "@typescript-eslint/eslint-plugin": "^5.1.0",
"@typescript-eslint/eslint-plugin": "^4.6.1", "@typescript-eslint/parser": "^5.1.0",
"@typescript-eslint/parser": "^4.6.1", "apollo-server-testing": "^2.23.0",
"eslint": "^7.12.1", "eslint": "^8.0.1",
"eslint-config-prettier": "7.2.0", "eslint-config-prettier": "8.3.0",
"eslint-plugin-prettier": "^3.1.4", "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.4.3", "prettier": "^2.4.1",
"ts-loader": "^8.0.8", "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.0.5" "ts-node": "^10.3.0",
"tsconfig-paths": "^3.11.0",
"typescript": "^4.4.4"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
@ -89,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,18 +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 { ArticlesModule } from './articles/articles.module';
import { PubSubModule } from './commons/pub-sub/pub-sub.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'),
@ -25,13 +26,12 @@ import { PubSubModule } from './commons/pub-sub/pub-sub.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,
@ -40,38 +40,15 @@ import { PubSubModule } from './commons/pub-sub/pub-sub.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,
TagsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService, AppResolver], providers: [AppService, AppResolver],

View File

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

View File

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

View File

@ -0,0 +1,90 @@
import {
Resolver,
Query,
Mutation,
Args,
Int,
ResolveField,
Parent,
} from '@nestjs/graphql';
import { ArticlesService } from './articles.service';
import { Article } from './entities/article.entity';
import { CreateArticleInput } from './dto/create-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)
export class ArticlesResolver {
constructor(private readonly articlesService: ArticlesService) {}
@Roles(AccountRole.admin, AccountRole.super)
@Mutation(() => Article)
createArticle(
@Args('createArticleInput') createArticleInput: CreateArticleInput,
) {
return this.articlesService.create(createArticleInput);
}
@Query(() => [Article], { name: 'articles' })
async findAll() {
return await this.articlesService.findAll();
}
@Query(() => Article, { name: 'article' })
findOne(@Args('id', { type: () => String }) id: string) {
return this.articlesService.findOne(id);
}
@Roles(AccountRole.admin, AccountRole.super)
@Mutation(() => Article)
async updateArticle(
@Args('updateArticleInput') updateArticleInput: UpdateArticleInput,
) {
const article = await this.articlesService.findOne(updateArticleInput.id);
return this.articlesService.update(article, updateArticleInput);
}
@Roles(AccountRole.admin, AccountRole.super)
@Mutation(() => Int)
removeArticle(@Args('id', { type: () => String }) id: string) {
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,27 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ArticlesService } from './articles.service';
import { Article } from './entities/article.entity';
describe('ArticlesService', () => {
let service: ArticlesService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ArticlesService,
{
provide: getRepositoryToken(Article),
useValue: new Repository(),
},
],
}).compile();
service = module.get<ArticlesService>(ArticlesService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseDbService } from '../commons/services/base-db.service';
import { CreateArticleInput } from './dto/create-article.input';
import { UpdateArticleInput } from './dto/update-article.input';
import { Article } from './entities/article.entity';
@Injectable()
export class ArticlesService extends BaseDbService<Article> {
readonly uniqueFields: Array<keyof Article> = ['title'];
constructor(
@InjectRepository(Article)
readonly repository: Repository<Article>,
) {
super(repository);
}
async create(createArticleInput: CreateArticleInput) {
await this.isDuplicateEntity(createArticleInput);
return await this.repository.save(
this.repository.create(createArticleInput),
);
}
async findAll() {
return await this.repository.find({
order: { createdAt: 'DESC' },
});
}
async update(article: Article, updateArticleInput: UpdateArticleInput) {
await this.isDuplicateEntityForUpdate(article.id, updateArticleInput);
return await this.repository.save(
this.repository.merge(article, updateArticleInput),
);
}
async remove(id: string) {
await this.canRemove([id]);
return await this.repository.softDelete({ id }).then((d) => d.affected);
}
}

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

@ -0,0 +1,20 @@
import { InputType } from '@nestjs/graphql';
import { IsDate, IsOptional, IsString, Length } from 'class-validator';
@InputType()
export class CreateArticleInput {
@IsString()
@Length(1, 100)
title: string;
@IsString()
@Length(2, 100000)
content: string;
@IsOptional()
@IsDate()
publishedAt?: Date;
@IsString({ each: true })
tags: string[];
}

View File

@ -0,0 +1,8 @@
import { CreateArticleInput } from './create-article.input';
import { InputType, Field, PartialType } from '@nestjs/graphql';
@InputType()
export class UpdateArticleInput extends PartialType(CreateArticleInput) {
@Field(() => String)
id: string;
}

View File

@ -0,0 +1,25 @@
import { ArticleHistory } from './../models/article-history.model';
import { HideField, ObjectType } from '@nestjs/graphql';
import { Column, Entity, Index } from 'typeorm';
import { AppBaseEntity } from '../../commons/entities/app-base-entity';
@Entity()
@ObjectType()
export class Article extends AppBaseEntity {
@Column()
title: string;
@Column({ type: 'text' })
content: string;
@Index()
@Column({ nullable: true })
publishedAt?: Date;
@Column({ type: 'varchar', array: true })
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

@ -112,7 +112,7 @@ export class BaseDbService<Entity extends AppBaseEntity> extends TypeormHelper {
} }
} }
async canYouRemoveWithIds(ids: string[]): Promise<void> { async canRemove(ids: string[]): Promise<void> {
return; return;
} }

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

@ -1,24 +1,49 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module'; import { AppModule } from './../src/app.module';
import { GraphQLModule } from '@nestjs/graphql';
import {
ApolloServerTestClient,
createTestClient,
} from 'apollo-server-testing';
import { gql } from 'apollo-server-express';
describe('AppController (e2e)', () => { describe('ArticleResolver (e2e)', () => {
let app: INestApplication; let app: INestApplication;
let apolloClient: ApolloServerTestClient;
beforeEach(async () => { beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({ const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule], imports: [AppModule],
}).compile(); }).compile();
app = moduleFixture.createNestApplication(); app = moduleFixture.createNestApplication();
await app.init(); await app.init();
const module: GraphQLModule = moduleFixture.get<GraphQLModule>(
GraphQLModule,
);
// apolloServer is protected, we need to cast module to any to get it
apolloClient = createTestClient((module as any).apolloServer);
});
it('QUERY hello', async () => {
const res = await apolloClient.query({
query: gql`
query {
hello {
message
}
}
`,
variables: {},
});
expect(res.data).toEqual({
hello: {
message: 'Hello, World!',
},
});
}); });
it('/ (GET)', () => { afterAll(async () => {
return request(app.getHttpServer()) await app?.close();
.get('/')
.expect(200)
.expect('Hello World!');
}); });
}); });

33
test/article.e2e-spec.ts Normal file
View File

@ -0,0 +1,33 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { GraphQLModule } from '@nestjs/graphql';
import {
ApolloServerTestClient,
createTestClient,
} from 'apollo-server-testing';
import { gql } from 'apollo-server-express';
describe('AppController (e2e)', () => {
let app: INestApplication;
let apolloClient: ApolloServerTestClient;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
const module: GraphQLModule = moduleFixture.get<GraphQLModule>(
GraphQLModule,
);
// apolloServer is protected, we need to cast module to any to get it
apolloClient = createTestClient((module as any).apolloServer);
});
afterAll(async () => {
await app?.close();
});
});

View File

View File

@ -1,8 +0,0 @@
for (let i = 1; i <= 5; i++) {
console.log(i * 10);
}
console.error('Error Message');
console.error('Error Message 2');
console.log('Bye-bye');
process.exit(1);

View File

@ -1,115 +0,0 @@
{
"secret": "boardcat",
"ref": "refs/heads/master",
"before": "429de1eaedf1da83f1e0e3ac3d8b20e771b7051c",
"after": "429de1eaedf1da83f1e0e3ac3d8b20e771b7051c",
"compare_url": "",
"commits": [
{
"id": "429de1eaedf1da83f1e0e3ac3d8b20e771b7051c",
"message": "test(pipeline-tasks): pass test cases.\n",
"url": "https://git.ivanli.cc/Fennec/fennec-be/commit/429de1eaedf1da83f1e0e3ac3d8b20e771b7051c",
"author": {
"name": "Ivan",
"email": "ivanli@live.cn",
"username": ""
},
"committer": {
"name": "Ivan",
"email": "ivanli@live.cn",
"username": ""
},
"verification": null,
"timestamp": "0001-01-01T00:00:00Z",
"added": null,
"removed": null,
"modified": null
}
],
"head_commit": null,
"repository": {
"id": 3,
"owner": {
"id": 3,
"login": "Fennec",
"full_name": "",
"email": "",
"avatar_url": "https://git.ivanli.cc/user/avatar/Fennec/-1",
"language": "",
"is_admin": false,
"last_login": "1970-01-01T08:00:00+08:00",
"created": "2021-01-30T16:46:11+08:00",
"username": "Fennec"
},
"name": "fennec-be",
"full_name": "Fennec/fennec-be",
"description": "Fennec CI/CD Back-End",
"empty": false,
"private": false,
"fork": false,
"template": false,
"parent": null,
"mirror": false,
"size": 1897,
"html_url": "https://git.ivanli.cc/Fennec/fennec-be",
"ssh_url": "ssh://gitea@git.ivanli.cc:7018/Fennec/fennec-be.git",
"clone_url": "https://git.ivanli.cc/Fennec/fennec-be.git",
"original_url": "",
"website": "",
"stars_count": 1,
"forks_count": 0,
"watchers_count": 1,
"open_issues_count": 0,
"open_pr_counter": 0,
"release_counter": 0,
"default_branch": "master",
"archived": false,
"created_at": "2021-01-31T09:58:38+08:00",
"updated_at": "2021-03-27T15:57:00+08:00",
"permissions": {
"admin": false,
"push": false,
"pull": false
},
"has_issues": true,
"internal_tracker": {
"enable_time_tracker": true,
"allow_only_contributors_to_track_time": true,
"enable_issue_dependencies": true
},
"has_wiki": true,
"has_pull_requests": true,
"has_projects": true,
"ignore_whitespace_conflicts": false,
"allow_merge_commits": true,
"allow_rebase": true,
"allow_rebase_explicit": true,
"allow_squash_merge": true,
"avatar_url": "",
"internal": false
},
"pusher": {
"id": 1,
"login": "Ivan",
"full_name": "Ivan Li",
"email": "ivan@noreply.%(DOMAIN)s",
"avatar_url": "https://git.ivanli.cc/user/avatar/Ivan/-1",
"language": "zh-CN",
"is_admin": true,
"last_login": "2021-03-26T22:28:05+08:00",
"created": "2021-01-23T18:15:30+08:00",
"username": "Ivan"
},
"sender": {
"id": 1,
"login": "Ivan",
"full_name": "Ivan Li",
"email": "ivan@noreply.%(DOMAIN)s",
"avatar_url": "https://git.ivanli.cc/user/avatar/Ivan/-1",
"language": "zh-CN",
"is_admin": true,
"last_login": "2021-03-26T22:28:05+08:00",
"created": "2021-01-23T18:15:30+08:00",
"username": "Ivan"
}
}

View File

@ -1,7 +0,0 @@
let timer;
let count = 0;
setTimeout(() => clearInterval(timer), 1_000);
timer = setInterval(() => {
console.log(++count * 10);
}, 95);

15
test/graphql-e2e.ts Normal file
View File

@ -0,0 +1,15 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const transformer = require('@nestjs/graphql/plugin');
module.exports.name = 'nestjs-graphql-transformer';
// you should change the version number anytime you change the configuration below - otherwise, jest will not detect changes
module.exports.version = 1;
module.exports.factory = (cs) => {
return transformer.before(
{
// @nestjs/graphql/plugin options (can be empty)
},
cs.tsCompiler.program,
);
};

View File

@ -5,5 +5,12 @@
"testRegex": ".e2e-spec.ts$", "testRegex": ".e2e-spec.ts$",
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
},
"globals": {
"ts-jest": {
"astTransformers": {
"before": ["<rootDir>/graphql-e2e.ts"]
}
}
} }
} }

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
} }
} }