0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(core)!: move phrases api to well-known path (#3374)

This commit is contained in:
Gao Sun 2023-03-13 11:43:27 +08:00 committed by GitHub
parent 268679b02e
commit dfc1f20d27
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 69 additions and 73 deletions

View file

@ -0,0 +1,6 @@
---
"@logto/core": major
"@logto/ui": major
---
**💥 BREAKING CHANGE 💥** Move `/api/phrase` API to `/api/.well-known/phrases`

View file

@ -19,7 +19,6 @@ import hookRoutes from './hook.js';
import interactionRoutes from './interaction/index.js'; import interactionRoutes from './interaction/index.js';
import logRoutes from './log.js'; import logRoutes from './log.js';
import logtoConfigRoutes from './logto-config.js'; import logtoConfigRoutes from './logto-config.js';
import phraseRoutes from './phrase.js';
import resourceRoutes from './resource.js'; import resourceRoutes from './resource.js';
import roleRoutes from './role.js'; import roleRoutes from './role.js';
import roleScopeRoutes from './role.scope.js'; import roleScopeRoutes from './role.scope.js';
@ -54,7 +53,6 @@ const createRouters = (tenant: TenantContext) => {
userAssetsRoutes(managementRouter, tenant); userAssetsRoutes(managementRouter, tenant);
const anonymousRouter: AnonymousRouter = new Router(); const anonymousRouter: AnonymousRouter = new Router();
phraseRoutes(anonymousRouter, tenant);
wellKnownRoutes(anonymousRouter, tenant); wellKnownRoutes(anonymousRouter, tenant);
statusRoutes(anonymousRouter, tenant); statusRoutes(anonymousRouter, tenant);
authnRoutes(anonymousRouter, tenant); authnRoutes(anonymousRouter, tenant);

View file

@ -1,53 +0,0 @@
import { isBuiltInLanguageTag } from '@logto/phrases-ui';
import { object, string } from 'zod';
import detectLanguage from '#src/i18n/detect-language.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type { AnonymousRouter, RouterInitArgs } from './types.js';
export default function phraseRoutes<T extends AnonymousRouter>(
...[router, { queries, libraries }]: RouterInitArgs<T>
) {
const {
customPhrases: { findAllCustomLanguageTags },
signInExperiences: { findDefaultSignInExperience },
} = queries;
const { getPhrases } = libraries.phrases;
const getLanguageInfo = async () => {
const { languageInfo } = await findDefaultSignInExperience();
return languageInfo;
};
router.get(
'/phrase',
koaGuard({
query: object({
lng: string().optional(),
}),
}),
async (ctx, next) => {
const {
query: { lng },
} = ctx.guard;
const { autoDetect, fallbackLanguage } = await getLanguageInfo();
const targetLanguage = lng ? [lng] : [];
const detectedLanguages = autoDetect ? detectLanguage(ctx) : [];
const acceptableLanguages = [...targetLanguage, ...detectedLanguages, fallbackLanguage];
const customLanguages = await findAllCustomLanguageTags();
const language =
acceptableLanguages.find(
(tag) => isBuiltInLanguageTag(tag) || customLanguages.includes(tag)
) ?? 'en';
ctx.set('Content-Language', language);
ctx.body = await getPhrases(language, customLanguages);
return next();
}
);
}

View file

@ -32,7 +32,7 @@ const tenantContext = new MockTenant(
{ phrases: { getPhrases: jest.fn().mockResolvedValue(en) } } { phrases: { getPhrases: jest.fn().mockResolvedValue(en) } }
); );
const phraseRoutes = await pickDefault(import('./phrase.js')); const phraseRoutes = await pickDefault(import('./well-known.js'));
const phraseRequest = createRequester({ const phraseRequest = createRequester({
anonymousRoutes: phraseRoutes, anonymousRoutes: phraseRoutes,
@ -54,7 +54,7 @@ describe('when auto-detect is not enabled', () => {
}, },
}); });
const response = await phraseRequest const response = await phraseRequest
.get('/phrase') .get('/.well-known/phrases')
.set('Accept-Language', `${zhCnTag},${zhHkTag}`); .set('Accept-Language', `${zhCnTag},${zhHkTag}`);
expect(response.headers['content-language']).toEqual('en'); expect(response.headers['content-language']).toEqual('en');
}); });
@ -71,13 +71,13 @@ describe('when auto-detect is not enabled', () => {
}); });
it('when there is no detected language', async () => { it('when there is no detected language', async () => {
const response = await phraseRequest.get('/phrase'); const response = await phraseRequest.get('/.well-known/phrases');
expect(response.headers['content-language']).toEqual(fallbackLanguage); expect(response.headers['content-language']).toEqual(fallbackLanguage);
}); });
it('when there are detected languages', async () => { it('when there are detected languages', async () => {
const response = await phraseRequest const response = await phraseRequest
.get('/phrase') .get('/.well-known/phrases')
.set('Accept-Language', `${zhCnTag},${zhHkTag}`); .set('Accept-Language', `${zhCnTag},${zhHkTag}`);
expect(response.headers['content-language']).toEqual(fallbackLanguage); expect(response.headers['content-language']).toEqual(fallbackLanguage);
}); });
@ -95,7 +95,7 @@ describe('when auto-detect is enabled', () => {
}, },
}); });
const response = await phraseRequest const response = await phraseRequest
.get('/phrase') .get('/.well-known/phrases')
.set('Accept-Language', unsupportedLanguageY); .set('Accept-Language', unsupportedLanguageY);
expect(response.headers['content-language']).toEqual('en'); expect(response.headers['content-language']).toEqual('en');
}); });
@ -113,7 +113,7 @@ describe('when auto-detect is enabled', () => {
describe('when there is no detected language', () => { describe('when there is no detected language', () => {
it('should be fallback language from sign-in experience', async () => { it('should be fallback language from sign-in experience', async () => {
const response = await phraseRequest.get('/phrase'); const response = await phraseRequest.get('/.well-known/phrases');
expect(response.headers['content-language']).toEqual(fallbackLanguage); expect(response.headers['content-language']).toEqual(fallbackLanguage);
}); });
}); });
@ -121,7 +121,7 @@ describe('when auto-detect is enabled', () => {
describe('when there are detected languages but all of them is unsupported', () => { describe('when there are detected languages but all of them is unsupported', () => {
it('should be first supported detected language', async () => { it('should be first supported detected language', async () => {
const response = await phraseRequest const response = await phraseRequest
.get('/phrase') .get('/.well-known/phrases')
.set('Accept-Language', `${unsupportedLanguageX},${unsupportedLanguageY}`); .set('Accept-Language', `${unsupportedLanguageX},${unsupportedLanguageY}`);
expect(response.headers['content-language']).toEqual(fallbackLanguage); expect(response.headers['content-language']).toEqual(fallbackLanguage);
}); });
@ -131,7 +131,7 @@ describe('when auto-detect is enabled', () => {
it('should be first supported detected language', async () => { it('should be first supported detected language', async () => {
const firstSupportedLanguage = zhCnTag; const firstSupportedLanguage = zhCnTag;
const response = await phraseRequest const response = await phraseRequest
.get('/phrase') .get('/.well-known/phrases')
.set('Accept-Language', `${unsupportedLanguageX},${firstSupportedLanguage},${zhHkTag}`); .set('Accept-Language', `${unsupportedLanguageX},${firstSupportedLanguage},${zhHkTag}`);
expect(response.headers['content-language']).toEqual(firstSupportedLanguage); expect(response.headers['content-language']).toEqual(firstSupportedLanguage);
}); });

View file

@ -49,7 +49,7 @@ const tenantContext = new MockTenant(
{ phrases: { getPhrases } } { phrases: { getPhrases } }
); );
const phraseRoutes = await pickDefault(import('./phrase.js')); const phraseRoutes = await pickDefault(import('./well-known.js'));
const { createRequester } = await import('#src/utils/test-utils.js'); const { createRequester } = await import('#src/utils/test-utils.js');
const phraseRequest = createRequester({ const phraseRequest = createRequester({
@ -63,7 +63,7 @@ describe('when the application is not admin-console', () => {
}); });
it('should call findDefaultSignInExperience', async () => { it('should call findDefaultSignInExperience', async () => {
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); await expect(phraseRequest.get('/.well-known/phrases')).resolves.toHaveProperty('status', 200);
expect(findDefaultSignInExperience).toBeCalledTimes(1); expect(findDefaultSignInExperience).toBeCalledTimes(1);
}); });
@ -75,7 +75,7 @@ describe('when the application is not admin-console', () => {
autoDetect: true, autoDetect: true,
}, },
}); });
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); await expect(phraseRequest.get('/.well-known/phrases')).resolves.toHaveProperty('status', 200);
expect(detectLanguageSpy).toBeCalledTimes(1); expect(detectLanguageSpy).toBeCalledTimes(1);
}); });
@ -87,12 +87,12 @@ describe('when the application is not admin-console', () => {
autoDetect: false, autoDetect: false,
}, },
}); });
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); await expect(phraseRequest.get('/.well-known/phrases')).resolves.toHaveProperty('status', 200);
expect(detectLanguageSpy).not.toBeCalled(); expect(detectLanguageSpy).not.toBeCalled();
}); });
it('should call findAllCustomLanguageTags', async () => { it('should call findAllCustomLanguageTags', async () => {
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); await expect(phraseRequest.get('/.well-known/phrases')).resolves.toHaveProperty('status', 200);
expect(findAllCustomLanguageTags).toBeCalledTimes(1); expect(findAllCustomLanguageTags).toBeCalledTimes(1);
}); });
@ -104,7 +104,7 @@ describe('when the application is not admin-console', () => {
fallbackLanguage: customizedLanguage, fallbackLanguage: customizedLanguage,
}, },
}); });
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); await expect(phraseRequest.get('/.well-known/phrases')).resolves.toHaveProperty('status', 200);
expect(getPhrases).toBeCalledTimes(1); expect(getPhrases).toBeCalledTimes(1);
expect(getPhrases).toBeCalledWith(customizedLanguage, [customizedLanguage]); expect(getPhrases).toBeCalledWith(customizedLanguage, [customizedLanguage]);
}); });
@ -117,7 +117,10 @@ describe('when the application is not admin-console', () => {
fallbackLanguage: customizedLanguage, fallbackLanguage: customizedLanguage,
}, },
}); });
await expect(phraseRequest.get('/phrase?lng=fr')).resolves.toHaveProperty('status', 200); await expect(phraseRequest.get('/.well-known/phrases?lng=fr')).resolves.toHaveProperty(
'status',
200
);
expect(getPhrases).toBeCalledWith('fr', [customizedLanguage]); expect(getPhrases).toBeCalledWith('fr', [customizedLanguage]);
}); });
}); });

View file

@ -1,18 +1,27 @@
import type { ConnectorMetadata } from '@logto/connector-kit'; import type { ConnectorMetadata } from '@logto/connector-kit';
import { ConnectorType } from '@logto/connector-kit'; import { ConnectorType } from '@logto/connector-kit';
import { isBuiltInLanguageTag } from '@logto/phrases-ui';
import { adminTenantId } from '@logto/schemas'; import { adminTenantId } from '@logto/schemas';
import { object, string } from 'zod';
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import detectLanguage from '#src/i18n/detect-language.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type { AnonymousRouter, RouterInitArgs } from './types.js'; import type { AnonymousRouter, RouterInitArgs } from './types.js';
export default function wellKnownRoutes<T extends AnonymousRouter>( export default function wellKnownRoutes<T extends AnonymousRouter>(
...[router, { libraries, id }]: RouterInitArgs<T> ...[router, { queries, libraries, id }]: RouterInitArgs<T>
) { ) {
const {
customPhrases: { findAllCustomLanguageTags },
signInExperiences: { findDefaultSignInExperience },
} = queries;
const { const {
signInExperiences: { getSignInExperience }, signInExperiences: { getSignInExperience },
connectors: { getLogtoConnectors }, connectors: { getLogtoConnectors },
phrases: { getPhrases },
} = libraries; } = libraries;
if (id === adminTenantId) { if (id === adminTenantId) {
@ -61,4 +70,36 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(
return next(); return next();
}); });
router.get(
'/.well-known/phrases',
koaGuard({
query: object({
lng: string().optional(),
}),
}),
async (ctx, next) => {
const {
query: { lng },
} = ctx.guard;
const {
languageInfo: { autoDetect, fallbackLanguage },
} = await findDefaultSignInExperience();
const targetLanguage = lng ? [lng] : [];
const detectedLanguages = autoDetect ? detectLanguage(ctx) : [];
const acceptableLanguages = [...targetLanguage, ...detectedLanguages, fallbackLanguage];
const customLanguages = await findAllCustomLanguageTags();
const language =
acceptableLanguages.find(
(tag) => isBuiltInLanguageTag(tag) || customLanguages.includes(tag)
) ?? 'en';
ctx.set('Content-Language', language);
ctx.body = await getPhrases(language, customLanguages);
return next();
}
);
} }

View file

@ -3,6 +3,7 @@
* The API will be deprecated in the future once SSR is implemented. * The API will be deprecated in the future once SSR is implemented.
*/ */
import { conditionalString } from '@silverhand/essentials';
import ky from 'ky'; import ky from 'ky';
import type { SignInExperienceResponse } from '@/types'; import type { SignInExperienceResponse } from '@/types';
@ -30,4 +31,4 @@ export const getPhrases = async ({
], ],
}, },
}) })
.get(`/api/phrase${language ? `?lng=${language}` : ''}`); .get(`/api/.well-known/phrases${conditionalString(language && `?lng=${language}`)}`);

View file

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Logto</title> <title>Logto</title>
<link rel="preload" href="/api/.well-known/sign-in-exp" as="fetch" crossorigin="anonymous"> <link rel="preload" href="/api/.well-known/sign-in-exp" as="fetch" crossorigin="anonymous">
<link rel="preload" href="/api/phrase" as="fetch" crossorigin="anonymous"> <link rel="preload" href="/api/.well-known/phrases" as="fetch" crossorigin="anonymous">
</head> </head>
<body> <body>