0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

refactor: integrate zod in schemas (#90)

This commit is contained in:
Gao Sun 2021-08-26 13:05:23 +08:00 committed by GitHub
parent 27ec6fcb00
commit 4973053fda
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 208 additions and 108 deletions

6
.gitignore vendored
View file

@ -14,11 +14,7 @@ node_modules
# logs # logs
logs logs
*.log *.log*
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# misc # misc
cache cache

View file

@ -39,7 +39,7 @@
"oidc-provider": "^7.4.1", "oidc-provider": "^7.4.1",
"slonik": "^23.8.3", "slonik": "^23.8.3",
"slonik-interceptor-preset": "^1.2.10", "slonik-interceptor-preset": "^1.2.10",
"zod": "^3.2.0" "zod": "^3.8.1"
}, },
"devDependencies": { "devDependencies": {
"@logto/eslint-config": "^0.1.0-rc.18", "@logto/eslint-config": "^0.1.0-rc.18",

View file

@ -5,7 +5,7 @@ import { DatabasePoolType, IdentifierSqlTokenType, sql } from 'slonik';
import { import {
conditionalSql, conditionalSql,
convertToIdentifiers, convertToIdentifiers,
convertToPrimitive, convertToPrimitiveOrSql,
excludeAutoSetFields, excludeAutoSetFields,
OmitAutoSetFields, OmitAutoSetFields,
} from './utils'; } from './utils';
@ -61,7 +61,7 @@ export const buildInsertInto: BuildInsertInto = <Schema extends SchemaLike<strin
sql`, ` sql`, `
)}) )})
values (${sql.join( values (${sql.join(
keys.map((key) => convertToPrimitive(data[key] ?? null)), keys.map((key) => convertToPrimitiveOrSql(key, data[key] ?? null)),
sql`, ` sql`, `
)}) )})
${conditionalSql(returning, () => sql`returning *`)} ${conditionalSql(returning, () => sql`returning *`)}

View file

@ -1,6 +1,7 @@
import { Falsy, notFalsy } from '@logto/essentials'; import { Falsy, notFalsy } from '@logto/essentials';
import { SchemaValuePrimitive, SchemaValue } from '@logto/schemas'; 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'; import { FieldIdentifiers, Table } from './types';
export const conditionalSql = <T>( export const conditionalSql = <T>(
@ -25,12 +26,14 @@ export const excludeAutoSetFields = <T extends string>(fields: readonly T[]) =>
* Note `undefined` is removed from the acceptable list, * Note `undefined` is removed from the acceptable list,
* since you should NOT call this function if ignoring the field is the desired behavior. * 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. * 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. * @param value The value to convert.
* @returns A primitive that can be saved into database. * @returns A primitive that can be saved into database.
*/ */
export const convertToPrimitive = ( export const convertToPrimitiveOrSql = (
key: string,
value: NonNullable<SchemaValue> | null value: NonNullable<SchemaValue> | null
): NonNullable<SchemaValuePrimitive> | null => { ): NonNullable<SchemaValuePrimitive> | SqlTokenType | null => {
if (value === null) { if (value === null) {
return null; return null;
} }
@ -39,6 +42,10 @@ export const convertToPrimitive = (
return JSON.stringify(value); 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') { if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return value; return value;
} }
@ -60,3 +67,5 @@ export const convertToIdentifiers = <T extends Table>(
{} as FieldIdentifiers<keyof T['fields']> {} as FieldIdentifiers<keyof T['fields']>
), ),
}); });
export const convertToTimestamp = (time = dayjs()) => sql`to_timestamp(${time.valueOf() / 1000})`;

View file

@ -1,6 +1,6 @@
import { OidcClientMetadata } from '@logto/schemas'; import { OidcClientMetadata } from '@logto/schemas';
export const generateOidcClientMetadata = (): OidcClientMetadata => ({ export const generateOidcClientMetadata = (): OidcClientMetadata => ({
redirect_uris: [], redirectUris: [],
post_logout_redirect_uris: [], postLogoutRedirectUris: [],
}); });

View file

