diff --git a/packages/core/src/database/insert.ts b/packages/core/src/database/insert.ts new file mode 100644 index 000000000..b9b6eb50f --- /dev/null +++ b/packages/core/src/database/insert.ts @@ -0,0 +1,87 @@ +import assert from 'assert'; +import RequestError from '@/errors/RequestError'; +import { SchemaLike, GeneratedSchema } from '@logto/schemas'; +import { DatabasePoolType, IdentifierSqlTokenType, sql } from 'slonik'; +import { + conditionalSql, + convertToIdentifiers, + convertToPrimitive, + excludeAutoSetFields, + OmitAutoSetFields, +} from './utils'; + +const setExcluded = (...fields: IdentifierSqlTokenType[]) => + sql.join( + fields.map((field) => sql`${field}=excluded.${field}`), + sql`, ` + ); + +type OnConflict = { + fields: IdentifierSqlTokenType[]; + setExcludedFields: IdentifierSqlTokenType[]; +}; + +type InsertIntoConfigReturning = { + returning: true; + onConflict?: OnConflict; +}; + +type InsertIntoConfig = { + returning?: false; + onConflict?: OnConflict; +}; + +interface BuildInsertInto { + >( + pool: DatabasePoolType, + { fieldKeys, ...rest }: GeneratedSchema, + config: InsertIntoConfigReturning + ): (data: OmitAutoSetFields) => Promise; + >( + pool: DatabasePoolType, + { fieldKeys, ...rest }: GeneratedSchema, + config?: InsertIntoConfig + ): (data: OmitAutoSetFields) => Promise; +} + +export const buildInsertInto: BuildInsertInto = >( + pool: DatabasePoolType, + { fieldKeys, ...rest }: GeneratedSchema, + config?: InsertIntoConfig | InsertIntoConfigReturning +) => { + const { table, fields } = convertToIdentifiers(rest); + const keys = excludeAutoSetFields(fieldKeys); + const returning = Boolean(config?.returning); + const onConflict = config?.onConflict; + + return async (data: OmitAutoSetFields): Promise => { + const result = await pool.query(sql` + insert into ${table} (${sql.join( + keys.map((key) => fields[key]), + sql`, ` + )}) + values (${sql.join( + keys.map((key) => convertToPrimitive(data[key] ?? null)), + sql`, ` + )}) + ${conditionalSql(returning, () => sql`returning *`)} + ${conditionalSql( + onConflict, + ({ fields, setExcludedFields }) => sql` + on conflict (${sql.join(fields, sql`, `)}) do update + set ${setExcluded(...setExcludedFields)} + ` + )} + `); + + const { + rows: [entry], + } = result; + + assert( + !returning || entry, + new RequestError({ code: 'entity.create_failed', name: rest.tableSingular }) + ); + return entry; + }; +}; diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts new file mode 100644 index 000000000..f47a8f0ca --- /dev/null +++ b/packages/core/src/database/types.ts @@ -0,0 +1,6 @@ +import { IdentifierSqlTokenType } from 'slonik'; + +export type Table = { table: string; fields: Record }; +export type FieldIdentifiers = { + [key in Key]: IdentifierSqlTokenType; +}; diff --git a/packages/core/src/database/utils.ts b/packages/core/src/database/utils.ts index 59a048c38..6dde30ea8 100644 --- a/packages/core/src/database/utils.ts +++ b/packages/core/src/database/utils.ts @@ -1,13 +1,51 @@ -import { IdentifierSqlTokenType, sql } from 'slonik'; +import { Falsy, notFalsy } from '@logto/essentials'; +import { SchemaValuePrimitive, SchemaValue } from '@logto/schemas'; +import { sql, SqlSqlTokenType } from 'slonik'; +import { FieldIdentifiers, Table } from './types'; -type Table = { table: string; fields: Record }; -type FieldIdentifiers = { - [key in Key]: IdentifierSqlTokenType; +export const conditionalSql = ( + value: T, + buildSql: (value: Exclude) => SqlSqlTokenType +) => (notFalsy(value) ? buildSql(value) : sql``); + +export const autoSetFields = Object.freeze(['createdAt', 'updatedAt'] as const); +// `Except` type will require omit fields to be the key of given type +// eslint-disable-next-line @typescript-eslint/ban-types +export type OmitAutoSetFields = Omit; +export type ExcludeAutoSetFields = Exclude; +export const excludeAutoSetFields = (fields: readonly T[]) => + Object.freeze( + fields.filter( + (field): field is ExcludeAutoSetFields => + !(autoSetFields as readonly string[]).includes(field) + ) + ); + +/** + * 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 value The value to convert. + * @returns A primitive that can be saved into database. + */ +export const convertToPrimitive = ( + value: NonNullable | null +): NonNullable | null => { + if (value === null) { + return null; + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + throw new Error(`Cannot convert to primitive from ${typeof value}`); }; -const convertToPrimitive = (value: T) => - value !== null && typeof value === 'object' ? JSON.stringify(value) : value; - export const convertToIdentifiers = ( { table, fields }: T, withPrefix = false @@ -22,24 +60,3 @@ export const convertToIdentifiers = ( {} as FieldIdentifiers ), }); - -export const insertInto = ( - table: IdentifierSqlTokenType, - fields: FieldIdentifiers, - fieldKeys: readonly Key[], - value: { [key in Key]?: Type[key] } -) => sql` - insert into ${table} (${sql.join( - fieldKeys.map((key) => fields[key]), - sql`, ` -)}) - values (${sql.join( - fieldKeys.map((key) => convertToPrimitive(value[key] ?? null)), - sql`, ` - )})`; - -export const setExcluded = (...fields: IdentifierSqlTokenType[]) => - sql.join( - fields.map((field) => sql`${field}=excluded.${field}`), - sql`, ` - ); diff --git a/packages/core/src/errors/RequestError/index.ts b/packages/core/src/errors/RequestError/index.ts index d3992240b..5517d840e 100644 --- a/packages/core/src/errors/RequestError/index.ts +++ b/packages/core/src/errors/RequestError/index.ts @@ -10,8 +10,12 @@ export default class RequestError extends Error { data: unknown; constructor(input: RequestErrorMetadata | LogtoErrorCode, data?: unknown) { - const { code, status = 400 } = typeof input === 'string' ? { code: input } : input; - const message = i18next.t(`errors:${code}`); + const { + code, + status = 400, + ...interpolation + } = typeof input === 'string' ? { code: input } : input; + const message = i18next.t(`errors:${code}`, interpolation); super(message); diff --git a/packages/core/src/oidc/adapter.ts b/packages/core/src/oidc/adapter.ts index fcd1b6fa2..c671e1ad7 100644 --- a/packages/core/src/oidc/adapter.ts +++ b/packages/core/src/oidc/adapter.ts @@ -9,6 +9,7 @@ import { } from '@/queries/oidc-model-instance'; import { findApplicationById } from '@/queries/application'; import { ApplicationDBEntry } from '@logto/schemas'; +import dayjs from 'dayjs'; export default function postgresAdapter(modelName: string): ReturnType { if (modelName === 'Client') { @@ -32,7 +33,13 @@ export default function postgresAdapter(modelName: string): ReturnType upsertInstance(modelName, id, payload, expiresIn), + upsert: async (id, payload, expiresIn) => + upsertInstance({ + modelName, + id, + payload, + expiresAt: dayjs().add(expiresIn, 'second').unix(), + }), find: async (id) => findPayloadById(modelName, id), findByUserCode: async (userCode) => findPayloadByPayloadField(modelName, 'userCode', userCode), findByUid: async (uid) => findPayloadByPayloadField(modelName, 'uid', uid), diff --git a/packages/core/src/oidc/utils.ts b/packages/core/src/oidc/utils.ts new file mode 100644 index 000000000..8b6827531 --- /dev/null +++ b/packages/core/src/oidc/utils.ts @@ -0,0 +1,6 @@ +import { OidcClientMetadata } from '@logto/schemas'; + +export const generateOidcClientMetadata = (): OidcClientMetadata => ({ + redirect_uris: [], + post_logout_redirect_uris: [], +}); diff --git a/packages/core/src/queries/application.ts b/packages/core/src/queries/application.ts index fdc7df5db..29ecdc95b 100644 --- a/packages/core/src/queries/application.ts +++ b/packages/core/src/queries/application.ts @@ -1,3 +1,4 @@ +import { buildInsertInto } from '@/database/insert'; import pool from '@/database/pool'; import { convertToIdentifiers } from '@/database/utils'; import { ApplicationDBEntry, Applications } from '@logto/schemas'; @@ -11,3 +12,7 @@ export const findApplicationById = async (id: string) => from ${table} where ${fields.id}=${id} `); + +export const insertApplication = buildInsertInto(pool, Applications, { + returning: true, +}); diff --git a/packages/core/src/queries/oidc-model-instance.ts b/packages/core/src/queries/oidc-model-instance.ts index 35addb5cf..35f820d22 100644 --- a/packages/core/src/queries/oidc-model-instance.ts +++ b/packages/core/src/queries/oidc-model-instance.ts @@ -1,5 +1,6 @@ +import { buildInsertInto } from '@/database/insert'; import pool from '@/database/pool'; -import { convertToIdentifiers, insertInto, setExcluded } from '@/database/utils'; +import { convertToIdentifiers } from '@/database/utils'; import { conditional } from '@logto/essentials'; import { OidcModelInstanceDBEntry, @@ -22,30 +23,12 @@ const withConsumed = (data: T, consumedAt?: number): WithConsumed => ({ const convertResult = (result: QueryResult | null) => conditional(result && withConsumed(result.payload, result.consumedAt)); -export const upsertInstance = async ( - modelName: string, - id: string, - payload: OidcModelInstancePayload, - expiresIn: number -) => { - await pool.query( - sql` - ${insertInto( - table, - fields, - ['modelName', 'id', 'payload', 'expiresAt'], - { - modelName, - id, - payload, - expiresAt: dayjs().add(expiresIn, 'second').unix(), - } - )} - on conflict (${fields.modelName}, ${fields.id}) do update - set ${setExcluded(fields.payload, fields.expiresAt)} - ` - ); -}; +export const upsertInstance = buildInsertInto(pool, OidcModelInstances, { + onConflict: { + fields: [fields.modelName, fields.id], + setExcludedFields: [fields.payload, fields.expiresAt], + }, +}); const findByModel = (modelName: string) => sql` select ${fields.payload}, ${fields.consumedAt} diff --git a/packages/core/src/queries/user.ts b/packages/core/src/queries/user.ts index cc33cccfe..ed462b161 100644 --- a/packages/core/src/queries/user.ts +++ b/packages/core/src/queries/user.ts @@ -1,7 +1,8 @@ import { UserDBEntry, Users } from '@logto/schemas'; import { sql } from 'slonik'; import pool from '@/database/pool'; -import { convertToIdentifiers, insertInto } from '@/database/utils'; +import { convertToIdentifiers } from '@/database/utils'; +import { buildInsertInto } from '@/database/insert'; const { table, fields } = convertToIdentifiers(Users); @@ -33,5 +34,4 @@ export const hasUserWithId = async (id: string) => where ${fields.id}=${id} `); -export const insertUser = async (user: UserDBEntry) => - pool.query(insertInto(table, fields, Users.fieldKeys, user)); +export const insertUser = buildInsertInto(pool, Users, { returning: true }); diff --git a/packages/core/src/routes/application.ts b/packages/core/src/routes/application.ts index fa916e838..5c4a28d7f 100644 --- a/packages/core/src/routes/application.ts +++ b/packages/core/src/routes/application.ts @@ -2,6 +2,11 @@ import Router from 'koa-router'; import { nativeEnum, object, string } from 'zod'; import { ApplicationType } from '@logto/schemas'; import koaGuard from '@/middleware/koa-guard'; +import { insertApplication } from '@/queries/application'; +import { buildIdGenerator } from '@/utils/id'; +import { generateOidcClientMetadata } from '@/oidc/utils'; + +const applicationId = buildIdGenerator(21); export default function applicationRoutes(router: Router) { router.post( @@ -15,7 +20,12 @@ export default function applicationRoutes(router: Router { const { name, type } = ctx.guard.body; - ctx.body = { name, type }; + ctx.body = await insertApplication({ + id: applicationId(), + type, + name, + oidcClientMetadata: generateOidcClientMetadata(), + }); return next(); } ); diff --git a/packages/core/src/routes/user.ts b/packages/core/src/routes/user.ts index 1d1a0af7f..3b5b1df90 100644 --- a/packages/core/src/routes/user.ts +++ b/packages/core/src/routes/user.ts @@ -2,13 +2,13 @@ import Router from 'koa-router'; import { object, string } from 'zod'; import { encryptPassword } from '@/utils/password'; import { hasUser, hasUserWithId, insertUser } from '@/queries/user'; -import { customAlphabet, nanoid } from 'nanoid'; +import { nanoid } from 'nanoid'; import { PasswordEncryptionMethod } from '@logto/schemas'; import koaGuard from '@/middleware/koa-guard'; import RequestError from '@/errors/RequestError'; +import { buildIdGenerator } from '@/utils/id'; -const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; -const userId = customAlphabet(alphabet, 12); +const userId = buildIdGenerator(12); const generateUserId = async (maxRetries = 500) => { for (let i = 0; i < maxRetries; ++i) { @@ -48,15 +48,13 @@ export default function userRoutes(router: Router) { passwordEncryptionMethod ); - await insertUser({ + ctx.body = await insertUser({ id, username, passwordEncrypted, passwordEncryptionMethod, passwordEncryptionSalt, }); - - ctx.body = { id }; return next(); } ); diff --git a/packages/core/src/utils/id.ts b/packages/core/src/utils/id.ts new file mode 100644 index 000000000..5072c37ee --- /dev/null +++ b/packages/core/src/utils/id.ts @@ -0,0 +1,5 @@ +import { customAlphabet } from 'nanoid'; + +export const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + +export const buildIdGenerator = (size: number) => customAlphabet(alphabet, size); diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index 7598e447f..eb7872cb9 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -37,6 +37,9 @@ const errors = { swagger: { invalid_zod_type: 'Invalid Zod type, please check route guard config.', }, + entity: { + create_failed: 'Failed to create {{name}}.', + }, }; const en = Object.freeze({ diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index 52785c233..02520a113 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -39,6 +39,9 @@ const errors = { swagger: { invalid_zod_type: '无效的 Zod 类型,请检查路由 guard 配置。', }, + entity: { + create_failed: '创建{{name}}失败。', + }, }; const zhCN: typeof en = Object.freeze({ diff --git a/packages/schemas/src/api/error.ts b/packages/schemas/src/api/error.ts index 147583635..ab5cb9c5f 100644 --- a/packages/schemas/src/api/error.ts +++ b/packages/schemas/src/api/error.ts @@ -1,6 +1,6 @@ import { LogtoErrorCode } from '@logto/phrases'; -export type RequestErrorMetadata = { +export type RequestErrorMetadata = Record & { code: LogtoErrorCode; status?: number; }; diff --git a/packages/schemas/src/db-entries/application.ts b/packages/schemas/src/db-entries/application.ts index f7be7c6b7..b4a3fbffd 100644 --- a/packages/schemas/src/db-entries/application.ts +++ b/packages/schemas/src/db-entries/application.ts @@ -1,6 +1,6 @@ // THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -import { OidcClientMetadata } from '../foundations'; +import { OidcClientMetadata, GeneratedSchema } from '../foundations'; import { ApplicationType } from './custom-types'; @@ -12,8 +12,9 @@ export type ApplicationDBEntry = { createdAt: number; }; -export const Applications = Object.freeze({ +export const Applications: GeneratedSchema = Object.freeze({ table: 'applications', + tableSingular: 'application', fields: { id: 'id', name: 'name', @@ -22,4 +23,4 @@ export const Applications = Object.freeze({ createdAt: 'created_at', }, fieldKeys: ['id', 'name', 'type', 'oidcClientMetadata', 'createdAt'], -} as const); +}); diff --git a/packages/schemas/src/db-entries/oidc-model-instance.ts b/packages/schemas/src/db-entries/oidc-model-instance.ts index 683952733..fcc6a71cf 100644 --- a/packages/schemas/src/db-entries/oidc-model-instance.ts +++ b/packages/schemas/src/db-entries/oidc-model-instance.ts @@ -1,6 +1,6 @@ // THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -import { OidcModelInstancePayload } from '../foundations'; +import { OidcModelInstancePayload, GeneratedSchema } from '../foundations'; export type OidcModelInstanceDBEntry = { modelName: string; @@ -10,8 +10,9 @@ export type OidcModelInstanceDBEntry = { consumedAt?: number; }; -export const OidcModelInstances = Object.freeze({ +export const OidcModelInstances: GeneratedSchema = Object.freeze({ table: 'oidc_model_instances', + tableSingular: 'oidc_model_instance', fields: { modelName: 'model_name', id: 'id', @@ -20,4 +21,4 @@ export const OidcModelInstances = Object.freeze({ consumedAt: 'consumed_at', }, fieldKeys: ['modelName', 'id', 'payload', 'expiresAt', 'consumedAt'], -} as const); +}); diff --git a/packages/schemas/src/db-entries/user.ts b/packages/schemas/src/db-entries/user.ts index 370fad704..071f0f866 100644 --- a/packages/schemas/src/db-entries/user.ts +++ b/packages/schemas/src/db-entries/user.ts @@ -1,5 +1,7 @@ // THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +import { GeneratedSchema } from '../foundations'; + import { PasswordEncryptionMethod } from './custom-types'; export type UserDBEntry = { @@ -12,8 +14,9 @@ export type UserDBEntry = { passwordEncryptionSalt?: string; }; -export const Users = Object.freeze({ +export const Users: GeneratedSchema = Object.freeze({ table: 'users', + tableSingular: 'user', fields: { id: 'id', username: 'username', @@ -32,4 +35,4 @@ export const Users = Object.freeze({ 'passwordEncryptionMethod', 'passwordEncryptionSalt', ], -} as const); +}); diff --git a/packages/schemas/src/foundations.ts b/packages/schemas/src/foundations.ts index fff2b7000..b6dae9b90 100644 --- a/packages/schemas/src/foundations.ts +++ b/packages/schemas/src/foundations.ts @@ -1,5 +1,21 @@ -export type OidcModelInstancePayload = { - [key: string]: unknown; +export type SchemaValuePrimitive = string | number | boolean | undefined; +export type SchemaValue = SchemaValuePrimitive | Record; +export type SchemaLike = { + [key in Key]: SchemaValue; +}; + +export type GeneratedSchema> = keyof Schema extends string + ? Readonly<{ + table: string; + tableSingular: string; + fields: { + [key in keyof Schema]: string; + }; + fieldKeys: ReadonlyArray; + }> + : never; + +export type OidcModelInstancePayload = Record & { userCode?: string; uid?: string; grantId?: string; diff --git a/packages/schemas/src/gen/index.ts b/packages/schemas/src/gen/index.ts index 2ea9962b6..f33d0c34f 100644 --- a/packages/schemas/src/gen/index.ts +++ b/packages/schemas/src/gen/index.ts @@ -184,6 +184,10 @@ const generate = async () => { }), })); + if (tableWithTypes.length > 0) { + tsTypes.push('GeneratedSchema'); + } + const importTsTypes = conditionalString( tsTypes.length > 0 && [ @@ -211,9 +215,13 @@ const generate = async () => { importTsTypes + importTypes + tableWithTypes - .map(({ name, fields }) => - [ - `export type ${pluralize(camelcase(name, { pascalCase: true }), 1)}DBEntry = {`, + .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( @@ -222,17 +230,20 @@ const generate = async () => { ), '};', '', - `export const ${camelcase(name, { pascalCase: true })} = Object.freeze({`, + `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)}',`), ' ],', - '} as const);', - ].join('\n') - ) + '});', + ].join('\n'); + }) .join('\n') + '\n'; await fs.writeFile(path.join(generatedDirectory, getOutputFileName(file) + '.ts'), content);