mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): add PUT /custom-phrases/:languageKey route (#1907)
This commit is contained in:
parent
31ecfd0b96
commit
0ae13f091b
7 changed files with 103 additions and 8 deletions
|
@ -1,4 +1,4 @@
|
||||||
import { SchemaLike, GeneratedSchema } from '@logto/schemas';
|
import { GeneratedSchema, SchemaLike } from '@logto/schemas';
|
||||||
import { has } from '@silverhand/essentials';
|
import { has } from '@silverhand/essentials';
|
||||||
import { IdentifierSqlToken, sql } from 'slonik';
|
import { IdentifierSqlToken, sql } from 'slonik';
|
||||||
|
|
||||||
|
@ -72,7 +72,6 @@ export const buildInsertInto: BuildInsertInto = <
|
||||||
insertingKeys.map((key) => convertToPrimitiveOrSql(key, data[key] ?? null)),
|
insertingKeys.map((key) => convertToPrimitiveOrSql(key, data[key] ?? null)),
|
||||||
sql`, `
|
sql`, `
|
||||||
)})
|
)})
|
||||||
${conditionalSql(returning, () => sql`returning *`)}
|
|
||||||
${conditionalSql(
|
${conditionalSql(
|
||||||
onConflict,
|
onConflict,
|
||||||
({ fields, setExcludedFields }) => sql`
|
({ fields, setExcludedFields }) => sql`
|
||||||
|
@ -80,6 +79,7 @@ export const buildInsertInto: BuildInsertInto = <
|
||||||
set ${setExcluded(...setExcludedFields)}
|
set ${setExcluded(...setExcludedFields)}
|
||||||
`
|
`
|
||||||
)}
|
)}
|
||||||
|
${conditionalSql(returning, () => sql`returning *`)}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
assertThat(!returning || entry, new InsertionError(schema, data));
|
assertThat(!returning || entry, new InsertionError(schema, data));
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { CustomPhrase, CustomPhrases } from '@logto/schemas';
|
import { CreateCustomPhrase, CustomPhrase, CustomPhrases } from '@logto/schemas';
|
||||||
import { sql } from 'slonik';
|
import { sql } from 'slonik';
|
||||||
|
|
||||||
|
import { buildInsertInto } from '@/database/insert-into';
|
||||||
import { convertToIdentifiers } from '@/database/utils';
|
import { convertToIdentifiers } from '@/database/utils';
|
||||||
import envSet from '@/env-set';
|
import envSet from '@/env-set';
|
||||||
import { DeletionError } from '@/errors/SlonikError';
|
import { DeletionError } from '@/errors/SlonikError';
|
||||||
|
@ -14,6 +15,14 @@ export const findCustomPhraseByLanguageKey = async (languageKey: string): Promis
|
||||||
where ${fields.languageKey} = ${languageKey}
|
where ${fields.languageKey} = ${languageKey}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
export const upsertCustomPhrase = buildInsertInto<CreateCustomPhrase, CustomPhrase>(CustomPhrases, {
|
||||||
|
returning: true,
|
||||||
|
onConflict: {
|
||||||
|
fields: [fields.languageKey],
|
||||||
|
setExcludedFields: [fields.translation],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const deleteCustomPhraseByLanguageKey = async (languageKey: string) => {
|
export const deleteCustomPhraseByLanguageKey = async (languageKey: string) => {
|
||||||
const { rowCount } = await envSet.pool.query(sql`
|
const { rowCount } = await envSet.pool.query(sql`
|
||||||
delete from ${table}
|
delete from ${table}
|
||||||
|
|
|
@ -37,9 +37,12 @@ const findCustomPhraseByLanguageKey = jest.fn(async (languageKey: string) => {
|
||||||
return mockCustomPhrase;
|
return mockCustomPhrase;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const upsertCustomPhrase = jest.fn(async (customPhrase: CustomPhrase) => customPhrase);
|
||||||
|
|
||||||
jest.mock('@/queries/custom-phrase', () => ({
|
jest.mock('@/queries/custom-phrase', () => ({
|
||||||
deleteCustomPhraseByLanguageKey: async (key: string) => deleteCustomPhraseByLanguageKey(key),
|
deleteCustomPhraseByLanguageKey: async (key: string) => deleteCustomPhraseByLanguageKey(key),
|
||||||
findCustomPhraseByLanguageKey: async (key: string) => findCustomPhraseByLanguageKey(key),
|
findCustomPhraseByLanguageKey: async (key: string) => findCustomPhraseByLanguageKey(key),
|
||||||
|
upsertCustomPhrase: async (customPhrase: CustomPhrase) => upsertCustomPhrase(customPhrase),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('customPhraseRoutes', () => {
|
describe('customPhraseRoutes', () => {
|
||||||
|
@ -67,6 +70,23 @@ describe('customPhraseRoutes', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PUT /custom-phrases/:languageKey', () => {
|
||||||
|
it('should call upsertCustomPhrase with specified language key', async () => {
|
||||||
|
await customPhraseRequest
|
||||||
|
.put(`/custom-phrases/${mockLanguageKey}`)
|
||||||
|
.send(mockCustomPhrases[mockLanguageKey]?.translation);
|
||||||
|
expect(upsertCustomPhrase).toBeCalledWith(mockCustomPhrases[mockLanguageKey]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the custom phrase after upserting', async () => {
|
||||||
|
const response = await customPhraseRequest
|
||||||
|
.put(`/custom-phrases/${mockLanguageKey}`)
|
||||||
|
.send(mockCustomPhrases[mockLanguageKey]?.translation);
|
||||||
|
expect(response.status).toEqual(200);
|
||||||
|
expect(response.body).toEqual(mockCustomPhrases[mockLanguageKey]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('DELETE /custom-phrases/:languageKey', () => {
|
describe('DELETE /custom-phrases/:languageKey', () => {
|
||||||
it('should call deleteCustomPhraseByLanguageKey', async () => {
|
it('should call deleteCustomPhraseByLanguageKey', async () => {
|
||||||
await customPhraseRequest.delete(`/custom-phrases/${mockLanguageKey}`);
|
await customPhraseRequest.delete(`/custom-phrases/${mockLanguageKey}`);
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { CustomPhrases } from '@logto/schemas';
|
import { CustomPhrases, translationGuard } from '@logto/schemas';
|
||||||
|
|
||||||
import koaGuard from '@/middleware/koa-guard';
|
import koaGuard from '@/middleware/koa-guard';
|
||||||
import {
|
import {
|
||||||
deleteCustomPhraseByLanguageKey,
|
deleteCustomPhraseByLanguageKey,
|
||||||
findCustomPhraseByLanguageKey,
|
findCustomPhraseByLanguageKey,
|
||||||
|
upsertCustomPhrase,
|
||||||
} from '@/queries/custom-phrase';
|
} from '@/queries/custom-phrase';
|
||||||
|
|
||||||
import { AuthedRouter } from './types';
|
import { AuthedRouter } from './types';
|
||||||
|
@ -27,6 +28,25 @@ export default function customPhraseRoutes<T extends AuthedRouter>(router: T) {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/custom-phrases/:languageKey',
|
||||||
|
koaGuard({
|
||||||
|
params: CustomPhrases.createGuard.pick({ languageKey: true }),
|
||||||
|
body: translationGuard,
|
||||||
|
response: CustomPhrases.guard,
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const {
|
||||||
|
params: { languageKey },
|
||||||
|
body,
|
||||||
|
} = ctx.guard;
|
||||||
|
|
||||||
|
ctx.body = await upsertCustomPhrase({ languageKey, translation: body });
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.delete(
|
router.delete(
|
||||||
'/custom-phrases/:languageKey',
|
'/custom-phrases/:languageKey',
|
||||||
koaGuard({
|
koaGuard({
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { ZodObject, ZodOptional } from 'zod';
|
||||||
import { isGuardMiddleware, WithGuardConfig } from '@/middleware/koa-guard';
|
import { isGuardMiddleware, WithGuardConfig } from '@/middleware/koa-guard';
|
||||||
import { fallbackDefaultPageSize, isPaginationMiddleware } from '@/middleware/koa-pagination';
|
import { fallbackDefaultPageSize, isPaginationMiddleware } from '@/middleware/koa-pagination';
|
||||||
import assertThat from '@/utils/assert-that';
|
import assertThat from '@/utils/assert-that';
|
||||||
import { zodTypeToSwagger } from '@/utils/zod';
|
import { translationSchemas, zodTypeToSwagger } from '@/utils/zod';
|
||||||
|
|
||||||
import { AnonymousRouter } from './types';
|
import { AnonymousRouter } from './types';
|
||||||
|
|
||||||
|
@ -163,6 +163,7 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
},
|
},
|
||||||
paths: Object.fromEntries(pathMap),
|
paths: Object.fromEntries(pathMap),
|
||||||
|
components: { schemas: translationSchemas },
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.body = document;
|
ctx.body = document;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ApplicationType, arbitraryObjectGuard } from '@logto/schemas';
|
import { ApplicationType, arbitraryObjectGuard, translationGuard } from '@logto/schemas';
|
||||||
import { string, boolean, number, object, nativeEnum, unknown, literal, union } from 'zod';
|
import { string, boolean, number, object, nativeEnum, unknown, literal, union } from 'zod';
|
||||||
|
|
||||||
import RequestError from '@/errors/RequestError';
|
import RequestError from '@/errors/RequestError';
|
||||||
|
@ -13,6 +13,12 @@ describe('zodTypeToSwagger', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('translation object guard', () => {
|
||||||
|
expect(zodTypeToSwagger(translationGuard)).toEqual({
|
||||||
|
$ref: '#/components/schemas/TranslationObject',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('string type', () => {
|
describe('string type', () => {
|
||||||
const notStartingWithDigitRegex = /^\D/;
|
const notStartingWithDigitRegex = /^\D/;
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { arbitraryObjectGuard } from '@logto/schemas';
|
import { arbitraryObjectGuard, translationGuard } from '@logto/schemas';
|
||||||
import { conditional, ValuesOf } from '@silverhand/essentials';
|
import { conditional, ValuesOf } from '@silverhand/essentials';
|
||||||
import { OpenAPIV3 } from 'openapi-types';
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
import {
|
import {
|
||||||
|
@ -19,6 +19,37 @@ import {
|
||||||
|
|
||||||
import RequestError from '@/errors/RequestError';
|
import RequestError from '@/errors/RequestError';
|
||||||
|
|
||||||
|
export const translationSchemas: Record<string, OpenAPIV3.SchemaObject> = {
|
||||||
|
TranslationObject: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
'[translationKey]': {
|
||||||
|
$ref: '#/components/schemas/Translation',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
input: {
|
||||||
|
username: 'Username',
|
||||||
|
password: 'Password',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
sign_in: 'Sign In',
|
||||||
|
continue: 'Continue',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Translation: {
|
||||||
|
oneOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$ref: '#/components/schemas/Translation',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export type ZodStringCheck = ValuesOf<ZodStringDef['checks']>;
|
export type ZodStringCheck = ValuesOf<ZodStringDef['checks']>;
|
||||||
|
|
||||||
const zodStringCheckToSwaggerFormat = (zodStringCheck: ZodStringCheck) => {
|
const zodStringCheckToSwaggerFormat = (zodStringCheck: ZodStringCheck) => {
|
||||||
|
@ -93,7 +124,9 @@ const zodLiteralToSwagger = (zodLiteral: ZodLiteral<unknown>): OpenAPIV3.SchemaO
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const zodTypeToSwagger = (config: unknown): OpenAPIV3.SchemaObject => {
|
export const zodTypeToSwagger = (
|
||||||
|
config: unknown
|
||||||
|
): OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject => {
|
||||||
if (config === arbitraryObjectGuard) {
|
if (config === arbitraryObjectGuard) {
|
||||||
return {
|
return {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
@ -101,6 +134,12 @@ export const zodTypeToSwagger = (config: unknown): OpenAPIV3.SchemaObject => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config === translationGuard) {
|
||||||
|
return {
|
||||||
|
$ref: '#/components/schemas/TranslationObject',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (config instanceof ZodOptional) {
|
if (config instanceof ZodOptional) {
|
||||||
return zodTypeToSwagger(config._def.innerType);
|
return zodTypeToSwagger(config._def.innerType);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue