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
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
*.log*
# misc
cache

View file

@ -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",

View file

@ -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 = <Schema extends SchemaLike<strin
sql`, `
)})
values (${sql.join(
keys.map((key) => convertToPrimitive(data[key] ?? null)),
keys.map((key) => convertToPrimitiveOrSql(key, data[key] ?? null)),
sql`, `
)})
${conditionalSql(returning, () => sql`returning *`)}

View file

@ -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 = <T>(
@ -25,12 +26,14 @@ export const excludeAutoSetFields = <T extends string>(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<SchemaValue> | null
): NonNullable<SchemaValuePrimitive> | null => {
): NonNullable<SchemaValuePrimitive> | 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 = <T extends Table>(
{} 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';
export const generateOidcClientMetadata = (): OidcClientMetadata => ({
redirect_uris: [],
post_logout_redirect_uris: [],
redirectUris: [],
postLogoutRedirectUris: [],
});

View file

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

View file

@ -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"
}
}

View file

@ -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<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({
table: 'applications',
tableSingular: 'application',
@ -23,4 +38,5 @@ export const Applications: GeneratedSchema<ApplicationDBEntry> = Object.freeze({
createdAt: 'created_at',
},
fieldKeys: ['id', 'name', 'type', 'oidcClientMetadata', 'createdAt'],
guard,
});

View file

@ -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<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({
table: 'oidc_model_instances',
tableSingular: 'oidc_model_instance',
@ -21,4 +36,5 @@ export const OidcModelInstances: GeneratedSchema<OidcModelInstanceDBEntry> = Obj
consumedAt: 'consumed_at',
},
fieldKeys: ['modelName', 'id', 'payload', 'expiresAt', 'consumedAt'],
guard,
});

View file

@ -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<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({
table: 'users',
tableSingular: 'user',
@ -35,4 +47,5 @@ export const Users: GeneratedSchema<UserDBEntry> = Object.freeze({
'passwordEncryptionMethod',
'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 SchemaValue = SchemaValuePrimitive | Record<string, unknown>;
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;
};
fieldKeys: ReadonlyArray<keyof Schema>;
guard: Guard<Schema>;
}>
: 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 { 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<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[];
};
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);
})

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
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==}