Selaa lähdekoodia

Make API consistent

Make the setup consistent between CLI and API.
master
TheoryOfNekomata 1 vuosi sitten
vanhempi
commit
caca1e5b47
11 muutettua tiedostoa jossa 324 lisäystä ja 356 poistoa
  1. +1
    -0
      .gitignore
  2. +0
    -8
      .idea/.gitignore
  3. +0
    -13
      .idea/backend-template.iml
  4. +0
    -8
      .idea/modules.xml
  5. +0
    -6
      .idea/vcs.xml
  6. +5
    -293
      packages/cli/src/cli.ts
  7. +2
    -4
      packages/cli/src/commands.ts
  8. +0
    -16
      packages/cli/src/common.ts
  9. +1
    -1
      packages/cli/src/modules/adder/adder.controller.ts
  10. +309
    -0
      packages/cli/src/packages/cli-wrapper.ts
  11. +6
    -7
      packages/web-api/src/routes.ts

+ 1
- 0
.gitignore Näytä tiedosto

@@ -1,2 +1,3 @@
node_modules
.DS_Store
.idea/

+ 0
- 8
.idea/.gitignore Näytä tiedosto

@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

+ 0
- 13
.idea/backend-template.iml Näytä tiedosto

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/packages/core/dist" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

+ 0
- 8
.idea/modules.xml Näytä tiedosto

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/backend-template.iml" filepath="$PROJECT_DIR$/.idea/backend-template.iml" />
</modules>
</component>
</project>

+ 0
- 6
.idea/vcs.xml Näytä tiedosto

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

+ 5
- 293
packages/cli/src/cli.ts Näytä tiedosto

@@ -1,297 +1,9 @@
import yargs, {Argv} from 'yargs';
import * as clack from '@clack/prompts';
import * as tty from 'tty';
import { CommandHandler } from './common';
import { Cli, CliOptions } from './packages/cli-wrapper';

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[]];

class DummyWriteStream extends tty.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;
}
}

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

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 generateLogger() {
return {
log: (message: unknown) => {
const stdout = this.testMode ? this.testModeResult.stdout : process.stdout;
stdout.write(`${message?.toString()}\n`);
},
warn: (message: unknown) => {
const stdout = this.testMode ? this.testModeResult.stdout : process.stdout;
stdout.write(`WARN: ${message?.toString()}\n`);
},
debug: (message: unknown) => {
const stdout = this.testMode ? this.testModeResult.stdout : process.stdout;
stdout.write(`DEBUG: ${message?.toString()}\n`);
},
error: (message: unknown) => {
const stderr = this.testMode ? this.testModeResult.stderr : process.stderr;
stderr.write(`${message?.toString()}\n`);
},
};
}

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

let exited = false;
const returnCode = await handlerFn({
self,
interactive,
logger: thisCli.generateLogger(),
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 {
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;
});
}
export const createCli = (options: CliOptions) => {
const cli = new Cli(options);

return this;
},
async run(args: string[]): Promise<TestModeResult> {
await thisCli.cli.parse(args);
return thisCli.testModeResult;
},
};
}
}
// TODO setup CLI here

export const createCli = (options: CliOptions) => {
return new Cli(options);
return cli;
};

+ 2
- 4
packages/cli/src/commands.ts Näytä tiedosto

@@ -1,10 +1,10 @@
import { Cli } from './cli';
import { Cli } from './packages/cli-wrapper';
import { AdderController, AdderControllerImpl } from './modules/adder';

export const addCommands = (cli: Cli) => {
const adderController: AdderController = new AdderControllerImpl();

cli
return cli
.command({
aliases: ['a'],
command: 'add',
@@ -19,6 +19,4 @@ export const addCommands = (cli: Cli) => {
describe: 'Subtract two numbers',
handler: adderController.subtractNumbers,
});

return cli;
};

+ 0
- 16
packages/cli/src/common.ts Näytä tiedosto

@@ -1,16 +0,0 @@
export interface Logger {
log: (message: unknown) => void;
error: (message: unknown) => void;
warn: (message: unknown) => void;
debug: (message: unknown) => void;
}

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

+ 1
- 1
packages/cli/src/modules/adder/adder.controller.ts Näytä tiedosto

@@ -4,7 +4,7 @@ import {
ArgumentOutOfRangeError,
InvalidArgumentTypeError,
} from './adder.service';
import {CommandHandler} from '@/common';
import { CommandHandler } from '../../packages/cli-wrapper';

export interface AdderController {
addNumbers: CommandHandler;


+ 309
- 0
packages/cli/src/packages/cli-wrapper.ts Näytä tiedosto

@@ -0,0 +1,309 @@
import tty from 'tty';
import yargs, {Argv} from 'yargs';
import * as clack from '@clack/prompts';

export interface Logger {
log: (message: unknown) => void;
error: (message: unknown) => void;
warn: (message: unknown) => void;
debug: (message: unknown) => void;
}

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[]];

class DummyWriteStream extends tty.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;
}
}

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

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 generateLogger() {
return {
log: (message: unknown) => {
const stdout = this.testMode ? this.testModeResult.stdout : process.stdout;
stdout.write(`${message?.toString()}\n`);
},
warn: (message: unknown) => {
const stdout = this.testMode ? this.testModeResult.stdout : process.stdout;
stdout.write(`WARN: ${message?.toString()}\n`);
},
debug: (message: unknown) => {
const stdout = this.testMode ? this.testModeResult.stdout : process.stdout;
stdout.write(`DEBUG: ${message?.toString()}\n`);
},
error: (message: unknown) => {
const stderr = this.testMode ? this.testModeResult.stderr : process.stderr;
stderr.write(`${message?.toString()}\n`);
},
};
}

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

let exited = false;
const returnCode = await handlerFn({
self,
interactive,
logger: thisCli.generateLogger(),
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 {
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;
},
};
}
}

+ 6
- 7
packages/web-api/src/routes.ts Näytä tiedosto

@@ -4,11 +4,10 @@ import { AdderController, AdderControllerImpl } from '@/modules/adder';
export const addRoutes = (server: FastifyInstance) => {
const adderController: AdderController = new AdderControllerImpl();

server.route({
method: 'POST',
url: '/',
handler: adderController.addNumbers,
});

return server;
return server
.route({
method: 'POST',
url: '/',
handler: adderController.addNumbers,
});
};

||||||
x
 
000:0
Ladataan…
Peruuta
Tallenna