Skip to content

Commit

Permalink
minor refactor, use helper fns
Browse files Browse the repository at this point in the history
  • Loading branch information
mmkal committed May 23, 2024
1 parent deb053f commit ade81eb
Show file tree
Hide file tree
Showing 4 changed files with 140 additions and 133 deletions.
27 changes: 14 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ You can also pass an existing tRPC router that's primarily designed to be deploy
Here's a more involved example, along with what it outputs:

<!-- codegen:start {preset: custom, require: tsx/cjs, source: ./readme-codegen.ts, export: dump, file: test/fixtures/calculator.ts} -->
<!-- hash:1332520d177c0bcba82272013ea1dd65 -->
<!-- hash:efe19a66f7467160525f69c8ce4daef3 -->
```ts
import * as trpcServer from '@trpc/server'
import {TrpcCliMeta, trpcCli} from 'trpc-cli'
Expand All @@ -70,7 +70,7 @@ const router = trpc.router({
add: trpc.procedure
.meta({
description:
'Add two numbers. Use this if you have apples, and someone else has some other apples, and you want to know how many apples in total you have.',
'Add two numbers. Use this if you and your friend both have apples, and you want to know how many apples there are in total.',
})
.input(
z.object({
Expand Down Expand Up @@ -129,21 +129,21 @@ void trpcCli({router}).run()
<!-- codegen:end -->


Then run `node path/to/yourfile.js --help` and you will see formatted help text for the `sum` and `divide` commands.
Run `node path/to/yourfile.js --help` for formatted help text for the `sum` and `divide` commands.

<!-- codegen:start {preset: custom, require: tsx/cjs, source: ./readme-codegen.ts, export: command, command: './node_modules/.bin/tsx test/fixtures/calculator --help'} -->
`node path/to/calculator --help` output:

```
Commands:
add Add two numbers. Use this if you have apples, and someone else has some other apples, and you want to know how many apples in total you have.
add Add two numbers. Use this if you and your friend both have apples, and you want to know how many apples there are in total.
subtract Subtract two numbers. Useful if you have a number and you want to make it smaller.
multiply Multiply two numbers together. Useful if you want to count the number of tiles on your bathroom wall and are short on time.
divide Divide two numbers. Useful if you have a number and you want to make it smaller and `subtract` isn't quite powerful enough for you.
Flags:
--full-errors Throw unedited raw errors rather than summarising to make more human-readable.
-h, --help Show help
-h, --help Show help
--verbose-errors Throw raw errors (by default errors are summarised)
```
<!-- codegen:end -->
Expand All @@ -156,7 +156,7 @@ You can also show help text for the corresponding procedures (which become "comm
```
add
Add two numbers. Use this if you have apples, and someone else has some other apples, and you want to know how many apples in total you have.
Add two numbers. Use this if you and your friend both have apples, and you want to know how many apples there are in total.
Usage:
add [flags...]
Expand Down Expand Up @@ -188,7 +188,7 @@ Invalid inputs are helpfully displayed, along with help text for the associated
```
add
Add two numbers. Use this if you have apples, and someone else has some other apples, and you want to know how many apples in total you have.
Add two numbers. Use this if you and your friend both have apples, and you want to know how many apples there are in total.
Usage:
add [flags...]
Expand Down Expand Up @@ -234,10 +234,11 @@ const appRouter = trpc.router({
- Return values are logged using `console.info` (can be configured to pass in a custom logger)
- `process.exit(...)` called with either 0 or 1 depending on successful resolve
- Help text shown on invalid inputs
- Support kebab-case flag aliases
- Support flag aliases via `alias` callback (see migrations example below)
- Union types work, but they should ideally be non-overlapping for best results
- Limitation: Only zod types are supported right now
- Limitation: Onlly object types are allowed as input. No positional arguments supported
- Limitation: Only object types are allowed as input. No positional arguments supported
- If there's interest, this could be added in future for inputs of type `z.string()` or `z.tuple([z.string(), ...])`
- Limitation: Nested-object input props must be passed as json
- e.g. `z.object({ foo: z.object({ bar: z.number() }) }))` can be supplied via using `--foo '{"bar": 123}'`
Expand Down Expand Up @@ -413,8 +414,8 @@ Commands:
search.byContent Look for migrations by their script content
Flags:
--full-errors Throw unedited raw errors rather than summarising to make more human-readable.
-h, --help Show help
-h, --help Show help
--verbose-errors Throw raw errors (by default errors are summarised)
```
<!-- codegen:end -->
Expand All @@ -432,7 +433,7 @@ Usage:
Flags:
-h, --help Show help
--step <number> Mark this many migrations as executed; exclusiveMinimum: 0; Do not use with: --to
--step <number> Mark this many migrations as executed; Exclusive minimum: 0; Do not use with: --to
--to <string> Mark migrations up to this one as exectued; Do not use with: --step
```
Expand All @@ -452,7 +453,7 @@ Usage:
Flags:
-h, --help Show help
-q, --search-term <string> Only show migrations whose `content` value contains this string
-s, --status <string> Filter to only show migrations with this status; enum: executed,pending
-s, --status <string> Filter to only show migrations with this status; Enum: executed,pending
```
<!-- codegen:end -->
Expand Down
218 changes: 113 additions & 105 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {Procedure, Router, TRPCError, inferRouterContext, initTRPC} from '@trpc/
import * as cleye from 'cleye'
import colors from 'picocolors'
import {ZodError, z} from 'zod'
import ztjs from 'zod-to-json-schema'
import ztjs, {JsonSchema7ObjectType, type JsonSchema7Type} from 'zod-to-json-schema'
import * as zodValidationError from 'zod-validation-error'

