Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: Move CLI services into classes #1355

Draft
wants to merge 20 commits into
base: main
Choose a base branch
from
4 changes: 3 additions & 1 deletion booster.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,7 @@
"path": "tools/eslint-config"
}
],
"settings": {}
"settings": {
"cSpell.words": ["dangerize", "denormalized", "mellancholize", "mocharc", "rushx"]
}
}
2,667 changes: 1,405 additions & 1,262 deletions common/config/rush/pnpm-lock.yaml

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions packages/application-tester/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@
"tslib": "^2.4.0",
"ws": "7.4.5",
"@types/sinon": "10.0.0",
"sinon": "9.2.3",
"@effect-ts/core": "^0.60.4"
"sinon": "9.2.3"
},
"devDependencies": {
"@boostercloud/eslint-config": "workspace:^1.7.0",
Expand Down
147 changes: 73 additions & 74 deletions packages/application-tester/src/effect/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { Effect, Has, Layer, succeedWith, Tag } from '@boostercloud/framework-types/dist/effect'
import { ShapeFn } from '@effect-ts/core/Effect'
import { SinonSpy } from 'sinon'
// import { ShapeFn } from '@effect-ts/core/Effect'
// import { SinonSpy } from 'sinon'

/*
* This module exposed testing utilities for working with Effect services in tests.
*
* If you don't even know what Effect is, you probably should start by reading the docs
* in the `@boostercloud/framework-types/dist/effect` module.
* in the `@boostercloud/@boostercloud/framework-types/effect` module.
*
* The key idea is that you can create a mock service that can be used in tests
* instead of the real service. This allows you to test your code without
Expand Down Expand Up @@ -75,83 +74,83 @@ import { SinonSpy } from 'sinon'
* @param fakes - A record of the methods to fake. E.g. `{ cwd: fake.returns(''), exec: fake.returns('') }`.
* @return {FakeServiceUtils} - An object with the layer to use in the dependency graph, and the fakes to assert the service was called with the right parameters.
*/
export const fakeService = <T extends ShapeFn<T>>(tag: Tag<T>, fakes: Fakes<T>): FakeServiceUtils<T> => {
// Assemble the fakes into a service that returns Effects in its functions.
// We disable the `any` warning because at this point the record is empty and TS will complain
// while we assemble it. The alternative was to use a `reduce` method, but the code became much more
// contrived, while not getting too much more type safety, as all the objects were of type unknown and we
// had to cast them explicitly
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fakeService = {} as any
// export const fakeService = <T extends ShapeFn<T>>(tag: Tag<T>, fakes: Fakes<T>): FakeServiceUtils<T> => {
// // Assemble the fakes into a service that returns Effects in its functions.
// // We disable the `any` warning because at this point the record is empty and TS will complain
// // while we assemble it. The alternative was to use a `reduce` method, but the code became much more
// // contrived, while not getting too much more type safety, as all the objects were of type unknown and we
// // had to cast them explicitly
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// const fakeService = {} as any

// Object.entries doesn't type properly the keys an values, it returns always string and unknown (yay!)
for (const [k, v] of Object.entries(fakes)) {
// We cast the value to a SinonSpy, as we know that's what we're passing in the `fakes` record.
// We don't really need to type the arguments or the return type, as that is typed by the return type
// of this function.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fakeFunction = v as SinonSpy<any[], any>
// // Object.entries doesn't type properly the keys an values, it returns always string and unknown (yay!)
// for (const [k, v] of Object.entries(fakes)) {
// // We cast the value to a SinonSpy, as we know that's what we're passing in the `fakes` record.
// // We don't really need to type the arguments or the return type, as that is typed by the return type
// // of this function.
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// const fakeFunction = v as SinonSpy<any[], any>

// We wrap the fake function in an Effect, so the layer can be used by the functions that require this
// service. We don't need to type the arguments or the return type, as that is typed by the return type.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fakeService[k] = (...args: any[]) => succeedWith(() => fakeFunction(...args))
}
// // We wrap the fake function in an Effect, so the layer can be used by the functions that require this
// // service. We don't need to type the arguments or the return type, as that is typed by the return type.
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// fakeService[k] = (...args: any[]) => succeedWith(() => fakeFunction(...args))
// }

// Create a layer with that service as the only dependency, we can combine it with other layers using
// `Layer.all` in the tests.
const layer = Layer.fromValue(tag)(fakeService)
// // Create a layer with that service as the only dependency, we can combine it with other layers using
// // `Layer.all` in the tests.
// const layer = Layer.fromValue(tag)(fakeService)

// Create a reset function to reset all the fakes
const reset = () => {
for (const f of Object.values(fakes)) {
const fake = f as EffectSpy<T, keyof T>
fake.resetHistory()
}
}
// // Create a reset function to reset all the fakes
// const reset = () => {
// for (const f of Object.values(fakes)) {
// const fake = f as EffectSpy<T, keyof T>
// fake.resetHistory()
// }
// }

// Return the layer, fakes, and reset function
return { layer, fakes, reset }
}
// // Return the layer, fakes, and reset function
// return { layer, fakes, reset }
// }

/**
* Utils to mock an entire service, without having to wire up the whole dependency graph.
* This is useful for unit testing, but not for integration testing.
*
* @typedef {Object} FakeServiceUtils
* @property {Layer} layer - The layer that can be used to replace the service in the dependency graph
* @property {Record<string, SinonSpy>} fakes - The fakes that can be used to assert the service was called with the right parameters
* @property {() => void} reset - A function to reset all the fakes
*/
export type FakeServiceUtils<T extends ShapeFn<T>> = {
layer: Layer.Layer<unknown, never, Has<T>>
fakes: Fakes<T>
reset: () => void
}
// /**
// * Utils to mock an entire service, without having to wire up the whole dependency graph.
// * This is useful for unit testing, but not for integration testing.
// *
// * @typedef {Object} FakeServiceUtils
// * @property {Layer} layer - The layer that can be used to replace the service in the dependency graph
// * @property {Record<string, SinonSpy>} fakes - The fakes that can be used to assert the service was called with the right parameters
// * @property {() => void} reset - A function to reset all the fakes
// */
// export type FakeServiceUtils<T extends ShapeFn<T>> = {
// layer: Layer.Layer<unknown, never, Has<T>>
// fakes: Fakes<T>
// reset: () => void
// }

/**
* Gets the result type from an Effect
*/
type EffectResult<T> =
// Disabling `any` warning because we won't be exposing this type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends Effect<any, any, infer A> ? A : never
// /**
// * Gets the result type from an Effect
// */
// type EffectResult<T> =
// // Disabling `any` warning because we won't be exposing this type
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// T extends Effect<any, any, infer A> ? A : never

/**
* Type to pass fakes on the creation of a fake service.
*/
type Fakes<T extends ShapeFn<T>> = {
[key in keyof T]: EffectSpy<T, key>
}
// /**
// * Type to pass fakes on the creation of a fake service.
// */
// type Fakes<T extends ShapeFn<T>> = {
// [key in keyof T]: EffectSpy<T, key>
// }

/**
* Allows overriding fakes in test service generators
*/
export type FakeOverrides<T extends ShapeFn<T>> = Partial<Fakes<T>>
// /**
// * Allows overriding fakes in test service generators
// */
// export type FakeOverrides<T extends ShapeFn<T>> = Partial<Fakes<T>>

/**
* Spy for a specific service function
*/
type EffectSpy<T extends ShapeFn<T>, key extends keyof T> =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
SinonSpy<any[], EffectResult<ReturnType<T[key]>>>
// /**
// * Spy for a specific service function
// */
// type EffectSpy<T extends ShapeFn<T>, key extends keyof T> =
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// SinonSpy<any[], EffectResult<ReturnType<T[key]>>>
17 changes: 12 additions & 5 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,23 @@
"chalk": "^2.4.2",
"child-process-promise": "^2.2.1",
"execa": "^2.0.3",
"fp-ts": "^2.11.0",
"fs-extra": "^8.1.0",
"inflected": "2.1.0",
"inquirer": "^7.0.0",
"mustache": "4.1.0",
"ora": "^3.4.0",
"ts-morph": "15.1.0",
"tslib": "^2.4.0",
"@effect-ts/core": "^0.60.4"
"semver": "^7.3.5",
"winston": "~3.8.2",
"diod": "~2.0.0",
"ts-pattern": "~4.2.1",
"@oclif/help": "~1.0.5"
},
"devDependencies": {
"@boostercloud/eslint-config": "workspace:^1.7.0",
"@boostercloud/application-tester": "workspace:^1.7.0",
"@boostercloud/metadata-booster": "workspace:^1.7.0",
"@oclif/dev-cli": "^1.26",
"@oclif/test": "^2.1",
"@types/chai": "4.2.18",
Expand Down Expand Up @@ -68,9 +72,12 @@
"rimraf": "3.0.2",
"sinon": "9.2.3",
"sinon-chai": "3.5.0",
"reflect-metadata": "0.1.13",
"ts-node": "^10.9.1",
"typescript": "4.7.4",
"eslint-plugin-unicorn": "~44.0.2"
"ttypescript": "1.5.13",
"eslint-plugin-unicorn": "~44.0.2",
"@types/semver": "5.5.0"
},
"engines": {
"node": ">=14.0.0"
Expand Down Expand Up @@ -98,9 +105,9 @@
"format": "prettier --write --ext '.js,.ts' **/*.ts **/*/*.ts",
"lint:check": "eslint --ext '.js,.ts' **/*.ts",
"lint:fix": "eslint --quiet --fix --ext '.js,.ts' **/*.ts",
"build": "tsc -b tsconfig.json && copyfiles -f src/templates/*.stub dist/templates",
"build": "ttsc -b tsconfig.json && copyfiles -f src/templates/*.stub dist/templates",
"clean": "rimraf ./dist tsconfig.tsbuildinfo",
"prepack": "tsc -b tsconfig.json",
"prepack": "ttsc -b tsconfig.json",
"test:cli": "npm run test",
"test": "nyc --extension .ts mocha --forbid-only \"test/**/*.test.ts\""
},
Expand Down
56 changes: 29 additions & 27 deletions packages/cli/src/commands/add/projection.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,33 @@
import * as Oclif from '@oclif/command'
import BaseCommand from '../../common/base-command'
import { HasName, HasProjection, joinParsers, parseName, parseProjectionField } from '../../services/generator/target'
import { Script } from '../../common/script'
import { BaseCommand, CliCommand, Flags } from '../../common/base-command'
import {
HasName,
HasProjection,
joinParsers,
parseName,
parseProjectionField,
} from '../../services/file-generator/target'
import Brand from '../../common/brand'
import { checkCurrentDirIsABoosterProject } from '../../services/project-checker'
import { generateProjection, getResourceSourceFile } from '../../services/method-generator'
import { Logger } from '@boostercloud/framework-types'
import { UserProject } from '../../services/user-project'
import { TaskLogger } from '../../services/task-logger'