@ -1,13 +1,12 @@
import { buildInsertInto } from '@/database/insert'; import { buildInsertInto } from '@/database/insert';
import pool from '@/database/pool'; import pool from '@/database/pool';
import { convertToIdentifiers } from '@/database/utils'; import { convertToIdentifiers, convertToTimestamp } from '@/database/utils';
import { conditional } from '@logto/essentials'; import { conditional } from '@logto/essentials';
import { import {
OidcModelInstanceDBEntry, OidcModelInstanceDBEntry,
OidcModelInstancePayload, OidcModelInstancePayload,
OidcModelInstances, OidcModelInstances,
} from '@logto/schemas'; } from '@logto/schemas';
import dayjs from 'dayjs';
import { sql, ValueExpressionType } from 'slonik'; import { sql, ValueExpressionType } from 'slonik';
export type WithConsumed<T> = T & { consumed?: boolean }; export type WithConsumed<T> = T & { consumed?: boolean };
@ -64,7 +63,7 @@ export const findPayloadByPayloadField = async <
export const consumeInstanceById = async (modelName: string, id: string) => { export const consumeInstanceById = async (modelName: string, id: string) => {
await pool.query(sql` await pool.query(sql`
update ${table} update ${table}
set ${fields.consumedAt}=${dayjs().valueOf()} set ${fields.consumedAt}=${convertToTimestamp()}
where ${fields.modelName}=${modelName} where ${fields.modelName}=${modelName}
and ${fields.id}=${id} and ${fields.id}=${id}
`); `);

View file

@ -32,7 +32,8 @@
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
"prettier": "^2.3.2", "prettier": "^2.3.2",
"ts-node": "^10.0.0", "ts-node": "^10.0.0",
"typescript": "^4.3.5" "typescript": "^4.3.5",
"zod": "^3.8.1"
}, },
"eslintConfig": { "eslintConfig": {
"extends": "@logto" "extends": "@logto"
@ -40,5 +41,8 @@
"prettier": "@logto/eslint-config/.prettierrc", "prettier": "@logto/eslint-config/.prettierrc",
"dependencies": { "dependencies": {
"@logto/phrases": "^0.1.0" "@logto/phrases": "^0.1.0"
},
"peerDependencies": {
"zod": "^3.8.1"
} }
} }

View file

@ -1,6 +1,13 @@
// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. // 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'; import { ApplicationType } from './custom-types';
@ -12,6 +19,14 @@ export type ApplicationDBEntry = {
createdAt: number; createdAt: number;
}; };
const guard: Guard<ApplicationDBEntry> = z.object({
id: z.string(),
name: z.string(),
type: z.nativeEnum(ApplicationType),
oidcClientMetadata: oidcClientMetadataGuard,
createdAt: z.number(),
});
export const Applications: GeneratedSchema<ApplicationDBEntry> = Object.freeze({ export const Applications: GeneratedSchema<ApplicationDBEntry> = Object.freeze({
table: 'applications', table: 'applications',
tableSingular: 'application', tableSingular: 'application',
@ -23,4 +38,5 @@ export const Applications: GeneratedSchema<ApplicationDBEntry> = Object.freeze({
createdAt: 'created_at', createdAt: 'created_at',
}, },
fieldKeys: ['id', 'name', 'type', 'oidcClientMetadata', 'createdAt'], fieldKeys: ['id', 'name', 'type', 'oidcClientMetadata', 'createdAt'],
guard,
}); });

View file

@ -1,6 +1,13 @@
// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. // 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 = { export type OidcModelInstanceDBEntry = {
modelName: string; modelName: string;
@ -10,6 +17,14 @@ export type OidcModelInstanceDBEntry = {
consumedAt?: number; consumedAt?: number;
}; };
const guard: Guard<OidcModelInstanceDBEntry> = z.object({
modelName: z.string(),
id: z.string(),
payload: oidcModelInstancePayloadGuard,
expiresAt: z.number(),
consumedAt: z.number().optional(),
});
export const OidcModelInstances: GeneratedSchema<OidcModelInstanceDBEntry> = Object.freeze({ export const OidcModelInstances: GeneratedSchema<OidcModelInstanceDBEntry> = Object.freeze({
table: 'oidc_model_instances', table: 'oidc_model_instances',
tableSingular: 'oidc_model_instance', tableSingular: 'oidc_model_instance',
@ -21,4 +36,5 @@ export const OidcModelInstances: GeneratedSchema<OidcModelInstanceDBEntry> = Obj
consumedAt: 'consumed_at', consumedAt: 'consumed_at',
}, },
fieldKeys: ['modelName', 'id', 'payload', 'expiresAt', 'consumedAt'], fieldKeys: ['modelName', 'id', 'payload', 'expiresAt', 'consumedAt'],
guard,
}); });

