From 4973053fda5758b14b1ddb558016bce5d3ec76f1 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Thu, 26 Aug 2021 13:05:23 +0800 Subject: [PATCH] refactor: integrate zod in schemas (#90) --- .gitignore | 6 +- packages/core/package.json | 2 +- packages/core/src/database/insert.ts | 4 +- packages/core/src/database/utils.ts | 15 +++- packages/core/src/oidc/utils.ts | 4 +- .../core/src/queries/oidc-model-instance.ts | 5 +- packages/schemas/package.json | 6 +- .../schemas/src/db-entries/application.ts | 18 +++- .../src/db-entries/oidc-model-instance.ts | 18 +++- packages/schemas/src/db-entries/user.ts | 15 +++- packages/schemas/src/foundations/index.ts | 2 + .../schemas/src/foundations/jsonb-types.ts | 22 +++++ .../schemas.ts} | 20 ++--- packages/schemas/src/gen/index.ts | 84 +++---------------- packages/schemas/src/gen/schema.ts | 48 +++++++++++ packages/schemas/src/gen/types.ts | 36 ++++++++ pnpm-lock.yaml | 11 +-- 17 files changed, 208 insertions(+), 108 deletions(-) create mode 100644 packages/schemas/src/foundations/index.ts create mode 100644 packages/schemas/src/foundations/jsonb-types.ts rename packages/schemas/src/{foundations.ts => foundations/schemas.ts} (69%) create mode 100644 packages/schemas/src/gen/schema.ts create mode 100644 packages/schemas/src/gen/types.ts diff --git a/.gitignore b/.gitignore index 54c0e44fa..b4645b707 100644 --- a/.gitignore +++ b/.gitignore @@ -14,11 +14,7 @@ node_modules # logs logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* +*.log* # misc cache diff --git a/packages/core/package.json b/packages/core/package.json index d6204d687..ae63f3172 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,7 +39,7 @@ "oidc-provider": "^7.4.1", "slonik": "^23.8.3", "slonik-interceptor-preset": "^1.2.10", - "zod": "^3.2.0" + "zod": "^3.8.1" }, "devDependencies": { "@logto/eslint-config": "^0.1.0-rc.18", diff --git a/packages/core/src/database/insert.ts b/packages/core/src/database/insert.ts index b9b6eb50f..798b93828 100644 --- a/packages/core/src/database/insert.ts +++ b/packages/core/src/database/insert.ts @@ -5,7 +5,7 @@ import { DatabasePoolType, IdentifierSqlTokenType, sql } from 'slonik'; import { conditionalSql, convertToIdentifiers, - convertToPrimitive, + convertToPrimitiveOrSql, excludeAutoSetFields, OmitAutoSetFields, } from './utils'; @@ -61,7 +61,7 @@ export const buildInsertInto: BuildInsertInto = convertToPrimitive(data[key] ?? null)), + keys.map((key) => convertToPrimitiveOrSql(key, data[key] ?? null)), sql`, ` )}) ${conditionalSql(returning, () => sql`returning *`)} diff --git a/packages/core/src/database/utils.ts b/packages/core/src/database/utils.ts index 6dde30ea8..7b3d9421a 100644 --- a/packages/core/src/database/utils.ts +++ b/packages/core/src/database/utils.ts @@ -1,6 +1,7 @@ import { Falsy, notFalsy } from '@logto/essentials'; import { SchemaValuePrimitive, SchemaValue } from '@logto/schemas'; -import { sql, SqlSqlTokenType } from 'slonik'; +import dayjs from 'dayjs'; +import { sql, SqlSqlTokenType, SqlTokenType } from 'slonik'; import { FieldIdentifiers, Table } from './types'; export const conditionalSql = ( @@ -25,12 +26,14 @@ export const excludeAutoSetFields = (fields: readonly T[]) => * Note `undefined` is removed from the acceptable list, * since you should NOT call this function if ignoring the field is the desired behavior. * Calling this function with `null` means an explicit `null` setting in database is expected. + * @param key The key of value. Will treat as `timestamp` if it ends with `_at` or 'At' AND value is a number; * @param value The value to convert. * @returns A primitive that can be saved into database. */ -export const convertToPrimitive = ( +export const convertToPrimitiveOrSql = ( + key: string, value: NonNullable | null -): NonNullable | null => { +): NonNullable | SqlTokenType | null => { if (value === null) { return null; } @@ -39,6 +42,10 @@ export const convertToPrimitive = ( return JSON.stringify(value); } + if (['_at', 'At'].some((value) => key.endsWith(value)) && typeof value === 'number') { + return sql`to_timestamp(${value / 1000})`; + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return value; } @@ -60,3 +67,5 @@ export const convertToIdentifiers = ( {} as FieldIdentifiers ), }); + +export const convertToTimestamp = (time = dayjs()) => sql`to_timestamp(${time.valueOf() / 1000})`; diff --git a/packages/core/src/oidc/utils.ts b/packages/core/src/oidc/utils.ts index 8b6827531..3c1ff71aa 100644 --- a/packages/core/src/oidc/utils.ts +++ b/packages/core/src/oidc/utils.ts @@ -1,6 +1,6 @@ import { OidcClientMetadata } from '@logto/schemas'; export const generateOidcClientMetadata = (): OidcClientMetadata => ({ - redirect_uris: [], - post_logout_redirect_uris: [], + redirectUris: [], + postLogoutRedirectUris: [], }); diff --git a/packages/core/src/queries/oidc-model-instance.ts b/packages/core/src/queries/oidc-model-instance.ts index 854f5a7a0..181b59dca 100644 --- a/packages/core/src/queries/oidc-model-instance.ts +++ b/packages/core/src/queries/oidc-model-instance.ts @@ -1,13 +1,12 @@ import { buildInsertInto } from '@/database/insert'; import pool from '@/database/pool'; -import { convertToIdentifiers } from '@/database/utils'; +import { convertToIdentifiers, convertToTimestamp } from '@/database/utils'; import { conditional } from '@logto/essentials'; import { OidcModelInstanceDBEntry, OidcModelInstancePayload, OidcModelInstances, } from '@logto/schemas'; -import dayjs from 'dayjs'; import { sql, ValueExpressionType } from 'slonik'; export type WithConsumed = T & { consumed?: boolean }; @@ -64,7 +63,7 @@ export const findPayloadByPayloadField = async < export const consumeInstanceById = async (modelName: string, id: string) => { await pool.query(sql` update ${table} - set ${fields.consumedAt}=${dayjs().valueOf()} + set ${fields.consumedAt}=${convertToTimestamp()} where ${fields.modelName}=${modelName} and ${fields.id}=${id} `); diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 9ddf206ec..2d237d2c5 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -32,7 +32,8 @@ "pluralize": "^8.0.0", "prettier": "^2.3.2", "ts-node": "^10.0.0", - "typescript": "^4.3.5" + "typescript": "^4.3.5", + "zod": "^3.8.1" }, "eslintConfig": { "extends": "@logto" @@ -40,5 +41,8 @@ "prettier": "@logto/eslint-config/.prettierrc", "dependencies": { "@logto/phrases": "^0.1.0" + }, + "peerDependencies": { + "zod": "^3.8.1" } } diff --git a/packages/schemas/src/db-entries/application.ts b/packages/schemas/src/db-entries/application.ts index b4a3fbffd..4f361b7db 100644 --- a/packages/schemas/src/db-entries/application.ts +++ b/packages/schemas/src/db-entries/application.ts @@ -1,6 +1,13 @@ // THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -import { OidcClientMetadata, GeneratedSchema } from '../foundations'; +import { z } from 'zod'; + +import { + OidcClientMetadata, + oidcClientMetadataGuard, + GeneratedSchema, + Guard, +} from '../foundations'; import { ApplicationType } from './custom-types'; @@ -12,6 +19,14 @@ export type ApplicationDBEntry = { createdAt: number; }; +const guard: Guard = z.object({ + id: z.string(), + name: z.string(), + type: z.nativeEnum(ApplicationType), + oidcClientMetadata: oidcClientMetadataGuard, + createdAt: z.number(), +}); + export const Applications: GeneratedSchema = Object.freeze({ table: 'applications', tableSingular: 'application', @@ -23,4 +38,5 @@ export const Applications: GeneratedSchema = Object.freeze({ createdAt: 'created_at', }, fieldKeys: ['id', 'name', 'type', 'oidcClientMetadata', 'createdAt'], + guard, }); diff --git a/packages/schemas/src/db-entries/oidc-model-instance.ts b/packages/schemas/src/db-entries/oidc-model-instance.ts index fcc6a71cf..e48b773e4 100644 --- a/packages/schemas/src/db-entries/oidc-model-instance.ts +++ b/packages/schemas/src/db-entries/oidc-model-instance.ts @@ -1,6 +1,13 @@ // THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -import { OidcModelInstancePayload, GeneratedSchema } from '../foundations'; +import { z } from 'zod'; + +import { + OidcModelInstancePayload, + oidcModelInstancePayloadGuard, + GeneratedSchema, + Guard, +} from '../foundations'; export type OidcModelInstanceDBEntry = { modelName: string; @@ -10,6 +17,14 @@ export type OidcModelInstanceDBEntry = { consumedAt?: number; }; +const guard: Guard = z.object({ + modelName: z.string(), + id: z.string(), + payload: oidcModelInstancePayloadGuard, + expiresAt: z.number(), + consumedAt: z.number().optional(), +}); + export const OidcModelInstances: GeneratedSchema = Object.freeze({ table: 'oidc_model_instances', tableSingular: 'oidc_model_instance', @@ -21,4 +36,5 @@ export const OidcModelInstances: GeneratedSchema = Obj consumedAt: 'consumed_at', }, fieldKeys: ['modelName', 'id', 'payload', 'expiresAt', 'consumedAt'], + guard, }); diff --git a/packages/schemas/src/db-entries/user.ts b/packages/schemas/src/db-entries/user.ts index 071f0f866..9d05a930c 100644 --- a/packages/schemas/src/db-entries/user.ts +++ b/packages/schemas/src/db-entries/user.ts @@ -1,6 +1,8 @@ // THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -import { GeneratedSchema } from '../foundations'; +import { z } from 'zod'; + +import { GeneratedSchema, Guard } from '../foundations'; import { PasswordEncryptionMethod } from './custom-types'; @@ -14,6 +16,16 @@ export type UserDBEntry = { passwordEncryptionSalt?: string; }; +const guard: Guard = z.object({ + id: z.string(), + username: z.string().optional(), + primaryEmail: z.string().optional(), + primaryPhone: z.string().optional(), + passwordEncrypted: z.string().optional(), + passwordEncryptionMethod: z.nativeEnum(PasswordEncryptionMethod).optional(), + passwordEncryptionSalt: z.string().optional(), +}); + export const Users: GeneratedSchema = Object.freeze({ table: 'users', tableSingular: 'user', @@ -35,4 +47,5 @@ export const Users: GeneratedSchema = Object.freeze({ 'passwordEncryptionMethod', 'passwordEncryptionSalt', ], + guard, }); diff --git a/packages/schemas/src/foundations/index.ts b/packages/schemas/src/foundations/index.ts new file mode 100644 index 000000000..2cd17d221 --- /dev/null +++ b/packages/schemas/src/foundations/index.ts @@ -0,0 +1,2 @@ +export * from './schemas'; +export * from './jsonb-types'; diff --git a/packages/schemas/src/foundations/jsonb-types.ts b/packages/schemas/src/foundations/jsonb-types.ts new file mode 100644 index 000000000..3367c74a9 --- /dev/null +++ b/packages/schemas/src/foundations/jsonb-types.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +export const oidcModelInstancePayloadGuard = z + .object({ + userCode: z.string().optional(), + uid: z.string().optional(), + grantId: z.string().optional(), + }) + /** + * Try to use `.passthrough()` if type has been fixed. + * https://github.com/colinhacks/zod/issues/452 + */ + .catchall(z.unknown()); + +export type OidcModelInstancePayload = z.infer; + +export const oidcClientMetadataGuard = z.object({ + redirectUris: z.string().array(), + postLogoutRedirectUris: z.string().array(), +}); + +export type OidcClientMetadata = z.infer; diff --git a/packages/schemas/src/foundations.ts b/packages/schemas/src/foundations/schemas.ts similarity index 69% rename from packages/schemas/src/foundations.ts rename to packages/schemas/src/foundations/schemas.ts index b6dae9b90..e566e15ce 100644 --- a/packages/schemas/src/foundations.ts +++ b/packages/schemas/src/foundations/schemas.ts @@ -1,3 +1,11 @@ +import { ZodObject, ZodType } from 'zod'; + +export type Guard> = ZodObject< + { + [key in keyof T]: ZodType; + } +>; + export type SchemaValuePrimitive = string | number | boolean | undefined; export type SchemaValue = SchemaValuePrimitive | Record; export type SchemaLike = { @@ -12,16 +20,6 @@ export type GeneratedSchema> = keyof Schema ex [key in keyof Schema]: string; }; fieldKeys: ReadonlyArray; + guard: Guard; }> : never; - -export type OidcModelInstancePayload = Record & { - userCode?: string; - uid?: string; - grantId?: string; -}; - -export type OidcClientMetadata = { - redirect_uris: string[]; - post_logout_redirect_uris: string[]; -}; diff --git a/packages/schemas/src/gen/index.ts b/packages/schemas/src/gen/index.ts index f33d0c34f..a11dbd96c 100644 --- a/packages/schemas/src/gen/index.ts +++ b/packages/schemas/src/gen/index.ts @@ -7,43 +7,8 @@ import uniq from 'lodash.uniq'; import { conditional, conditionalString } from '@logto/essentials'; import { findFirstParentheses, getType, normalizeWhitespaces, removeParentheses } from './utils'; - -type Field = { - name: string; - type?: string; - customType?: string; - tsType?: string; - required: boolean; - isArray: boolean; -}; - -// eslint-disable-next-line @typescript-eslint/ban-types -type FieldWithType = Omit & { type: string }; - -type Type = { - name: string; - type: 'enum'; - values: string[]; -}; - -type GeneratedType = Type & { - tsName: string; -}; - -type Table = { - name: string; - fields: Field[]; -}; - -type TableWithType = { - name: string; - fields: FieldWithType[]; -}; - -type FileData = { - types: Type[]; - tables: Table[]; -}; +import { FileData, Table, Field, Type, GeneratedType, TableWithType } from './types'; +import { generateSchema } from './schema'; const directory = 'tables'; @@ -175,19 +140,23 @@ const generate = async () => { assert(finalType, `Type ${customType ?? 'N/A'} not found`); if (tsType) { - tsTypes.push(tsType); - } else if (type === undefined) { + tsTypes.push(tsType, `${camelcase(tsType)}Guard`); + } else if (!type) { customTypes.push(finalType); } - return { ...rest, type: finalType }; + return { ...rest, tsType, type: finalType, isEnum: !tsType && !type }; }), })); if (tableWithTypes.length > 0) { - tsTypes.push('GeneratedSchema'); + tsTypes.push('GeneratedSchema', 'Guard'); } + const importZod = conditionalString( + tableWithTypes.length > 0 && "import { z } from 'zod';\n\n" + ); + const importTsTypes = conditionalString( tsTypes.length > 0 && [ @@ -212,39 +181,10 @@ const generate = async () => { const content = header + + importZod + importTsTypes + importTypes + - tableWithTypes - .map(({ name, fields }) => { - const databaseEntryType = `${pluralize( - camelcase(name, { pascalCase: true }), - 1 - )}DBEntry`; - return [ - `export type ${databaseEntryType} = {`, - ...fields.map( - ({ name, type, isArray, required }) => - ` ${camelcase(name)}${conditionalString( - !required && '?' - )}: ${type}${conditionalString(isArray && '[]')};` - ), - '};', - '', - `export const ${camelcase(name, { - pascalCase: true, - })}: GeneratedSchema<${databaseEntryType}> = Object.freeze({`, - ` table: '${name}',`, - ` tableSingular: '${pluralize(name, 1)}',`, - ' fields: {', - ...fields.map(({ name }) => ` ${camelcase(name)}: '${name}',`), - ' },', - ' fieldKeys: [', - ...fields.map(({ name }) => ` '${camelcase(name)}',`), - ' ],', - '});', - ].join('\n'); - }) - .join('\n') + + tableWithTypes.map((table) => generateSchema(table)).join('\n') + '\n'; await fs.writeFile(path.join(generatedDirectory, getOutputFileName(file) + '.ts'), content); }) diff --git a/packages/schemas/src/gen/schema.ts b/packages/schemas/src/gen/schema.ts new file mode 100644 index 000000000..91c9eb36d --- /dev/null +++ b/packages/schemas/src/gen/schema.ts @@ -0,0 +1,48 @@ +import pluralize from 'pluralize'; +import camelcase from 'camelcase'; +import { conditionalString } from '@logto/essentials'; +import { TableWithType } from './types'; + +export const generateSchema = ({ name, fields }: TableWithType) => { + const databaseEntryType = `${pluralize(camelcase(name, { pascalCase: true }), 1)}DBEntry`; + return [ + `export type ${databaseEntryType} = {`, + ...fields.map( + ({ name, type, isArray, required }) => + ` ${camelcase(name)}${conditionalString(!required && '?')}: ${type}${conditionalString( + isArray && '[]' + )};` + ), + '};', + '', + `const guard: Guard<${databaseEntryType}> = z.object({`, + ...fields.map(({ name, type, isArray, isEnum, required, tsType }) => { + if (tsType) { + return ` ${camelcase(name)}: ${camelcase(tsType)}Guard${conditionalString( + !required && '.optional()' + )},`; + } + + return ` ${camelcase(name)}: z.${ + isEnum ? `nativeEnum(${type})` : `${type}()` + }${conditionalString(isArray && '.array()')}${conditionalString( + !required && '.optional()' + )},`; + }), + ' });', + '', + `export const ${camelcase(name, { + pascalCase: true, + })}: GeneratedSchema<${databaseEntryType}> = Object.freeze({`, + ` table: '${name}',`, + ` tableSingular: '${pluralize(name, 1)}',`, + ' fields: {', + ...fields.map(({ name }) => ` ${camelcase(name)}: '${name}',`), + ' },', + ' fieldKeys: [', + ...fields.map(({ name }) => ` '${camelcase(name)}',`), + ' ],', + ' guard,', + '});', + ].join('\n'); +}; diff --git a/packages/schemas/src/gen/types.ts b/packages/schemas/src/gen/types.ts new file mode 100644 index 000000000..efbbfb15d --- /dev/null +++ b/packages/schemas/src/gen/types.ts @@ -0,0 +1,36 @@ +export type Field = { + name: string; + type?: string; + customType?: string; + tsType?: string; + required: boolean; + isArray: boolean; +}; + +// eslint-disable-next-line @typescript-eslint/ban-types +export type FieldWithType = Omit & { type: string; isEnum: boolean }; + +export type Type = { + name: string; + type: 'enum'; + values: string[]; +}; + +export type GeneratedType = Type & { + tsName: string; +}; + +export type Table = { + name: string; + fields: Field[]; +}; + +export type TableWithType = { + name: string; + fields: FieldWithType[]; +}; + +export type FileData = { + types: Type[]; + tables: Table[]; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e457a4fb3..f60c94108 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,7 +62,7 @@ importers: ts-jest: ^27.0.5 tsc-watch: ^4.4.0 typescript: ^4.3.5 - zod: ^3.2.0 + zod: ^3.8.1 dependencies: '@logto/essentials': 1.1.0-rc.2 '@logto/phrases': link:../phrases @@ -87,7 +87,7 @@ importers: oidc-provider: 7.5.4 slonik: 23.8.5 slonik-interceptor-preset: 1.2.10 - zod: 3.5.1 + zod: 3.8.1 devDependencies: '@logto/eslint-config': 0.1.0-rc.18_2055f56ab8dafa07df5c7ad406c8a4ab '@logto/ts-config': 0.1.0-rc.18_54a571252e94826a463f89cda755e2f0 @@ -139,6 +139,7 @@ importers: prettier: ^2.3.2 ts-node: ^10.0.0 typescript: ^4.3.5 + zod: ^3.8.1 dependencies: '@logto/phrases': link:../phrases devDependencies: @@ -155,6 +156,7 @@ importers: prettier: 2.3.2 ts-node: 10.1.0_13403c2f2d9ddab699dd2f492f123cbf typescript: 4.3.5 + zod: 3.8.1 packages/ui: specifiers: @@ -16664,9 +16666,8 @@ packages: engines: {node: '>=10'} dev: true - /zod/3.5.1: - resolution: {integrity: sha512-Gg9GTai0iDHowuYM9VNhdFMmesgt44ufzqaE5CPHshpuK5fCzbibdqCnrWuYH6ZmOn/N+BlGmwZtVSijhKmhKw==} - dev: false + /zod/3.8.1: + resolution: {integrity: sha512-u4Uodl7dLh8nXZwqXL1SM5FAl5b4lXYHOxMUVb9lqhlEAZhA2znX+0oW480m0emGFMxpoRHzUncAqRkc4h8ZJA==} /zwitch/1.0.5: resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}