Scaffold backend with Fastify CLI and replace Tap with Jest for consistency.master
@@ -1,11 +1,16 @@ | |||||
# Requirements | # Requirements | ||||
## Authentication | |||||
- As a client, I want to log in to the service. | - As a client, I want to log in to the service. | ||||
- [ ] In the front-end, the client provides a username, and a password to request login to the back-end. | - [ ] In the front-end, the client provides a username, and a password to request login to the back-end. | ||||
- [ ] In the back-end, the server processes the login request from the front-end. | - [ ] In the back-end, the server processes the login request from the front-end. | ||||
- As a client, I want to log out from the service. | - As a client, I want to log out from the service. | ||||
- [ ] In the front-end, the client requests logout to the backend. | - [ ] In the front-end, the client requests logout to the backend. | ||||
- [ ] In the back-end, the server processes the logout request from the front-end. | - [ ] In the back-end, the server processes the logout request from the front-end. | ||||
## Ringtone | |||||
- As a client, I want to download a ringtone. | - As a client, I want to download a ringtone. | ||||
- [ ] In the front-end, the client provides a ringtone ID to request a ringtone from the back-end. | - [ ] In the front-end, the client provides a ringtone ID to request a ringtone from the back-end. | ||||
- [ ] In the back-end, the server sends the ringtone data to the front-end. | - [ ] In the back-end, the server sends the ringtone data to the front-end. | ||||
@@ -1,11 +0,0 @@ | |||||
export default class Ringtone { | |||||
name: string | |||||
data: string | |||||
createdAt: Date | |||||
updatedAt: Date | |||||
deletedAt?: Date | |||||
} |
@@ -0,0 +1,4 @@ | |||||
*.log | |||||
.DS_Store | |||||
node_modules | |||||
dist |
@@ -0,0 +1,21 @@ | |||||
MIT License | |||||
Copyright (c) 2021 TheoryOfNekomata | |||||
Permission is hereby granted, free of charge, to any person obtaining a copy | |||||
of this software and associated documentation files (the "Software"), to deal | |||||
in the Software without restriction, including without limitation the rights | |||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||||
copies of the Software, and to permit persons to whom the Software is | |||||
furnished to do so, subject to the following conditions: | |||||
The above copyright notice and this permission notice shall be included in all | |||||
copies or substantial portions of the Software. | |||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||||
SOFTWARE. |
@@ -0,0 +1 @@ | |||||
# Tonality Common Library |
@@ -0,0 +1,54 @@ | |||||
{ | |||||
"version": "0.1.0", | |||||
"license": "MIT", | |||||
"main": "dist/index.js", | |||||
"typings": "dist/index.d.ts", | |||||
"files": [ | |||||
"dist", | |||||
"src" | |||||
], | |||||
"engines": { | |||||
"node": ">=10" | |||||
}, | |||||
"scripts": { | |||||
"start": "tsdx watch", | |||||
"build": "tsdx build", | |||||
"lint": "tsdx lint", | |||||
"prepare": "tsdx build", | |||||
"size": "size-limit", | |||||
"analyze": "size-limit --why" | |||||
}, | |||||
"peerDependencies": {}, | |||||
"husky": { | |||||
"hooks": { | |||||
"pre-commit": "tsdx lint" | |||||
} | |||||
}, | |||||
"prettier": { | |||||
"printWidth": 80, | |||||
"semi": true, | |||||
"singleQuote": true, | |||||
"trailingComma": "es5" | |||||
}, | |||||
"name": "@tonality/library-common", | |||||
"author": "TheoryOfNekomata", | |||||
"module": "dist/library-common.esm.js", | |||||
"size-limit": [ | |||||
{ | |||||
"path": "dist/library-common.cjs.production.min.js", | |||||
"limit": "10 KB" | |||||
}, | |||||
{ | |||||
"path": "dist/library-common.esm.js", | |||||
"limit": "10 KB" | |||||
} | |||||
], | |||||
"devDependencies": { | |||||
"@size-limit/preset-small-lib": "^4.10.2", | |||||
"husky": "^6.0.0", | |||||
"size-limit": "^4.10.2", | |||||
"tsdx": "^0.14.1", | |||||
"tslib": "^2.2.0", | |||||
"typescript": "^4.2.4" | |||||
} | |||||
} |
@@ -0,0 +1,25 @@ | |||||
export namespace models { | |||||
export class Composer { | |||||
id?: string | |||||
name: string | |||||
bio?: string | |||||
} | |||||
export class Ringtone { | |||||
id?: string | |||||
name: string | |||||
data: string | |||||
createdAt: Date | |||||
updatedAt: Date | |||||
deletedAt?: Date | |||||
composerId: string | |||||
} | |||||
} |
@@ -0,0 +1,35 @@ | |||||
{ | |||||
// see https://www.typescriptlang.org/tsconfig to better understand tsconfigs | |||||
"include": ["src", "types"], | |||||
"compilerOptions": { | |||||
"module": "esnext", | |||||
"lib": ["dom", "esnext"], | |||||
"importHelpers": true, | |||||
// output .d.ts declaration files for consumers | |||||
"declaration": true, | |||||
// output .js.map sourcemap files for consumers | |||||
"sourceMap": true, | |||||
// match output dir to input dir. e.g. dist/index instead of dist/src/index | |||||
"rootDir": "./src", | |||||
// stricter type-checking for stronger correctness. Recommended by TS | |||||
"strict": false, | |||||
// linter checks for common issues | |||||
"noImplicitReturns": true, | |||||
"noFallthroughCasesInSwitch": true, | |||||
// noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative | |||||
"noUnusedLocals": false, | |||||
"noUnusedParameters": true, | |||||
// use Node's module resolution algorithm, instead of the legacy TS one | |||||
"moduleResolution": "node", | |||||
// transpile JSX to React.createElement | |||||
"jsx": "react", | |||||
// interop between ESM and CJS modules. Recommended by TS | |||||
"esModuleInterop": true, | |||||
// significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS | |||||
"skipLibCheck": true, | |||||
// error out if import and file system have a casing mismatch. Recommended by TS | |||||
"forceConsistentCasingInFileNames": true, | |||||
// `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` | |||||
"noEmit": true, | |||||
} | |||||
} |
@@ -0,0 +1,67 @@ | |||||
# Logs | |||||
logs | |||||
*.log | |||||
npm-debug.log* | |||||
# Runtime data | |||||
pids | |||||
*.pid | |||||
*.seed | |||||
# Directory for instrumented libs generated by jscoverage/JSCover | |||||
lib-cov | |||||
# Coverage directory used by tools like istanbul | |||||
coverage | |||||
# nyc test coverage | |||||
.nyc_output | |||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) | |||||
.grunt | |||||
# node-waf configuration | |||||
.lock-wscript | |||||
# Compiled binary addons (http://nodejs.org/api/addons.html) | |||||
build/Release | |||||
# Dependency directories | |||||
node_modules | |||||
jspm_packages | |||||
# Optional npm cache directory | |||||
.npm | |||||
# Optional REPL history | |||||
.node_repl_history | |||||
# 0x | |||||
profile-* | |||||
# mac files | |||||
.DS_Store | |||||
# vim swap files | |||||
*.swp | |||||
# webstorm | |||||
.idea | |||||
# vscode | |||||
.vscode | |||||
*code-workspace | |||||
# clinic | |||||
profile* | |||||
*clinic* | |||||
*flamegraph* | |||||
# generated code | |||||
examples/typescript-server.js | |||||
test/types/index.js | |||||
# compiled app | |||||
dist | |||||
.env | |||||
.database/ |
@@ -0,0 +1 @@ | |||||
# Tonality Core Service |
@@ -0,0 +1 @@ | |||||
/// <reference types="jest" /> |
@@ -0,0 +1,14 @@ | |||||
module.exports = { | |||||
preset: 'ts-jest', | |||||
testEnvironment: 'node', | |||||
globals: { | |||||
'ts-jest': { | |||||
tsconfig: '<rootDir>/tsconfig.test.json', | |||||
}, | |||||
}, | |||||
collectCoverageFrom: [ | |||||
'src/modules/**/*.{js,ts}', | |||||
'src/utils/**/*.{js,ts}', | |||||
], | |||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'] | |||||
}; |
@@ -0,0 +1,45 @@ | |||||
{ | |||||
"name": "@tonality/service-core", | |||||
"version": "1.0.0", | |||||
"description": "", | |||||
"main": "app.ts", | |||||
"directories": { | |||||
"test": "test" | |||||
}, | |||||
"scripts": { | |||||
"test": "jest", | |||||
"start": "fastify start -l info dist/app.js", | |||||
"build:ts": "tsc", | |||||
"dev": "tsc && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"tsc -w\" \"fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js\"" | |||||
}, | |||||
"keywords": [], | |||||
"author": "TheoryOfNekomata", | |||||
"license": "MIT", | |||||
"dependencies": { | |||||
"@abraham/reflection": "^0.8.0", | |||||
"dotenv": "^9.0.2", | |||||
"fastify": "^3.0.0", | |||||
"fastify-autoload": "^3.3.1", | |||||
"fastify-cli": "^2.11.0", | |||||
"fastify-plugin": "^3.0.0", | |||||
"fastify-sensible": "^3.1.0", | |||||
"knex": "^0.95.5", | |||||
"tsyringe": "^4.5.0", | |||||
"uuid": "^8.3.2" | |||||
}, | |||||
"devDependencies": { | |||||
"@types/jest": "^26.0.23", | |||||
"@types/node": "^15.0.0", | |||||
"@types/uuid": "^8.3.0", | |||||
"concurrently": "^6.0.0", | |||||
"cross-env": "^7.0.3", | |||||
"fastify-tsconfig": "^1.0.1", | |||||
"jest": "^26.6.3", | |||||
"ts-jest": "^26.5.6", | |||||
"typescript": "^4.1.3" | |||||
}, | |||||
"optionalDependencies": { | |||||
"@types/sqlite3": "^3.1.7", | |||||
"sqlite3": "^5.0.2" | |||||
} | |||||
} |
@@ -0,0 +1,40 @@ | |||||
import '@abraham/reflection' | |||||
import { config } from 'dotenv' | |||||
import {join} from 'path' | |||||
import AutoLoad, {AutoloadPluginOptions} from 'fastify-autoload' | |||||
import {FastifyPluginAsync} from 'fastify' | |||||
export type AppOptions = { | |||||
// Place your custom options for app below here. | |||||
} & Partial<AutoloadPluginOptions>; | |||||
const app: FastifyPluginAsync<AppOptions> = async ( | |||||
fastify, | |||||
opts, | |||||
): Promise<void> => { | |||||
// Place here your custom code! | |||||
// Do not touch the following lines | |||||
// This loads all plugins defined in plugins | |||||
// those should be support plugins that are reused | |||||
// through your application | |||||
void fastify.register(AutoLoad, { | |||||
dir: join(__dirname, 'plugins'), | |||||
options: opts, | |||||
}); | |||||
// This loads all plugins defined in routes | |||||
// define your routes in one of these | |||||
void fastify.register(AutoLoad, { | |||||
dir: join(__dirname, 'routes'), | |||||
options: opts, | |||||
}); | |||||
}; | |||||
config() | |||||
export default app; | |||||
export {app}; |
@@ -0,0 +1,10 @@ | |||||
import { models } from '@tonality/library-common' | |||||
export default interface ComposerService { | |||||
get(id: string): Promise<models.Composer> | |||||
browse(skip?: number, take?: number): Promise<models.Composer[]> | |||||
search(q?: string): Promise<models.Composer[]> | |||||
create(data: Partial<models.Composer>): Promise<models.Composer> | |||||
update(data: Partial<models.Composer>): Promise<models.Composer> | |||||
delete(id: string): Promise<void> | |||||
} |
@@ -0,0 +1,143 @@ | |||||
import {inject, singleton} from 'tsyringe' | |||||
import {RingtoneService} from './service' | |||||
import {DoubleDeletionError} from './response' | |||||
@singleton() | |||||
export class RingtoneController { | |||||
constructor( | |||||
@inject('RingtoneService') | |||||
private readonly ringtoneService: RingtoneService | |||||
) { | |||||
} | |||||
get = async (request: any, reply: any) => { | |||||
try { | |||||
const data = await this.ringtoneService.get(request.params['id']) | |||||
if (typeof (data.deletedAt as Date) !== 'undefined') { | |||||
reply.gone() | |||||
return | |||||
} | |||||
if (!data) { | |||||
reply.notFound() | |||||
return | |||||
} | |||||
return { | |||||
data, | |||||
} | |||||
} catch (err) { | |||||
reply.internalServerError(err.message) | |||||
} | |||||
} | |||||
browse = async (request: any, reply: any) => { | |||||
try { | |||||
// TODO deserialize query | |||||
const { 'skip': skipRaw, 'take': takeRaw } = request.query | |||||
const skipNumber = Number(skipRaw) | |||||
const takeNumber = Number(takeRaw) | |||||
const skip = !isNaN(skipNumber) ? skipNumber : undefined | |||||
const take = !isNaN(takeNumber) ? takeNumber : undefined | |||||
return { | |||||
data: await this.ringtoneService.browse(skip, take), | |||||
skip, | |||||
take, | |||||
} | |||||
} catch (err) { | |||||
reply.internalServerError(err.message) | |||||
} | |||||
} | |||||
search = async (request: any, reply: any) => { | |||||
try { | |||||
const { 'q': query } = request.query | |||||
return { | |||||
data: await this.ringtoneService.search(query), | |||||
query, | |||||
} | |||||
} catch (err) { | |||||
reply.internalServerError(err.message) | |||||
} | |||||
} | |||||
create = async (request: any, reply: any) => { | |||||
try { | |||||
const data = await this.ringtoneService.create(request.body) | |||||
reply.status(201) | |||||
return { | |||||
data, | |||||
} | |||||
} catch (err) { | |||||
reply.internalServerError(err.message) | |||||
} | |||||
} | |||||
update = async (request: any, reply: any) => { | |||||
try { | |||||
// TODO validate data | |||||
const data = await this.ringtoneService.update({ | |||||
...request.body, | |||||
id: request.params['id'], | |||||
}) | |||||
if (data.deletedAt !== null) { | |||||
reply.gone() | |||||
return | |||||
} | |||||
if (!data) { | |||||
reply.notFound() | |||||
return | |||||
} | |||||
return { | |||||
data, | |||||
} | |||||
} catch (err) { | |||||
reply.internalServerError(err.message) | |||||
} | |||||
} | |||||
softDelete = async (request: any, reply: any) => { | |||||
try { | |||||
// TODO validate data | |||||
const data = await this.ringtoneService.softDelete(request.params['id']) | |||||
if (!data) { | |||||
reply.notFound() | |||||
return | |||||
} | |||||
return { | |||||
data, | |||||
} | |||||
} catch (err) { | |||||
reply.internalServerError(err.message) | |||||
} | |||||
} | |||||
undoDelete = async (request: any, reply: any) => { | |||||
try { | |||||
// TODO validate data | |||||
const data = await this.ringtoneService.undoDelete(request.params['id']) | |||||
if (!data) { | |||||
reply.notFound() | |||||
return | |||||
} | |||||
return { | |||||
data, | |||||
} | |||||
} catch (err) { | |||||
reply.internalServerError(err.message) | |||||
} | |||||
} | |||||
hardDelete = async (request: any, reply: any) => { | |||||
try { | |||||
// TODO validate data | |||||
await this.ringtoneService.hardDelete(request.params['id']) | |||||
reply.status(204) | |||||
} catch (err) { | |||||
if (err instanceof DoubleDeletionError) { | |||||
reply.notFound(err.message) | |||||
} | |||||
reply.internalServerError(err.message) | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,17 @@ | |||||
import { container } from 'tsyringe' | |||||
import { RingtoneController } from './controller' | |||||
import { RingtoneService } from './service' | |||||
import { RingtoneRepository } from './repository' | |||||
const ringtoneModule = container.createChildContainer() | |||||
ringtoneModule.register('RingtoneController', { useClass: RingtoneController }) | |||||
ringtoneModule.register('RingtoneService', { useClass: RingtoneService }) | |||||
ringtoneModule.register('RingtoneRepository', { useClass: RingtoneRepository }) | |||||
ringtoneModule.register('DATABASE_TYPE', { useValue: process.env.DATABASE_TYPE }) | |||||
ringtoneModule.register('DATABASE_LOCATION', { useValue: process.env.DATABASE_LOCATION }) | |||||
ringtoneModule.register('DATABASE_USERNAME', { useValue: process.env.DATABASE_USERNAME }) | |||||
ringtoneModule.register('DATABASE_PASSWORD', { useValue: process.env.DATABASE_PASSWORD }) | |||||
ringtoneModule.register('DATABASE_SCHEMA', { useValue: process.env.DATABASE_SCHEMA }) | |||||
export default ringtoneModule |
@@ -0,0 +1,261 @@ | |||||
import {inject, injectable} from 'tsyringe' | |||||
import * as sqlite from 'sqlite3' | |||||
import {models} from '@tonality/library-common' | |||||
@injectable() | |||||
export class RingtoneRepository { | |||||
private readonly database: sqlite.Database; | |||||
constructor( | |||||
@inject('DATABASE_LOCATION') | |||||
DATABASE_LOCATION: string | |||||
) { | |||||
this.database = new sqlite.Database(DATABASE_LOCATION) | |||||
} | |||||
async get(id: string): Promise<models.Ringtone> { | |||||
return new Promise((resolve, reject) => { | |||||
this.database.get( | |||||
` | |||||
SELECT * | |||||
FROM ringtone | |||||
WHERE id = ?; | |||||
`, | |||||
[id], | |||||
(err, result) => { | |||||
if (err) { | |||||
reject(err) | |||||
return | |||||
} | |||||
resolve(result) | |||||
} | |||||
) | |||||
}) | |||||
} | |||||
async browse(skip: number, take: number): Promise<models.Ringtone[]> { | |||||
return new Promise((resolve, reject) => { | |||||
this.database.all( | |||||
` | |||||
SELECT * | |||||
FROM ringtone | |||||
LIMIT ? | |||||
OFFSET ?; | |||||
`, | |||||
[take, skip], | |||||
(err, result) => { | |||||
if (err) { | |||||
reject(err) | |||||
return | |||||
} | |||||
resolve(result) | |||||
}) | |||||
}) | |||||
} | |||||
async search(q: string): Promise<models.Ringtone[]> { | |||||
return new Promise((resolve, reject) => { | |||||
this.database.all( | |||||
` | |||||
SELECT * | |||||
FROM ringtone r | |||||
JOIN composer c ON r.composerId = c.id | |||||
WHERE r.name LIKE ? ESCAPE '\'; | |||||
`, | |||||
[`%${q.replace('\\', '\\\\')}%`], | |||||
(err, result) => { | |||||
if (err) { | |||||
reject(err) | |||||
return | |||||
} | |||||
resolve(result) | |||||
} | |||||
) | |||||
}) | |||||
} | |||||
async create(data: Partial<models.Ringtone>): Promise<models.Ringtone> { | |||||
return new Promise((resolve, reject) => { | |||||
this.database.serialize(() => { | |||||
this.database.run( | |||||
` | |||||
INSERT | |||||
INTO ringtone | |||||
VALUES (?, ?, ?, ?, ?, NULL, ?); | |||||
`, | |||||
[data.id, data.name, data.data, data.createdAt, data.updatedAt, data.composerId], | |||||
(err) => { | |||||
if (err) { | |||||
reject(err) | |||||
return | |||||
} | |||||
} | |||||
) | |||||
this.database.get( | |||||
` | |||||
SELECT * | |||||
FROM ringtone | |||||
WHERE id = ?; | |||||
`, | |||||
[data.id], | |||||
(err, result) => { | |||||
if (err) { | |||||
reject(err) | |||||
return | |||||
} | |||||
resolve(result) | |||||
} | |||||
) | |||||
}) | |||||
}) | |||||
} | |||||
async update(data: Partial<models.Ringtone>): Promise<models.Ringtone> { | |||||
return new Promise((resolve, reject) => { | |||||
this.database.serialize(() => { | |||||
this.database.run( | |||||
` | |||||
UPDATE ringtone | |||||
SET | |||||
name = ?, | |||||
data = ?, | |||||
updatedAt = ? | |||||
WHERE id = ?; | |||||
`, | |||||
[data.name, data.data, data.updatedAt, data.id], | |||||
(err) => { | |||||
if (err) { | |||||
reject(err) | |||||
return | |||||
} | |||||
} | |||||
) | |||||
this.database.get( | |||||
` | |||||
SELECT * | |||||
FROM ringtone | |||||
WHERE id = ?; | |||||
`, | |||||
[data.id], | |||||
(err, result) => { | |||||
if (err) { | |||||
reject(err) | |||||
return | |||||
} | |||||
console.log(result, data.id) | |||||
resolve(result) | |||||
} | |||||
) | |||||
}) | |||||
}) | |||||
} | |||||
async softDelete(id: string, deletedAt: Date): Promise<models.Ringtone> { | |||||
return new Promise((resolve, reject) => { | |||||
this.database.serialize(() => { | |||||
this.database.run( | |||||
` | |||||
UPDATE ringtone | |||||
SET deletedAt = ? | |||||
WHERE id = ? | |||||
`, | |||||
[deletedAt, id], | |||||
(err) => { | |||||
if (err) { | |||||
reject(err) | |||||
return | |||||
} | |||||
} | |||||
) | |||||
this.database.get( | |||||
` | |||||
SELECT * | |||||
FROM ringtone | |||||
WHERE id = ?; | |||||
`, | |||||
[id], | |||||
(err, result) => { | |||||
if (err) { | |||||
reject(err) | |||||
return | |||||
} | |||||
resolve(result) | |||||
} | |||||
) | |||||
}) | |||||
}) | |||||
} | |||||
async undoDelete(id: string): Promise<models.Ringtone> { | |||||
return new Promise((resolve, reject) => { | |||||
this.database.serialize(() => { | |||||
this.database.run( | |||||
` | |||||
UPDATE ringtone | |||||
SET deletedAt = NULL | |||||
WHERE id = ? | |||||
`, | |||||
[id], | |||||
(err) => { | |||||
if (err) { | |||||
reject(err) | |||||
return | |||||
} | |||||
} | |||||
) | |||||
this.database.get( | |||||
` | |||||
SELECT * | |||||
FROM ringtone | |||||
WHERE id = ?; | |||||
`, | |||||
[id], | |||||
(err, result) => { | |||||
if (err) { | |||||
reject(err) | |||||
return | |||||
} | |||||
resolve(result) | |||||
} | |||||
) | |||||
}) | |||||
}) | |||||
} | |||||
async hardDelete(id: string): Promise<models.Ringtone> { | |||||
return new Promise((resolve, reject) => { | |||||
this.database.serialize(() => { | |||||
this.database.get( | |||||
` | |||||
SELECT * | |||||
FROM ringtone | |||||
WHERE id = ?; | |||||
`, | |||||
[id], | |||||
(err, result) => { | |||||
if (err) { | |||||
reject(err) | |||||
return | |||||
} | |||||
resolve(result) | |||||
} | |||||
) | |||||
this.database.run( | |||||
` | |||||
DELETE | |||||
FROM ringtone | |||||
WHERE id = ? | |||||
`, | |||||
[id], | |||||
(err) => { | |||||
if (err) { | |||||
reject(err) | |||||
return | |||||
} | |||||
} | |||||
) | |||||
}) | |||||
}) | |||||
} | |||||
} |
@@ -0,0 +1 @@ | |||||
export class DoubleDeletionError extends Error {} |
@@ -0,0 +1,7 @@ | |||||
describe('ringtone service', () => { | |||||
describe('retrieval of single ringtone', () => { | |||||
it('should get from the repository', () => { | |||||
}) | |||||
}) | |||||
}) |
@@ -0,0 +1,61 @@ | |||||
import {inject, singleton} from 'tsyringe' | |||||
import {v4} from 'uuid' | |||||
import { models } from '@tonality/library-common' | |||||
import {RingtoneRepository} from './repository' | |||||
import {DoubleDeletionError} from './response' | |||||
@singleton() | |||||
export class RingtoneService { | |||||
constructor( | |||||
@inject('RingtoneRepository') | |||||
private readonly ringtoneRepository: RingtoneRepository | |||||
) { | |||||
} | |||||
async get(id: string): Promise<models.Ringtone> { | |||||
return this.ringtoneRepository.get(id) | |||||
} | |||||
async browse(skip: number = 0, take: number = 10): Promise<models.Ringtone[]> { | |||||
return this.ringtoneRepository.browse(skip, take) | |||||
} | |||||
async search(q: string = ''): Promise<models.Ringtone[]> { | |||||
return this.ringtoneRepository.search(q) | |||||
} | |||||
async create(data: Partial<models.Ringtone>): Promise<models.Ringtone> { | |||||
const { createdAt, updatedAt, deletedAt, ...safeData } = data | |||||
const now = new Date() | |||||
const newId = v4() | |||||
return this.ringtoneRepository.create({ | |||||
...safeData, | |||||
id: newId, | |||||
createdAt: now, | |||||
updatedAt: now, | |||||
}) | |||||
} | |||||
async update(data: Partial<models.Ringtone>): Promise<models.Ringtone> { | |||||
const { createdAt, updatedAt, deletedAt, ...safeData } = data | |||||
return this.ringtoneRepository.update({ | |||||
...safeData, | |||||
updatedAt: new Date(), | |||||
}) | |||||
} | |||||
async softDelete(id: string): Promise<models.Ringtone> { | |||||
return this.ringtoneRepository.softDelete(id, new Date()) | |||||
} | |||||
async undoDelete(id: string): Promise<models.Ringtone> { | |||||
return this.ringtoneRepository.undoDelete(id) | |||||
} | |||||
async hardDelete(id: string): Promise<void> { | |||||
const deleted = await this.ringtoneRepository.hardDelete(id) | |||||
if (!deleted) { | |||||
throw new DoubleDeletionError('Attempt to delete non-existing ringtone.') | |||||
} | |||||
} | |||||
} |
@@ -0,0 +1,13 @@ | |||||
import fp from 'fastify-plugin'; | |||||
import sensible, {SensibleOptions} from 'fastify-sensible'; | |||||
/** | |||||
* This plugins adds some utilities to handle http errors | |||||
* | |||||
* @see https://github.com/fastify/fastify-sensible | |||||
*/ | |||||
export default fp<SensibleOptions>(async (fastify, opts) => { | |||||
fastify.register(sensible, { | |||||
errorHandler: false, | |||||
}); | |||||
}); |
@@ -0,0 +1,16 @@ | |||||
import {FastifyPluginAsync} from 'fastify' | |||||
import ringtoneModule from '../../../modules/ringtone' | |||||
import {RingtoneController} from '../../../modules/ringtone/controller' | |||||
const ringtones: FastifyPluginAsync = async (fastify): Promise<void> => { | |||||
const ringtoneController = ringtoneModule.resolve<RingtoneController>('RingtoneController') | |||||
fastify.get('/', ringtoneController.browse) | |||||
fastify.get('/:id', ringtoneController.get) | |||||
fastify.post('/', ringtoneController.create) | |||||
fastify.patch('/:id', ringtoneController.update) | |||||
fastify.post('/:id/delete', ringtoneController.softDelete) | |||||
fastify.delete('/:id/delete', ringtoneController.undoDelete) | |||||
fastify.delete('/:id', ringtoneController.hardDelete) | |||||
} | |||||
export default ringtones |
@@ -0,0 +1,10 @@ | |||||
import {FastifyPluginAsync} from 'fastify' | |||||
import ringtoneModule from '../../../../modules/ringtone' | |||||
import {RingtoneController} from '../../../../modules/ringtone/controller' | |||||
const ringtones: FastifyPluginAsync = async (fastify): Promise<void> => { | |||||
const ringtoneController = ringtoneModule.resolve<RingtoneController>('RingtoneController') | |||||
fastify.get('/', ringtoneController.search) | |||||
} | |||||
export default ringtones |
@@ -0,0 +1,9 @@ | |||||
import {FastifyPluginAsync} from "fastify"; | |||||
const example: FastifyPluginAsync = async (fastify, opts): Promise<void> => { | |||||
fastify.get('/', async function (request, reply) { | |||||
return 'this is an example'; | |||||
}); | |||||
}; | |||||
export default example; |
@@ -0,0 +1,9 @@ | |||||
import {FastifyPluginAsync} from 'fastify' | |||||
const root: FastifyPluginAsync = async (fastify, opts): Promise<void> => { | |||||
fastify.get('/', async function (request, reply) { | |||||
return {root: true}; | |||||
}) | |||||
} | |||||
export default root |
@@ -0,0 +1,111 @@ | |||||
import {Knex, knex} from 'knex' | |||||
export type QueryBuilder = Knex | |||||
export type DatabaseOptions = { | |||||
engine: unknown, | |||||
type: string, | |||||
location: string, | |||||
username?: string, | |||||
password?: string, | |||||
schema?: string, | |||||
version?: string, | |||||
} | |||||
const normalTypes: Record<string, string> = { | |||||
postgres: 'pg', | |||||
pg: 'pg', | |||||
postgresql: 'pg', | |||||
mysql: 'mysql', | |||||
mariadb: 'mysql', | |||||
sqlite: 'sqlite3', | |||||
sqlite3: 'sqlite3', | |||||
} | |||||
export const createQueryBuilder = ({ | |||||
type, | |||||
engine, | |||||
location, | |||||
...etcOptions | |||||
}: DatabaseOptions) => { | |||||
const { [type]: theType = null } = normalTypes | |||||
if (theType === null) { | |||||
throw new RangeError(`Invalid type: ${type}`) | |||||
} | |||||
switch (theType as string) { | |||||
case 'sqlite3': | |||||
return knex({ | |||||
client: theType, | |||||
connection: { | |||||
filename: location, | |||||
} | |||||
}) | |||||
default: | |||||
break | |||||
} | |||||
const { username, password, schema, version, } = etcOptions | |||||
return knex({ | |||||
client: theType, | |||||
version, | |||||
connection: { | |||||
host: location, | |||||
user: username, | |||||
password, | |||||
database: schema, | |||||
}, | |||||
}) | |||||
} | |||||
export class Database { | |||||
private readonly queryBuilder: QueryBuilder | |||||
constructor({ | |||||
type, | |||||
engine, | |||||
location, | |||||
...etcOptions | |||||
}: DatabaseOptions) { | |||||
const { [type]: theType = null } = normalTypes | |||||
if (theType === null) { | |||||
throw new RangeError(`Invalid type: ${type}`) | |||||
} | |||||
switch (theType as string) { | |||||
case 'sqlite3': | |||||
this.queryBuilder = knex({ | |||||
client: theType, | |||||
connection: { | |||||
filename: location, | |||||
} | |||||
}) | |||||
return | |||||
default: | |||||
break | |||||
} | |||||
const { username, password, schema, version, } = etcOptions | |||||
this.queryBuilder = knex({ | |||||
client: theType, | |||||
version, | |||||
connection: { | |||||
host: location, | |||||
user: username, | |||||
password, | |||||
database: schema, | |||||
}, | |||||
}) | |||||
} | |||||
async run(cb: (qb: QueryBuilder) => any) { | |||||
const rawQuery = cb(this.queryBuilder) | |||||
if (!rawQuery) { | |||||
throw new Error('Invalid callback.') | |||||
} | |||||
return rawQuery | |||||
} | |||||
} |
@@ -0,0 +1,4 @@ | |||||
export interface Paginated<T> extends Array<T> { | |||||
take: number, | |||||
skip: number, | |||||
} |
@@ -0,0 +1,22 @@ | |||||
// This file contains code that we reuse between our tests. | |||||
import Fastify from 'fastify' | |||||
import fp from 'fastify-plugin' | |||||
import App from '../src/app' | |||||
// Fill in this config with all the configurations | |||||
// needed for testing the application | |||||
export const config = async () => { | |||||
return {}; | |||||
} | |||||
// Automatically build and tear down our instance | |||||
export const build = async () => { | |||||
const app = Fastify() | |||||
// fastify-plugin ensures that all decorators | |||||
// are exposed for testing purposes, this is | |||||
// different from the production setup | |||||
void app.register(fp(App), await config()) | |||||
await app.ready() | |||||
return app | |||||
} | |||||
@@ -0,0 +1,83 @@ | |||||
import {models} from '@tonality/library-common' | |||||
export default class MockRingtoneRepository { | |||||
constructor(private items: models.Ringtone[]) {} | |||||
async get(id: string) { | |||||
return this.items.find(item => item.id === id) | |||||
} | |||||
async browse(skip: number, take: number) { | |||||
return this.items.slice(skip, skip + take) | |||||
} | |||||
async search(q: string) { | |||||
return this.items.filter(item => item.name.toLowerCase().includes(q.toLowerCase())) | |||||
} | |||||
async create(data: Partial<models.Ringtone>) { | |||||
let newItem: models.Ringtone | |||||
this.items.push(newItem = { | |||||
id: data.id, | |||||
name: data.name, | |||||
data: data.data, | |||||
createdAt: data.createdAt, | |||||
updatedAt: data.updatedAt, | |||||
deletedAt: null, | |||||
composerId: data.composerId, | |||||
}) | |||||
return newItem | |||||
} | |||||
async update(data: Partial<models.Ringtone>) { | |||||
let updated: models.Ringtone | |||||
this.items = this.items.map(item => ( | |||||
item.id === data.id | |||||
? (updated = { | |||||
...item, | |||||
...data, | |||||
id: item.id, | |||||
}) | |||||
: item | |||||
)) | |||||
return updated | |||||
} | |||||
async softDelete(id: string, deletedAt: Date) { | |||||
let deleted: models.Ringtone | |||||
this.items = this.items.map(item => ( | |||||
item.id === id | |||||
? (deleted = { | |||||
...item, | |||||
deletedAt, | |||||
}) | |||||
: item | |||||
)) | |||||
return deleted | |||||
} | |||||
async undoDelete(id: string) { | |||||
let undo: models.Ringtone | |||||
this.items = this.items.map(item => ( | |||||
item.id === id | |||||
? (undo = { | |||||
...item, | |||||
deletedAt: null, | |||||
}) | |||||
: item | |||||
)) | |||||
return undo | |||||
} | |||||
async hardDelete(id: string) { | |||||
let deleted: models.Ringtone | |||||
this.items = this.items.filter(item => { | |||||
if (item.id === id) { | |||||
deleted = item | |||||
return false | |||||
} | |||||
return true | |||||
}) | |||||
return deleted | |||||
} | |||||
} |
@@ -0,0 +1,87 @@ | |||||
import {FastifyInstance} from 'fastify' | |||||
import {build} from '../../helper' | |||||
import ringtoneModule from '../../../src/modules/ringtone' | |||||
import MockRingtoneRepository from '../../mocks/repositories/Ringtone' | |||||
describe('ringtone resource', () => { | |||||
let app: FastifyInstance | |||||
beforeEach(async () => { | |||||
ringtoneModule.registerInstance('RingtoneRepository', new MockRingtoneRepository([ | |||||
{ | |||||
id: '00000000-0000-0000-000000000000', | |||||
name: 'Ringtone 1', | |||||
data: '4c4', | |||||
createdAt: new Date('2021-05-01'), | |||||
updatedAt: new Date('2021-05-01'), | |||||
deletedAt: null, | |||||
composerId: '00000000-0000-0000-000000000000', | |||||
}, | |||||
{ | |||||
id: '00000000-0000-0000-000000000001', | |||||
name: 'Ringtone 2', | |||||
data: '4c4 8c4', | |||||
createdAt: new Date('2021-05-01'), | |||||
updatedAt: new Date('2021-05-01'), | |||||
deletedAt: null, | |||||
composerId: '00000000-0000-0000-000000000000', | |||||
}, | |||||
{ | |||||
id: '00000000-0000-0000-000000000002', | |||||
name: 'Deleted Ringtone 1', | |||||
data: '4c4', | |||||
createdAt: new Date('2021-05-01'), | |||||
updatedAt: new Date('2021-05-01'), | |||||
deletedAt: new Date('2021-05-05'), | |||||
composerId: '00000000-0000-0000-000000000000', | |||||
} | |||||
])) | |||||
app = await build() | |||||
}) | |||||
afterEach(async () => { | |||||
ringtoneModule.clearInstances() | |||||
await app.close() | |||||
}) | |||||
describe('collection', () => { | |||||
it('should be browsable', async () => { | |||||
const res = await app.inject({ | |||||
url: '/api/ringtones', | |||||
method: 'GET', | |||||
}) | |||||
const parsedPayload = JSON.parse(res.payload) | |||||
expect(Array.isArray(parsedPayload.data)).toBe(true) | |||||
}) | |||||
it('should be searchable', async () => { | |||||
// TODO | |||||
}) | |||||
it('should be extendable', async () => { | |||||
const res = await app.inject({ | |||||
url: '/api/ringtones', | |||||
method: 'POST', | |||||
headers: { | |||||
'Content-Type': 'application/json', | |||||
}, | |||||
payload: JSON.stringify({ | |||||
name: 'New Ringtone', | |||||
data: '4c4', | |||||
composerId: '00000000-0000-0000-000000000000', | |||||
}) | |||||
}) | |||||
const parsedPayload = JSON.parse(res.payload) | |||||
expect(parsedPayload.data).toEqual({ | |||||
id: expect.any(String), | |||||
name: 'New Ringtone', | |||||
data: '4c4', | |||||
createdAt: expect.any(String), | |||||
updatedAt: expect.any(String), | |||||
deletedAt: null, | |||||
composerId: '00000000-0000-0000-000000000000', | |||||
}) | |||||
console.log(parsedPayload) | |||||
}) | |||||
}) | |||||
}) |
@@ -0,0 +1,14 @@ | |||||
{ | |||||
"extends": "fastify-tsconfig", | |||||
"compilerOptions": { | |||||
"outDir": "dist", | |||||
"experimentalDecorators": true, | |||||
"emitDecoratorMetadata": true | |||||
}, | |||||
"include": [ | |||||
"src/**/*.ts" | |||||
], | |||||
"exclude": [ | |||||
"**/*.test.ts" | |||||
] | |||||
} |
@@ -0,0 +1,20 @@ | |||||
{ | |||||
"extends": "fastify-tsconfig", | |||||
"compilerOptions": { | |||||
"strict": false, | |||||
"noEmit": true, | |||||
"experimentalDecorators": true, | |||||
"emitDecoratorMetadata": true, | |||||
"types": [ | |||||
"node", | |||||
"jest" | |||||
] | |||||
}, | |||||
"include": [ | |||||
"test/routes/**/*.ts", | |||||
"src/**/*.test.ts" | |||||
], | |||||
"exclude": [ | |||||
"dist/**/*" | |||||
] | |||||
} |