@@ -0,0 +1,11 @@ | |||
root = true | |||
[*] | |||
charset = utf-8 | |||
end_of_line = lf | |||
indent_size = tab | |||
indent_style = tab | |||
insert_final_newline = true | |||
max_line_length = 120 | |||
tab_width = 2 | |||
trim_trailing_whitespace = true |
@@ -0,0 +1,3 @@ | |||
node_modules | |||
.DS_Store | |||
.idea/ |
@@ -0,0 +1,6 @@ | |||
- We could make a common code generation strategy between | |||
CLI and Web API since we have unified the code structure. | |||
- What this means is we just need to declare the connection | |||
from the core SDK to the CLI or Web API functions, then we | |||
can run some sort of code generation, so we don't have to | |||
bother writing the Web API/CLI code manually. |
@@ -0,0 +1,107 @@ | |||
# Logs | |||
logs | |||
*.log | |||
npm-debug.log* | |||
yarn-debug.log* | |||
yarn-error.log* | |||
lerna-debug.log* | |||
# Diagnostic reports (https://nodejs.org/api/report.html) | |||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json | |||
# Runtime data | |||
pids | |||
*.pid | |||
*.seed | |||
*.pid.lock | |||
# Directory for instrumented libs generated by jscoverage/JSCover | |||
lib-cov | |||
# Coverage directory used by tools like istanbul | |||
coverage | |||
*.lcov | |||
# nyc test coverage | |||
.nyc_output | |||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) | |||
.grunt | |||
# Bower dependency directory (https://bower.io/) | |||
bower_components | |||
# node-waf configuration | |||
.lock-wscript | |||
# Compiled binary addons (https://nodejs.org/api/addons.html) | |||
build/Release | |||
# Dependency directories | |||
node_modules/ | |||
jspm_packages/ | |||
# TypeScript v1 declaration files | |||
typings/ | |||
# TypeScript cache | |||
*.tsbuildinfo | |||
# Optional npm cache directory | |||
.npm | |||
# Optional eslint cache | |||
.eslintcache | |||
# Microbundle cache | |||
.rpt2_cache/ | |||
.rts2_cache_cjs/ | |||
.rts2_cache_es/ | |||
.rts2_cache_umd/ | |||
# Optional REPL history | |||
.node_repl_history | |||
# Output of 'npm pack' | |||
*.tgz | |||
# Yarn Integrity file | |||
.yarn-integrity | |||
# dotenv environment variables file | |||
.env | |||
.env.production | |||
.env.development | |||
# parcel-bundler cache (https://parceljs.org/) | |||
.cache | |||
# Next.js build output | |||
.next | |||
# Nuxt.js build / generate output | |||
.nuxt | |||
dist | |||
# Gatsby files | |||
.cache/ | |||
# Comment in the public line in if your project uses Gatsby and *not* Next.js | |||
# https://nextjs.org/blog/next-9-1#public-directory-support | |||
# public | |||
# vuepress build output | |||
.vuepress/dist | |||
# Serverless directories | |||
.serverless/ | |||
# FuseBox cache | |||
.fusebox/ | |||
# DynamoDB Local files | |||
.dynamodb/ | |||
# TernJS port file | |||
.tern-port | |||
.npmrc |
@@ -0,0 +1,71 @@ | |||
{ | |||
"name": "@modal-sh/cli", | |||
"version": "0.0.0", | |||
"files": [ | |||
"dist", | |||
"src" | |||
], | |||
"engines": { | |||
"node": ">=12" | |||
}, | |||
"license": "UNLICENSED", | |||
"keywords": [ | |||
"pridepack" | |||
], | |||
"devDependencies": { | |||
"@types/node": "^20.12.7", | |||
"@types/yargs": "^17.0.32", | |||
"pridepack": "2.6.0", | |||
"tslib": "^2.6.2", | |||
"typescript": "^5.4.5", | |||
"vitest": "^1.5.0" | |||
}, | |||
"scripts": { | |||
"prepublishOnly": "pridepack clean && pridepack build", | |||
"build": "pridepack build", | |||
"type-check": "pridepack check", | |||
"lint": "pridepack lint", | |||
"clean": "pridepack clean", | |||
"watch": "pridepack watch", | |||
"start": "pridepack start", | |||
"dev": "pridepack dev", | |||
"test": "vitest" | |||
}, | |||
"private": true, | |||
"description": "CLI app.", | |||
"repository": { | |||
"url": "", | |||
"type": "git" | |||
}, | |||
"homepage": "", | |||
"bugs": { | |||
"url": "" | |||
}, | |||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||
"publishConfig": { | |||
"access": "restricted" | |||
}, | |||
"types": "./dist/types/index.d.ts", | |||
"main": "./dist/cjs/production/index.js", | |||
"module": "./dist/esm/production/index.js", | |||
"exports": { | |||
".": { | |||
"development": { | |||
"require": "./dist/cjs/development/index.js", | |||
"import": "./dist/esm/development/index.js" | |||
}, | |||
"require": "./dist/cjs/production/index.js", | |||
"import": "./dist/esm/production/index.js", | |||
"types": "./dist/types/index.d.ts" | |||
} | |||
}, | |||
"typesVersions": { | |||
"*": {} | |||
}, | |||
"dependencies": { | |||
"@clack/prompts": "^0.7.0", | |||
"@modal-sh/core": "workspace:*", | |||
"pino": "^8.20.0", | |||
"yargs": "^17.7.2" | |||
} | |||
} |
@@ -0,0 +1,3 @@ | |||
{ | |||
"target": "es2018" | |||
} |
@@ -0,0 +1,9 @@ | |||
import { Cli, CliOptions } from './packages/cli-wrapper'; | |||
export const createCli = (options: CliOptions) => { | |||
const cli = new Cli(options); | |||
// TODO setup CLI here | |||
return cli; | |||
}; |
@@ -0,0 +1,22 @@ | |||
import { Cli } from './packages/cli-wrapper'; | |||
import { AdderController, AdderControllerImpl } from './modules/adder'; | |||
export const addCommands = (cli: Cli) => { | |||
const adderController: AdderController = new AdderControllerImpl(); | |||
return cli | |||
.command({ | |||
aliases: ['a'], | |||
command: 'add', | |||
parameters: ['<a> <b>'], | |||
describe: 'Add two numbers', | |||
handler: adderController.addNumbers, | |||
}) | |||
.command({ | |||
aliases: ['s'], | |||
command: 'subtract', | |||
parameters: ['<a> <b>'], | |||
describe: 'Subtract two numbers', | |||
handler: adderController.subtractNumbers, | |||
}); | |||
}; |
@@ -0,0 +1,11 @@ | |||
import { addCommands } from '@/commands'; | |||
import { createCli } from '@/cli'; | |||
import { hideBin } from 'yargs/helpers'; | |||
const cli = createCli({ | |||
name: 'cli', | |||
}); | |||
addCommands(cli); | |||
cli.run(hideBin(process.argv)); |
@@ -0,0 +1,82 @@ | |||
import { | |||
AdderService, | |||
AdderServiceImpl, | |||
ArgumentOutOfRangeError, | |||
InvalidArgumentTypeError, | |||
} from './adder.service'; | |||
import { CommandHandler } from '../../packages/cli-wrapper'; | |||
export interface AdderController { | |||
addNumbers: CommandHandler; | |||
subtractNumbers: CommandHandler; | |||
} | |||
export class AdderControllerImpl implements AdderController { | |||
constructor( | |||
private readonly adderService: AdderService = new AdderServiceImpl(), | |||
) { | |||
// noop | |||
} | |||
readonly addNumbers: CommandHandler = (params) => { | |||
if (!params.interactive) { | |||
const checkArgs = params.args as Record<string, unknown>; | |||
if (typeof checkArgs.a === 'undefined') { | |||
params.logger.error('Missing required argument: a'); | |||
return -1; | |||
} | |||
if (typeof checkArgs.b === 'undefined') { | |||
params.logger.error('Missing required argument: b'); | |||
return -1; | |||
} | |||
} | |||
const { a, b } = params.args; | |||
try { | |||
const response = this.adderService.addNumbers({ a: Number(a), b: Number(b) }); | |||
params.logger.info(response); | |||
} catch (errorRaw) { | |||
const error = errorRaw as Error; | |||
params.logger.error(error.message); | |||
if (error instanceof InvalidArgumentTypeError) { | |||
return -1; | |||
} | |||
if (error instanceof ArgumentOutOfRangeError) { | |||
return -2; | |||
} | |||
return -3; | |||
} | |||
return 0; | |||
} | |||
readonly subtractNumbers: CommandHandler = (params) => { | |||
if (!params.interactive) { | |||
const checkArgs = params.args as Record<string, unknown>; | |||
if (typeof checkArgs.a === 'undefined') { | |||
params.logger.error('Missing required argument: a'); | |||
return -1; | |||
} | |||
if (typeof checkArgs.b === 'undefined') { | |||
params.logger.error('Missing required argument: b'); | |||
return -1; | |||
} | |||
} | |||
const { a, b } = params.args; | |||
try { | |||
const response = this.adderService.addNumbers({ a: Number(a), b: -(Number(b)) }); | |||
params.logger.info(response); | |||
} catch (errorRaw) { | |||
const error = errorRaw as Error; | |||
params.logger.error(error.message); | |||
if (error instanceof InvalidArgumentTypeError) { | |||
return -1; | |||
} | |||
if (error instanceof ArgumentOutOfRangeError) { | |||
return -2; | |||
} | |||
return -3; | |||
} | |||
return 0; | |||
} | |||
} |
@@ -0,0 +1,21 @@ | |||
import { | |||
add, | |||
AddFunctionOptions, | |||
ArgumentOutOfRangeError, | |||
InvalidArgumentTypeError, | |||
} from '@modal-sh/core'; | |||
export interface AdderService { | |||
addNumbers(options: AddFunctionOptions): number; | |||
} | |||
export class AdderServiceImpl implements AdderService { | |||
addNumbers(options: AddFunctionOptions) { | |||
return add(options); | |||
} | |||
} | |||
export { | |||
ArgumentOutOfRangeError, | |||
InvalidArgumentTypeError, | |||
}; |
@@ -0,0 +1,2 @@ | |||
export * from './adder.controller'; | |||
export * from './adder.service'; |
@@ -0,0 +1,337 @@ | |||
import yargs, { Argv } from 'yargs'; | |||
import * as clack from '@clack/prompts'; | |||
import { DummyWriteStream } from './write-stream'; | |||
import pino, {LogFn} from 'pino'; | |||
import * as util from 'util'; | |||
export interface Logger extends pino.BaseLogger {} | |||
export interface CommandHandlerArgs { | |||
self: any; | |||
interactive: boolean; | |||
send: (message: number) => void; | |||
logger: Logger; | |||
args: any; | |||
} | |||
export type CommandHandler = (args: CommandHandlerArgs) => void | number | Promise<number> | Promise<void>; | |||
export interface CommandArgs { | |||
aliases?: string[]; | |||
command: string; | |||
parameters: string[]; | |||
describe: string; | |||
handler: CommandHandler; | |||
interactiveMode?: boolean; | |||
} | |||
type PromptValueArgs = [Record<string, string>, ...never[]] | |||
| [string, string] | |||
| [[string, string], ...never[]] | |||
| [[string, string][], ...never[]]; | |||
export interface TestModeResult { | |||
exitCode?: number; | |||
stdout: DummyWriteStream; | |||
stderr: DummyWriteStream; | |||
} | |||
interface InteractiveModeOptions { | |||
option: string; | |||
alias: string; | |||
intro: string; | |||
cancelled: string; | |||
outro: string; | |||
describe: string; | |||
} | |||
export interface CliOptions { | |||
name: string; | |||
interactiveMode?: Partial<InteractiveModeOptions>; | |||
logger?: Logger | boolean; | |||
} | |||
export class Cli { | |||
private testMode = false; | |||
private promptValues = {} as Record<string, unknown>; | |||
private testModeResult: TestModeResult = { | |||
exitCode: undefined as number | undefined, | |||
stdout: new DummyWriteStream(), | |||
stderr: new DummyWriteStream(), | |||
}; | |||
private readonly cli: Argv; | |||
constructor(private readonly options: CliOptions) { | |||
this.cli = yargs() | |||
.scriptName(options.name) | |||
.help() | |||
.fail(false) | |||
.strict(false); | |||
} | |||
private exit(code: number) { | |||
if (this.testMode) { | |||
this.testModeResult.exitCode = code; | |||
return; | |||
} | |||
process.exit(code); | |||
} | |||
private async prompt(messages: Record<string, string>, interactiveModeOptions: InteractiveModeOptions) { | |||
const { | |||
intro, | |||
cancelled, | |||
outro, | |||
} = interactiveModeOptions; | |||
const messagesEntries = Object.entries(messages); | |||
if (messagesEntries.length > 0) { | |||
clack.intro(intro); | |||
const values = await clack.group( | |||
Object.fromEntries( | |||
messagesEntries.map(([key, value]) => [key, () => clack.text({ message: value })]) | |||
), | |||
{ | |||
onCancel: () => { | |||
clack.cancel(cancelled); | |||
this.exit(1); | |||
} | |||
} | |||
); | |||
clack.outro(outro); | |||
return values; | |||
} | |||
return {}; | |||
} | |||
private async generateCommandArgs( | |||
context: any, | |||
originalCommandArgs: Record<string, unknown>, | |||
interactive: boolean, | |||
interactiveModeOptions: InteractiveModeOptions, | |||
) { | |||
if (!interactive) { | |||
return originalCommandArgs; | |||
} | |||
// TODO properly filter attributes | |||
const clackGroup = context.optional | |||
.filter((optional: { cmd: string[] }) => { | |||
const { | |||
'$0': _$0, | |||
i: _i, | |||
interactive: _interactive, | |||
test: _test, | |||
_: __, | |||
...rest | |||
} = originalCommandArgs; | |||
return !Object.keys(rest).includes(optional.cmd[0]); | |||
}) | |||
.reduce( | |||
(group: Record<string, string>, optional: { cmd: string[], describe: string }) => ({ | |||
...group, | |||
[optional.cmd[0]]: optional.describe ?? optional.cmd[0], | |||
}), | |||
{} as Record<string, string>, | |||
); | |||
const promptedArgs = this.testMode | |||
? this.promptValues | |||
: await this.prompt(clackGroup, interactiveModeOptions); | |||
return { | |||
...originalCommandArgs, | |||
...promptedArgs, | |||
}; | |||
} | |||
private buildHandler(handlerFn: Function, interactiveModeOptions: InteractiveModeOptions) { | |||
const thisCli = this; | |||
return async function handler(this: any, commandArgs: Record<string, unknown>) { | |||
const self = this; | |||
const { option, alias } = interactiveModeOptions; | |||
const interactiveLong = option in commandArgs ? Boolean(commandArgs.interactive) : false; | |||
const interactiveShort = alias in commandArgs ? Boolean(commandArgs.i) : false; | |||
const interactive = interactiveLong || interactiveShort; | |||
const buildArgs = await thisCli.generateCommandArgs( | |||
self, | |||
commandArgs, | |||
interactive, | |||
interactiveModeOptions, | |||
); | |||
const stdoutLogFn: LogFn = (...args: unknown[]) => { | |||
const stream = thisCli.testMode ? thisCli.testModeResult.stdout : process.stdout; | |||
const [arg0, arg1, ...etcArgs] = args; | |||
if (typeof arg0 === 'string' || typeof arg0 === 'number') { | |||
if (arg1) { | |||
if (etcArgs.length > 0) { | |||
stream.write(util.format(`${arg0}\n`, arg1, ...etcArgs)); | |||
} else { | |||
stream.write(util.format(`${arg0}\n`, arg1)); | |||
} | |||
} else { | |||
stream.write(util.format(`${arg0}\n`)); | |||
} | |||
} else if (typeof arg0 === 'object' && (typeof arg1 === 'string' || typeof arg1 === 'number')) { | |||
if (etcArgs.length > 0) { | |||
stream.write(util.format(`${arg1}\n`, ...etcArgs)); | |||
} else { | |||
stream.write(util.format(`${arg1}\n`)); | |||
} | |||
} | |||
}; | |||
const stderrLogFn: LogFn = (...args: unknown[]) => { | |||
const stream = thisCli.testMode ? thisCli.testModeResult.stderr : process.stderr; | |||
const [arg0, arg1, ...etcArgs] = args; | |||
if (typeof arg0 === 'string' || typeof arg0 === 'number') { | |||
if (arg1) { | |||
if (etcArgs.length > 0) { | |||
stream.write(util.format(`${arg0}\n`, arg1, ...etcArgs)); | |||
} else { | |||
stream.write(util.format(`${arg0}\n`, arg1)); | |||
} | |||
} else { | |||
stream.write(util.format(`${arg0}\n`)); | |||
} | |||
} else if (typeof arg0 === 'object' && (typeof arg1 === 'string' || typeof arg1 === 'number')) { | |||
if (etcArgs.length > 0) { | |||
stream.write(util.format(`${arg1}\n`, ...etcArgs)); | |||
} else { | |||
stream.write(util.format(`${arg1}\n`)); | |||
} | |||
} | |||
}; | |||
const loggerFn = thisCli.options.logger; | |||
const defaultLogger = { | |||
level: 'info', | |||
enabled: typeof loggerFn === 'boolean' ? loggerFn : true, | |||
debug: stdoutLogFn, | |||
info: stdoutLogFn, | |||
warn: stdoutLogFn, | |||
error: stderrLogFn, | |||
fatal: stderrLogFn, | |||
trace: stdoutLogFn, | |||
silent: () => {}, | |||
} as Logger; | |||
const loggerBooleanFn = typeof loggerFn === 'boolean' && loggerFn ? defaultLogger : { | |||
level: 'silent', | |||
debug: () => {}, | |||
info: () => {}, | |||
warn: () => {}, | |||
error: () => {}, | |||
fatal: () => {}, | |||
trace: () => {}, | |||
silent: () => {}, | |||
} as Logger; | |||
const logger = typeof loggerFn === 'undefined' ? defaultLogger : (typeof loggerFn === 'function' ? loggerFn : loggerBooleanFn); | |||
let exited = false; | |||
const returnCode = await handlerFn({ | |||
self, | |||
interactive, | |||
logger, | |||
send: (code: number) => { | |||
exited = true; | |||
thisCli.exit(code); | |||
}, | |||
args: buildArgs, | |||
}); | |||
if (!exited) { | |||
thisCli.exit(returnCode ? returnCode : 0); | |||
} | |||
}; | |||
} | |||
command({ parameters, command, interactiveMode = true, handler, ...args }: CommandArgs): Cli { | |||
// TODO type-safe declaration of positional/optional arguments | |||
const { | |||
option = 'interactive', | |||
alias = 'i', | |||
describe = 'Interactive mode', | |||
intro = 'Please provide the following values:', | |||
cancelled = 'Operation cancelled.', | |||
outro = 'Thank you!', | |||
} = this.options?.interactiveMode ?? {}; | |||
const commandArgs = { | |||
...args, | |||
command: `${command} ${parameters.join(' ').replace(/</g, '[').replace(/>/g, ']')}`, | |||
usage: parameters.map((p) => `${command} ${p}`).join('\n'), | |||
handler: this.buildHandler(handler, { | |||
option, | |||
alias, | |||
intro, | |||
cancelled, | |||
outro, | |||
describe, | |||
}), | |||
} as Record<string, unknown> | |||
if (interactiveMode) { | |||
commandArgs.options = { | |||
...(commandArgs.options ?? {}), | |||
[option]: { | |||
alias, | |||
describe, | |||
type: 'boolean', | |||
default: false, | |||
hidden: true, | |||
}, | |||
}; | |||
} | |||
this.cli.command(commandArgs as unknown as Parameters<typeof this.cli.command>[0]); | |||
return this; | |||
} | |||
async run(args: string[]): Promise<void> { | |||
if (args.length > 0) { | |||
await this.cli.parse(args); | |||
return; | |||
} | |||
this.cli.showHelp(); | |||
} | |||
test() { | |||
this.testMode = true; | |||
this.promptValues = {}; | |||
const thisCli = this; | |||
return { | |||
promptValue(...args: PromptValueArgs) { | |||
const [testArg] = args; | |||
if (typeof testArg === 'string') { | |||
const [key, value] = args as [string, unknown]; | |||
thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; | |||
} | |||
if (Array.isArray(testArg)) { | |||
const [key, value] = testArg as [string, unknown]; | |||
thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; | |||
} | |||
if (typeof testArg === 'object' && testArg !== null) { | |||
Object.entries(testArg).forEach(([key, value]) => { | |||
thisCli.promptValues[key] = thisCli.promptValues[key] ?? value; | |||
}); | |||
} | |||
return this; | |||
}, | |||
async run(args: string[]): Promise<TestModeResult> { | |||
await thisCli.cli.parse(args); | |||
return thisCli.testModeResult; | |||
}, | |||
}; | |||
} | |||
} |
@@ -0,0 +1,19 @@ | |||
import {WriteStream} from 'tty'; | |||
export class DummyWriteStream extends WriteStream { | |||
private bufferInternal = Buffer.from(''); | |||
constructor() { | |||
super(0); | |||
} | |||
write(input: Uint8Array | string, _encoding?: BufferEncoding | ((err?: Error) => void), _cb?: (err?: Error) => void): boolean { | |||
this.bufferInternal = Buffer.concat([this.bufferInternal, Buffer.from(input)]); | |||
// noop | |||
return true; | |||
} | |||
get buffer(): Buffer { | |||
return this.bufferInternal; | |||
} | |||
} |
@@ -0,0 +1,68 @@ | |||
import { describe, it, expect, vi, beforeAll } from 'vitest'; | |||
import { createCli } from '../src/cli'; | |||
import { addCommands } from '../src/commands'; | |||
import { AdderServiceImpl, InvalidArgumentTypeError, ArgumentOutOfRangeError } from '../src/modules/adder'; | |||
import { Cli } from '../src/packages/cli-wrapper'; | |||
vi.mock('process'); | |||
describe('cli', () => { | |||
let cli: Cli; | |||
beforeAll(() => { | |||
cli = createCli({ | |||
name: 'cli-test', | |||
logger: false, | |||
}); | |||
addCommands(cli); | |||
}); | |||
it('returns result when successful', async () => { | |||
const response = await cli.test().run(['add', '1', '2']); | |||
expect(response.exitCode).toBe(0); | |||
}); | |||
it('returns error when given invalid inputs', async () => { | |||
vi.spyOn(AdderServiceImpl.prototype, 'addNumbers').mockImplementationOnce(() => { | |||
throw new InvalidArgumentTypeError('Invalid input'); | |||
}); | |||
const response = await cli.test().run(['add', '1', '2']); | |||
expect(response.exitCode).toBe(-1); | |||
}); | |||
it('returns error when given out-of-range inputs', async () => { | |||
vi.spyOn(AdderServiceImpl.prototype, 'addNumbers').mockImplementationOnce(() => { | |||
throw new ArgumentOutOfRangeError('Out of range'); | |||
}); | |||
const response = await cli.test().run(['add', '1', '2']); | |||
expect(response.exitCode).toBe(-2); | |||
}); | |||
it('returns error when an unexpected error occurs', async () => { | |||
vi.spyOn(AdderServiceImpl.prototype, 'addNumbers').mockImplementationOnce(() => { | |||
throw new Error('Unexpected error'); | |||
}); | |||
const response = await cli.test().run(['add', '1', '2']); | |||
expect(response.exitCode).toBe(-3); | |||
}); | |||
it('returns error when given insufficient arguments', async () => { | |||
const response = await cli.test().run(['add', '1']); | |||
expect(response.exitCode).toBe(-1); | |||
}); | |||
describe('on interactive mode', () => { | |||
it('prompts values for insufficient arguments', async () => { | |||
const response = await cli | |||
.test() | |||
.promptValue({ | |||
a: '1', | |||
b: '2', | |||
}) | |||
.run(['add', '-i']); | |||
expect(response.exitCode).not.toBe(-1); | |||
}); | |||
}); | |||
}); |
@@ -0,0 +1,21 @@ | |||
{ | |||
"exclude": ["node_modules"], | |||
"include": ["src", "types"], | |||
"compilerOptions": { | |||
"module": "ESNext", | |||
"lib": ["ESNext"], | |||
"importHelpers": true, | |||
"declaration": true, | |||
"sourceMap": true, | |||
"rootDir": "./src", | |||
"strict": true, | |||
"noUnusedLocals": true, | |||
"noUnusedParameters": true, | |||
"noImplicitReturns": true, | |||
"noFallthroughCasesInSwitch": true, | |||
"moduleResolution": "node", | |||
"jsx": "react", | |||
"esModuleInterop": true, | |||
"target": "es2018" | |||
} | |||
} |
@@ -0,0 +1,107 @@ | |||
# Logs | |||
logs | |||
*.log | |||
npm-debug.log* | |||
yarn-debug.log* | |||
yarn-error.log* | |||
lerna-debug.log* | |||
# Diagnostic reports (https://nodejs.org/api/report.html) | |||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json | |||
# Runtime data | |||
pids | |||
*.pid | |||
*.seed | |||
*.pid.lock | |||
# Directory for instrumented libs generated by jscoverage/JSCover | |||
lib-cov | |||
# Coverage directory used by tools like istanbul | |||
coverage | |||
*.lcov | |||
# nyc test coverage | |||
.nyc_output | |||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) | |||
.grunt | |||
# Bower dependency directory (https://bower.io/) | |||
bower_components | |||
# node-waf configuration | |||
.lock-wscript | |||
# Compiled binary addons (https://nodejs.org/api/addons.html) | |||
build/Release | |||
# Dependency directories | |||
node_modules/ | |||
jspm_packages/ | |||
# TypeScript v1 declaration files | |||
typings/ | |||
# TypeScript cache | |||
*.tsbuildinfo | |||
# Optional npm cache directory | |||
.npm | |||
# Optional eslint cache | |||
.eslintcache | |||
# Microbundle cache | |||
.rpt2_cache/ | |||
.rts2_cache_cjs/ | |||
.rts2_cache_es/ | |||
.rts2_cache_umd/ | |||
# Optional REPL history | |||
.node_repl_history | |||
# Output of 'npm pack' | |||
*.tgz | |||
# Yarn Integrity file | |||
.yarn-integrity | |||
# dotenv environment variables file | |||
.env | |||
.env.production | |||
.env.development | |||
# parcel-bundler cache (https://parceljs.org/) | |||
.cache | |||
# Next.js build output | |||
.next | |||
# Nuxt.js build / generate output | |||
.nuxt | |||
dist | |||
# Gatsby files | |||
.cache/ | |||
# Comment in the public line in if your project uses Gatsby and *not* Next.js | |||
# https://nextjs.org/blog/next-9-1#public-directory-support | |||
# public | |||
# vuepress build output | |||
.vuepress/dist | |||
# Serverless directories | |||
.serverless/ | |||
# FuseBox cache | |||
.fusebox/ | |||
# DynamoDB Local files | |||
.dynamodb/ | |||
# TernJS port file | |||
.tern-port | |||
.npmrc |
@@ -0,0 +1,64 @@ | |||
{ | |||
"name": "@modal-sh/core", | |||
"version": "0.0.0", | |||
"files": [ | |||
"dist", | |||
"src" | |||
], | |||
"engines": { | |||
"node": ">=12" | |||
}, | |||
"license": "UNLICENSED", | |||
"keywords": [ | |||
"pridepack" | |||
], | |||
"devDependencies": { | |||
"@types/node": "^20.12.7", | |||
"pridepack": "2.6.0", | |||
"tslib": "^2.6.2", | |||
"typescript": "^5.4.5", | |||
"vitest": "^1.5.0" | |||
}, | |||
"scripts": { | |||
"prepublishOnly": "pridepack clean && pridepack build", | |||
"build": "pridepack build", | |||
"type-check": "pridepack check", | |||
"lint": "pridepack lint", | |||
"clean": "pridepack clean", | |||
"watch": "pridepack watch", | |||
"start": "pridepack start", | |||
"dev": "pridepack dev", | |||
"test": "vitest" | |||
}, | |||
"private": true, | |||
"description": "Core library.", | |||
"repository": { | |||
"url": "", | |||
"type": "git" | |||
}, | |||
"homepage": "", | |||
"bugs": { | |||
"url": "" | |||
}, | |||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||
"publishConfig": { | |||
"access": "restricted" | |||
}, | |||
"types": "./dist/types/index.d.ts", | |||
"main": "./dist/cjs/production/index.js", | |||
"module": "./dist/esm/production/index.js", | |||
"exports": { | |||
".": { | |||
"development": { | |||
"require": "./dist/cjs/development/index.js", | |||
"import": "./dist/esm/development/index.js" | |||
}, | |||
"require": "./dist/cjs/production/index.js", | |||
"import": "./dist/esm/production/index.js", | |||
"types": "./dist/types/index.d.ts" | |||
} | |||
}, | |||
"typesVersions": { | |||
"*": {} | |||
} | |||
} |
@@ -0,0 +1,3 @@ | |||
{ | |||
"target": "es2018" | |||
} |
@@ -0,0 +1,34 @@ | |||
export interface AddFunctionOptions { | |||
a: number; | |||
b: number; | |||
} | |||
export interface AddFunction { | |||
(options: AddFunctionOptions): number; | |||
} | |||
export class InvalidArgumentTypeError extends TypeError { | |||
constructor(message: string) { | |||
super(message); | |||
this.name = 'InvalidArgumentTypeError'; | |||
} | |||
} | |||
export class ArgumentOutOfRangeError extends RangeError { | |||
constructor(message: string) { | |||
super(message); | |||
this.name = 'ArgumentOutOfRangeError'; | |||
} | |||
} | |||
export const add: AddFunction = (options: AddFunctionOptions): number => { | |||
const { a, b } = options as unknown as Record<string, unknown>; | |||
if (typeof a !== 'number' || typeof b !== 'number') { | |||
throw new InvalidArgumentTypeError('a and b must be numbers'); | |||
} | |||
if (!Number.isFinite(a) || !Number.isFinite(b)) { | |||
throw new ArgumentOutOfRangeError('a and b must be finite numbers'); | |||
} | |||
return a + b; | |||
}; |
@@ -0,0 +1,16 @@ | |||
import { describe, it, expect } from 'vitest'; | |||
import { add } from '../src'; | |||
describe('core', () => { | |||
it('returns result', () => { | |||
expect(add({ a: 1, b: 1 })).toEqual(2); | |||
}); | |||
it('throws when given invalid argument types', () => { | |||
expect(() => add({ a: '1' as unknown as number, b: 1 })).toThrow(TypeError); | |||
}); | |||
it('throws when given out-of-range arguments', () => { | |||
expect(() => add({ a: Infinity, b: 1 })).toThrow(RangeError); | |||
}); | |||
}); |
@@ -0,0 +1,21 @@ | |||
{ | |||
"exclude": ["node_modules"], | |||
"include": ["src", "types"], | |||
"compilerOptions": { | |||
"module": "ESNext", | |||
"lib": ["ESNext"], | |||
"importHelpers": true, | |||
"declaration": true, | |||
"sourceMap": true, | |||
"rootDir": "./src", | |||
"strict": true, | |||
"noUnusedLocals": true, | |||
"noUnusedParameters": true, | |||
"noImplicitReturns": true, | |||
"noFallthroughCasesInSwitch": true, | |||
"moduleResolution": "node", | |||
"jsx": "react", | |||
"esModuleInterop": true, | |||
"target": "es2018" | |||
} | |||
} |
@@ -0,0 +1,107 @@ | |||
# Logs | |||
logs | |||
*.log | |||
npm-debug.log* | |||
yarn-debug.log* | |||
yarn-error.log* | |||
lerna-debug.log* | |||
# Diagnostic reports (https://nodejs.org/api/report.html) | |||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json | |||
# Runtime data | |||
pids | |||
*.pid | |||
*.seed | |||
*.pid.lock | |||
# Directory for instrumented libs generated by jscoverage/JSCover | |||
lib-cov | |||
# Coverage directory used by tools like istanbul | |||
coverage | |||
*.lcov | |||
# nyc test coverage | |||
.nyc_output | |||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) | |||
.grunt | |||
# Bower dependency directory (https://bower.io/) | |||
bower_components | |||
# node-waf configuration | |||
.lock-wscript | |||
# Compiled binary addons (https://nodejs.org/api/addons.html) | |||
build/Release | |||
# Dependency directories | |||
node_modules/ | |||
jspm_packages/ | |||
# TypeScript v1 declaration files | |||
typings/ | |||
# TypeScript cache | |||
*.tsbuildinfo | |||
# Optional npm cache directory | |||
.npm | |||
# Optional eslint cache | |||
.eslintcache | |||
# Microbundle cache | |||
.rpt2_cache/ | |||
.rts2_cache_cjs/ | |||
.rts2_cache_es/ | |||
.rts2_cache_umd/ | |||
# Optional REPL history | |||
.node_repl_history | |||
# Output of 'npm pack' | |||
*.tgz | |||
# Yarn Integrity file | |||
.yarn-integrity | |||
# dotenv environment variables file | |||
.env | |||
.env.production | |||
.env.development | |||
# parcel-bundler cache (https://parceljs.org/) | |||
.cache | |||
# Next.js build output | |||
.next | |||
# Nuxt.js build / generate output | |||
.nuxt | |||
dist | |||
# Gatsby files | |||
.cache/ | |||
# Comment in the public line in if your project uses Gatsby and *not* Next.js | |||
# https://nextjs.org/blog/next-9-1#public-directory-support | |||
# public | |||
# vuepress build output | |||
.vuepress/dist | |||
# Serverless directories | |||
.serverless/ | |||
# FuseBox cache | |||
.fusebox/ | |||
# DynamoDB Local files | |||
.dynamodb/ | |||
# TernJS port file | |||
.tern-port | |||
.npmrc |
@@ -0,0 +1,68 @@ | |||
{ | |||
"name": "@modal-sh/web-api", | |||
"version": "0.0.0", | |||
"files": [ | |||
"dist", | |||
"src" | |||
], | |||
"engines": { | |||
"node": ">=12" | |||
}, | |||
"keywords": [ | |||
"pridepack" | |||
], | |||
"devDependencies": { | |||
"@types/node": "^20.12.7", | |||
"pridepack": "2.6.0", | |||
"tslib": "^2.6.2", | |||
"typescript": "^5.4.5", | |||
"vite": "^5.2.10", | |||
"vitest": "^1.5.0" | |||
}, | |||
"dependencies": { | |||
"@modal-sh/core": "workspace:*", | |||
"fastify": "^4.26.2" | |||
}, | |||
"scripts": { | |||
"prepublishOnly": "pridepack clean && pridepack build", | |||
"build": "pridepack build", | |||
"type-check": "pridepack check", | |||
"lint": "pridepack lint", | |||
"clean": "pridepack clean", | |||
"watch": "pridepack watch", | |||
"start": "pridepack start", | |||
"dev": "pridepack dev", | |||
"test": "vitest" | |||
}, | |||
"private": true, | |||
"description": "Web API.", | |||
"repository": { | |||
"url": "", | |||
"type": "git" | |||
}, | |||
"homepage": "", | |||
"bugs": { | |||
"url": "" | |||
}, | |||
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>", | |||
"publishConfig": { | |||
"access": "restricted" | |||
}, | |||
"types": "./dist/types/index.d.ts", | |||
"main": "./dist/cjs/production/index.js", | |||
"module": "./dist/esm/production/index.js", | |||
"exports": { | |||
".": { | |||
"development": { | |||
"require": "./dist/cjs/development/index.js", | |||
"import": "./dist/esm/development/index.js" | |||
}, | |||
"require": "./dist/cjs/production/index.js", | |||
"import": "./dist/esm/production/index.js", | |||
"types": "./dist/types/index.d.ts" | |||
} | |||
}, | |||
"typesVersions": { | |||
"*": {} | |||
} | |||
} |
@@ -0,0 +1,3 @@ | |||
{ | |||
"target": "es2018" | |||
} |
@@ -0,0 +1,7 @@ | |||
export namespace meta { | |||
export const host = process.env.HOST ?? '0.0.0.0'; | |||
export const port = Number(process.env.PORT ?? 8080); | |||
export const env = process.env.NODE_ENV ?? 'development'; | |||
} |
@@ -0,0 +1,20 @@ | |||
import { createServer } from './server'; | |||
import { addRoutes } from './routes'; | |||
import * as config from './config'; | |||
const server = createServer({ | |||
logger: config.meta.env !== 'test', | |||
}); | |||
addRoutes(server); | |||
server.listen( | |||
{ port: config.meta.port, host: config.meta.host }, | |||
(err, address) => { | |||
if (err) { | |||
server.log.error(err.message); | |||
process.exit(1); | |||
} | |||
server.log.info(`server listening on ${address}`); | |||
}, | |||
); |
@@ -0,0 +1,60 @@ | |||
import { RouteHandlerMethod } from 'fastify'; | |||
import { | |||
AdderService, | |||
AdderServiceImpl, | |||
ArgumentOutOfRangeError, | |||
InvalidArgumentTypeError, | |||
} from './adder.service'; | |||
import { constants } from 'http2'; | |||
export interface AdderController { | |||
addNumbers: RouteHandlerMethod; | |||
subtractNumbers: RouteHandlerMethod; | |||
} | |||
export class AdderControllerImpl implements AdderController { | |||
constructor( | |||
private readonly adderService: AdderService = new AdderServiceImpl(), | |||
) { | |||
// noop | |||
} | |||
readonly addNumbers: RouteHandlerMethod = async (request, reply) => { | |||
const { a, b } = request.body as { a: number; b: number }; | |||
try { | |||
const response = this.adderService.addNumbers({ a, b }); | |||
reply.send(response); | |||
} catch (errorRaw) { | |||
if (errorRaw instanceof InvalidArgumentTypeError) { | |||
request.log.info(errorRaw); | |||
reply.status(constants.HTTP_STATUS_BAD_REQUEST).send(errorRaw.message); | |||
return; | |||
} | |||
if (errorRaw instanceof ArgumentOutOfRangeError) { | |||
reply.status(constants.HTTP_STATUS_BAD_REQUEST).send(errorRaw.message); | |||
return; | |||
} | |||
const error = errorRaw as Error; | |||
reply.status(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR).send(error.message); | |||
} | |||
} | |||
readonly subtractNumbers: RouteHandlerMethod = async (request, reply) => { | |||
const { a, b } = request.body as { a: number; b: number }; | |||
try { | |||
const response = this.adderService.addNumbers({ a, b: -b }); | |||
reply.send(response); | |||
} catch (errorRaw) { | |||
if (errorRaw instanceof InvalidArgumentTypeError) { | |||
reply.status(constants.HTTP_STATUS_BAD_REQUEST).send(errorRaw.message); | |||
return; | |||
} | |||
if (errorRaw instanceof ArgumentOutOfRangeError) { | |||
reply.status(constants.HTTP_STATUS_BAD_REQUEST).send(errorRaw.message); | |||
return; | |||
} | |||
const error = errorRaw as Error; | |||
reply.status(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR).send(error.message); | |||
} | |||
} | |||
} |
@@ -0,0 +1,21 @@ | |||
import { | |||
add, | |||
AddFunctionOptions, | |||
ArgumentOutOfRangeError, | |||
InvalidArgumentTypeError, | |||
} from '@modal-sh/core'; | |||
export interface AdderService { | |||
addNumbers(options: AddFunctionOptions): number; | |||
} | |||
export class AdderServiceImpl implements AdderService { | |||
addNumbers(options: AddFunctionOptions) { | |||
return add(options); | |||
} | |||
} | |||
export { | |||
ArgumentOutOfRangeError, | |||
InvalidArgumentTypeError, | |||
}; |
@@ -0,0 +1,2 @@ | |||
export * from './adder.controller'; | |||
export * from './adder.service'; |
@@ -0,0 +1,36 @@ | |||
import { FastifyInstance } from 'fastify'; | |||
import { AdderController, AdderControllerImpl } from './modules/adder'; | |||
export const addRoutes = (server: FastifyInstance) => { | |||
const adderController: AdderController = new AdderControllerImpl(); | |||
return server | |||
.route({ | |||
method: 'POST', | |||
url: '/add', | |||
schema: { | |||
body: { | |||
type: 'object', | |||
properties: { | |||
a: { type: 'number' }, | |||
b: { type: 'number' }, | |||
} | |||
} | |||
}, | |||
handler: adderController.addNumbers, | |||
}) | |||
.route({ | |||
method: 'POST', | |||
url: '/subtract', | |||
schema: { | |||
body: { | |||
type: 'object', | |||
properties: { | |||
a: { type: 'number' }, | |||
b: { type: 'number' }, | |||
} | |||
} | |||
}, | |||
handler: adderController.subtractNumbers, | |||
}); | |||
}; |
@@ -0,0 +1,11 @@ | |||
import fastify, { FastifyServerOptions } from 'fastify'; | |||
export interface CreateServerOptions extends FastifyServerOptions {} | |||
export const createServer = (options = {} as CreateServerOptions) => { | |||
const server = fastify(options); | |||
// TODO set up server stuff here | |||
return server; | |||
}; |
@@ -0,0 +1,157 @@ | |||
import { FastifyInstance } from 'fastify'; | |||
import { | |||
describe, | |||
it, | |||
expect, | |||
beforeAll, | |||
afterAll, | |||
vi, | |||
} from 'vitest'; | |||
import { constants } from 'http2'; | |||
import { createServer } from '../src/server'; | |||
import { addRoutes } from '../src/routes'; | |||
import { | |||
AdderServiceImpl, | |||
ArgumentOutOfRangeError, | |||
InvalidArgumentTypeError, | |||
} from '../src/modules/adder'; | |||
describe('add', () => { | |||
let server: FastifyInstance; | |||
const body = { a: 1, b: 2 }; | |||
beforeAll(() => { | |||
server = createServer(); | |||
addRoutes(server); | |||
}); | |||
afterAll(async () => { | |||
await server.close(); | |||
}); | |||
it('returns result when successful', async () => { | |||
const response = await server | |||
.inject() | |||
.post('/add') | |||
.body(body) | |||
.headers({ | |||
'Accept': 'application/json', | |||
}); | |||
expect(response.statusCode).toBe(constants.HTTP_STATUS_OK); | |||
}); | |||
it('returns error when given invalid inputs', async () => { | |||
vi.spyOn(AdderServiceImpl.prototype, 'addNumbers').mockImplementationOnce(() => { | |||
throw new InvalidArgumentTypeError('Invalid input'); | |||
}); | |||
const response = await server | |||
.inject() | |||
.post('/add') | |||
.body(body) | |||
.headers({ | |||
'Accept': 'application/json', | |||
}); | |||
expect(response.statusCode).toBe(constants.HTTP_STATUS_BAD_REQUEST); | |||
}); | |||
it('returns error when given out-of-range inputs', async () => { | |||
vi.spyOn(AdderServiceImpl.prototype, 'addNumbers').mockImplementationOnce(() => { | |||
throw new ArgumentOutOfRangeError('Out of range'); | |||
}); | |||
const response = await server | |||
.inject() | |||
.post('/add') | |||
.body(body) | |||
.headers({ | |||
'Accept': 'application/json', | |||
}); | |||
expect(response.statusCode).toBe(constants.HTTP_STATUS_BAD_REQUEST); | |||
}); | |||
it('returns error when an unexpected error occurs', async () => { | |||
vi.spyOn(AdderServiceImpl.prototype, 'addNumbers').mockImplementationOnce(() => { | |||
throw new Error('Unexpected error'); | |||
}); | |||
const response = await server | |||
.inject() | |||
.post('/add') | |||
.body({ a: 1, b: 2 }) | |||
.headers({ | |||
'Accept': 'application/json', | |||
}); | |||
expect(response.statusCode).toBe(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||
}); | |||
}); | |||
describe('subtract', () => { | |||
let server: FastifyInstance; | |||
const body = { a: 1, b: 2 }; | |||
beforeAll(() => { | |||
server = createServer(); | |||
addRoutes(server); | |||
}); | |||
afterAll(async () => { | |||
await server.close(); | |||
}); | |||
it('returns result when successful', async () => { | |||
const response = await server | |||
.inject() | |||
.post('/subtract') | |||
.body(body) | |||
.headers({ | |||
'Accept': 'application/json', | |||
}); | |||
expect(response.statusCode).toBe(constants.HTTP_STATUS_OK); | |||
}); | |||
it('returns error when given invalid inputs', async () => { | |||
vi.spyOn(AdderServiceImpl.prototype, 'addNumbers').mockImplementationOnce(() => { | |||
throw new InvalidArgumentTypeError('Invalid input'); | |||
}); | |||
const response = await server | |||
.inject() | |||
.post('/subtract') | |||
.body(body) | |||
.headers({ | |||
'Accept': 'application/json', | |||
}); | |||
expect(response.statusCode).toBe(constants.HTTP_STATUS_BAD_REQUEST); | |||
}); | |||
it('returns error when given out-of-range inputs', async () => { | |||
vi.spyOn(AdderServiceImpl.prototype, 'addNumbers').mockImplementationOnce(() => { | |||
throw new ArgumentOutOfRangeError('Out of range'); | |||
}); | |||
const response = await server | |||
.inject() | |||
.post('/subtract') | |||
.body(body) | |||
.headers({ | |||
'Accept': 'application/json', | |||
}); | |||
expect(response.statusCode).toBe(constants.HTTP_STATUS_BAD_REQUEST); | |||
}); | |||
it('returns error when an unexpected error occurs', async () => { | |||
vi.spyOn(AdderServiceImpl.prototype, 'addNumbers').mockImplementationOnce(() => { | |||
throw new Error('Unexpected error'); | |||
}); | |||
const response = await server | |||
.inject() | |||
.post('/subtract') | |||
.body({ a: 1, b: 2 }) | |||
.headers({ | |||
'Accept': 'application/json', | |||
}); | |||
expect(response.statusCode).toBe(constants.HTTP_STATUS_INTERNAL_SERVER_ERROR); | |||
}); | |||
}); |
@@ -0,0 +1,21 @@ | |||
{ | |||
"exclude": ["node_modules"], | |||
"include": ["src", "types"], | |||
"compilerOptions": { | |||
"module": "ESNext", | |||
"lib": ["ESNext"], | |||
"importHelpers": true, | |||
"declaration": true, | |||
"sourceMap": true, | |||
"rootDir": "./src", | |||
"strict": true, | |||
"noUnusedLocals": true, | |||
"noUnusedParameters": true, | |||
"noImplicitReturns": true, | |||
"noFallthroughCasesInSwitch": true, | |||
"moduleResolution": "node", | |||
"jsx": "react", | |||
"esModuleInterop": true, | |||
"target": "es2018" | |||
} | |||
} |
@@ -0,0 +1,2 @@ | |||
packages: | |||
- 'packages/*' |