Selaa lähdekoodia

Set up code for backend

Scaffold backend with Fastify CLI and replace Tap with Jest for
consistency.
master
TheoryOfNekomata 3 vuotta sitten
vanhempi
commit
3d7dbd4439
36 muutettua tiedostoa jossa 15013 lisäystä ja 11 poistoa
  1. +5
    -0
      REQUIREMENTS.md
  2. +0
    -11
      packages/app-web/src/models/Ringtone.ts
  3. +4
    -0
      packages/library-common/.gitignore
  4. +21
    -0
      packages/library-common/LICENSE
  5. +1
    -0
      packages/library-common/README.md
  6. +54
    -0
      packages/library-common/package.json
  7. +25
    -0
      packages/library-common/src/index.ts
  8. +35
    -0
      packages/library-common/tsconfig.json
  9. +8683
    -0
      packages/library-common/yarn.lock
  10. +67
    -0
      packages/service-core/.gitignore
  11. +1
    -0
      packages/service-core/README.md
  12. +1
    -0
      packages/service-core/global.d.ts
  13. +14
    -0
      packages/service-core/jest.config.js
  14. +0
    -0
      packages/service-core/jest.setup.ts
  15. +45
    -0
      packages/service-core/package.json
  16. +40
    -0
      packages/service-core/src/app.ts
  17. +10
    -0
      packages/service-core/src/modules/composer/service.ts
  18. +143
    -0
      packages/service-core/src/modules/ringtone/controller.ts
  19. +17
    -0
      packages/service-core/src/modules/ringtone/index.ts
  20. +261
    -0
      packages/service-core/src/modules/ringtone/repository.ts
  21. +1
    -0
      packages/service-core/src/modules/ringtone/response.ts
  22. +7
    -0
      packages/service-core/src/modules/ringtone/service.test.ts
  23. +61
    -0
      packages/service-core/src/modules/ringtone/service.ts
  24. +13
    -0
      packages/service-core/src/plugins/sensible.ts
  25. +16
    -0
      packages/service-core/src/routes/api/ringtones/index.ts
  26. +10
    -0
      packages/service-core/src/routes/api/search/ringtones/index.ts
  27. +9
    -0
      packages/service-core/src/routes/example/index.ts
  28. +9
    -0
      packages/service-core/src/routes/root.ts
  29. +111
    -0
      packages/service-core/src/utils/database/index.ts
  30. +4
    -0
      packages/service-core/src/utils/helpers/index.ts
  31. +22
    -0
      packages/service-core/test/helper.ts
  32. +83
    -0
      packages/service-core/test/mocks/repositories/Ringtone.ts
  33. +87
    -0
      packages/service-core/test/routes/api/ringtones.test.ts
  34. +14
    -0
      packages/service-core/tsconfig.json
  35. +20
    -0
      packages/service-core/tsconfig.test.json
  36. +5119
    -0
      packages/service-core/yarn.lock

+ 5
- 0
REQUIREMENTS.md Näytä tiedosto

@@ -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.


+ 0
- 11
packages/app-web/src/models/Ringtone.ts Näytä tiedosto

@@ -1,11 +0,0 @@
export default class Ringtone {
name: string

data: string

createdAt: Date

updatedAt: Date

deletedAt?: Date
}

+ 4
- 0
packages/library-common/.gitignore Näytä tiedosto

@@ -0,0 +1,4 @@
*.log
.DS_Store
node_modules
dist

+ 21
- 0
packages/library-common/LICENSE Näytä tiedosto

@@ -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.

+ 1
- 0
packages/library-common/README.md Näytä tiedosto

@@ -0,0 +1 @@
# Tonality Common Library

+ 54
- 0
packages/library-common/package.json Näytä tiedosto

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

+ 25
- 0
packages/library-common/src/index.ts Näytä tiedosto

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

+ 35
- 0
packages/library-common/tsconfig.json Näytä tiedosto

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

+ 8683
- 0
packages/library-common/yarn.lock
File diff suppressed because it is too large
Näytä tiedosto


+ 67
- 0
packages/service-core/.gitignore Näytä tiedosto

@@ -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/

+ 1
- 0
packages/service-core/README.md Näytä tiedosto

@@ -0,0 +1 @@
# Tonality Core Service

+ 1
- 0
packages/service-core/global.d.ts Näytä tiedosto

@@ -0,0 +1 @@
/// <reference types="jest" />

+ 14
- 0
packages/service-core/jest.config.js Näytä tiedosto

@@ -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
packages/service-core/jest.setup.ts Näytä tiedosto


+ 45
- 0
packages/service-core/package.json Näytä tiedosto

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

+ 40
- 0
packages/service-core/src/app.ts Näytä tiedosto

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

+ 10
- 0
packages/service-core/src/modules/composer/service.ts Näytä tiedosto

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

+ 143
- 0
packages/service-core/src/modules/ringtone/controller.ts Näytä tiedosto

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

+ 17
- 0
packages/service-core/src/modules/ringtone/index.ts Näytä tiedosto

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

+ 261
- 0
packages/service-core/src/modules/ringtone/repository.ts Näytä tiedosto

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

+ 1
- 0
packages/service-core/src/modules/ringtone/response.ts Näytä tiedosto

@@ -0,0 +1 @@
export class DoubleDeletionError extends Error {}

+ 7
- 0
packages/service-core/src/modules/ringtone/service.test.ts Näytä tiedosto

@@ -0,0 +1,7 @@
describe('ringtone service', () => {
describe('retrieval of single ringtone', () => {
it('should get from the repository', () => {

})
})
})

+ 61
- 0
packages/service-core/src/modules/ringtone/service.ts Näytä tiedosto

@@ -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.')
}
}
}

+ 13
- 0
packages/service-core/src/plugins/sensible.ts Näytä tiedosto

@@ -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,
});
});

+ 16
- 0
packages/service-core/src/routes/api/ringtones/index.ts Näytä tiedosto

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

+ 10
- 0
packages/service-core/src/routes/api/search/ringtones/index.ts Näytä tiedosto

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

+ 9
- 0
packages/service-core/src/routes/example/index.ts Näytä tiedosto

@@ -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;

+ 9
- 0
packages/service-core/src/routes/root.ts Näytä tiedosto

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

+ 111
- 0
packages/service-core/src/utils/database/index.ts Näytä tiedosto

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

+ 4
- 0
packages/service-core/src/utils/helpers/index.ts Näytä tiedosto

@@ -0,0 +1,4 @@
export interface Paginated<T> extends Array<T> {
take: number,
skip: number,
}

+ 22
- 0
packages/service-core/test/helper.ts Näytä tiedosto

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


+ 83
- 0
packages/service-core/test/mocks/repositories/Ringtone.ts Näytä tiedosto

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

+ 87
- 0
packages/service-core/test/routes/api/ringtones.test.ts Näytä tiedosto

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

+ 14
- 0
packages/service-core/tsconfig.json Näytä tiedosto

@@ -0,0 +1,14 @@
{
"extends": "fastify-tsconfig",
"compilerOptions": {
"outDir": "dist",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": [
"src/**/*.ts"
],
"exclude": [
"**/*.test.ts"
]
}

+ 20
- 0
packages/service-core/tsconfig.test.json Näytä tiedosto

@@ -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/**/*"
]
}

+ 5119
- 0
packages/service-core/yarn.lock
File diff suppressed because it is too large
Näytä tiedosto


Ladataan…
Peruuta
Tallenna