Scaffold backend with Fastify CLI and replace Tap with Jest for consistency.master
@@ -1,11 +1,16 @@ | |||
# Requirements | |||
## Authentication | |||
- 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 back-end, the server processes the login request from the front-end. | |||
- As a client, I want to log out from the service. | |||
- [ ] 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. | |||
## 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 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/**/*" | |||
] | |||
} |