export default class Projection extends BaseCommand {
public static description = 'add new projection to read model'
@CliCommand()
class Implementation {
constructor(readonly logger: Logger, readonly userProject: UserProject, readonly taskLogger: TaskLogger) {}

async run(flags: Flags<typeof Projection>): Promise<void> {
const readModelName = flags['read-model']
const projectionName = flags.entity
this.logger.info(`boost ${Brand.energize('add:projection')} 🚀`)
const templateInfo = await joinParsers(parseName(readModelName), parseProjectionField(projectionName))
await this.userProject.performChecks()
await this.taskLogger.logTask('Generating projection', () => generateProjectionMethod(templateInfo))
}
}
export default class Projection extends BaseCommand<typeof Projection> {
public static description = 'add new projection to a read model class'

static usage = 'projection --read-model ReadModel --entity Entity:id'

Expand All @@ -17,13 +37,13 @@ export default class Projection extends BaseCommand {
]

public static flags = {
help: Oclif.flags.help({ char: 'h' }),
'read-model': Oclif.flags.string({
description: 'read-model name',
required: true,
multiple: false,
dependsOn: ['entity'],
}),

entity: Oclif.flags.string({
description: 'an entity name',
required: true,
Expand All @@ -32,28 +52,10 @@ export default class Projection extends BaseCommand {
}),
}

public async run(): Promise<void> {
const { flags } = this.parse(Projection)
const readModel = flags['read-model']
const entity = flags.entity

return run(readModel, entity)
}
implementation = Implementation
}

type ProjectionInfo = HasName & HasProjection

const run = async (rawReadModel: string, rawProjection: string): Promise<void> =>
Script.init(
`boost ${Brand.energize('add:projection')} 🚧`,
joinParsers(parseName(rawReadModel), parseProjectionField(rawProjection))
)
.step('Verifying project', checkCurrentDirIsABoosterProject)
.step('Generating projection', generateProjectionMethod)
.info('Projection generated!')
.done()

async function generateProjectionMethod(info: ProjectionInfo): Promise<void> {
async function generateProjectionMethod(info: HasName & HasProjection): Promise<void> {
const readModelSourceFile = getResourceSourceFile(info.name)
const readModelClass = readModelSourceFile.getClassOrThrow(info.name)

Expand Down
45 changes: 22 additions & 23 deletions packages/cli/src/commands/add/reducer.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import * as Oclif from '@oclif/command'
import BaseCommand from '../../common/base-command'
import { HasName, HasReaction, joinParsers, parseName, parseReaction } from '../../services/generator/target'
import { Script } from '../../common/script'
import { BaseCommand, CliCommand, Flags } from '../../common/base-command'
import { HasName, HasReaction, joinParsers, parseName, parseReaction } from '../../services/file-generator/target'
import Brand from '../../common/brand'
import { checkCurrentDirIsABoosterProject } from '../../services/project-checker'
import { generateReducers, getResourceSourceFile } from '../../services/method-generator'
import { UserProject } from '../../services/user-project'
import { Logger } from '@boostercloud/framework-types'
import { TaskLogger } from '../../services/task-logger'

export default class Reducer extends BaseCommand {
@CliCommand()
class Implementation {
constructor(readonly logger: Logger, readonly userProject: UserProject, readonly taskLogger: TaskLogger) {}

async run(flags: Flags<typeof Reducer>): Promise<void> {
const entity = flags.entity
const events = flags.event

this.logger.info(`boost ${Brand.energize('add:reducer')} 🚀`)
const templateInfo = await joinParsers(parseName(entity), parseReaction(events))

const reducerWord = events.length > 1 ? 'reducers' : 'reducer'
await this.taskLogger.logTask(`Generating ${reducerWord}`, () => generateReducerMethods(templateInfo))
}
}
export default class Reducer extends BaseCommand<typeof Reducer> {
public static description = 'add new reducer to entity'

static usage = 'reducer --entity Entity --event Event'
Expand All @@ -17,7 +33,6 @@ export default class Reducer extends BaseCommand {
]

public static flags = {
help: Oclif.flags.help({ char: 'h' }),
entity: Oclif.flags.string({
description: 'an entity name',
required: true,
Expand All @@ -32,27 +47,11 @@ export default class Reducer extends BaseCommand {
}),
}

public async run(): Promise<void> {
const { flags } = this.parse(Reducer)
const entity = flags.entity
const events = flags.event

return run(entity, events)
}
implementation = Implementation
}

type ReducerInfo = HasName & HasReaction

/* eslint-disable @typescript-eslint/no-extra-parens */
const pluralize = (word: string, count: number): string => (count === 1 ? word : `${word}s`)

const run = async (rawEntity: string, rawEvents: string[]): Promise<void> =>
Script.init(`boost ${Brand.energize('add:reducer')} 🚧`, joinParsers(parseName(rawEntity), parseReaction(rawEvents)))
.step('Verifying project', checkCurrentDirIsABoosterProject)
.step(`Generating ${pluralize('reducer', rawEvents.length)}`, generateReducerMethods)
.info(`${pluralize('Reducer', rawEvents.length)} generated!`)
.done()

async function generateReducerMethods(info: ReducerInfo): Promise<void> {
const entitySourceFile = getResourceSourceFile(info.name)
const entityClass = entitySourceFile.getClassOrThrow(info.name)
Expand Down
Loading