View file

@ -1,6 +1,8 @@
// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. // 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'; import { PasswordEncryptionMethod } from './custom-types';
@ -14,6 +16,16 @@ export type UserDBEntry = {
passwordEncryptionSalt?: string; passwordEncryptionSalt?: string;
}; };
const guard: Guard<UserDBEntry> = 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<UserDBEntry> = Object.freeze({ export const Users: GeneratedSchema<UserDBEntry> = Object.freeze({
table: 'users', table: 'users',
tableSingular: 'user', tableSingular: 'user',
@ -35,4 +47,5 @@ export const Users: GeneratedSchema<UserDBEntry> = Object.freeze({
'passwordEncryptionMethod', 'passwordEncryptionMethod',
'passwordEncryptionSalt', 'passwordEncryptionSalt',
], ],
guard,
}); });

View file

@ -0,0 +1,2 @@
export * from './schemas';
export * from './jsonb-types';

View file

@ -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<typeof oidcModelInstancePayloadGuard>;
export const oidcClientMetadataGuard = z.object({
redirectUris: z.string().array(),
postLogoutRedirectUris: z.string().array(),
});
export type OidcClientMetadata = z.infer<typeof oidcClientMetadataGuard>;

View file

@ -1,3 +1,11 @@
import { ZodObject, ZodType } from 'zod';
export type Guard<T extends Record<string, unknown>> = ZodObject<
{
[key in keyof T]: ZodType<T[key]>;
}
>;
export type SchemaValuePrimitive = string | number | boolean | undefined; export type SchemaValuePrimitive = string | number | boolean | undefined;
export type SchemaValue = SchemaValuePrimitive | Record<string, unknown>; export type SchemaValue = SchemaValuePrimitive | Record<string, unknown>;
export type SchemaLike<Key extends string> = { export type SchemaLike<Key extends string> = {
@ -12,16 +20,6 @@ export type GeneratedSchema<Schema extends SchemaLike<string>> = keyof Schema ex
[key in keyof Schema]: string; [key in keyof Schema]: string;
}; };
fieldKeys: ReadonlyArray<keyof Schema>; fieldKeys: ReadonlyArray<keyof Schema>;
guard: Guard<Schema>;
}> }>
: never; : never;
export type OidcModelInstancePayload = Record<string, unknown> & {
userCode?: string;
uid?: string;
grantId?: string;
};
export type OidcClientMetadata = {
redirect_uris: string[];
post_logout_redirect_uris: string[];
};

View file

