From 44172d99b566d11d9ceda04f1d0bf72b6d05ce76 Mon Sep 17 00:00:00 2001 From: David Luecke Date: Thu, 6 Oct 2022 17:01:05 -0700 Subject: [PATCH] feat(schema): Make schemas validation library independent and add TypeBox support (#2772) --- package-lock.json | 11 ++ package.json | 2 +- packages/authentication/src/options.ts | 113 +---------- packages/cli/package.json | 1 + packages/cli/src/app/index.ts | 50 +++-- packages/cli/src/app/templates/app.tpl.ts | 10 +- packages/cli/src/app/templates/client.tpl.ts | 2 +- .../src/app/templates/configuration.tpl.ts | 49 ----- .../cli/src/app/templates/declarations.tpl.ts | 4 +- .../cli/src/app/templates/index.html.tpl.ts | 62 ++---- .../cli/src/app/templates/package.json.tpl.ts | 8 +- .../cli/src/app/templates/readme.md.tpl.ts | 22 ++- packages/cli/src/app/templates/schemas.tpl.ts | 90 +++++++++ packages/cli/src/authentication/index.ts | 10 +- .../authentication/templates/config.tpl.ts | 2 +- .../templates/declarations.tpl.ts | 4 +- .../templates/schema.json.tpl.ts | 108 +++++++++++ .../templates/schema.typebox.tpl.ts | 94 +++++++++ .../src/authentication/templates/test.tpl.ts | 3 +- .../templates/user.resolver.tpl.ts | 110 ----------- .../templates/user.schema.tpl.ts | 91 --------- packages/cli/src/commons.ts | 4 + .../cli/src/connection/templates/knex.tpl.ts | 16 -- .../src/connection/templates/mongodb.tpl.ts | 10 - packages/cli/src/service/index.ts | 49 ++++- .../cli/src/service/templates/class.tpl.ts | 78 -------- .../cli/src/service/templates/client.tpl.ts | 49 +++-- .../cli/src/service/templates/resolver.tpl.ts | 82 -------- .../src/service/templates/schema.json.tpl.ts | 85 ++++++++ .../cli/src/service/templates/schema.tpl.ts | 83 -------- .../service/templates/schema.typebox.tpl.ts | 70 +++++++ .../cli/src/service/templates/service.tpl.ts | 116 +++++++++-- packages/cli/src/service/type/custom.tpl.ts | 82 ++++---- packages/cli/src/service/type/knex.tpl.ts | 76 +++++--- packages/cli/src/service/type/mongodb.tpl.ts | 78 +++++--- packages/cli/test/generators.test.ts | 32 ++- packages/configuration/src/index.ts | 10 +- packages/schema/package.json | 1 + packages/schema/src/default-schemas.ts | 157 +++++++++++++++ packages/schema/src/hooks/resolve.ts | 2 + packages/schema/src/hooks/validate.ts | 41 ++-- packages/schema/src/index.ts | 9 +- packages/schema/src/json-schema.ts | 182 ++++++++++++++++++ packages/schema/src/query.ts | 97 ---------- packages/schema/src/resolver.ts | 25 +++ packages/schema/src/schema.ts | 8 +- packages/schema/test/fixture.ts | 133 ++++++------- packages/schema/test/hooks.test.ts | 8 +- packages/typebox/LICENSE | 22 +++ packages/typebox/README.md | 23 +++ packages/typebox/package.json | 68 +++++++ packages/typebox/src/default-schemas.ts | 87 +++++++++ packages/typebox/src/index.ts | 75 ++++++++ packages/typebox/test/index.test.ts | 50 +++++ packages/typebox/tsconfig.json | 9 + 55 files changed, 1702 insertions(+), 1061 deletions(-) delete mode 100644 packages/cli/src/app/templates/configuration.tpl.ts create mode 100644 packages/cli/src/app/templates/schemas.tpl.ts create mode 100644 packages/cli/src/authentication/templates/schema.json.tpl.ts create mode 100644 packages/cli/src/authentication/templates/schema.typebox.tpl.ts delete mode 100644 packages/cli/src/authentication/templates/user.resolver.tpl.ts delete mode 100644 packages/cli/src/authentication/templates/user.schema.tpl.ts delete mode 100644 packages/cli/src/service/templates/class.tpl.ts delete mode 100644 packages/cli/src/service/templates/resolver.tpl.ts create mode 100644 packages/cli/src/service/templates/schema.json.tpl.ts delete mode 100644 packages/cli/src/service/templates/schema.tpl.ts create mode 100644 packages/cli/src/service/templates/schema.typebox.tpl.ts create mode 100644 packages/schema/src/default-schemas.ts create mode 100644 packages/schema/src/json-schema.ts delete mode 100644 packages/schema/src/query.ts create mode 100644 packages/typebox/LICENSE create mode 100644 packages/typebox/README.md create mode 100644 packages/typebox/package.json create mode 100644 packages/typebox/src/default-schemas.ts create mode 100644 packages/typebox/src/index.ts create mode 100644 packages/typebox/test/index.test.ts create mode 100644 packages/typebox/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 8d71f56fa1..31f6bf7362 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@feathershq/pinion": "^0.3.5", "@feathersjs/hooks": "^0.7.5", "@koa/cors": "^3.4.2", + "@sinclair/typebox": "^0.24.44", "@types/bcryptjs": "^2.4.2", "@types/config": "^3.3.0", "@types/cookie-session": "^2.0.44", @@ -3627,6 +3628,11 @@ "node": ">=12" } }, + "node_modules/@sinclair/typebox": { + "version": "0.24.44", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.44.tgz", + "integrity": "sha512-ka0W0KN5i6LfrSocduwliMMpqVgohtPFidKdMEOUjoOFCHcOOYkKsPRxfs5f15oPNHTm6ERAm0GV/+/LTKeiWg==" + }, "node_modules/@sindresorhus/is": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.3.0.tgz", @@ -21679,6 +21685,11 @@ "config-chain": "^1.1.11" } }, + "@sinclair/typebox": { + "version": "0.24.44", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.44.tgz", + "integrity": "sha512-ka0W0KN5i6LfrSocduwliMMpqVgohtPFidKdMEOUjoOFCHcOOYkKsPRxfs5f15oPNHTm6ERAm0GV/+/LTKeiWg==" + }, "@sindresorhus/is": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.3.0.tgz", diff --git a/package.json b/package.json index 5dbb149cb7..262f60bf65 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "update-dependencies": "ncu -u && lerna exec -- ncu -u -x node-fetch -x chalk -x axios", "clean": "find . -name node_modules -exec rm -rf '{}' + && find . -name package-lock.json -exec rm -rf '{}' +", "test:deno": "deno test --config deno/tsconfig.json deno/test.ts", - "test": "npm run lint && npm run compile && c8 lerna run test" + "test": "npm run lint && npm run compile && c8 lerna run test --ignore @feathersjs/tests" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^5.39.0", diff --git a/packages/authentication/src/options.ts b/packages/authentication/src/options.ts index 3f5d7f45ee..2176889087 100644 --- a/packages/authentication/src/options.ts +++ b/packages/authentication/src/options.ts @@ -1,4 +1,4 @@ -import { FromSchema } from '@feathersjs/schema' +import { FromSchema, authenticationSettingsSchema } from '@feathersjs/schema' export const defaultOptions = { authStrategies: [] as string[], @@ -11,115 +11,6 @@ export const defaultOptions = { } } -export const authenticationSettingsSchema = { - type: 'object', - required: ['secret', 'entity', 'authStrategies'], - properties: { - secret: { - type: 'string', - description: 'The JWT signing secret' - }, - entity: { - oneOf: [ - { - type: 'null' - }, - { - type: 'string' - } - ], - description: 'The name of the authentication entity (e.g. user)' - }, - entityId: { - type: 'string', - description: 'The name of the authentication entity id property' - }, - service: { - type: 'string', - description: 'The path of the entity service' - }, - authStrategies: { - type: 'array', - items: { type: 'string' }, - description: 'A list of authentication strategy names that are allowed to create JWT access tokens' - }, - parseStrategies: { - type: 'array', - items: { type: 'string' }, - description: - 'A list of authentication strategy names that should parse HTTP headers for authentication information (defaults to `authStrategies`)' - }, - jwtOptions: { - type: 'object' - }, - jwt: { - type: 'object', - properties: { - header: { - type: 'string', - default: 'Authorization', - description: 'The HTTP header containing the JWT' - }, - schemes: { - type: 'array', - items: { type: 'string' }, - description: 'An array of schemes to support' - } - } - }, - local: { - type: 'object', - required: ['usernameField', 'passwordField'], - properties: { - usernameField: { - type: 'string', - description: 'Name of the username field (e.g. `email`)' - }, - passwordField: { - type: 'string', - description: 'Name of the password field (e.g. `password`)' - }, - hashSize: { - type: 'number', - description: 'The BCrypt salt length' - }, - errorMessage: { - type: 'string', - default: 'Invalid login', - description: 'The error message to return on errors' - }, - entityUsernameField: { - type: 'string', - description: - 'Name of the username field on the entity if authentication request data and entity field names are different' - }, - entityPasswordField: { - type: 'string', - description: - 'Name of the password field on the entity if authentication request data and entity field names are different' - } - } - }, - oauth: { - type: 'object', - properties: { - redirect: { - type: 'string' - }, - origins: { - type: 'array', - items: { type: 'string' } - }, - defaults: { - type: 'object', - properties: { - key: { type: 'string' }, - secret: { type: 'string' } - } - } - } - } - } -} as const +export { authenticationSettingsSchema } export type AuthenticationConfiguration = FromSchema diff --git a/packages/cli/package.json b/packages/cli/package.json index b4ce7c4ce7..1eacf354a9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -69,6 +69,7 @@ "@feathersjs/koa": "^5.0.0-pre.29", "@feathersjs/mongodb": "^5.0.0-pre.29", "@feathersjs/schema": "^5.0.0-pre.29", + "@feathersjs/typebox": "^5.0.0-pre.29", "@feathersjs/socketio": "^5.0.0-pre.29", "@feathersjs/transport-commons": "^5.0.0-pre.29", "@types/mocha": "^10.0.0", diff --git a/packages/cli/src/app/index.ts b/packages/cli/src/app/index.ts index d3d017fec4..c358a9de8c 100644 --- a/packages/cli/src/app/index.ts +++ b/packages/cli/src/app/index.ts @@ -70,7 +70,14 @@ export const generate = (ctx: AppGeneratorArguments) => type: 'input', when: !ctx.name, message: 'What is the name of your application?', - default: ctx.cwd.split(sep).pop() + default: ctx.cwd.split(sep).pop(), + validate: (input) => { + if (ctx.dependencyVersions[input]) { + return `Application can not have the same name as a dependency` + } + + return true + } }, { name: 'description', @@ -109,10 +116,21 @@ export const generate = (ctx: AppGeneratorArguments) => { value: 'pnpm', name: 'pnpm' } ] }, + { + type: 'list', + name: 'schema', + when: !ctx.schema, + message: 'What is your preferred schema (model) definition format?', + choices: [ + { value: 'typebox', name: `TypeBox ${chalk.grey('(recommended)')}` }, + { value: 'json', name: 'JSON schema' } + ] + }, ...connectionPrompts(ctx), ...authenticationPrompts({ ...ctx, - service: 'users', + service: 'user', + path: 'users', entity: 'user' }) ]) @@ -120,26 +138,22 @@ export const generate = (ctx: AppGeneratorArguments) => .then(runGenerators(__dirname, 'templates')) .then(copyFiles(fromFile(__dirname, 'static'), toFile('.'))) .then(initializeBaseContext()) - .then( - when( - ({ authStrategies }) => authStrategies.length > 0, - async (ctx) => { - const { dependencies } = await connectionGenerator(ctx) + .then(async (ctx) => { + const { dependencies } = await connectionGenerator(ctx) - return { - ...ctx, - dependencies - } - } - ) - ) + return { + ...ctx, + dependencies + } + }) .then( when( ({ authStrategies }) => authStrategies.length > 0, async (ctx) => { const { dependencies } = await authenticationGenerator({ ...ctx, - service: 'users', + service: 'user', + path: 'users', entity: 'user' }) @@ -152,7 +166,7 @@ export const generate = (ctx: AppGeneratorArguments) => ) .then( install( - ({ transports, framework, dependencyVersions, dependencies }) => { + ({ transports, framework, dependencyVersions, dependencies, schema }) => { const hasSocketio = transports.includes('websockets') dependencies.push( @@ -177,6 +191,10 @@ export const generate = (ctx: AppGeneratorArguments) => dependencies.push('@feathersjs/express', 'compression') } + if (schema === 'typebox') { + dependencies.push('@feathersjs/typebox') + } + return addVersions(dependencies, dependencyVersions) }, false, diff --git a/packages/cli/src/app/templates/app.tpl.ts b/packages/cli/src/app/templates/app.tpl.ts index 00df7012ce..4977c95935 100644 --- a/packages/cli/src/app/templates/app.tpl.ts +++ b/packages/cli/src/app/templates/app.tpl.ts @@ -9,7 +9,7 @@ import { koa, rest, bodyParser, errorHandler, parseAuthentication, cors } from ' ${transports.includes('websockets') ? "import socketio from '@feathersjs/socketio'" : ''} import type { Application } from './declarations' -import { configurationSchema } from './configuration' +import { configurationValidator } from './schemas/configuration' import { logErrorHook } from './logger' import { services } from './services/index' import { channels } from './channels' @@ -17,12 +17,12 @@ import { channels } from './channels' const app: Application = koa(feathers()) // Load our app configuration (see config/ folder) -app.configure(configuration(configurationSchema)) +app.configure(configuration(configurationValidator)) // Set up Koa middleware +app.use(cors()) app.use(serveStatic(app.get('public'))) app.use(errorHandler()) -app.use(cors()) app.use(parseAuthentication()) app.use(bodyParser()) @@ -69,7 +69,7 @@ import configuration from '@feathersjs/configuration' ${transports.includes('websockets') ? "import socketio from '@feathersjs/socketio'" : ''} import type { Application } from './declarations' -import { configurationSchema } from './configuration' +import { configurationValidator } from './schemas/configuration' import { logger, logErrorHook } from './logger' import { services } from './services/index' import { channels } from './channels' @@ -77,7 +77,7 @@ import { channels } from './channels' const app: Application = express(feathers()) // Load app configuration -app.configure(configuration(configurationSchema)) +app.configure(configuration(configurationValidator)) app.use(cors()) app.use(compress()) app.use(json()) diff --git a/packages/cli/src/app/templates/client.tpl.ts b/packages/cli/src/app/templates/client.tpl.ts index eae05291f1..ab83dd0f1e 100644 --- a/packages/cli/src/app/templates/client.tpl.ts +++ b/packages/cli/src/app/templates/client.tpl.ts @@ -6,7 +6,7 @@ const template = ({}: AppGeneratorContext) => /* ts */ `import { feathers } from import type { Paginated, ClientService, TransportConnection, Params } from '@feathersjs/feathers' export interface ServiceTypes { - // A mapping of client side services + // } export const createClient = (connection: TransportConnection) => { diff --git a/packages/cli/src/app/templates/configuration.tpl.ts b/packages/cli/src/app/templates/configuration.tpl.ts deleted file mode 100644 index a042b25a4a..0000000000 --- a/packages/cli/src/app/templates/configuration.tpl.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { generator, toFile } from '@feathershq/pinion' -import { renderSource } from '../../commons' -import { AppGeneratorContext } from '../index' - -const template = ({}: AppGeneratorContext) => /* ts */ `import { schema, Ajv } from '@feathersjs/schema' -import type { Infer } from '@feathersjs/schema' -import { authenticationSettingsSchema } from '@feathersjs/authentication' - -export const configurationSchema = schema( - { - $id: 'ApplicationConfiguration', - type: 'object', - additionalProperties: false, - required: [ 'host', 'port', 'public', 'paginate' ], - properties: { - host: { type: 'string' }, - port: { type: 'number' }, - public: { type: 'string' }, - authentication: authenticationSettingsSchema, - origins: { - type: 'array', - items: { - type: 'string' - } - }, - paginate: { - type: 'object', - additionalProperties: false, - required: [ 'default', 'max' ], - properties: { - default: { type: 'number' }, - max: { type: 'number' } - } - } - } - } as const, - new Ajv() -) - -export type ConfigurationSchema = Infer -` - -export const generate = (ctx: AppGeneratorContext) => - generator(ctx).then( - renderSource( - template, - toFile(({ lib }) => lib, 'configuration') - ) - ) diff --git a/packages/cli/src/app/templates/declarations.tpl.ts b/packages/cli/src/app/templates/declarations.tpl.ts index be2fcaa029..106fc7e538 100644 --- a/packages/cli/src/app/templates/declarations.tpl.ts +++ b/packages/cli/src/app/templates/declarations.tpl.ts @@ -5,11 +5,11 @@ const template = ({ framework }: AppGeneratorContext) => /* ts */ `import { HookContext as FeathersHookContext, NextFunction } from '@feathersjs/feathers' import { Application as FeathersApplication } from '@feathersjs/${framework}' -import { ConfigurationSchema } from './configuration' +import { ApplicationConfiguration } from './schemas/configuration' export { NextFunction } -export interface Configuration extends ConfigurationSchema {} +export interface Configuration extends ApplicationConfiguration {} // A mapping of service names to types. Will be extended in service files. export interface ServiceTypes {} diff --git a/packages/cli/src/app/templates/index.html.tpl.ts b/packages/cli/src/app/templates/index.html.tpl.ts index 1486485807..171da72480 100644 --- a/packages/cli/src/app/templates/index.html.tpl.ts +++ b/packages/cli/src/app/templates/index.html.tpl.ts @@ -14,70 +14,30 @@ const template = ({ name, description }: AppGeneratorContext) => /* html */ ` -
-

${name}

- - - - -
+ + ` export const generate = (ctx: AppGeneratorContext) => diff --git a/packages/cli/src/app/templates/package.json.tpl.ts b/packages/cli/src/app/templates/package.json.tpl.ts index ad90ada521..e8c75b04b7 100644 --- a/packages/cli/src/app/templates/package.json.tpl.ts +++ b/packages/cli/src/app/templates/package.json.tpl.ts @@ -16,7 +16,7 @@ const tsPackageJson = (lib: string) => ({ scripts: { dev: `nodemon -x ts-node ${lib}/index.ts`, compile: 'shx rm -rf lib/ && tsc', - start: 'npm run compile && node lib/', + start: 'node lib/', prettier: 'npx prettier "**/*.ts" --write', mocha: 'cross-env NODE_ENV=test mocha test/ --require ts-node/register --recursive --extension .ts --exit', @@ -33,7 +33,8 @@ const packageJson = ({ framework, transports, lib, - test + test, + schema }: AppGeneratorContext) => ({ name, description, @@ -52,7 +53,8 @@ const packageJson = ({ packager, database, framework, - transports + transports, + schema }, directories: { lib, diff --git a/packages/cli/src/app/templates/readme.md.tpl.ts b/packages/cli/src/app/templates/readme.md.tpl.ts index 20fe12547f..7ac54db41f 100644 --- a/packages/cli/src/app/templates/readme.md.tpl.ts +++ b/packages/cli/src/app/templates/readme.md.tpl.ts @@ -1,7 +1,7 @@ import { generator, renderTemplate, toFile } from '@feathershq/pinion' import { AppGeneratorContext } from '../index' -const template = ({ name, description }: AppGeneratorContext) => /* md */ `# ${name} +const template = ({ name, description, language, database }: AppGeneratorContext) => /* md */ `# ${name} > ${description} @@ -21,7 +21,17 @@ This project uses [Feathers](http://feathersjs.com). An open source framework fo 3. Start your app - \`\`\` + \`\`\`${ + language === 'ts' + ? ` + npm run compile # Compile TypeScript source` + : '' + }${ + database !== 'mongodb' + ? ` + npm run migrate # Run migrations to set up the database` + : '' +} npm start \`\`\` @@ -31,13 +41,11 @@ Run \`npm test\` and all your tests in the \`test/\` directory will be run. ## Scaffolding -Feathers has a powerful command line interface. Here are a few things it can do: +This app comes with a powerful command line interface for Feathers. Here are a few things it can do: \`\`\` -$ npm install -g @feathersjs/cli # Install Feathers CLI - -$ feathers generate service # Generate a new Service -$ feathers help # Show all commands +$ npx feathers help # Show all commands +$ npx feathers generate service # Generate a new Service \`\`\` ## Help diff --git a/packages/cli/src/app/templates/schemas.tpl.ts b/packages/cli/src/app/templates/schemas.tpl.ts new file mode 100644 index 0000000000..e0020b297e --- /dev/null +++ b/packages/cli/src/app/templates/schemas.tpl.ts @@ -0,0 +1,90 @@ +import { generator, toFile } from '@feathershq/pinion' +import { renderSource } from '../../commons' +import { AppGeneratorContext } from '../index' + +const validatorTemplate = /* ts */ `import { Ajv, addFormats } from '@feathersjs/schema' +import type { FormatsPluginOptions } from '@feathersjs/schema' + +const formats: FormatsPluginOptions = [ + 'date-time', + 'time', + 'date', + 'email', + 'hostname', + 'ipv4', + 'ipv6', + 'uri', + 'uri-reference', + 'uuid', + 'uri-template', + 'json-pointer', + 'relative-json-pointer', + 'regex' +] + +export const dataValidator = addFormats(new Ajv({}), formats) + +export const queryValidator = addFormats(new Ajv({ + coerceTypes: true +}), formats) +` + +const configurationJsonTemplate = + ({}: AppGeneratorContext) => /* ts */ `import { defaultAppSettings, jsonSchema } from '@feathersjs/schema' +import type { FromSchema } from '@feathersjs/schema' + +import { dataValidator } from './validators' + +export const configurationSchema = { + type: 'object', + additionalProperties: false, + required: [ 'host', 'port', 'public' ], + properties: { + ...defaultAppSettings, + host: { type: 'string' }, + port: { type: 'number' }, + public: { type: 'string' } + } +} as const + +export const configurationValidator = jsonSchema.getValidator(configurationSchema, dataValidator) + +export type ApplicationConfiguration = FromSchema +` + +const configurationTypeboxTemplate = + ({}: AppGeneratorContext) => /* ts */ `import { jsonSchema } from '@feathersjs/schema' +import { Type, defaultAppConfiguration } from '@feathersjs/typebox' +import type { Static } from '@feathersjs/typebox' + +import { dataValidator } from './validators' + +export const configurationSchema = Type.Intersect([ + defaultAppConfiguration, + Type.Object({ + host: Type.String(), + port: Type.Number(), + public: Type.String() + }) +]) + +export type ApplicationConfiguration = Static + +export const configurationValidator = jsonSchema.getValidator(configurationSchema, dataValidator) +` + +export const generate = (ctx: AppGeneratorContext) => + generator(ctx) + .then( + renderSource( + async (ctx) => + ctx.schema === 'typebox' ? configurationTypeboxTemplate(ctx) : configurationJsonTemplate(ctx), + toFile(({ lib }) => lib, 'schemas', 'configuration') + ) + ) + .then( + renderSource( + validatorTemplate, + toFile(({ lib }) => lib, 'schemas', 'validators') + ) + ) diff --git a/packages/cli/src/authentication/index.ts b/packages/cli/src/authentication/index.ts index 4f4fce804f..553e81c4e9 100644 --- a/packages/cli/src/authentication/index.ts +++ b/packages/cli/src/authentication/index.ts @@ -17,7 +17,7 @@ export interface AuthenticationGeneratorContext extends ServiceGeneratorContext } export type AuthenticationGeneratorArguments = FeathersBaseContext & - Partial> + Partial> export const prompts = (ctx: AuthenticationGeneratorArguments) => [ { @@ -59,6 +59,13 @@ export const prompts = (ctx: AuthenticationGeneratorArguments) => [ type: 'input', when: !ctx.service, message: 'What is your authentication service name?', + default: 'user' + }, + { + name: 'path', + type: 'input', + when: !ctx.path, + message: 'What path should the service be registered on?', default: 'users' }, { @@ -80,7 +87,6 @@ export const generate = (ctx: AuthenticationGeneratorArguments) => const serviceContext = await serviceGenerator({ ...ctx, name: ctx.service, - path: ctx.service, isEntityService: true, type: getDatabaseAdapter(ctx.feathers?.database) }) diff --git a/packages/cli/src/authentication/templates/config.tpl.ts b/packages/cli/src/authentication/templates/config.tpl.ts index 14d72b0026..598f0a84cc 100644 --- a/packages/cli/src/authentication/templates/config.tpl.ts +++ b/packages/cli/src/authentication/templates/config.tpl.ts @@ -8,7 +8,7 @@ export const generate = (ctx: AuthenticationGeneratorContext) => mergeJSON(({ authStrategies }) => { const authentication: any = { entity: ctx.entity, - service: ctx.service, + service: ctx.path, secret: crypto.randomBytes(24).toString('base64'), authStrategies: ['jwt'], jwtOptions: { diff --git a/packages/cli/src/authentication/templates/declarations.tpl.ts b/packages/cli/src/authentication/templates/declarations.tpl.ts index 913e3735bb..07e6513640 100644 --- a/packages/cli/src/authentication/templates/declarations.tpl.ts +++ b/packages/cli/src/authentication/templates/declarations.tpl.ts @@ -5,7 +5,7 @@ const importTemplate = ({ upperName, folder, fileName -}: AuthenticationGeneratorContext) => /* ts */ `import { ${upperName}Result } from './services/${folder.join( +}: AuthenticationGeneratorContext) => /* ts */ `import { ${upperName} } from './services/${folder.join( '/' )}/${fileName}.schema' ` @@ -16,7 +16,7 @@ const paramsTemplate = ({ }: AuthenticationGeneratorContext) => /* ts */ `// Add the ${entity} as an optional property to all params declare module '@feathersjs/feathers' { interface Params { - ${entity}?: ${upperName}Result + ${entity}?: ${upperName} } } ` diff --git a/packages/cli/src/authentication/templates/schema.json.tpl.ts b/packages/cli/src/authentication/templates/schema.json.tpl.ts new file mode 100644 index 0000000000..6e7ef992fd --- /dev/null +++ b/packages/cli/src/authentication/templates/schema.json.tpl.ts @@ -0,0 +1,108 @@ +import { generator, toFile, when } from '@feathershq/pinion' +import { renderSource } from '../../commons' +import { AuthenticationGeneratorContext } from '../index' + +const template = ({ + camelName, + upperName, + authStrategies, + type, + relative +}: AuthenticationGeneratorContext) => /* ts */ `import { resolve, jsonSchema } from '@feathersjs/schema' +import type { FromSchema } from '@feathersjs/schema' +${authStrategies.includes('local') ? `import { passwordHash } from '@feathersjs/authentication-local'` : ''} + +import type { HookContext } from '${relative}/declarations' +import { dataValidator, queryValidator } from '${relative}/schemas/validators' + +// Schema for the basic data model (e.g. creating new entries) +export const ${camelName}DataSchema = { + $id: '${upperName}Data', + type: 'object', + additionalProperties: false, + required: [ ${authStrategies.includes('local') ? "'email'" : ''} ], + properties: { + ${authStrategies + .map((name) => + name === 'local' + ? ` email: { type: 'string' }, + password: { type: 'string' }` + : ` ${name}Id: { type: 'string' }` + ) + .join(',\n')} + } +} as const +export type ${upperName}Data = FromSchema +export const ${camelName}DataValidator = jsonSchema.getDataValidator(${camelName}DataSchema, dataValidator) +export const ${camelName}DataResolver = resolve<${upperName}Data, HookContext>({ + properties: { + ${authStrategies.includes('local') ? `password: passwordHash({ strategy: 'local' })` : ''} + } +}) + +// Schema for the data that is being returned +export const ${camelName}Schema = { + $id: '${upperName}', + type: 'object', + additionalProperties: false, + required: [ ...${camelName}DataSchema.required, '${type === 'mongodb' ? '_id' : 'id'}' ], + properties: { + ...${camelName}DataSchema.properties, + ${type === 'mongodb' ? '_id' : 'id'}: { + type: '${type === 'mongodb' ? 'string' : 'number'}' + } + } +} as const +export type ${upperName} = FromSchema +export const ${camelName}Resolver = resolve<${upperName}, HookContext>({ + properties: {} +}) + +export const ${camelName}ExternalResolver = resolve<${upperName}, HookContext>({ + properties: { + // The password should never be visible externally + password: async () => undefined + } +}) + +// Schema for allowed query properties +export const ${camelName}QuerySchema = { + $id: '${upperName}Query', + type: 'object', + additionalProperties: false, + properties: { + ...jsonSchema.querySyntax(${camelName}Schema.properties) + } +} as const +export type ${upperName}Query = FromSchema +export const ${camelName}QueryValidator = jsonSchema.getValidator(${camelName}QuerySchema, queryValidator) +export const ${camelName}QueryResolver = resolve<${upperName}Query, HookContext>({ + properties: { + // If there is a user (e.g. with authentication), they are only allowed to see their own data + ${type === 'mongodb' ? '_id' : 'id'}: async (value, user, context) => { + if (context.params.user) { + return context.params.user.${type === 'mongodb' ? '_id' : 'id'} + } + + return value + } + } +}) +` + +export const generate = (ctx: AuthenticationGeneratorContext) => + generator(ctx).then( + when( + ({ schema }) => schema === 'json', + renderSource( + template, + toFile(({ lib, folder, fileName }: AuthenticationGeneratorContext) => [ + lib, + 'services', + ...folder, + `${fileName}.schema` + ]), + { force: true } + ) + ) + ) diff --git a/packages/cli/src/authentication/templates/schema.typebox.tpl.ts b/packages/cli/src/authentication/templates/schema.typebox.tpl.ts new file mode 100644 index 0000000000..66b3eb5599 --- /dev/null +++ b/packages/cli/src/authentication/templates/schema.typebox.tpl.ts @@ -0,0 +1,94 @@ +import { generator, toFile, when } from '@feathershq/pinion' +import { renderSource } from '../../commons' +import { AuthenticationGeneratorContext } from '../index' + +export const template = ({ + camelName, + upperName, + authStrategies, + type, + relative +}: AuthenticationGeneratorContext) => /* ts */ `import { jsonSchema, resolve } from '@feathersjs/schema' +import { Type, querySyntax } from '@feathersjs/typebox' +import type { Static } from '@feathersjs/typebox' +${authStrategies.includes('local') ? `import { passwordHash } from '@feathersjs/authentication-local'` : ''} + +import type { HookContext } from '${relative}/declarations' +import { dataValidator, queryValidator } from '${relative}/schemas/validators' + +// Schema for the basic data model (e.g. creating new entries) +export const ${camelName}DataSchema = Type.Object({ + ${authStrategies + .map((name) => + name === 'local' + ? ` email: Type.String(), + password: Type.String()` + : ` ${name}Id: Type.Optional(Type.String())` + ) + .join(',\n')} +}, { $id: '${upperName}Data', additionalProperties: false }) +export type ${upperName}Data = Static +export const ${camelName}DataValidator = jsonSchema.getDataValidator(${camelName}DataSchema, dataValidator) +export const ${camelName}DataResolver = resolve<${upperName}Data, HookContext>({ + properties: { + ${authStrategies.includes('local') ? `password: passwordHash({ strategy: 'local' })` : ''} + } +}) + +// Schema for the data that is being returned +export const ${camelName}Schema = Type.Intersect([ + ${camelName}DataSchema, + Type.Object({ + ${type === 'mongodb' ? '_id: Type.String()' : 'id: Type.Number()'} + }) +], { $id: '${upperName}' }) +export type ${upperName} = Static +export const ${camelName}Resolver = resolve<${upperName}, HookContext>({ + properties: {} +}) + +export const ${camelName}ExternalResolver = resolve<${upperName}, HookContext>({ + properties: { + // The password should never be visible externally + password: async () => undefined + } +}) + +// Schema for allowed query properties +export const ${camelName}QuerySchema = Type.Intersect([ + querySyntax(${camelName}Schema), + // Add additional query properties here + Type.Object({}) +]) +export type ${upperName}Query = Static +export const ${camelName}QueryValidator = jsonSchema.getValidator(${camelName}QuerySchema, queryValidator) +export const ${camelName}QueryResolver = resolve<${upperName}Query, HookContext>({ + properties: { + // If there is a user (e.g. with authentication), they are only allowed to see their own data + ${type === 'mongodb' ? '_id' : 'id'}: async (value, user, context) => { + if (context.params.user) { + return context.params.user.${type === 'mongodb' ? '_id' : 'id'} + } + + return value + } + } +}) +` + +export const generate = (ctx: AuthenticationGeneratorContext) => + generator(ctx).then( + when( + ({ schema }) => schema === 'typebox', + renderSource( + template, + toFile(({ lib, folder, fileName }: AuthenticationGeneratorContext) => [ + lib, + 'services', + ...folder, + `${fileName}.schema` + ]), + { force: true } + ) + ) + ) diff --git a/packages/cli/src/authentication/templates/test.tpl.ts b/packages/cli/src/authentication/templates/test.tpl.ts index 39c6e80597..722cc2f427 100644 --- a/packages/cli/src/authentication/templates/test.tpl.ts +++ b/packages/cli/src/authentication/templates/test.tpl.ts @@ -4,6 +4,7 @@ import { AuthenticationGeneratorContext } from '../index' const template = ({ authStrategies, + path, lib }: AuthenticationGeneratorContext) => /* ts */ `import assert from 'assert'; import { app } from '../${lib}/app'; @@ -19,7 +20,7 @@ describe('authentication', () => { before(async () => { try { - await app.service('users').create(userInfo) + await app.service('${path}').create(userInfo) } catch (error) { // Do nothing, it just means the user already exists and can be tested } diff --git a/packages/cli/src/authentication/templates/user.resolver.tpl.ts b/packages/cli/src/authentication/templates/user.resolver.tpl.ts deleted file mode 100644 index 5eaa6c8b5c..0000000000 --- a/packages/cli/src/authentication/templates/user.resolver.tpl.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { generator, toFile } from '@feathershq/pinion' -import { renderSource } from '../../commons' -import { AuthenticationGeneratorContext } from '../index' - -const template = ({ - camelName, - upperName, - relative, - authStrategies, - type, - fileName -}: AuthenticationGeneratorContext) => /* ts */ `import { resolve } from '@feathersjs/schema' -${authStrategies.includes('local') ? `import { passwordHash } from '@feathersjs/authentication-local'` : ''} -import type { HookContext } from '${relative}/declarations' -import type { - ${upperName}Data, - ${upperName}Patch, - ${upperName}Result, - ${upperName}Query, -} from './${fileName}.schema' -import { - ${camelName}DataSchema, - ${camelName}PatchSchema, - ${camelName}ResultSchema, - ${camelName}QuerySchema -} from './${fileName}.schema' - - -// Resolver for the basic data model (e.g. creating new entries) -export const ${camelName}DataResolver = resolve<${upperName}Data, HookContext>({ - schema: ${camelName}DataSchema, - validate: 'before', - properties: { - ${authStrategies.includes('local') ? `password: passwordHash({ strategy: 'local' })` : ''} - } -}) - - -// Resolver for making partial updates -export const ${camelName}PatchResolver = resolve<${upperName}Patch, HookContext>({ - schema: ${camelName}PatchSchema, - validate: 'before', - properties: { - ${authStrategies.includes('local') ? `password: passwordHash({ strategy: 'local' })` : ''} - } -}) - - -// Resolver for the data that is being returned -export const ${camelName}ResultResolver = resolve<${upperName}Result, HookContext>({ - schema: ${camelName}ResultSchema, - validate: false, - properties: {} -}) - - -// Resolver for the "safe" version that external clients are allowed to see -export const ${camelName}DispatchResolver = resolve<${upperName}Result, HookContext>({ - schema: ${camelName}ResultSchema, - validate: false, - properties: { - // The password should never be visible externally - password: async () => undefined - } -}) - - -// Resolver for allowed query properties -export const ${camelName}QueryResolver = resolve<${upperName}Query, HookContext>({ - schema: ${camelName}QuerySchema, - validate: 'before', - properties: { - // If there is a user (e.g. with authentication), they are only allowed to see their own data - ${type === 'mongodb' ? '_id' : 'id'}: async (value, user, context) => { - if (context.params.user) { - return context.params.user.${type === 'mongodb' ? '_id' : 'id'} - } - - return value - } - } -}) - - -// Export all resolvers in a format that can be used with the resolveAll hook -export const ${camelName}Resolvers = { - result: ${camelName}ResultResolver, - dispatch: ${camelName}DispatchResolver, - data: { - create: ${camelName}DataResolver, - update: ${camelName}DataResolver, - patch: ${camelName}PatchResolver - }, - query: ${camelName}QueryResolver -} -` - -export const generate = (ctx: AuthenticationGeneratorContext) => - generator(ctx).then( - renderSource( - template, - toFile(({ lib, folder, fileName }: AuthenticationGeneratorContext) => [ - lib, - 'services', - ...folder, - `${fileName}.resolver` - ]), - { force: true } - ) - ) diff --git a/packages/cli/src/authentication/templates/user.schema.tpl.ts b/packages/cli/src/authentication/templates/user.schema.tpl.ts deleted file mode 100644 index 2b8e156dad..0000000000 --- a/packages/cli/src/authentication/templates/user.schema.tpl.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { generator, toFile } from '@feathershq/pinion' -import { renderSource } from '../../commons' -import { AuthenticationGeneratorContext } from '../index' - -const template = ({ - camelName, - upperName, - authStrategies, - type -}: AuthenticationGeneratorContext) => /* ts */ `import { schema, querySyntax } from '@feathersjs/schema' -import type { Infer } from '@feathersjs/schema' - -// Schema for the basic data model (e.g. creating new entries) -export const ${camelName}DataSchema = schema({ - $id: '${upperName}Data', - type: 'object', - additionalProperties: false, - required: [ ${authStrategies.includes('local') ? "'email'" : ''} ], - properties: { - ${authStrategies - .map((name) => - name === 'local' - ? ` email: { type: 'string' }, - password: { type: 'string' }` - : ` ${name}Id: { type: 'string' }` - ) - .join(',\n')} - } -} as const) - -export type ${upperName}Data = Infer - - -// Schema for making partial updates -export const ${camelName}PatchSchema = schema({ - $id: '${upperName}Patch', - type: 'object', - additionalProperties: false, - required: [], - properties: { - ...${camelName}DataSchema.properties - } -} as const) - -export type ${upperName}Patch = Infer - -// Schema for the data that is being returned -export const ${camelName}ResultSchema = schema({ - $id: '${upperName}Result', - type: 'object', - additionalProperties: false, - required: [ '${type === 'mongodb' ? '_id' : 'id'}' ], - properties: { - ...${camelName}DataSchema.properties, - ${type === 'mongodb' ? '_id' : 'id'}: { - type: '${type === 'mongodb' ? 'string' : 'number'}' - } - } -} as const) - -export type ${upperName}Result = Infer - -// Queries shouldn't allow doing anything with the password -const { password, ...${camelName}QueryProperties } = ${camelName}ResultSchema.properties - -// Schema for allowed query properties -export const ${camelName}QuerySchema = schema({ - $id: '${upperName}Query', - type: 'object', - additionalProperties: false, - properties: { - ...querySyntax(${camelName}QueryProperties) - } -} as const) - -export type ${upperName}Query = Infer -` - -export const generate = (ctx: AuthenticationGeneratorContext) => - generator(ctx).then( - renderSource( - template, - toFile(({ lib, folder, fileName }: AuthenticationGeneratorContext) => [ - lib, - 'services', - ...folder, - `${fileName}.schema` - ]), - { force: true } - ) - ) diff --git a/packages/cli/src/commons.ts b/packages/cli/src/commons.ts index ce005d471d..d21a031ac7 100644 --- a/packages/cli/src/commons.ts +++ b/packages/cli/src/commons.ts @@ -54,6 +54,10 @@ export type FeathersAppInfo = { * The HTTP framework used */ framework: 'koa' | 'express' + /** + * The main schema definition format + */ + schema: 'typebox' | 'json' } export interface AppPackageJson extends PackageJson { diff --git a/packages/cli/src/connection/templates/knex.tpl.ts b/packages/cli/src/connection/templates/knex.tpl.ts index c1a653d570..bbc0f5a9df 100644 --- a/packages/cli/src/connection/templates/knex.tpl.ts +++ b/packages/cli/src/connection/templates/knex.tpl.ts @@ -32,19 +32,11 @@ const config = app.get('${database}') ${language === 'js' ? 'export default config' : 'module.exports = config'} ` -const configurationTemplate = ({ database }: ConnectionGeneratorContext) => `${database}: { - type: 'object', - properties: { - client: { type: 'string' }, - connection: { type: 'string' } - } -},` const importTemplate = ({ database }: ConnectionGeneratorContext) => `import { ${database} } from './${database}'` const configureTemplate = ({ database }: ConnectionGeneratorContext) => `app.configure(${database})` const toAppFile = toFile(({ lib }) => [lib, 'app']) -const toConfig = toFile(({ lib }) => [lib, 'configuration']) export const generate = (ctx: ConnectionGeneratorContext) => generator(ctx) @@ -67,13 +59,5 @@ export const generate = (ctx: ConnectionGeneratorContext) => toFile('package.json') ) ) - .then( - injectSource( - configurationTemplate, - before('authentication: authenticationSettingsSchema'), - toConfig, - false - ) - ) .then(injectSource(importTemplate, before('import { services } from'), toAppFile)) .then(injectSource(configureTemplate, before('app.configure(services)'), toAppFile)) diff --git a/packages/cli/src/connection/templates/mongodb.tpl.ts b/packages/cli/src/connection/templates/mongodb.tpl.ts index 018a9c733d..4c17545d91 100644 --- a/packages/cli/src/connection/templates/mongodb.tpl.ts +++ b/packages/cli/src/connection/templates/mongodb.tpl.ts @@ -22,8 +22,6 @@ export const mongodb = (app: Application) => { } ` -const configurationTemplate = ({ database }: ConnectionGeneratorContext) => - ` ${database}: { type: 'string' },` const importTemplate = "import { mongodb } from './mongodb'" const configureTemplate = 'app.configure(mongodb)' const toAppFile = toFile(({ lib }) => [lib, 'app']) @@ -36,13 +34,5 @@ export const generate = (ctx: ConnectionGeneratorContext) => toFile(({ lib }) => lib, 'mongodb') ) ) - .then( - injectSource( - configurationTemplate, - before('authentication: authenticationSettingsSchema'), - toFile(({ lib }) => [lib, 'configuration']), - false - ) - ) .then(injectSource(importTemplate, before('import { services } from'), toAppFile)) .then(injectSource(configureTemplate, before('app.configure(services)'), toAppFile)) diff --git a/packages/cli/src/service/index.ts b/packages/cli/src/service/index.ts index 16108085af..d40aef02f9 100644 --- a/packages/cli/src/service/index.ts +++ b/packages/cli/src/service/index.ts @@ -49,6 +49,10 @@ export interface ServiceGeneratorContext extends FeathersBaseContext { * The chosen service type */ type: 'knex' | 'mongodb' | 'custom' + /** + * Which schema definition format to use + */ + schema: 'typebox' | 'json' | false /** * Wether this service uses authentication */ @@ -63,7 +67,9 @@ export interface ServiceGeneratorContext extends FeathersBaseContext { * Parameters the generator is called with */ export type ServiceGeneratorArguments = FeathersBaseContext & - Partial> + Partial< + Pick + > export const generate = (ctx: ServiceGeneratorArguments) => generator(ctx) @@ -71,19 +77,33 @@ export const generate = (ctx: ServiceGeneratorArguments) => .then(checkPreconditions()) .then( prompt( - ({ name, path, type, authentication, isEntityService }) => [ + ({ name, path, type, schema, authentication, isEntityService }) => [ { name: 'name', type: 'input', when: !name, - message: 'What is the name of your service?' + message: 'What is the name of your service?', + validate: (input) => { + if (!input || input === 'authentication') { + return 'Invalid service name' + } + + return true + } }, { name: 'path', type: 'input', when: !path, message: 'Which path should the service be registered on?', - default: (answers: ServiceGeneratorArguments) => `${_.kebabCase(answers.name)}` + default: (answers: ServiceGeneratorArguments) => `${_.kebabCase(answers.name)}`, + validate: (input) => { + if (!input || input === 'authentication') { + return 'Invalid service path' + } + + return true + } }, { name: 'authentication', @@ -111,6 +131,27 @@ export const generate = (ctx: ServiceGeneratorArguments) => name: 'A custom service' } ] + }, + { + name: 'schema', + type: 'list', + when: schema === undefined, + message: 'Which schema definition format do you want to use?', + default: ctx.feathers?.schema || 'json', + choices: [ + { + value: 'typebox', + name: 'TypeBox' + }, + { + value: 'json', + name: 'JSON schema' + }, + { + value: false, + name: 'No schema' + } + ] } ] ) diff --git a/packages/cli/src/service/templates/class.tpl.ts b/packages/cli/src/service/templates/class.tpl.ts deleted file mode 100644 index 69a8f89b7d..0000000000 --- a/packages/cli/src/service/templates/class.tpl.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { generator, toFile } from '@feathershq/pinion' -import { renderSource } from '../../commons' -import { ServiceGeneratorContext } from '../index' - -const template = ({ - camelName, - upperName, - fileName, - isEntityService, - authentication -}: ServiceGeneratorContext) => /* ts */ `import { resolveAll } from '@feathersjs/schema' -${isEntityService || authentication ? `import { authenticate } from '@feathersjs/authentication'` : ''} -import type { - ${upperName}Data, - ${upperName}Result, - ${upperName}Query, -} from './${fileName}.schema' -import { ${camelName}Resolvers } from './${fileName}.resolver' - -export const ${camelName}Hooks = { - around: { - all: [${ - authentication - ? ` - authenticate('jwt'),` - : '' - } ${ - !isEntityService - ? ` - resolveAll(${camelName}Resolvers)` - : '' -} - ]${ - isEntityService - ? `, - get: [ - authenticate('jwt'), - resolveAll(${camelName}Resolvers) - ], - find: [ - authenticate('jwt'), - resolveAll(${camelName}Resolvers) - ], - create: [ - resolveAll(${camelName}Resolvers) - ], - patch: [ - authenticate('jwt'), - resolveAll(${camelName}Resolvers) - ], - update: [ - authenticate('jwt'), - resolveAll(${camelName}Resolvers) - ], - remove: [ - authenticate('jwt'), - resolveAll(${camelName}Resolvers) - ]` - : '' - } - }, - before: {}, - after: {}, - error: {} -} -` -export const generate = (ctx: ServiceGeneratorContext) => - generator(ctx).then( - renderSource( - template, - toFile(({ lib, folder, fileName }) => [ - lib, - 'services', - ...folder, - `${fileName}.class` - ]) - ) - ) diff --git a/packages/cli/src/service/templates/client.tpl.ts b/packages/cli/src/service/templates/client.tpl.ts index efc738598b..f514a9c6ef 100644 --- a/packages/cli/src/service/templates/client.tpl.ts +++ b/packages/cli/src/service/templates/client.tpl.ts @@ -1,36 +1,53 @@ -import { generator, inject, toFile, when, after } from '@feathershq/pinion' +import { generator, inject, toFile, when, after, before } from '@feathershq/pinion' +import { injectSource } from '../../commons' import { ServiceGeneratorContext } from '../index' const schemaImports = ({ upperName, folder, fileName }: ServiceGeneratorContext) => /* ts */ `import type { + ${upperName}, ${upperName}Data, - ${upperName}Patch, - ${upperName}Result, ${upperName}Query, -} from './services/${folder.join('/')}/${fileName}.schema' +} from './services/${folder.join('/')}/${fileName}' export type { + ${upperName}, ${upperName}Data, - ${upperName}Patch, - ${upperName}Result, ${upperName}Query, }` const declarationTemplate = ({ path, upperName }: ServiceGeneratorContext) => ` '${path}': ClientService< - ${upperName}Result, + ${upperName}, ${upperName}Data, - ${upperName}Patch, - Paginated<${upperName}Result>, + Partial<${upperName}Data>, + Paginated<${upperName}>, Params<${upperName}Query> - >` + > & { + // Add custom methods here + }` + +const registrationTemplate = ({ + path +}: ServiceGeneratorContext) => ` client.use('${path}', connection.service('${path}'), { + // List all standard and custom methods + methods: ['find', 'get', 'create', 'update', 'patch', 'remove'] +}) +` const toClientFile = toFile(({ lib }) => [lib, 'client.ts']) export const generate = async (ctx: ServiceGeneratorContext) => - generator(ctx).then( - when( - (ctx) => ctx.language === 'ts', - inject(schemaImports, after("from '@feathersjs/feathers'"), toClientFile), - inject(declarationTemplate, after('export interface ServiceTypes'), toClientFile) + generator(ctx) + .then( + injectSource( + registrationTemplate, + before('return client'), + toFile(({ lib }) => [lib, 'client']) + ) + ) + .then( + when( + (ctx) => ctx.language === 'ts', + inject(schemaImports, after("from '@feathersjs/feathers'"), toClientFile), + inject(declarationTemplate, after('export interface ServiceTypes'), toClientFile) + ) ) - ) diff --git a/packages/cli/src/service/templates/resolver.tpl.ts b/packages/cli/src/service/templates/resolver.tpl.ts deleted file mode 100644 index 4cf83ffb72..0000000000 --- a/packages/cli/src/service/templates/resolver.tpl.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { generator, toFile } from '@feathershq/pinion' -import { renderSource } from '../../commons' -import { ServiceGeneratorContext } from '../index' - -const template = ({ - camelName, - upperName, - relative, - fileName -}: ServiceGeneratorContext) => /* ts */ `import { resolve } from '@feathersjs/schema' -import type { HookContext } from '${relative}/declarations' - -import type { - ${upperName}Data, - ${upperName}Patch, - ${upperName}Result, - ${upperName}Query, -} from './${fileName}.schema' -import { - ${camelName}DataSchema, - ${camelName}PatchSchema, - ${camelName}ResultSchema, - ${camelName}QuerySchema -} from './${fileName}.schema' - - -// Resolver for the basic data model (e.g. creating new entries) -export const ${camelName}DataResolver = resolve<${upperName}Data, HookContext>({ - schema: ${camelName}DataSchema, - validate: 'before', - properties: {} -}) - - -// Resolver for making partial updates -export const ${camelName}PatchResolver = resolve<${upperName}Patch, HookContext>({ - schema: ${camelName}PatchSchema, - validate: 'before', - properties: {} -}) - - -// Resolver for the data that is being returned -export const ${camelName}ResultResolver = resolve<${upperName}Result, HookContext>({ - schema: ${camelName}ResultSchema, - validate: false, - properties: {} -}) - - -// Resolver for query properties -export const ${camelName}QueryResolver = resolve<${upperName}Query, HookContext>({ - schema: ${camelName}QuerySchema, - validate: 'before', - properties: {} -}) - - -// Export all resolvers in a format that can be used with the resolveAll hook -export const ${camelName}Resolvers = { - result: ${camelName}ResultResolver, - data: { - create: ${camelName}DataResolver, - update: ${camelName}DataResolver, - patch: ${camelName}PatchResolver - }, - query: ${camelName}QueryResolver -} -` - -export const generate = (ctx: ServiceGeneratorContext) => - generator(ctx).then( - renderSource( - template, - toFile(({ lib, folder, fileName }: ServiceGeneratorContext) => [ - lib, - 'services', - ...folder, - `${fileName}.resolver` - ]) - ) - ) diff --git a/packages/cli/src/service/templates/schema.json.tpl.ts b/packages/cli/src/service/templates/schema.json.tpl.ts new file mode 100644 index 0000000000..c377ebb9ae --- /dev/null +++ b/packages/cli/src/service/templates/schema.json.tpl.ts @@ -0,0 +1,85 @@ +import { generator, toFile, when } from '@feathershq/pinion' +import { renderSource } from '../../commons' +import { ServiceGeneratorContext } from '../index' + +const template = ({ + camelName, + upperName, + relative, + type +}: ServiceGeneratorContext) => /* ts */ `import { jsonSchema, resolve } from '@feathersjs/schema' +import type { FromSchema } from '@feathersjs/schema' + +import type { HookContext } from '${relative}/declarations' +import { dataValidator, queryValidator } from '${relative}/schemas/validators' + +// Schema for the basic data model (e.g. creating new entries) +export const ${camelName}DataSchema = { + $id: '${upperName}Data', + type: 'object', + additionalProperties: false, + required: [ 'text' ], + properties: { + text: { + type: 'string' + } + } +} as const +export type ${upperName}Data = FromSchema +export const ${camelName}DataValidator = jsonSchema.getDataValidator(${camelName}DataSchema, dataValidator) +export const ${camelName}DataResolver = resolve<${upperName}Data, HookContext>({ + properties: {} +}) + +// Schema for the data that is being returned +export const ${camelName}Schema = { + $id: '${upperName}', + type: 'object', + additionalProperties: false, + required: [ ...${camelName}DataSchema.required, '${type === 'mongodb' ? '_id' : 'id'}' ], + properties: { + ...${camelName}DataSchema.properties, + ${type === 'mongodb' ? '_id' : 'id'}: { + type: '${type === 'mongodb' ? 'string' : 'number'}' + } + } +} as const +export type ${upperName} = FromSchema +export const ${camelName}Resolver = resolve<${upperName}, HookContext>({ + properties: {} +}) +export const ${camelName}ExternalResolver = resolve<${upperName}, HookContext>({ + properties: {} +}) + +// Schema for allowed query properties +export const ${camelName}QuerySchema = { + $id: '${upperName}Query', + type: 'object', + additionalProperties: false, + properties: { + ...jsonSchema.querySyntax(${camelName}Schema.properties) + } +} as const +export type ${upperName}Query = FromSchema +export const ${camelName}QueryValidator = jsonSchema.getValidator(${camelName}QuerySchema, queryValidator) +export const ${camelName}QueryResolver = resolve<${upperName}Query, HookContext>({ + properties: {} +}) +` + +export const generate = (ctx: ServiceGeneratorContext) => + generator(ctx).then( + when( + ({ schema }) => schema === 'json', + renderSource( + template, + toFile(({ lib, folder, fileName }: ServiceGeneratorContext) => [ + lib, + 'services', + ...folder, + `${fileName}.schema` + ]) + ) + ) + ) diff --git a/packages/cli/src/service/templates/schema.tpl.ts b/packages/cli/src/service/templates/schema.tpl.ts deleted file mode 100644 index c8cab59c71..0000000000 --- a/packages/cli/src/service/templates/schema.tpl.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { generator, toFile } from '@feathershq/pinion' -import { renderSource } from '../../commons' -import { ServiceGeneratorContext } from '../index' - -const template = ({ - camelName, - upperName, - type -}: ServiceGeneratorContext) => /* ts */ `import { schema, querySyntax } from '@feathersjs/schema' -import type { Infer } from '@feathersjs/schema' - -// Schema for the basic data model (e.g. creating new entries) -export const ${camelName}DataSchema = schema({ - $id: '${upperName}Data', - type: 'object', - additionalProperties: false, - required: [ 'text' ], - properties: { - text: { - type: 'string' - } - } -} as const) - -export type ${upperName}Data = Infer - - -// Schema for making partial updates -export const ${camelName}PatchSchema = schema({ - $id: '${upperName}Patch', - type: 'object', - additionalProperties: false, - required: [], - properties: { - ...${camelName}DataSchema.properties - } -} as const) - -export type ${upperName}Patch = Infer - - -// Schema for the data that is being returned -export const ${camelName}ResultSchema = schema({ - $id: '${upperName}Result', - type: 'object', - additionalProperties: false, - required: [ ...${camelName}DataSchema.required, '${type === 'mongodb' ? '_id' : 'id'}' ], - properties: { - ...${camelName}DataSchema.properties, - ${type === 'mongodb' ? '_id' : 'id'}: { - type: '${type === 'mongodb' ? 'string' : 'number'}' - } - } -} as const) - -export type ${upperName}Result = Infer - - -// Schema for allowed query properties -export const ${camelName}QuerySchema = schema({ - $id: '${upperName}Query', - type: 'object', - additionalProperties: false, - properties: { - ...querySyntax(${camelName}ResultSchema.properties) - } -} as const) - -export type ${upperName}Query = Infer -` - -export const generate = (ctx: ServiceGeneratorContext) => - generator(ctx).then( - renderSource( - template, - toFile(({ lib, folder, fileName }: ServiceGeneratorContext) => [ - lib, - 'services', - ...folder, - `${fileName}.schema` - ]) - ) - ) diff --git a/packages/cli/src/service/templates/schema.typebox.tpl.ts b/packages/cli/src/service/templates/schema.typebox.tpl.ts new file mode 100644 index 0000000000..b2cd3f2a2a --- /dev/null +++ b/packages/cli/src/service/templates/schema.typebox.tpl.ts @@ -0,0 +1,70 @@ +import { generator, toFile, when } from '@feathershq/pinion' +import { renderSource } from '../../commons' +import { ServiceGeneratorContext } from '../index' + +const template = ({ + camelName, + upperName, + relative, + type +}: ServiceGeneratorContext) => /* ts */ `import { jsonSchema, resolve } from '@feathersjs/schema' +import { Type, querySyntax } from '@feathersjs/typebox' +import type { Static } from '@feathersjs/typebox' + +import type { HookContext } from '${relative}/declarations' +import { dataValidator, queryValidator } from '${relative}/schemas/validators' + +// Schema for the basic data model (e.g. creating new entries) +export const ${camelName}DataSchema = Type.Object({ + text: Type.String() +}, { $id: '${upperName}Data', additionalProperties: false }) +export type ${upperName}Data = Static +export const ${camelName}DataValidator = jsonSchema.getDataValidator(${camelName}DataSchema, dataValidator) +export const ${camelName}DataResolver = resolve<${upperName}Data, HookContext>({ + properties: {} +}) + +// Schema for the data that is being returned +export const ${camelName}Schema = Type.Intersect([ + ${camelName}DataSchema, + Type.Object({ + ${type === 'mongodb' ? '_id: Type.String()' : 'id: Type.Number()'} + }) +], { $id: '${upperName}', additionalProperties: false }) +export type ${upperName} = Static +export const ${camelName}Resolver = resolve<${upperName}, HookContext>({ + properties: {} +}) + +export const ${camelName}ExternalResolver = resolve<${upperName}, HookContext>({ + properties: {} +}) + +// Schema for allowed query properties +export const ${camelName}QuerySchema = Type.Intersect([ + querySyntax(${camelName}Schema), + // Add additional query properties here + Type.Object({}) +]) +export type ${upperName}Query = Static +export const ${camelName}QueryValidator = jsonSchema.getValidator(${camelName}QuerySchema, queryValidator) +export const ${camelName}QueryResolver = resolve<${upperName}Query, HookContext>({ + properties: {} +}) +` + +export const generate = (ctx: ServiceGeneratorContext) => + generator(ctx).then( + when( + ({ schema }) => schema === 'typebox', + renderSource( + template, + toFile(({ lib, folder, fileName }: ServiceGeneratorContext) => [ + lib, + 'services', + ...folder, + `${fileName}.schema` + ]) + ) + ) + ) diff --git a/packages/cli/src/service/templates/service.tpl.ts b/packages/cli/src/service/templates/service.tpl.ts index 549499fb46..0e397471f2 100644 --- a/packages/cli/src/service/templates/service.tpl.ts +++ b/packages/cli/src/service/templates/service.tpl.ts @@ -1,31 +1,97 @@ -import { generator, prepend, toFile, after } from '@feathershq/pinion' +import { generator, toFile, after, prepend } from '@feathershq/pinion' import { injectSource, renderSource } from '../../commons' import { ServiceGeneratorContext } from '../index' -const template = ({ - relative, +export const template = ({ + camelName, + authentication, + isEntityService, path, className, - camelName, + relative, + schema, fileName -}: ServiceGeneratorContext) => /* ts */ `import type { Application } from '${relative}/declarations' +}: ServiceGeneratorContext) => /* ts */ ` +${authentication || isEntityService ? `import { authenticate } from '@feathersjs/authentication'` : ''} +${ + schema + ? ` +import { hooks as schemaHooks } from '@feathersjs/schema' + +import { + ${camelName}DataValidator, + ${camelName}QueryValidator, + ${camelName}Resolver, + ${camelName}DataResolver, + ${camelName}QueryResolver, + ${camelName}ExternalResolver +} from './${fileName}.schema' +` + : '' +} -import { ${className}, ${camelName}Hooks } from './${fileName}.class' +import type { Application } from '${relative}/declarations' +import { ${className}, getOptions } from './${fileName}.class' -// A configure function that registers the service and its hooks via \`app.configure\` -export function ${camelName} (app: Application) { - const options = { // Service options will go here - } +export * from './${fileName}.class' +${schema ? `export * from './${fileName}.schema'` : ''} +// A configure function that registers the service and its hooks via \`app.configure\` +export const ${camelName} = (app: Application) => { // Register our service on the Feathers application - app.use('${path}', new ${className}(options), { + app.use('${path}', new ${className}(getOptions(app)), { // A list of all methods this service exposes externally methods: ['find', 'get', 'create', 'update', 'patch', 'remove'], // You can add additional custom events to be sent to clients here events: [] }) // Initialize hooks - app.service('${path}').hooks(${camelName}Hooks) + app.service('${path}').hooks({ + around: { + all: [${ + authentication + ? ` + authenticate('jwt'),` + : '' + } + ]${ + isEntityService + ? `, + find: [ authenticate('jwt') ], + get: [ authenticate('jwt') ], + create: [], + update: [ authenticate('jwt') ], + patch: [ authenticate('jwt') ], + remove: [ authenticate('jwt') ]` + : '' + } + }, + before: { + all: [${ + schema + ? ` + schemaHooks.validateQuery(${camelName}QueryValidator), + schemaHooks.validateData(${camelName}DataValidator), + schemaHooks.resolveQuery(${camelName}QueryResolver), + schemaHooks.resolveData(${camelName}DataResolver) + ` + : '' + }] + }, + after: { + all: [${ + schema + ? ` + schemaHooks.resolveResult(${camelName}Resolver), + schemaHooks.resolveExternal(${camelName}ExternalResolver) + ` + : '' + }] + }, + error: { + all: [] + } + }) } // Add this service to the service type index @@ -36,11 +102,6 @@ declare module '${relative}/declarations' { } ` -const importTemplate = ({ camelName, folder, fileName }: ServiceGeneratorContext) => - `import { ${camelName} } from './${folder.join('/')}/${fileName}.service'` - -const configureTemplate = ({ camelName }: ServiceGeneratorContext) => ` app.configure(${camelName})` - const toServiceIndex = toFile(({ lib }: ServiceGeneratorContext) => [lib, 'services', `index`]) export const generate = (ctx: ServiceGeneratorContext) => @@ -48,13 +109,26 @@ export const generate = (ctx: ServiceGeneratorContext) => .then( renderSource( template, - toFile(({ lib, folder, fileName }) => [ + toFile(({ lib, fileName, folder }: ServiceGeneratorContext) => [ lib, 'services', ...folder, - `${fileName}.service` + `${fileName}` ]) ) ) - .then(injectSource(importTemplate, prepend(), toServiceIndex)) - .then(injectSource(configureTemplate, after('export const services'), toServiceIndex)) + .then( + injectSource( + ({ camelName, folder, fileName }) => + `import { ${camelName} } from './${folder.join('/')}/${fileName}'`, + prepend(), + toServiceIndex + ) + ) + .then( + injectSource( + ({ camelName }) => ` app.configure(${camelName})`, + after('export const services'), + toServiceIndex + ) + ) diff --git a/packages/cli/src/service/type/custom.tpl.ts b/packages/cli/src/service/type/custom.tpl.ts index 37161dd3f6..1c244601c9 100644 --- a/packages/cli/src/service/type/custom.tpl.ts +++ b/packages/cli/src/service/type/custom.tpl.ts @@ -1,13 +1,26 @@ -import { generator, toFile, after, prepend, append } from '@feathershq/pinion' -import { injectSource } from '../../commons' +import { generator, toFile } from '@feathershq/pinion' +import { renderSource } from '../../commons' import { ServiceGeneratorContext } from '../index' -export const template = ({ - className, - upperName, - relative -}: ServiceGeneratorContext) => /* ts */ `import type { Application } from '${relative}/declarations' - +export const template = ({ className, upperName, schema, fileName, relative }: ServiceGeneratorContext) => ` +import type { Id, NullableId, Params } from '@feathersjs/feathers' + +import type { Application } from '${relative}/declarations' +${ + schema + ? `import type { + ${upperName}, + ${upperName}Data, + ${upperName}Query +} from './${fileName}.schema' +` + : ` +export type ${upperName} = any +export type ${upperName}Data = any +export type ${upperName}Query = any +` +} + export interface ${className}Options { app: Application } @@ -21,20 +34,20 @@ export class ${className} { constructor (public options: ${className}Options) { } - async find (_params?: ${upperName}Params): Promise<${upperName}Result[]> { + async find (_params?: ${upperName}Params): Promise<${upperName}[]> { return [] } - async get (id: Id, _params?: ${upperName}Params): Promise<${upperName}Result> { + async get (id: Id, _params?: ${upperName}Params): Promise<${upperName}> { return { id: 0, text: \`A new message with ID: \${id}!\` } } - async create (data: ${upperName}Data, params?: ${upperName}Params): Promise<${upperName}Result> - async create (data: ${upperName}Data[], params?: ${upperName}Params): Promise<${upperName}Result[]> - async create (data: ${upperName}Data|${upperName}Data[], params?: ${upperName}Params): Promise<${upperName}Result|${upperName}Result[]> { + async create (data: ${upperName}Data, params?: ${upperName}Params): Promise<${upperName}> + async create (data: ${upperName}Data[], params?: ${upperName}Params): Promise<${upperName}[]> + async create (data: ${upperName}Data|${upperName}Data[], params?: ${upperName}Params): Promise<${upperName}|${upperName}[]> { if (Array.isArray(data)) { return Promise.all(data.map(current => this.create(current, params))); } @@ -45,49 +58,42 @@ export class ${className} { } } - async update (id: NullableId, data: ${upperName}Data, _params?: ${upperName}Params): Promise<${upperName}Result> { + async update (id: NullableId, data: ${upperName}Data, _params?: ${upperName}Params): Promise<${upperName}> { return { id: 0, ...data } } - async patch (id: NullableId, data: ${upperName}Data, _params?: ${upperName}Params): Promise<${upperName}Result> { + async patch (id: NullableId, data: ${upperName}Data, _params?: ${upperName}Params): Promise<${upperName}> { return { id: 0, ...data } } - async remove (id: NullableId, _params?: ${upperName}Params): Promise<${upperName}Result> { + async remove (id: NullableId, _params?: ${upperName}Params): Promise<${upperName}> { return { id: 0, text: 'removed' } } } -` -export const importTemplate = "import type { Id, NullableId, Params } from '@feathersjs/feathers'" - -const optionTemplate = ({}: ServiceGeneratorContext) => ` app` - -const toServiceFile = toFile(({ lib, folder, fileName }) => [ - lib, - 'services', - ...folder, - `${fileName}.service` -]) - -const toClassFile = toFile(({ lib, folder, fileName }) => [ - lib, - 'services', - ...folder, - `${fileName}.class` -]) +export const getOptions = (app: Application) => { + return { app } +} +` export const generate = (ctx: ServiceGeneratorContext) => - generator(ctx) - .then(injectSource(template, append(), toClassFile)) - .then(injectSource(importTemplate, prepend(), toClassFile)) - .then(injectSource(optionTemplate, after('const options ='), toServiceFile, false)) + generator(ctx).then( + renderSource( + template, + toFile(({ lib, folder, fileName }) => [ + lib, + 'services', + ...folder, + `${fileName}.class` + ]) + ) + ) diff --git a/packages/cli/src/service/type/knex.tpl.ts b/packages/cli/src/service/type/knex.tpl.ts index b1037708c4..b3378949b6 100644 --- a/packages/cli/src/service/type/knex.tpl.ts +++ b/packages/cli/src/service/type/knex.tpl.ts @@ -1,5 +1,5 @@ -import { generator, toFile, after, prepend, append } from '@feathershq/pinion' -import { injectSource, renderSource } from '../../commons' +import { generator, toFile } from '@feathershq/pinion' +import { renderSource } from '../../commons' import { ServiceGeneratorContext } from '../index' const migrationTemplate = ({ @@ -18,42 +18,62 @@ export async function down(knex: Knex): Promise { } ` -export const importTemplate = /* ts */ `import { KnexService } from \'@feathersjs/knex\' -import type { KnexAdapterParams } from \'@feathersjs/knex\'` +export const template = ({ + className, + upperName, + kebabName, + feathers, + schema, + fileName, + relative +}: ServiceGeneratorContext) => /* ts */ `import { KnexService } from '@feathersjs/knex' +import type { KnexAdapterParams } from '@feathersjs/knex' -export const classCode = ({ className, upperName }: ServiceGeneratorContext) => - `export interface ${upperName}Params extends KnexAdapterParams<${upperName}Query> { +import type { Application } from '${relative}/declarations' +${ + schema + ? `import type { + ${upperName}, + ${upperName}Data, + ${upperName}Query +} from './${fileName}.schema' +` + : ` +export type ${upperName} = any +export type ${upperName}Data = any +export type ${upperName}Query = any +` +} + +export interface ${upperName}Params extends KnexAdapterParams<${upperName}Query> { } // By default calls the standard Knex adapter service methods but can be customized with your own functionality. -export class ${className} extends KnexService<${upperName}Result, ${upperName}Data, ${upperName}Params> { +export class ${className} extends KnexService<${upperName}, ${upperName}Data, ${upperName}Params> { } -` -export const optionTemplate = ({ kebabName, feathers }: ServiceGeneratorContext) => - ` paginate: app.get('paginate'), +export const getOptions = (app: Application) => { + return { + paginate: app.get('paginate'), Model: app.get('${feathers.database}Client'), - name: '${kebabName}'` - -const toServiceFile = toFile(({ lib, folder, fileName }) => [ - lib, - 'services', - ...folder, - `${fileName}.service` -]) - -const toClassFile = toFile(({ lib, folder, fileName }) => [ - lib, - 'services', - ...folder, - `${fileName}.class` -]) + name: '${kebabName}' + } +} +` export const generate = (ctx: ServiceGeneratorContext) => generator(ctx) - .then(injectSource(classCode, append(), toClassFile)) - .then(injectSource(importTemplate, prepend(), toClassFile)) - .then(injectSource(optionTemplate, after('const options ='), toServiceFile, false)) + .then( + renderSource( + template, + toFile(({ lib, folder, fileName }) => [ + lib, + 'services', + ...folder, + `${fileName}.class` + ]) + ) + ) .then( renderSource( migrationTemplate, diff --git a/packages/cli/src/service/type/mongodb.tpl.ts b/packages/cli/src/service/type/mongodb.tpl.ts index 060a6ee541..e99238ddf9 100644 --- a/packages/cli/src/service/type/mongodb.tpl.ts +++ b/packages/cli/src/service/type/mongodb.tpl.ts @@ -1,41 +1,57 @@ -import { generator, toFile, after, prepend, append } from '@feathershq/pinion' -import { injectSource } from '../../commons' +import { generator, toFile } from '@feathershq/pinion' +import { renderSource } from '../../commons' import { ServiceGeneratorContext } from '../index' -export const importTemplate = /* ts */ `import { MongoDBService } from \'@feathersjs/mongodb\' -import type { MongoDBAdapterParams } from \'@feathersjs/mongodb\'` - -export const classCode = ({ +export const template = ({ className, - upperName -}: ServiceGeneratorContext) => /* ts */ `export interface ${upperName}Params extends MongoDBAdapterParams<${upperName}Query> { -} + upperName, + kebabName, + schema, + fileName, + relative +}: ServiceGeneratorContext) => /* ts */ `import { MongoDBService } from \'@feathersjs/mongodb\' +import type { MongoDBAdapterParams } from \'@feathersjs/mongodb\' -// By default calls the standard MongoDB adapter service methods but can be customized with your own functionality. -export class ${className} extends MongoDBService<${upperName}Result, ${upperName}Data, ${upperName}Params> { -} +import type { Application } from '${relative}/declarations' +${ + schema + ? `import type { + ${upperName}, + ${upperName}Data, + ${upperName}Query +} from './${fileName}.schema' +` + : ` +export type ${upperName} = any +export type ${upperName}Data = any +export type ${upperName}Query = any ` +} -const optionTemplate = ({ kebabName }: ServiceGeneratorContext) => - ` paginate: app.get('paginate'), - Model: app.get('mongodbClient').then(db => db.collection('${kebabName}'))` +export interface ${upperName}Params extends MongoDBAdapterParams<${upperName}Query> { +} -const toServiceFile = toFile(({ lib, folder, fileName }) => [ - lib, - 'services', - ...folder, - `${fileName}.service` -]) +// By default calls the standard MongoDB adapter service methods but can be customized with your own functionality. +export class ${className} extends MongoDBService<${upperName}, ${upperName}Data, ${upperName}Params> { +} -const toClassFile = toFile(({ lib, folder, fileName }) => [ - lib, - 'services', - ...folder, - `${fileName}.class` -]) +export const getOptions = (app: Application) => { + return { + paginate: app.get('paginate'), + Model: app.get('mongodbClient').then(db => db.collection('${kebabName}')) + } +} +` export const generate = (ctx: ServiceGeneratorContext) => - generator(ctx) - .then(injectSource(classCode, append(), toClassFile)) - .then(injectSource(importTemplate, prepend(), toClassFile)) - .then(injectSource(optionTemplate, after('const options ='), toServiceFile, false)) + generator(ctx).then( + renderSource( + template, + toFile(({ lib, folder, fileName }) => [ + lib, + 'services', + ...folder, + `${fileName}.class` + ]) + ) + ) diff --git a/packages/cli/test/generators.test.ts b/packages/cli/test/generators.test.ts index 07808a2db9..7a9e7b84fb 100644 --- a/packages/cli/test/generators.test.ts +++ b/packages/cli/test/generators.test.ts @@ -38,6 +38,7 @@ describe('@feathersjs/cli', () => { before(async () => { cwd = await mkdtemp(path.join(os.tmpdir(), name + '-')) + console.log(cwd) context = await generateApp( getContext( { @@ -52,7 +53,7 @@ describe('@feathersjs/cli', () => { connectionString: `${name}.sqlite`, transports: ['rest', 'websockets'], authStrategies: ['local', 'github'], - _: ['generate', 'app'] + schema: 'typebox' }, { cwd } ) @@ -72,13 +73,12 @@ describe('@feathersjs/cli', () => { { dependencyVersions, database: 'mongodb' as const, - connectionString: `mongodb://localhost:27017/${name}`, - _: ['generate', 'connection'] + connectionString: `mongodb://localhost:27017/${name}` }, { cwd } ) ) - const mongoServiceContext = await generateService( + const mongoService1Context = await generateService( getContext( { dependencyVersions, @@ -86,7 +86,20 @@ describe('@feathersjs/cli', () => { path: 'path/to/test', authentication: true, type: 'mongodb', - _: ['generate', 'service'] + schema: false + }, + { cwd } + ) + ) + const messageServiceContext = await generateService( + getContext( + { + dependencyVersions, + name: 'message', + path: 'messages', + authentication: true, + type: 'mongodb', + schema: 'typebox' }, { cwd } ) @@ -94,7 +107,8 @@ describe('@feathersjs/cli', () => { const testResult = await context.pinion.exec('npm', ['test'], { cwd }) assert.ok(connectionContext) - assert.ok(mongoServiceContext) + assert.ok(mongoService1Context) + assert.ok(messageServiceContext) assert.strictEqual(testResult, 0) }) @@ -103,11 +117,11 @@ describe('@feathersjs/cli', () => { getContext( { dependencyVersions, - name: 'Custom Service', - path: 'custom', + name: 'Custom', + path: 'customized', authentication: false, type: 'custom', - _: ['generate', 'service'] + schema: 'json' }, { cwd } ) diff --git a/packages/configuration/src/index.ts b/packages/configuration/src/index.ts index de3da8aab7..a34606d679 100644 --- a/packages/configuration/src/index.ts +++ b/packages/configuration/src/index.ts @@ -1,11 +1,13 @@ import { Application, ApplicationHookContext, NextFunction } from '@feathersjs/feathers' import { createDebug } from '@feathersjs/commons' -import { Schema } from '@feathersjs/schema' +import { Schema, Validator } from '@feathersjs/schema' import config from 'config' const debug = createDebug('@feathersjs/configuration') -export = function init(schema?: Schema) { +export = function init(schema?: Schema | Validator) { + const validator: Validator = typeof schema === 'function' ? schema : schema?.validate.bind(schema) + return (app?: Application) => { if (!app) { return config @@ -21,11 +23,11 @@ export = function init(schema?: Schema) { app.set(name, value) }) - if (schema) { + if (validator) { app.hooks({ setup: [ async (_context: ApplicationHookContext, next: NextFunction) => { - await schema.validate(configuration) + await validator(configuration) await next() } ] diff --git a/packages/schema/package.json b/packages/schema/package.json index 029b5a1207..aa6a2381ea 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -60,6 +60,7 @@ "@feathersjs/hooks": "^0.7.5", "@types/json-schema": "^7.0.11", "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", "json-schema": "^0.4.0", "json-schema-to-ts": "^2.5.5" }, diff --git a/packages/schema/src/default-schemas.ts b/packages/schema/src/default-schemas.ts new file mode 100644 index 0000000000..a3ef7726c3 --- /dev/null +++ b/packages/schema/src/default-schemas.ts @@ -0,0 +1,157 @@ +import { FromSchema } from 'json-schema-to-ts' + +export const authenticationSettingsSchema = { + type: 'object', + required: ['secret', 'entity', 'authStrategies'], + properties: { + secret: { + type: 'string', + description: 'The JWT signing secret' + }, + entity: { + oneOf: [ + { + type: 'null' + }, + { + type: 'string' + } + ], + description: 'The name of the authentication entity (e.g. user)' + }, + entityId: { + type: 'string', + description: 'The name of the authentication entity id property' + }, + service: { + type: 'string', + description: 'The path of the entity service' + }, + authStrategies: { + type: 'array', + items: { type: 'string' }, + description: 'A list of authentication strategy names that are allowed to create JWT access tokens' + }, + parseStrategies: { + type: 'array', + items: { type: 'string' }, + description: + 'A list of authentication strategy names that should parse HTTP headers for authentication information (defaults to `authStrategies`)' + }, + jwtOptions: { + type: 'object' + }, + jwt: { + type: 'object', + properties: { + header: { + type: 'string', + default: 'Authorization', + description: 'The HTTP header containing the JWT' + }, + schemes: { + type: 'array', + items: { type: 'string' }, + description: 'An array of schemes to support' + } + } + }, + local: { + type: 'object', + required: ['usernameField', 'passwordField'], + properties: { + usernameField: { + type: 'string', + description: 'Name of the username field (e.g. `email`)' + }, + passwordField: { + type: 'string', + description: 'Name of the password field (e.g. `password`)' + }, + hashSize: { + type: 'number', + description: 'The BCrypt salt length' + }, + errorMessage: { + type: 'string', + default: 'Invalid login', + description: 'The error message to return on errors' + }, + entityUsernameField: { + type: 'string', + description: + 'Name of the username field on the entity if authentication request data and entity field names are different' + }, + entityPasswordField: { + type: 'string', + description: + 'Name of the password field on the entity if authentication request data and entity field names are different' + } + } + }, + oauth: { + type: 'object', + properties: { + redirect: { + type: 'string' + }, + origins: { + type: 'array', + items: { type: 'string' } + }, + defaults: { + type: 'object', + properties: { + key: { type: 'string' }, + secret: { type: 'string' } + } + } + } + } + } +} as const + +export type AuthenticationConfiguration = FromSchema + +export const sqlSettingsSchema = { + type: 'object', + properties: { + client: { type: 'string' }, + connection: { type: 'string' } + } +} as const + +/** + * Schema for properties that are available in a standard Feathers application. + */ +export const defaultAppSettings = { + authentication: authenticationSettingsSchema, + origins: { + type: 'array', + items: { + type: 'string' + } + }, + paginate: { + type: 'object', + additionalProperties: false, + required: ['default', 'max'], + properties: { + default: { type: 'number' }, + max: { type: 'number' } + } + }, + mongodb: { type: 'string' }, + mysql: sqlSettingsSchema, + postgresql: sqlSettingsSchema, + sqlite: sqlSettingsSchema, + mssql: sqlSettingsSchema +} as const + +export const defaultAppConfiguration = { + type: 'object', + additionalProperties: false, + properties: defaultAppSettings +} as const + +export type DefaultAppConfiguration = FromSchema diff --git a/packages/schema/src/hooks/resolve.ts b/packages/schema/src/hooks/resolve.ts index ac6364126c..8312c450a9 100644 --- a/packages/schema/src/hooks/resolve.ts +++ b/packages/schema/src/hooks/resolve.ts @@ -170,6 +170,8 @@ export const resolveDispatch = }) } +export const resolveExternal = resolveDispatch + export const resolveAll = (map: ResolveAllSettings) => { const middleware = [] diff --git a/packages/schema/src/hooks/validate.ts b/packages/schema/src/hooks/validate.ts index 5dcf2c901d..8c1b37a8da 100644 --- a/packages/schema/src/hooks/validate.ts +++ b/packages/schema/src/hooks/validate.ts @@ -1,14 +1,16 @@ import { HookContext, NextFunction } from '@feathersjs/feathers' -import { BadRequest } from '../../../errors/lib' -import { Schema } from '../schema' +import { BadRequest } from '@feathersjs/errors' +import { Schema, Validator } from '../schema' +import { DataValidatorMap } from '../json-schema' -export const validateQuery = - (schema: Schema) => - async (context: H, next?: NextFunction) => { +export const validateQuery = (schema: Schema | Validator) => { + const validator: Validator = typeof schema === 'function' ? schema : schema.validate.bind(schema) + + return async (context: H, next?: NextFunction) => { const data = context?.params?.query || {} try { - const query = await schema.validate(data) + const query = await validator(data) context.params = { ...context.params, @@ -22,23 +24,30 @@ export const validateQuery = throw error.ajv ? new BadRequest(error.message, error.errors) : error } } +} -export const validateData = - (schema: Schema) => - async (context: H, next?: NextFunction) => { +export const validateData = (schema: Schema | DataValidatorMap) => { + return async (context: H, next?: NextFunction) => { const data = context.data + const validator = + typeof (schema as Schema).validate === 'function' + ? (schema as Schema).validate.bind(schema) + : (schema as any)[context.method] - try { - if (Array.isArray(data)) { - context.data = await Promise.all(data.map((current) => schema.validate(current))) - } else { - context.data = await schema.validate(data) + if (validator) { + try { + if (Array.isArray(data)) { + context.data = await Promise.all(data.map((current) => validator(current))) + } else { + context.data = await validator(data) + } + } catch (error: any) { + throw error.ajv ? new BadRequest(error.message, error.errors) : error } - } catch (error: any) { - throw error.ajv ? new BadRequest(error.message, error.errors) : error } if (typeof next === 'function') { return next() } } +} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 9d583be639..6e5e2ba8fa 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,10 +1,17 @@ +import addFormats, { FormatName, FormatOptions, FormatsPluginOptions } from 'ajv-formats' import { ResolverStatus } from './resolver' export type { FromSchema } from 'json-schema-to-ts' +export { addFormats, FormatName, FormatOptions, FormatsPluginOptions } + export * from './schema' export * from './resolver' export * from './hooks' -export * from './query' +export * from './json-schema' +export * from './default-schemas' + +export * as hooks from './hooks' +export * as jsonSchema from './json-schema' export type Infer = S['_type'] diff --git a/packages/schema/src/json-schema.ts b/packages/schema/src/json-schema.ts new file mode 100644 index 0000000000..46c64f30c9 --- /dev/null +++ b/packages/schema/src/json-schema.ts @@ -0,0 +1,182 @@ +import { _ } from '@feathersjs/commons' +import { JSONSchema } from 'json-schema-to-ts' +import { TObject } from '@sinclair/typebox' +import { JSONSchemaDefinition, Ajv, Validator } from './schema' + +export type DataSchemaMap = { + create: JSONSchemaDefinition | TObject + update?: JSONSchemaDefinition | TObject + patch?: JSONSchemaDefinition | TObject +} + +export type DataValidatorMap = { + create: Validator + update: Validator + patch: Validator +} + +/** + * Returns a compiled validation function for a schema and AJV validator instance. + * + * @param schema The JSON schema definition + * @param validator The AJV validation instance + * @returns A compiled validation function + */ +export const getValidator = ( + schema: JSONSchemaDefinition | TObject, + validator: Ajv +): Validator => + validator.compile({ + $async: true, + ...(schema as any) + }) as any as Validator + +/** + * Returns compiled validation functions to validate data for the `create`, `update` and `patch` + * service methods. If not passed explicitly, the `update` validator will be the same as the `create` + * and `patch` will be the `create` validator with no required fields. + * + * @param def Either general JSON schema definition or a mapping of `create`, `update` and `patch` + * to their respecitve JSON schema + * @param validator The Ajv instance to use as the validator + * @returns A map of validator functions + */ +export const getDataValidator = ( + def: JSONSchemaDefinition | TObject | DataSchemaMap, + validator: Ajv +): DataValidatorMap => { + const schema = ((def as any).create ? def : { create: def }) as DataSchemaMap + + return { + create: getValidator(schema.create, validator), + update: getValidator( + schema.update || { + ...(schema.create as any), + $id: `${schema.create.$id}Update` + }, + validator + ), + patch: getValidator( + schema.patch || { + ...(schema.create as any), + $id: `${schema.create.$id}Patch`, + required: [] + }, + validator + ) + } +} + +export type PropertyQuery = { + anyOf: [ + D, + { + type: 'object' + additionalProperties: false + properties: { + $gt: D + $gte: D + $lt: D + $lte: D + $ne: D + $in: { + type: 'array' + items: D + } + $nin: { + type: 'array' + items: D + } + } + } + ] +} + +/** + * Create a Feathers query syntax compatible JSON schema definition for a property definition. + * + * @param def The property definition (e.g. `{ type: 'string' }`) + * @returns A JSON schema definition for the Feathers query syntax for this property. + */ +export const queryProperty = (def: T) => { + const definition = _.omit(def, 'default') + return { + anyOf: [ + definition, + { + type: 'object', + additionalProperties: false, + properties: { + $gt: definition, + $gte: definition, + $lt: definition, + $lte: definition, + $ne: definition, + $in: { + type: 'array', + items: definition + }, + $nin: { + type: 'array', + items: definition + } + } + } + ] + } as const +} + +/** + * Creates Feathers a query syntax compatible JSON schema for multiple properties. + * + * @param definition A map of property definitions + * @returns The JSON schema definition for the Feathers query syntax for multiple properties + */ +export const queryProperties = (definition: T) => + Object.keys(definition).reduce((res, key) => { + const result = res as any + + result[key] = queryProperty(definition[key]) + + return result + }, {} as { [K in keyof T]: PropertyQuery }) + +/** + * Creates a JSON schema for the complete Feathers query syntax including `$limit`, $skip` + * and `$sort` and `$select` for the allowed properties. + * + * @param definition The property definitions to create the query syntax schema for + * @returns A JSON schema for the complete query syntax + */ +export const querySyntax = (definition: T) => + ({ + $limit: { + type: 'number', + minimum: 0 + }, + $skip: { + type: 'number', + minimum: 0 + }, + $sort: { + type: 'object', + properties: Object.keys(definition).reduce((res, key) => { + const result = res as any + + result[key] = { + type: 'number', + enum: [1, -1] + } + + return result + }, {} as { [K in keyof T]: { readonly type: 'number'; readonly enum: [1, -1] } }) + }, + $select: { + type: 'array', + items: { + type: 'string', + enum: Object.keys(definition) as any as (keyof T)[] + } + }, + ...queryProperties(definition) + } as const) diff --git a/packages/schema/src/query.ts b/packages/schema/src/query.ts deleted file mode 100644 index e786069ea5..0000000000 --- a/packages/schema/src/query.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { _ } from '@feathersjs/commons' -import { JSONSchema } from 'json-schema-to-ts' - -export type PropertyQuery = { - anyOf: [ - D, - { - type: 'object' - additionalProperties: false - properties: { - $gt: D - $gte: D - $lt: D - $lte: D - $ne: D - $in: { - type: 'array' - items: D - } - $nin: { - type: 'array' - items: D - } - } - } - ] -} - -export const queryProperty = (def: T) => { - const definition = _.omit(def, 'default') - return { - anyOf: [ - definition, - { - type: 'object', - additionalProperties: false, - properties: { - $gt: definition, - $gte: definition, - $lt: definition, - $lte: definition, - $ne: definition, - $in: { - type: 'array', - items: definition - }, - $nin: { - type: 'array', - items: definition - } - } - } - ] - } as const -} - -export const queryProperties = (definition: T) => - Object.keys(definition).reduce((res, key) => { - const result = res as any - - result[key] = queryProperty(definition[key]) - - return result - }, {} as { [K in keyof T]: PropertyQuery }) - -export const querySyntax = (definition: T) => - ({ - $limit: { - type: 'number', - minimum: 0 - }, - $skip: { - type: 'number', - minimum: 0 - }, - $sort: { - type: 'object', - properties: Object.keys(definition).reduce((res, key) => { - const result = res as any - - result[key] = { - type: 'number', - enum: [1, -1] - } - - return result - }, {} as { [K in keyof T]: { readonly type: 'number'; readonly enum: [1, -1] } }) - }, - $select: { - type: 'array', - items: { - type: 'string', - enum: Object.keys(definition) as any as (keyof T)[] - } - }, - ...queryProperties(definition) - } as const) diff --git a/packages/schema/src/resolver.ts b/packages/schema/src/resolver.ts index eb8343c309..26ad0af727 100644 --- a/packages/schema/src/resolver.ts +++ b/packages/schema/src/resolver.ts @@ -20,8 +20,18 @@ export type ResolverConverter = ( export interface ResolverConfig { schema?: Schema + /** + * @deprecated Use the `validateData` and `validateQuery` hooks explicitly instead + */ validate?: 'before' | 'after' | false + /** + * The properties to resolve + */ properties: PropertyResolverMap + /** + * A converter function that is run before property resolvers + * to transform the initial data into a different format. + */ converter?: ResolverConverter } @@ -37,6 +47,15 @@ export class Resolver { constructor(public options: ResolverConfig) {} + /** + * Resolve a single property + * + * @param name The name of the property + * @param data The current data + * @param context The current resolver context + * @param status The current resolver status + * @returns The resolver property + */ async resolveProperty( name: K, data: D, @@ -122,6 +141,12 @@ export class Resolver { } } +/** + * Create a new resolver with ``. + * + * @param options The configuration for the returned resolver + * @returns A new resolver instance + */ export function resolve(options: ResolverConfig) { return new Resolver(options) } diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index f2c2a9f038..293d086d52 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -9,9 +9,15 @@ export const DEFAULT_AJV = new Ajv({ export { Ajv } +/** + * A validation function that takes data and returns the (possibly coerced) + * data or throws a validation error. + */ +export type Validator = (data: T) => Promise + export type JSONSchemaDefinition = JSONSchema & { $id: string - $async?: boolean + $async?: true properties?: { [key: string]: JSONSchema } required?: readonly string[] } diff --git a/packages/schema/test/fixture.ts b/packages/schema/test/fixture.ts index 72437ae04a..e2b429cd3f 100644 --- a/packages/schema/test/fixture.ts +++ b/packages/schema/test/fixture.ts @@ -3,28 +3,28 @@ import { memory, MemoryService } from '@feathersjs/memory' import { GeneralError } from '@feathersjs/errors' import { - schema, resolve, - Infer, resolveResult, resolveQuery, resolveData, validateData, validateQuery, querySyntax, - Combine, resolveDispatch, resolveAll, - Ajv + Ajv, + FromSchema, + getValidator, + getDataValidator } from '../src' import { AdapterParams } from '../../memory/node_modules/@feathersjs/adapter-commons/lib' const fixtureAjv = new Ajv({ coerceTypes: true, - addUsedSchema: true // default + addUsedSchema: false }) -export const userSchema = schema({ +export const userDataSchema = { $id: 'UserData', type: 'object', additionalProperties: false, @@ -33,28 +33,13 @@ export const userSchema = schema({ email: { type: 'string' }, password: { type: 'string' } } -} as const) +} as const -export const userResultSchema = schema( - { - $id: 'UserResult', - type: 'object', - additionalProperties: false, - required: ['id', ...userSchema.required], - properties: { - ...userSchema.properties, - id: { type: 'number' } - } - } as const, - fixtureAjv -) +export const userDataValidator = getDataValidator(userDataSchema, fixtureAjv) -export type User = Infer -export type UserResult = Infer & { name: string } +export type UserData = FromSchema -export const userDataResolver = resolve>({ - schema: userSchema, - validate: 'before', +export const userDataResolver = resolve>({ properties: { password: async () => { return 'hashed' @@ -62,29 +47,40 @@ export const userDataResolver = resolve>({ } }) -export const userResultResolver = resolve>({ - schema: userResultSchema, +export const userSchema = { + $id: 'User', + type: 'object', + additionalProperties: false, + required: ['id', ...userDataSchema.required], + properties: { + ...userDataSchema.properties, + id: { type: 'number' }, + name: { type: 'string' } + } +} as const + +export type User = FromSchema + +export const userResolver = resolve>({ properties: { name: async (_value, user) => user.email.split('@')[0] } }) -export const userDispatchResolver = resolve>({ - schema: userResultSchema, +export const userExternalResolver = resolve>({ properties: { password: async () => undefined, email: async () => '[redacted]' } }) -export const secondUserResultResolver = resolve>({ - schema: userResultSchema, +export const secondUserResolver = resolve>({ properties: { name: async (value, user) => `${value} (${user.email})` } }) -export const messageSchema = schema({ +export const messageDataSchema = { $id: 'MessageData', type: 'object', additionalProperties: false, @@ -93,33 +89,30 @@ export const messageSchema = schema({ text: { type: 'string' }, userId: { type: 'number' } } -} as const) +} as const -export const messageResultSchema = schema( - { - $id: 'MessageResult', - type: 'object', - additionalProperties: false, - required: ['id', ...messageSchema.required], - properties: { - ...messageSchema.properties, - id: { type: 'number' }, - user: { $ref: 'UserResult' } - } - } as const, - fixtureAjv -) +export type MessageData = FromSchema -export type Message = Infer -export type MessageResult = Combine< - typeof messageResultSchema, +export const messageSchema = { + $id: 'MessageResult', + type: 'object', + additionalProperties: false, + required: ['id', ...messageDataSchema.required], + properties: { + ...messageDataSchema.properties, + id: { type: 'number' }, + user: { $ref: 'User' } + } +} as const + +export type Message = FromSchema< + typeof messageSchema, { - user: User + references: [typeof userSchema] } > -export const messageResultResolver = resolve>({ - schema: messageResultSchema, +export const messageResolver = resolve>({ properties: { user: async (_value, message, context) => { const { userId } = message @@ -128,30 +121,32 @@ export const messageResultResolver = resolve -export type MessageQuery = Infer +export const messageQueryValidator = getValidator(messageQuerySchema, fixtureAjv) export const messageQueryResolver = resolve>({ - schema: messageQuerySchema, - validate: 'before', properties: { userId: async (value, _query, context) => { if (context.params?.user) { @@ -169,9 +164,9 @@ interface ServiceParams extends AdapterParams { } type ServiceTypes = { - users: MemoryService - messages: MemoryService - paginatedMessages: MemoryService + users: MemoryService + messages: MemoryService + paginatedMessages: MemoryService } type Application = FeathersApplication @@ -188,27 +183,27 @@ app.use('paginatedMessages', memory({ paginate: { default: 10 } })) app.service('messages').hooks([ resolveAll({ - result: messageResultResolver, + result: messageResolver, query: messageQueryResolver }), - validateQuery(messageQuerySchema) + validateQuery(messageQueryValidator) ]) app .service('paginatedMessages') .hooks([ - validateQuery(messageQuerySchema), + validateQuery(messageQueryValidator), resolveQuery(messageQueryResolver), - resolveResult(messageResultResolver) + resolveResult(messageResolver) ]) app .service('users') - .hooks([resolveDispatch(userDispatchResolver), resolveResult(userResultResolver, secondUserResultResolver)]) + .hooks([resolveDispatch(userExternalResolver), resolveResult(userResolver, secondUserResolver)]) app.service('users').hooks({ create: [ - validateData(userSchema), + validateData(userDataValidator), resolveData({ create: userDataResolver, patch: userDataResolver, diff --git a/packages/schema/test/hooks.test.ts b/packages/schema/test/hooks.test.ts index de1a2b22da..e1ee48dd82 100644 --- a/packages/schema/test/hooks.test.ts +++ b/packages/schema/test/hooks.test.ts @@ -1,13 +1,13 @@ import { createContext } from '@feathersjs/feathers' import assert from 'assert' -import { app, MessageResult, UserResult } from './fixture' +import { app, Message, User } from './fixture' describe('@feathersjs/schema/hooks', () => { const text = 'Hi there' - let message: MessageResult - let messageOnPaginatedService: MessageResult - let user: UserResult + let message: Message + let messageOnPaginatedService: Message + let user: User before(async () => { user = ( diff --git a/packages/typebox/LICENSE b/packages/typebox/LICENSE new file mode 100644 index 0000000000..59604f46f3 --- /dev/null +++ b/packages/typebox/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2022 Feathers + +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. + diff --git a/packages/typebox/README.md b/packages/typebox/README.md new file mode 100644 index 0000000000..6f5982118c --- /dev/null +++ b/packages/typebox/README.md @@ -0,0 +1,23 @@ +# @feathersjs/typebox + +[![CI](https://github.com/feathersjs/feathers/workflows/CI/badge.svg)](https://github.com/feathersjs/feathers/actions?query=workflow%3ACI) +[![Download Status](https://img.shields.io/npm/dm/@feathersjs/typebox.svg?style=flat-square)](https://www.npmjs.com/package/@feathersjs/typebox) +[![Discord](https://badgen.net/badge/icon/discord?icon=discord&label)](https://discord.gg/qa8kez8QBx) + +> [TypeBox](https://github.com/sinclairzx81/typebox) integration for @feathersjs/schema + +## Installation + +``` +npm install @feathersjs/typebox --save +``` + +## Documentation + +Refer to the [Feathers documentation](https://docs.feathersjs.com) for more details. + +## License + +Copyright (c) 2022 [Feathers contributors](https://github.com/feathersjs/feathers/graphs/contributors) + +Licensed under the [MIT license](LICENSE). diff --git a/packages/typebox/package.json b/packages/typebox/package.json new file mode 100644 index 0000000000..7d264f595f --- /dev/null +++ b/packages/typebox/package.json @@ -0,0 +1,68 @@ +{ + "name": "@feathersjs/typebox", + "description": "TypeBox integration for @feathersjs/schema", + "version": "5.0.0-pre.29", + "homepage": "https://feathersjs.com", + "main": "lib/", + "types": "lib/", + "keywords": [ + "feathers", + "feathers-plugin" + ], + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/daffl" + }, + "repository": { + "type": "git", + "url": "git://github.com/feathersjs/feathers.git", + "directory": "packages/schema" + }, + "author": { + "name": "Feathers contributors", + "email": "hello@feathersjs.com", + "url": "https://feathersjs.com" + }, + "contributors": [], + "bugs": { + "url": "https://github.com/feathersjs/feathers/issues" + }, + "engines": { + "node": ">= 12" + }, + "files": [ + "CHANGELOG.md", + "LICENSE", + "README.md", + "src/**", + "lib/**", + "*.d.ts", + "*.js" + ], + "scripts": { + "prepublish": "npm run compile", + "pack": "npm pack --pack-destination ../cli/test/build", + "compile": "shx rm -rf lib/ && tsc && npm run pack", + "mocha": "mocha --config ../../.mocharc.json --recursive test/**.test.ts test/**/*.test.ts", + "test": "npm run compile && npm run mocha" + }, + "directories": { + "lib": "lib" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@sinclair/typebox": "^0.24.44" + }, + "devDependencies": { + "@feathersjs/schema": "^5.0.0-pre.29", + "@types/mocha": "^10.0.0", + "@types/node": "^18.8.2", + "mocha": "^10.0.0", + "shx": "^0.3.4", + "typescript": "^4.8.4" + }, + "gitHead": "4314dc89a41a8bbaabf00b47697bf7887861d17d" +} diff --git a/packages/typebox/src/default-schemas.ts b/packages/typebox/src/default-schemas.ts new file mode 100644 index 0000000000..e5dd715db5 --- /dev/null +++ b/packages/typebox/src/default-schemas.ts @@ -0,0 +1,87 @@ +import { Type, Static } from '@sinclair/typebox' + +export const authenticationSettingsSchema = Type.Object({ + secret: Type.String({ description: 'The JWT signing secret' }), + entity: Type.Optional(Type.String({ description: 'The name of the authentication entity (e.g. user)' })), + entityId: Type.Optional(Type.String({ description: 'The name of the authentication entity id property' })), + service: Type.Optional(Type.String({ description: 'The path of the entity service' })), + authStrategies: Type.Array(Type.String(), { + description: 'A list of authentication strategy names that are allowed to create JWT access tokens' + }), + parseStrategies: Type.Optional( + Type.Array(Type.String(), { + description: + 'A list of authentication strategy names that should parse HTTP headers for authentication information (defaults to `authStrategies`)' + }) + ), + jwtOptions: Type.Optional(Type.Object({})), + jwt: Type.Optional( + Type.Object({ + header: Type.String({ default: 'Authorization', description: 'The HTTP header containing the JWT' }), + schemes: Type.String({ description: 'An array of schemes to support' }) + }) + ), + local: Type.Optional( + Type.Object({ + usernameField: Type.String({ description: 'Name of the username field (e.g. `email`)' }), + passwordField: Type.String({ description: 'Name of the password field (e.g. `password`)' }), + hashSize: Type.Optional(Type.Number({ description: 'The BCrypt salt length' })), + errorMessage: Type.Optional(Type.String({ description: 'The error message to return on errors' })), + entityUsernameField: Type.Optional( + Type.String({ + description: + 'Name of the username field on the entity if authentication request data and entity field names are different' + }) + ), + entityPasswordField: Type.Optional( + Type.String({ + description: + 'Name of the password field on the entity if authentication request data and entity field names are different' + }) + ) + }) + ), + oauth: Type.Optional( + Type.Object({ + redirect: Type.Optional(Type.String()), + origins: Type.Optional(Type.Array(Type.String())), + defaults: Type.Optional( + Type.Object({ + key: Type.Optional(Type.String()), + secret: Type.Optional(Type.String()) + }) + ) + }) + ) +}) + +export const sqlSettingsSchema = Type.Optional( + Type.Object({ + client: Type.String(), + connection: Type.String() + }) +) + +export const defaultAppConfiguration = Type.Object( + { + authentication: Type.Optional(authenticationSettingsSchema), + paginate: Type.Optional( + Type.Object( + { + default: Type.Number(), + max: Type.Number() + }, + { additionalProperties: false } + ) + ), + origins: Type.Optional(Type.Array(Type.String())), + mongodb: Type.Optional(Type.String()), + mysql: sqlSettingsSchema, + postgresql: sqlSettingsSchema, + sqlite: sqlSettingsSchema, + mssql: sqlSettingsSchema + }, + { $id: 'ApplicationConfiguration', additionalProperties: false } +) + +export type DefaultAppConfiguration = Static diff --git a/packages/typebox/src/index.ts b/packages/typebox/src/index.ts new file mode 100644 index 0000000000..066c37c1c0 --- /dev/null +++ b/packages/typebox/src/index.ts @@ -0,0 +1,75 @@ +import { Type, TObject, TInteger, TOptional, TSchema, TIntersect } from '@sinclair/typebox' + +export * from '@sinclair/typebox' +export * from './default-schemas' + +const arrayOfKeys = (type: T) => { + const keys = Object.keys(type.properties) + return Type.Unsafe<(keyof T['properties'])[]>({ type: 'array', items: { type: 'string', enum: keys } }) +} + +export function sortDefinition(schema: T) { + const properties = Object.keys(schema.properties).reduce((res, key) => { + const result = res as any + + result[key] = Type.Optional(Type.Integer({ minimum: -1, maximum: 1 })) + + return result + }, {} as { [K in keyof T['properties']]: TOptional }) + + return { + type: 'object', + additionalProperties: false, + properties + } as TObject +} + +export const queryProperty = (def: T) => { + return Type.Optional( + Type.Union([ + def, + Type.Object({ + $gt: Type.Optional(def), + $gte: Type.Optional(def), + $lt: Type.Optional(def), + $lte: Type.Optional(def), + $ne: Type.Optional(def), + $in: Type.Optional(Type.Array(def)), + $nin: Type.Optional(Type.Array(def)) + }) + ]) + ) +} + +type QueryProperty = ReturnType> + +export const queryProperties = (type: T) => { + const properties = Object.keys(type.properties).reduce((res, key) => { + const result = res as any + + result[key] = queryProperty(type.properties[key]) + + return result + }, {} as { [K in keyof T['properties']]: QueryProperty }) + + return { + type: 'object', + additionalProperties: false, + properties + } as TObject +} + +export const querySyntax = (type: T) => { + return Type.Intersect([ + Type.Object( + { + $limit: Type.Optional(Type.Number({ minimum: 0 })), + $skip: Type.Optional(Type.Number({ minimum: 0 })), + $sort: Type.Optional(sortDefinition(type)), + $select: Type.Optional(arrayOfKeys(type)) + }, + { additionalProperties: false } + ), + queryProperties(type) + ]) +} diff --git a/packages/typebox/test/index.test.ts b/packages/typebox/test/index.test.ts new file mode 100644 index 0000000000..88472f7620 --- /dev/null +++ b/packages/typebox/test/index.test.ts @@ -0,0 +1,50 @@ +import assert from 'assert' +import { Ajv } from '@feathersjs/schema' +import { querySyntax, Type, Static, defaultAppConfiguration } from '../src' + +describe('@feathersjs/schema/typebox', () => { + it('querySyntax', async () => { + const schema = Type.Object({ + name: Type.String(), + age: Type.Number() + }) + + const querySchema = querySyntax(schema) + + type Query = Static + + const query: Query = { + name: 'Dave', + age: { $gt: 42, $in: [50, 51] }, + $select: ['age', 'name'], + $sort: { + age: 1 + } + } + + const validator = new Ajv().compile(querySchema) + const validated = (await validator(query)) as any as Query + + assert.ok(validated) + }) + + it('defaultAppConfiguration', async () => { + const configSchema = Type.Intersect([ + defaultAppConfiguration, + Type.Object({ + host: Type.String(), + port: Type.Number(), + public: Type.String() + }) + ]) + + const validator = new Ajv().compile(configSchema) + const validated = await validator({ + host: 'something', + port: 3030, + public: './' + }) + + assert.ok(validated) + }) +}) diff --git a/packages/typebox/tsconfig.json b/packages/typebox/tsconfig.json new file mode 100644 index 0000000000..316fd41336 --- /dev/null +++ b/packages/typebox/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig", + "include": [ + "src/**/*.ts" + ], + "compilerOptions": { + "outDir": "lib" + } +}