0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

Merge pull request #82 from logto-io/gao-log-3

feat: `POST /applicaiton`
This commit is contained in:
Gao Sun 2021-08-18 15:42:02 +08:00 committed by GitHub
commit d861b7a623
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 250 additions and 84 deletions

View file

@ -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 {
<Schema extends SchemaLike<string>>(
pool: DatabasePoolType,
{ fieldKeys, ...rest }: GeneratedSchema<Schema>,
config: InsertIntoConfigReturning
): (data: OmitAutoSetFields<Schema>) => Promise<Schema>;
<Schema extends SchemaLike<string>>(
pool: DatabasePoolType,
{ fieldKeys, ...rest }: GeneratedSchema<Schema>,
config?: InsertIntoConfig
): (data: OmitAutoSetFields<Schema>) => Promise<void>;
}
export const buildInsertInto: BuildInsertInto = <Schema extends SchemaLike<string>>(
pool: DatabasePoolType,
{ fieldKeys, ...rest }: GeneratedSchema<Schema>,
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<Schema>): Promise<Schema | void> => {
const result = await pool.query<Schema>(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;
};
};

View file

@ -0,0 +1,6 @@
import { IdentifierSqlTokenType } from 'slonik';
export type Table = { table: string; fields: Record<string, string> };
export type FieldIdentifiers<Key extends string | number | symbol> = {
[key in Key]: IdentifierSqlTokenType;
};

View file

@ -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<string, string> };
type FieldIdentifiers<Key extends string | number | symbol> = {
[key in Key]: IdentifierSqlTokenType;
export const conditionalSql = <T>(
value: T,
buildSql: (value: Exclude<T, Falsy>) => 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<T> = Omit<T, typeof autoSetFields[number]>;
export type ExcludeAutoSetFields<T> = Exclude<T, typeof autoSetFields[number]>;
export const excludeAutoSetFields = <T extends string>(fields: readonly T[]) =>
Object.freeze(
fields.filter(
(field): field is ExcludeAutoSetFields<T> =>
!(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<SchemaValue> | null
): NonNullable<SchemaValuePrimitive> | 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 = <T>(value: T) =>
value !== null && typeof value === 'object' ? JSON.stringify(value) : value;
export const convertToIdentifiers = <T extends Table>(
{ table, fields }: T,
withPrefix = false
@ -22,24 +60,3 @@ export const convertToIdentifiers = <T extends Table>(
{} as FieldIdentifiers<keyof T['fields']>
),
});
export const insertInto = <Type, Key extends keyof Type = keyof Type>(
table: IdentifierSqlTokenType,
fields: FieldIdentifiers<Key>,
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`, `
);

View file

@ -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<string, LogtoErrorI18nKey>(`errors:${code}`);
const {
code,
status = 400,
...interpolation
} = typeof input === 'string' ? { code: input } : input;
const message = i18next.t<string, LogtoErrorI18nKey>(`errors:${code}`, interpolation);
super(message);

View file

@ -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<AdapterFactory> {
if (modelName === 'Client') {
@ -32,7 +33,13 @@ export default function postgresAdapter(modelName: string): ReturnType<AdapterFa
}
return {
upsert: async (id, payload, expiresIn) => 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),

View file

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

View file

@ -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<ApplicationDBEntry>(pool, Applications, {
returning: true,
});

View file

@ -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 = <T>(data: T, consumedAt?: number): WithConsumed<T> => ({
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<OidcModelInstanceDBEntry>(
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<OidcModelInstanceDBEntry>(pool, OidcModelInstances, {
onConflict: {
fields: [fields.modelName, fields.id],
setExcludedFields: [fields.payload, fields.expiresAt],
},
});
const findByModel = (modelName: string) => sql`
select ${fields.payload}, ${fields.consumedAt}

View file

@ -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<UserDBEntry>(pool, Users, { returning: true });

View file

@ -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<StateT, ContextT>(router: Router<StateT, ContextT>) {
router.post(
@ -15,7 +20,12 @@ export default function applicationRoutes<StateT, ContextT>(router: Router<State
async (ctx, next) => {
const { name, type } = ctx.guard.body;
ctx.body = { name, type };
ctx.body = await insertApplication({
id: applicationId(),
type,
name,
oidcClientMetadata: generateOidcClientMetadata(),
});
return next();
}
);

View file

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

View file

@ -0,0 +1,5 @@
import { customAlphabet } from 'nanoid';
export const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
export const buildIdGenerator = (size: number) => customAlphabet(alphabet, size);

View file

@ -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({

View file

@ -39,6 +39,9 @@ const errors = {
swagger: {
invalid_zod_type: '无效的 Zod 类型,请检查路由 guard 配置。',
},
entity: {
create_failed: '创建{{name}}失败。',
},
};
const zhCN: typeof en = Object.freeze({

View file

@ -1,6 +1,6 @@
import { LogtoErrorCode } from '@logto/phrases';
export type RequestErrorMetadata = {
export type RequestErrorMetadata = Record<string, unknown> & {
code: LogtoErrorCode;
status?: number;
};

View file

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

View file

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

View file

@ -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<UserDBEntry> = Object.freeze({
table: 'users',
tableSingular: 'user',
fields: {
id: 'id',
username: 'username',
@ -32,4 +35,4 @@ export const Users = Object.freeze({
'passwordEncryptionMethod',
'passwordEncryptionSalt',
],
} as const);
});

View file

@ -1,5 +1,21 @@
export type OidcModelInstancePayload = {
[key: string]: unknown;
export type SchemaValuePrimitive = string | number | boolean | undefined;
export type SchemaValue = SchemaValuePrimitive | Record<string, unknown>;
export type SchemaLike<Key extends string> = {
[key in Key]: SchemaValue;
};
export type GeneratedSchema<Schema extends SchemaLike<string>> = keyof Schema extends string
? Readonly<{
table: string;
tableSingular: string;
fields: {
[key in keyof Schema]: string;
};
fieldKeys: ReadonlyArray<keyof Schema>;
}>
: never;
export type OidcModelInstancePayload = Record<string, unknown> & {
userCode?: string;
uid?: string;
grantId?: string;

View file

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