@ -7,43 +7,8 @@ import uniq from 'lodash.uniq';
import { conditional, conditionalString } from '@logto/essentials'; import { conditional, conditionalString } from '@logto/essentials';
import { findFirstParentheses, getType, normalizeWhitespaces, removeParentheses } from './utils'; import { findFirstParentheses, getType, normalizeWhitespaces, removeParentheses } from './utils';
import { FileData, Table, Field, Type, GeneratedType, TableWithType } from './types';
type Field = { import { generateSchema } from './schema';
name: string;
type?: string;
customType?: string;
tsType?: string;
required: boolean;
isArray: boolean;
};
// eslint-disable-next-line @typescript-eslint/ban-types
type FieldWithType = Omit<Field, 'type' | 'customType'> & { 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[];
};
const directory = 'tables'; const directory = 'tables';
@ -175,19 +140,23 @@ const generate = async () => {
assert(finalType, `Type ${customType ?? 'N/A'} not found`); assert(finalType, `Type ${customType ?? 'N/A'} not found`);
if (tsType) { if (tsType) {
tsTypes.push(tsType); tsTypes.push(tsType, `${camelcase(tsType)}Guard`);
} else if (type === undefined) { } else if (!type) {
customTypes.push(finalType); customTypes.push(finalType);
} }
return { ...rest, type: finalType }; return { ...rest, tsType, type: finalType, isEnum: !tsType && !type };
}), }),
})); }));
if (tableWithTypes.length > 0) { 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( const importTsTypes = conditionalString(
tsTypes.length > 0 && tsTypes.length > 0 &&
[ [
@ -212,39 +181,10 @@ const generate = async () => {
const content = const content =
header + header +
importZod +
importTsTypes + importTsTypes +
importTypes + importTypes +
tableWithTypes tableWithTypes.map((table) => generateSchema(table)).join('\n') +
.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') +
'\n'; '\n';
await fs.writeFile(path.join(generatedDirectory, getOutputFileName(file) + '.ts'), content); await fs.writeFile(path.join(generatedDirectory, getOutputFileName(file) + '.ts'), content);
}) })

View file

@ -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');
};

View file

@ -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<Field, 'type' | 'customType'> & { 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[];
};

View file

@ -62,7 +62,7 @@ importers:
ts-jest: ^27.0.5 ts-jest: ^27.0.5
tsc-watch: ^4.4.0 tsc-watch: ^4.4.0
typescript: ^4.3.5 typescript: ^4.3.5
zod: ^3.2.0 zod: ^3.8.1
dependencies: dependencies:
'@logto/essentials': 1.1.0-rc.2 '@logto/essentials': 1.1.0-rc.2
'@logto/phrases': link:../phrases '@logto/phrases': link:../phrases
@ -87,7 +87,7 @@ importers:
oidc-provider: 7.5.4 oidc-provider: 7.5.4
slonik: 23.8.5 slonik: 23.8.5
slonik-interceptor-preset: 1.2.10 slonik-interceptor-preset: 1.2.10
zod: 3.5.1 zod: 3.8.1
devDependencies: devDependencies:
'@logto/eslint-config': 0.1.0-rc.18_2055f56ab8dafa07df5c7ad406c8a4ab '@logto/eslint-config': 0.1.0-rc.18_2055f56ab8dafa07df5c7ad406c8a4ab
'@logto/ts-config': 0.1.0-rc.18_54a571252e94826a463f89cda755e2f0 '@logto/ts-config': 0.1.0-rc.18_54a571252e94826a463f89cda755e2f0
@ -139,6 +139,7 @@ importers:
prettier: ^2.3.2 prettier: ^2.3.2
ts-node: ^10.0.0 ts-node: ^10.0.0
typescript: ^4.3.5 typescript: ^4.3.5
zod: ^3.8.1
dependencies: dependencies:
'@logto/phrases': link:../phrases '@logto/phrases': link:../phrases
devDependencies: devDependencies:
@ -155,6 +156,7 @@ importers:
prettier: 2.3.2 prettier: 2.3.2
ts-node: 10.1.0_13403c2f2d9ddab699dd2f492f123cbf ts-node: 10.1.0_13403c2f2d9ddab699dd2f492f123cbf
typescript: 4.3.5 typescript: 4.3.5
zod: 3.8.1
packages/ui: packages/ui:
specifiers: specifiers:
@ -16664,9 +16666,8 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
dev: true dev: true
/zod/3.5.1: /zod/3.8.1:
resolution: {integrity: sha512-Gg9GTai0iDHowuYM9VNhdFMmesgt44ufzqaE5CPHshpuK5fCzbibdqCnrWuYH6ZmOn/N+BlGmwZtVSijhKmhKw==} resolution: {integrity: sha512-u4Uodl7dLh8nXZwqXL1SM5FAl5b4lXYHOxMUVb9lqhlEAZhA2znX+0oW480m0emGFMxpoRHzUncAqRkc4h8ZJA==}
dev: false
/zwitch/1.0.5: /zwitch/1.0.5:
resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==} resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}