Browse Source

Set up code for backend

Scaffold backend with Fastify CLI and replace Tap with Jest for
consistency.
master
TheoryOfNekomata 3 years ago
parent
commit
3d7dbd4439
36 changed files with 15013 additions and 11 deletions
  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 View File

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

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

data: string

createdAt: Date

updatedAt: Date

deletedAt?: Date
}

+ 4
- 0
packages/library-common/.gitignore View File

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

+ 21
- 0
packages/library-common/LICENSE View File

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

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

+ 54
- 0
packages/library-common/package.json View File

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

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

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


+ 67
- 0
packages/service-core/.gitignore View File

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

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

+ 1
- 0
packages/service-core/global.d.ts View File

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

+ 14
- 0
packages/service-core/jest.config.js View File

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


+ 45
- 0
packages/service-core/package.json View File

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

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

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

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

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

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

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

+ 7
- 0
packages/service-core/src/modules/ringtone/service.test.ts View File

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

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

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

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

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

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

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

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

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

+ 22
- 0
packages/service-core/test/helper.ts View File

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

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

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

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

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


Loading…
Cancel
Save