export type TrpcCliParams<R extends Router<any>> = {
Expand Down Expand Up @@ -41,9 +41,9 @@ export const trpcCli = <R extends Router<any>>({router: appRouter, context, alia
const parsedArgv = cleye.cli(
{
flags: {
fullErrors: {
verboseErrors: {
type: Boolean,
description: `Throw unedited raw errors rather than summarising to make more human-readable.`,
description: `Throw raw errors (by default errors are summarised)`,
default: false,
},
},
Expand All @@ -54,112 +54,18 @@ export const trpcCli = <R extends Router<any>>({router: appRouter, context, alia
throw new TypeError(`Only zod schemas are supported, got ${input?.constructor.name}`)
}
})
const zodSchema: z.ZodType<any> =
value._def.inputs.length === 1
? (value._def.inputs[0] as never)
: (z.intersection(...(value._def.inputs as [never, never])) as never)

const jsonSchema = value._def.inputs.length > 0 ? ztjs(zodSchema) : {} // todo: inspect zod schema directly, don't convert to json-schema first

const objectProperties = (sch: typeof jsonSchema) => ('properties' in sch ? sch.properties : {})

const flattenedProperties = (sch: typeof jsonSchema): ReturnType<typeof objectProperties> => {
if ('properties' in sch) {
return sch.properties
}
if ('allOf' in sch) {
return Object.fromEntries(
sch.allOf!.flatMap(subSchema => Object.entries(flattenedProperties(subSchema as typeof jsonSchema))),
)
}
if ('anyOf' in sch) {
const isExcluded = (v: typeof jsonSchema) => Object.keys(v).join(',') === 'not'
const entries = sch.anyOf!.flatMap(subSchema => {
const flattened = flattenedProperties(subSchema as typeof jsonSchema)
const excluded = Object.entries(flattened).flatMap(([name, propSchema]) => {
return isExcluded(propSchema) ? [`--${name}`] : []
})
return Object.entries(flattened).map(([k, v]): [typeof k, typeof v] => {
if (!isExcluded(v) && excluded.length > 0) {
return [k, Object.assign({}, v, {'Do not use with': excluded}) as typeof v]
}
return [k, v]
})
})

return Object.fromEntries(
entries.sort((a, b) => {
const scores = [a, b].map(([_k, v]) => (isExcluded(v) ? 0 : 1)) // Put the excluded ones first, so that `Object.fromEntries` will override them with the non-excluded ones (`Object.fromEntries([['a', 1], ['a', 2]])` => `{a: 2}`)
return scores[0] - scores[1]
}),
)
}
return {}
}
const jsonSchema = procedureInputsToJsonSchema(value) // todo: inspect zod schema directly, don't convert to json-schema first

const properties = flattenedProperties(jsonSchema)

if (Object.entries(properties).length === 0) {
// throw new TypeError(`Schemas looking like ${Object.keys(jsonSchema).join(', ')} are not supported`)
// todo: disallow non-object schemas, while still allowing for no schema
// throw new TypeError(`Schemas looking like ${Object.keys(jsonSchema).join(', ')} are not supported`)
}

const flags = Object.fromEntries(
Object.entries(properties).map(([propertyKey, propertyValue]) => {
const jsonSchemaType =
'type' in propertyValue && typeof propertyValue.type === 'string' ? propertyValue.type : null
let cliType
switch (jsonSchemaType) {
case 'string': {
cliType = String
break
}
case 'integer':
case 'number': {
cliType = Number
break
}
case 'boolean': {
cliType = Boolean
break
}
case 'array': {
cliType = [String]
break
}
case 'object': {
cliType = (s: string) => JSON.parse(s) as {}
break
}
default: {
jsonSchemaType satisfies 'null' | null // make sure we were exhaustive (forgot integer at one point)
cliType = (x: unknown) => x
break
}
}

const getDescription = (v: typeof propertyValue): string => {
if ('items' in v) {
return [getDescription(v.items as typeof propertyValue), '(list)'].filter(Boolean).join(' ')
}
return (
Object.entries(v)
.filter(([k, vv]) => {
if (k === 'default' || k === 'additionalProperties') return false
if (k === 'type' && typeof vv === 'string') return false
return true
})
.sort(([a], [b]) => {
const scores = [a, b].map(k => (k === 'description' ? 0 : 1))
return scores[0] - scores[1]
})
.map(([k, vv], i) => {
if (k === 'description' && i === 0) return String(vv)
if (k === 'properties') return `Object (json formatted)`
return `${k}: ${vv}`
})
.join('; ') || ''
)
}
const cleyeType = getCleyeType(propertyValue)

let description: string | undefined = getDescription(propertyValue)
if ('required' in jsonSchema && !jsonSchema.required?.includes(propertyKey)) {
Expand All @@ -170,7 +76,7 @@ export const trpcCli = <R extends Router<any>>({router: appRouter, context, alia
return [
propertyKey,
{
type: cliType,
type: cleyeType,
description,
default: propertyValue.default,
},
Expand All @@ -196,13 +102,13 @@ export const trpcCli = <R extends Router<any>>({router: appRouter, context, alia
props?.argv,
)

let {fullErrors, ...unknownFlags} = parsedArgv.unknownFlags
fullErrors ||= parsedArgv.flags.fullErrors
let {verboseErrors, ...unknownFlags} = parsedArgv.unknownFlags
verboseErrors ||= parsedArgv.flags.verboseErrors

const caller = initTRPC.context<NonNullable<typeof context>>().create({}).createCallerFactory(appRouter)(context)

const die = (message: string, {cause, help = true}: {cause?: unknown; help?: boolean} = {}) => {
if (fullErrors) {
if (verboseErrors) {
throw (cause as Error) || new Error(message)
}
logger.error?.(colors.red(message))
Expand Down Expand Up @@ -268,3 +174,105 @@ export const trpcCli = <R extends Router<any>>({router: appRouter, context, alia

return {run}
}

const capitaliseFromCamelCase = (camel: string) => {
const parts = camel.split(/(?=[A-Z])/)
return capitalise(parts.map(p => p.toLowerCase()).join(' '))
}

const capitalise = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1)

const flattenedProperties = (sch: JsonSchema7Type): JsonSchema7ObjectType['properties'] => {
if ('properties' in sch) {
return sch.properties
}
if ('allOf' in sch) {
return Object.fromEntries(
sch.allOf!.flatMap(subSchema => Object.entries(flattenedProperties(subSchema as JsonSchema7Type))),
)
}
if ('anyOf' in sch) {
const isExcluded = (v: JsonSchema7Type) => Object.keys(v).join(',') === 'not'
const entries = sch.anyOf!.flatMap(subSchema => {
const flattened = flattenedProperties(subSchema as JsonSchema7Type)
const excluded = Object.entries(flattened).flatMap(([name, propSchema]) => {
return isExcluded(propSchema) ? [`--${name}`] : []
})
return Object.entries(flattened).map(([k, v]): [typeof k, typeof v] => {
if (!isExcluded(v) && excluded.length > 0) {
return [k, Object.assign({}, v, {'Do not use with': excluded}) as typeof v]
}
return [k, v]
})
})

return Object.fromEntries(
entries.sort((a, b) => {
const scores = [a, b].map(([_k, v]) => (isExcluded(v) ? 0 : 1)) // Put the excluded ones first, so that `Object.fromEntries` will override them with the non-excluded ones (`Object.fromEntries([['a', 1], ['a', 2]])` => `{a: 2}`)
return scores[0] - scores[1]
}),
)
}
return {}
}

const getDescription = (v: JsonSchema7Type): string => {
if ('items' in v) {
return [getDescription(v.items as JsonSchema7Type), '(list)'].filter(Boolean).join(' ')
}
return (
Object.entries(v)
.filter(([k, vv]) => {
if (k === 'default' || k === 'additionalProperties') return false
if (k === 'type' && typeof vv === 'string') return false
return true
})
.sort(([a], [b]) => {
const scores = [a, b].map(k => (k === 'description' ? 0 : 1))
return scores[0] - scores[1]
})
.map(([k, vv], i) => {
if (k === 'description' && i === 0) return String(vv)
if (k === 'properties') return `Object (json formatted)`
return `${capitaliseFromCamelCase(k)}: ${vv}`
})
.join('; ') || ''
)
}

export function procedureInputsToJsonSchema(value: Procedure<any, any>): JsonSchema7Type {
if (value._def.inputs.length === 0) return {}

const zodSchema: z.ZodType<any> =
value._def.inputs.length === 1
? (value._def.inputs[0] as never)
: (z.intersection(...(value._def.inputs as [never, never])) as never)

return ztjs(zodSchema)
}

function getCleyeType(schema: JsonSchema7Type) {
const _type = 'type' in schema && typeof schema.type === 'string' ? schema.type : null
switch (_type) {
case 'string': {
return String
}
case 'integer':
case 'number': {
return Number
}
case 'boolean': {
return Boolean
}
case 'array': {
return [String]
}
case 'object': {
return (s: string) => JSON.parse(s) as {}
}
default: {
_type satisfies 'null' | null // make sure we were exhaustive (forgot integer at one point)
return (x: unknown) => x
}
}
}
2 changes: 1 addition & 1 deletion test/fixtures/calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const router = trpc.router({
add: trpc.procedure
.meta({
description:
'Add two numbers. Use this if you have apples, and someone else has some other apples, and you want to know how many apples in total you have.',
'Add two numbers. Use this if you and your friend both have apples, and you want to know how many apples there are in total.',
})
.input(
z.object({
Expand Down
Loading

0 comments on commit ade81eb

Please sign in to comment.