Add lessons on the API. (#78)

This commit is contained in:
Zoe Roux
2022-09-26 22:48:26 +09:00
committed by GitHub
parent cdca0d4942
commit a897d7693c
28 changed files with 591 additions and 99 deletions

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = tab
indent_size = tab

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_NAME=
POSTGRES_HOST=
DATABASE_URL=
JWT_SECRET=

3
.gitignore vendored
View File

@@ -6,3 +6,6 @@ include
.env
prisma/migrations/*
.vscode
output.xml
report.html
log.html

View File

@@ -1,6 +1,3 @@
FROM node:17
WORKDIR /app
COPY ./package.json ./
RUN npm install
COPY . .
CMD npx prisma generate ; npx prisma migrate dev ; npm run start:dev

View File

@@ -1,5 +1,14 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
"sourceRoot": "src",
"compilerOptions": {
"plugins": [{
"name": "@nestjs/swagger",
"options": {
"introspectComments": true,
"dtoFileNameSuffix": []
}
}]
}
}

35
back/package-lock.json generated
View File

@@ -27,7 +27,8 @@
"passport-local": "^1.0.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0"
"rxjs": "^7.2.0",
"swagger-ui-express": "^4.5.0"
},
"devDependencies": {
"@nestjs/cli": "^8.0.0",
@@ -8194,6 +8195,25 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swagger-ui-dist": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.14.0.tgz",
"integrity": "sha512-TBzhheU15s+o54Cgk9qxuYcZMiqSm/SkvKnapoGHOF66kz0Y5aGjpzj5BT/vpBbn6rTPJ9tUYXQxuDWfsjiGMw=="
},
"node_modules/swagger-ui-express": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.5.0.tgz",
"integrity": "sha512-DHk3zFvsxrkcnurGvQlAcLuTDacAVN1JHKDgcba/gr2NFRE4HGwP1YeHIXMiGznkWR4AeS7X5vEblNn4QljuNA==",
"dependencies": {
"swagger-ui-dist": ">=4.11.0"
},
"engines": {
"node": ">= v0.10.32"
},
"peerDependencies": {
"express": ">=4.0.0"
}
},
"node_modules/symbol-observable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",
@@ -15400,6 +15420,19 @@
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true
},
"swagger-ui-dist": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.14.0.tgz",
"integrity": "sha512-TBzhheU15s+o54Cgk9qxuYcZMiqSm/SkvKnapoGHOF66kz0Y5aGjpzj5BT/vpBbn6rTPJ9tUYXQxuDWfsjiGMw=="
},
"swagger-ui-express": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.5.0.tgz",
"integrity": "sha512-DHk3zFvsxrkcnurGvQlAcLuTDacAVN1JHKDgcba/gr2NFRE4HGwP1YeHIXMiGznkWR4AeS7X5vEblNn4QljuNA==",
"requires": {
"swagger-ui-dist": ">=4.11.0"
}
},
"symbol-observable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",

View File

@@ -39,7 +39,8 @@
"passport-local": "^1.0.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0"
"rxjs": "^7.2.0",
"swagger-ui-express": "^4.5.0"
},
"devDependencies": {
"@nestjs/cli": "^8.0.0",

View File

@@ -0,0 +1,26 @@
-- CreateEnum
CREATE TYPE "DifficultyPoint" AS ENUM ('TwoHands', 'Rhythm', 'NoteCombo', 'Arpeggio', 'Distance', 'LeftHand', 'RightHand', 'LeadHandChange', 'ChordComplexity', 'ChordTiming', 'Length', 'PedalPoint', 'Precision');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"email" TEXT NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Lesson" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"requiredLvl" INTEGER NOT NULL,
"difficulyPoint" "DifficultyPoint" NOT NULL,
CONSTRAINT "Lesson_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");

View File

@@ -0,0 +1,28 @@
/*
Warnings:
- You are about to drop the column `difficulyPoint` on the `Lesson` table. All the data in the column will be lost.
- You are about to drop the column `requiredLvl` on the `Lesson` table. All the data in the column will be lost.
- Added the required column `mainSkill` to the `Lesson` table without a default value. This is not possible if the table is not empty.
- Added the required column `requiredLevel` to the `Lesson` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Lesson" DROP COLUMN "difficulyPoint",
DROP COLUMN "requiredLvl",
ADD COLUMN "mainSkill" "DifficultyPoint" NOT NULL,
ADD COLUMN "requiredLevel" INTEGER NOT NULL;
-- CreateTable
CREATE TABLE "LessonHistory" (
"lessonID" INTEGER NOT NULL,
"userID" INTEGER NOT NULL,
CONSTRAINT "LessonHistory_pkey" PRIMARY KEY ("lessonID","userID")
);
-- AddForeignKey
ALTER TABLE "LessonHistory" ADD CONSTRAINT "LessonHistory_userID_fkey" FOREIGN KEY ("userID") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LessonHistory" ADD CONSTRAINT "LessonHistory_lessonID_fkey" FOREIGN KEY ("lessonID") REFERENCES "Lesson"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `title` on the `Lesson` table. All the data in the column will be lost.
- Added the required column `name` to the `Lesson` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Lesson" DROP COLUMN "title",
ADD COLUMN "name" TEXT NOT NULL;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -10,8 +10,45 @@ datasource db {
}
model User {
id Int @default(autoincrement()) @id
id Int @id @default(autoincrement())
username String @unique
password String
email String
LessonHistory LessonHistory[]
}
model Lesson {
id Int @id @default(autoincrement())
name String
description String
requiredLevel Int
mainSkill Skill
LessonHistory LessonHistory[]
}
model LessonHistory {
lesson Lesson @relation(fields: [lessonID], references: [id])
lessonID Int
user User @relation(fields: [userID], references: [id])
userID Int
@@id([lessonID, userID])
}
enum Skill {
TwoHands
Rhythm
NoteCombo
Arpeggio
Distance
LeftHand
RightHand
LeadHandChange
ChordComplexity
ChordTiming
Length
PedalPoint
Precision
@@map("DifficultyPoint")
}

View File

@@ -5,9 +5,10 @@ import { PrismaService } from './prisma/prisma.service';
import { UsersModule } from './users/users.module';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { LessonModule } from './lesson/lesson.module';
@Module({
imports: [UsersModule, PrismaModule, AuthModule],
imports: [UsersModule, PrismaModule, AuthModule, LessonModule],
controllers: [AppController],
providers: [AppService, PrismaService],
})

View File

@@ -1,22 +1,24 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import PayloadInterface from './interface/payload.interface';
@Injectable()
export class AuthService {
constructor(
private userService: UsersService,
private jwtService: JwtService
private jwtService: JwtService,
) {}
async validateUser(username: string, password: string): Promise<PayloadInterface> {
const user = await this.userService.user({username});
async validateUser(
username: string,
password: string,
): Promise<PayloadInterface | null> {
const user = await this.userService.user({ username });
if (user && bcrypt.compareSync(password, user.password)) {
return {
username: user.username,
id: user.id
id: user.id,
};
}
return null;
@@ -26,7 +28,7 @@ export class AuthService {
const payload = { username: user.username, id: user.id };
const access_token = this.jwtService.sign(payload);
return {
access_token
access_token,
};
}
}

View File

@@ -1,4 +1,3 @@
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
@@ -11,7 +10,10 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
super();
}
async validate(username: string, password: string): Promise<PayloadInterface> {
async validate(
username: string,
password: string,
): Promise<PayloadInterface> {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException();

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LessonController } from './lesson.controller';
describe('LessonController', () => {
let controller: LessonController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [LessonController],
}).compile();
controller = module.get<LessonController>(LessonController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,103 @@
import {
Controller,
Get,
Res,
Query,
Req,
Request,
Param,
ParseIntPipe,
DefaultValuePipe,
BadRequestException,
Post,
Body,
Delete,
NotFoundException,
} from '@nestjs/common';
import { Plage } from 'src/models/plage';
import { LessonService } from './lesson.service';
import { ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
import { Prisma, Skill } from '@prisma/client';
export class Lesson {
@ApiProperty()
id: number;
@ApiProperty()
name: string;
@ApiProperty()
description: string;
@ApiProperty()
requiredLevel: number;
@ApiProperty()
mainSkill: Skill;
}
@ApiTags('lessons')
@Controller('lesson')
export class LessonController {
constructor(private lessonService: LessonService) {}
@ApiOperation({
summary: 'Get all lessons',
})
@Get()
async getAll(
@Req() request: Request,
@Query() filter: Prisma.LessonWhereInput,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
@Query('take', new DefaultValuePipe(20), ParseIntPipe) take: number,
): Promise<Plage<Lesson>> {
try {
const ret = await this.lessonService.getAll({
skip,
take,
where: {
...filter,
requiredLevel: filter.requiredLevel
? +filter.requiredLevel
: undefined,
},
});
return new Plage(ret, request);
} catch (e) {
console.log(e);
throw new BadRequestException(null, e?.toString());
}
}
@ApiOperation({
summary: 'Get a particular lessons',
})
@Get(':id')
async get(@Param('id', ParseIntPipe) id: number): Promise<Lesson> {
const ret = await this.lessonService.get(id);
if (!ret) throw new NotFoundException();
return ret;
}
@ApiOperation({
summary: 'Create a lessons',
})
@Post()
async post(@Body() lesson: Lesson): Promise<Lesson> {
try {
return await this.lessonService.create(lesson);
} catch (e) {
console.log(e);
throw new BadRequestException(null, e.toString());
}
}
@ApiOperation({
summary: 'Delete a lessons',
})
@Delete(':id')
async delete(@Param('id', ParseIntPipe) id: number): Promise<Lesson> {
try {
return await this.lessonService.delete(id);
} catch (e) {
console.log(e);
throw new BadRequestException(null, e.toString());
}
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/prisma/prisma.module';
import { LessonController } from './lesson.controller';
import { LessonService } from './lesson.service';
@Module({
imports: [PrismaModule],
controllers: [LessonController],
providers: [LessonService],
})
export class LessonModule {}

View File

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

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { Lesson, Prisma } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
@Injectable()
export class LessonService {
constructor(private prisma: PrismaService) {}
async getAll(params: {
skip?: number;
take?: number;
cursor?: Prisma.LessonWhereUniqueInput;
where?: Prisma.LessonWhereInput;
orderBy?: Prisma.LessonOrderByWithRelationInput;
}): Promise<Lesson[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prisma.lesson.findMany({
skip,
take,
cursor,
where,
orderBy,
});
}
async get(id: number): Promise<Lesson | null> {
return this.prisma.lesson.findFirst({
where: {
id: id,
},
});
}
async create(lesson: Lesson): Promise<Lesson> {
return this.prisma.lesson.create({ data: lesson });
}
async delete(id: number): Promise<Lesson> {
return this.prisma.lesson.delete({ where: { id: id } });
}
}

View File

@@ -1,11 +1,21 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { PrismaService } from './prisma/prisma.service';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const prismaService = app.get(PrismaService);
await prismaService.enableShutdownHooks(app)
await prismaService.enableShutdownHooks(app);
const config = new DocumentBuilder()
.setTitle('Chromacase')
.setDescription('The chromacase API')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);
await app.listen(3000);
}
bootstrap();

51
back/src/models/plage.ts Normal file
View File

@@ -0,0 +1,51 @@
/*
* Thanks to https://github.com/Arthi-chaud/Meelo/blob/master/src/pagination/models/paginated-response.ts
*/
import { ApiProperty } from '@nestjs/swagger';
export class Plage<T> {
@ApiProperty()
metadata: {
this: string;
next: string | null;
previous: string | null;
};
@ApiProperty()
data: T[];
constructor(data: T[], request: Request | any) {
this.data = data;
let take = Number(request.query['take'] ?? 20).valueOf();
if (take == 0) take = 20;
let skipped: number = Number(request.query['skip'] ?? 0).valueOf();
if (skipped % take) {
skipped += take - (skipped % take);
}
this.metadata = {
this: this.buildUrl(request.path, request.query),
next:
data.length >= take
? this.buildUrl(request.path, {
...request.query,
skip: skipped + take,
})
: null,
previous: skipped
? this.buildUrl(request.path, {
...request.query,
skip: Math.max(0, skipped - take),
})
: null,
};
}
private buildUrl(route: string, queryParameters: any) {
if (queryParameters.skip == 0) delete queryParameters.skip;
const builtQueryParameters = new URLSearchParams(
queryParameters,
).toString();
if (builtQueryParameters.length) return `${route}?${builtQueryParameters}`;
return route;
}
}

View File

@@ -7,7 +7,6 @@ import * as bcrypt from 'bcryptjs';
@Injectable()
export class UsersService {
[x: string]: any;
constructor(private prisma: PrismaService) {}
async user(
@@ -36,7 +35,7 @@ export class UsersService {
}
async createUser(data: Prisma.UserCreateInput): Promise<User> {
data.password = await bcrypt.hash(data.password, 8)
data.password = await bcrypt.hash(data.password, 8);
return this.prisma.user.create({
data,
});

View File

@@ -0,0 +1,80 @@
*** Settings ***
Documentation Tests of the /lesson route.
... Ensures that the lesson CRUD works corectly.
Resource ../rest.resource
*** Test Cases ***
Post a lesson
[Documentation] Get a lesson
&{res}= POST
... /lesson
... {"name": "toto", "requiredLevel": 3, "mainSkill": "TwoHands", "description": "What am i doing"}
Output
Integer response status 201
[Teardown] DELETE /lesson/${res.body.id}
Get a lesson
[Documentation] Get a lesson
&{res}= POST
... /lesson
... {"name": "toto", "requiredLevel": 3, "mainSkill": "TwoHands", "description": "What am i doing"}
Output
Integer response status 201
&{get}= GET /lesson/${res.body.id}
Output
Should Be Equal ${res.body} ${get.body}
[Teardown] DELETE /lesson/${res.body.id}
Get a non-lesson
[Documentation] Get a lesson
&{get}= GET /lesson/toto
Output
Integer response status 400
Get a not-existing-lesson
[Documentation] Get a lesson
&{get}= GET /lesson/99999999
Output
Integer response status 404
Get all lessons
[Documentation] Get a lesson
&{res}= POST
... /lesson
... {"name": "toto", "requiredLevel": 3, "mainSkill": "TwoHands", "description": "What am i doing"}
Output
Integer response status 201
&{res2}= POST
... /lesson
... {"name": "tata", "requiredLevel": 3, "mainSkill": "TwoHands", "description": "What am i doing"}
Output
Integer response status 201
&{get}= GET /lesson
Output
Should Contain ${get.body.data} ${res.body}
Should Contain ${get.body.data} ${res2.body}
[Teardown] Run Keywords DELETE /lesson/${res.body.id}
... AND DELETE /lesson/${res2.body.id}
Get all lessons filtered
[Documentation] Get a lesson
&{res}= POST
... /lesson
... {"name": "toto", "requiredLevel": 3, "mainSkill": "TwoHands", "description": "What am i doing"}
Output
Integer response status 201
&{res2}= POST
... /lesson
... {"name": "tata", "requiredLevel": 3, "mainSkill": "Distance", "description": "What am i doing"}
Output
Integer response status 201
&{get}= GET /lesson?mainSkill=Distance
Output
Should Not Contain ${get.body.data} ${res.body}
Should Contain ${get.body.data} ${res2.body}
[Teardown] Run Keywords DELETE /lesson/${res.body.id}
... AND DELETE /lesson/${res2.body.id}

View File

@@ -1,14 +1,14 @@
*** Settings ***
Documentation Tests of the /users route.
... Ensures that the users CRUD works corectly.
Resource ../rest.resource
*** Keywords ***
*** Test Cases ***
Create a user
[Documentation] Create a user
POST /users {"username": "i-don-t-exist", "password": "pass", "email": "wow@gmail.com"}
&{res}= POST /users {"username": "louis-boufon", "password": "pass", "email": "wow@gmail.com"}
Output
Integer response status 201
[Teardown] DELETE /users/1
[Teardown] DELETE /users/${res.body.id}

View File

@@ -12,10 +12,10 @@
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"strictNullChecks": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
"noFallthroughCasesInSwitch": true
}
}

12
node_modules/.yarn-integrity generated vendored
View File

@@ -1,12 +0,0 @@
{
"systemParams": "linux-x64-108",
"modulesFolders": [
"node_modules"
],
"flags": [],
"linkedModules": [],
"topLevelPatterns": [],
"lockfileEntries": {},
"files": [],
"artifacts": {}
}

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "Chromacase",
"lockfileVersion": 2,
"requires": true,
"packages": {}
}