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:
parent
268679b02e
commit
dfc1f20d27
8 changed files with 69 additions and 73 deletions
6
.changeset-staged/stupid-jokes-brush.md
Normal file
6
.changeset-staged/stupid-jokes-brush.md
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
"@logto/core": major
|
||||||
|
"@logto/ui": major
|
||||||
|
---
|
||||||
|
|
||||||
|
**💥 BREAKING CHANGE 💥** Move `/api/phrase` API to `/api/.well-known/phrases`
|
|
@ -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);
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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);
|
||||||
});
|
});
|
|
@ -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]);
|
||||||
});
|
});
|
||||||
});
|
});
|
|
@ -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();
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}`)}`);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue