瀏覽代碼

Set up code for backend

Scaffold backend with Fastify CLI and replace Tap with Jest for
consistency.
master
TheoryOfNekomata 3 年之前
父節點
當前提交
3d7dbd4439
共有 36 個檔案被更改,包括 15013 行新增11 行删除
  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 查看文件

@@ -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 查看文件

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

data: string

createdAt: Date

updatedAt: Date

deletedAt?: Date
}

+ 4
- 0
packages/library-common/.gitignore 查看文件

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

+ 21
- 0
packages/library-common/LICENSE 查看文件

@@ -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 查看文件

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

+ 54
- 0
packages/library-common/package.json 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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
文件差異過大導致無法顯示
查看文件


+ 67
- 0
packages/service-core/.gitignore 查看文件

@@ -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 查看文件

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

+ 1
- 0
packages/service-core/global.d.ts 查看文件

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

+ 14
- 0
packages/service-core/jest.config.js 查看文件

@@ -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 查看文件


+ 45
- 0
packages/service-core/package.json 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

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

+ 7
- 0
packages/service-core/src/modules/ringtone/service.test.ts 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

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

+ 22
- 0
packages/service-core/test/helper.ts 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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
文件差異過大導致無法顯示
查看文件


Loading…
取消
儲存