mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
feat: add operationId
to HTTP methods on paths (#6108)
* feat: add operationId to HTTP methods on paths * refactor(core): strictly handle routes for building operation id * chore: add changeset * refactor: reorg code * refactor: use get as verb for singular items --------- Co-authored-by: Gao Sun <gao@silverhand.io>
This commit is contained in:
parent
06ef19905b
commit
d60f6ce48e
8 changed files with 329 additions and 19 deletions
21
.changeset/brown-cobras-know.md
Normal file
21
.changeset/brown-cobras-know.md
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
---
|
||||||
|
"@logto/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
build `operationId` for Management API in OpenAPI response (credit to @mostafa)
|
||||||
|
|
||||||
|
As per [the specification](https://swagger.io/docs/specification/paths-and-operations/):
|
||||||
|
|
||||||
|
> `operationId` is an optional unique string used to identify an operation. If provided, these IDs must be unique among all operations described in your API.
|
||||||
|
|
||||||
|
This greatly simplifies the creation of client SDKs in different languages, because it generates more meaningful function names instead of auto-generated ones, like the following examples:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- org, _, err := s.Client.OrganizationsAPI.ApiOrganizationsIdGet(ctx, req.GetId()).Execute()
|
||||||
|
+ org, _, err := s.Client.OrganizationsAPI.GetOrganization(ctx, req.GetId()).Execute()
|
||||||
|
```
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- users, _, err := s.Client.OrganizationsAPI.ApiOrganizationsIdUsersGet(ctx, req.GetId()).Execute()
|
||||||
|
+ users, _, err := s.Client.OrganizationsAPI.ListOrganizationUsers(ctx, req.GetId()).Execute()
|
||||||
|
```
|
|
@ -92,7 +92,7 @@ describe('GET /swagger.json', () => {
|
||||||
it('should parse the path parameters', async () => {
|
it('should parse the path parameters', async () => {
|
||||||
const queryParametersRouter = new Router();
|
const queryParametersRouter = new Router();
|
||||||
queryParametersRouter.get(
|
queryParametersRouter.get(
|
||||||
'/mock/:id/:field',
|
'/mock/:id/fields/:field',
|
||||||
koaGuard({
|
koaGuard({
|
||||||
params: object({
|
params: object({
|
||||||
id: number(),
|
id: number(),
|
||||||
|
@ -103,7 +103,7 @@ describe('GET /swagger.json', () => {
|
||||||
);
|
);
|
||||||
// Test plural
|
// Test plural
|
||||||
queryParametersRouter.get(
|
queryParametersRouter.get(
|
||||||
'/mocks/:id/:field',
|
'/mocks/:id/fields/:field',
|
||||||
koaGuard({
|
koaGuard({
|
||||||
params: object({
|
params: object({
|
||||||
id: number(),
|
id: number(),
|
||||||
|
@ -116,7 +116,7 @@ describe('GET /swagger.json', () => {
|
||||||
|
|
||||||
const response = await swaggerRequest.get('/swagger.json');
|
const response = await swaggerRequest.get('/swagger.json');
|
||||||
expect(response.body.paths).toMatchObject({
|
expect(response.body.paths).toMatchObject({
|
||||||
'/api/mock/{id}/{field}': {
|
'/api/mock/{id}/fields/{field}': {
|
||||||
get: {
|
get: {
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
|
@ -131,7 +131,7 @@ describe('GET /swagger.json', () => {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/api/mocks/{id}/{field}': {
|
'/api/mocks/{id}/fields/{field}': {
|
||||||
get: {
|
get: {
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
|
|
|
@ -28,9 +28,11 @@ import {
|
||||||
findSupplementFiles,
|
findSupplementFiles,
|
||||||
normalizePath,
|
normalizePath,
|
||||||
removeUnnecessaryOperations,
|
removeUnnecessaryOperations,
|
||||||
|
shouldThrow,
|
||||||
validateSupplement,
|
validateSupplement,
|
||||||
validateSwaggerDocument,
|
validateSwaggerDocument,
|
||||||
} from './utils/general.js';
|
} from './utils/general.js';
|
||||||
|
import { buildOperationId, customRoutes, throwByDifference } from './utils/operation-id.js';
|
||||||
import {
|
import {
|
||||||
buildParameters,
|
buildParameters,
|
||||||
paginationParameters,
|
paginationParameters,
|
||||||
|
@ -54,6 +56,7 @@ type RouteObject = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildOperation = (
|
const buildOperation = (
|
||||||
|
method: OpenAPIV3.HttpMethods,
|
||||||
stack: IMiddleware[],
|
stack: IMiddleware[],
|
||||||
path: string,
|
path: string,
|
||||||
isAuthGuarded: boolean
|
isAuthGuarded: boolean
|
||||||
|
@ -111,6 +114,7 @@ const buildOperation = (
|
||||||
const [firstSegment] = path.split('/').slice(1);
|
const [firstSegment] = path.split('/').slice(1);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
operationId: buildOperationId(method, path),
|
||||||
tags: [buildTag(path)],
|
tags: [buildTag(path)],
|
||||||
parameters: [...pathParameters, ...queryParameters],
|
parameters: [...pathParameters, ...queryParameters],
|
||||||
requestBody,
|
requestBody,
|
||||||
|
@ -170,6 +174,12 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
|
||||||
allRouters: R[]
|
allRouters: R[]
|
||||||
) {
|
) {
|
||||||
router.get('/swagger.json', async (ctx, next) => {
|
router.get('/swagger.json', async (ctx, next) => {
|
||||||
|
/**
|
||||||
|
* A set to store all custom routes that have been built.
|
||||||
|
* @see {@link customRoutes}
|
||||||
|
*/
|
||||||
|
const builtCustomRoutes = new Set<string>();
|
||||||
|
|
||||||
const routes = allRouters.flatMap<RouteObject>((router) => {
|
const routes = allRouters.flatMap<RouteObject>((router) => {
|
||||||
const isAuthGuarded = isManagementApiRouter(router);
|
const isAuthGuarded = isManagementApiRouter(router);
|
||||||
|
|
||||||
|
@ -184,7 +194,11 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
|
||||||
.filter((method): method is OpenAPIV3.HttpMethods => method !== 'head')
|
.filter((method): method is OpenAPIV3.HttpMethods => method !== 'head')
|
||||||
.map((httpMethod) => {
|
.map((httpMethod) => {
|
||||||
const path = normalizePath(routerPath);
|
const path = normalizePath(routerPath);
|
||||||
const operation = buildOperation(stack, routerPath, isAuthGuarded);
|
const operation = buildOperation(httpMethod, stack, routerPath, isAuthGuarded);
|
||||||
|
|
||||||
|
if (customRoutes[`${httpMethod} ${routerPath}`]) {
|
||||||
|
builtCustomRoutes.add(`${httpMethod} ${routerPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path,
|
path,
|
||||||
|
@ -196,6 +210,9 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ensure all custom routes are built.
|
||||||
|
throwByDifference(builtCustomRoutes);
|
||||||
|
|
||||||
const pathMap = new Map<string, OpenAPIV3.PathItemObject>();
|
const pathMap = new Map<string, OpenAPIV3.PathItemObject>();
|
||||||
const tags = new Set<string>();
|
const tags = new Set<string>();
|
||||||
|
|
||||||
|
@ -286,7 +303,7 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
|
||||||
getConsoleLogFromContext(ctx).warn('Skip validating swagger document in unit test.');
|
getConsoleLogFromContext(ctx).warn('Skip validating swagger document in unit test.');
|
||||||
}
|
}
|
||||||
// Don't throw for integrity check in production as it has no benefit.
|
// Don't throw for integrity check in production as it has no benefit.
|
||||||
else if (!EnvSet.values.isProduction || EnvSet.values.isIntegrationTest) {
|
else if (shouldThrow()) {
|
||||||
for (const document of supplementDocuments) {
|
for (const document of supplementDocuments) {
|
||||||
validateSupplement(baseDocument, document);
|
validateSupplement(baseDocument, document);
|
||||||
}
|
}
|
||||||
|
|
|
@ -262,3 +262,5 @@ export const removeUnnecessaryOperations = (
|
||||||
|
|
||||||
return document;
|
return document;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const shouldThrow = () => !EnvSet.values.isProduction || EnvSet.values.isIntegrationTest;
|
||||||
|
|
53
packages/core/src/routes/swagger/utils/operation-id.test.ts
Normal file
53
packages/core/src/routes/swagger/utils/operation-id.test.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
|
|
||||||
|
import { buildOperationId, customRoutes } from './operation-id.js';
|
||||||
|
|
||||||
|
describe('buildOperationId', () => {
|
||||||
|
it('should return the custom operation id if it exists', () => {
|
||||||
|
for (const [path, operationId] of Object.entries(customRoutes)) {
|
||||||
|
const [method, pathSegments] = path.split(' ');
|
||||||
|
expect(buildOperationId(method! as OpenAPIV3.HttpMethods, pathSegments!)).toBe(operationId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip interactions APIs', () => {
|
||||||
|
expect(buildOperationId(OpenAPIV3.HttpMethods.GET, '/interaction/footballs')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle JIT APIs', () => {
|
||||||
|
expect(buildOperationId(OpenAPIV3.HttpMethods.GET, '/footballs/:footballId/jit/bars')).toBe(
|
||||||
|
'ListFootballJitBars'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if the path is invalid', () => {
|
||||||
|
expect(() =>
|
||||||
|
buildOperationId(OpenAPIV3.HttpMethods.GET, '/footballs/:footballId/bar/baz')
|
||||||
|
).toThrow();
|
||||||
|
expect(() => buildOperationId(OpenAPIV3.HttpMethods.GET, '/')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should singularize the item for POST requests', () => {
|
||||||
|
expect(buildOperationId(OpenAPIV3.HttpMethods.POST, '/footballs/:footballId/bars')).toBe(
|
||||||
|
'CreateFootballBar'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should singularize for single item requests', () => {
|
||||||
|
expect(buildOperationId(OpenAPIV3.HttpMethods.DELETE, '/footballs/:footballId')).toBe(
|
||||||
|
'DeleteFootball'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use "Get" if the last item is singular', () => {
|
||||||
|
expect(buildOperationId(OpenAPIV3.HttpMethods.GET, '/footballs/:footballId/bar')).toBe(
|
||||||
|
'GetFootballBar'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use "List" if the last item is plural', () => {
|
||||||
|
expect(buildOperationId(OpenAPIV3.HttpMethods.GET, '/footballs/:footballId/bars')).toBe(
|
||||||
|
'ListFootballBars'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
211
packages/core/src/routes/swagger/utils/operation-id.ts
Normal file
211
packages/core/src/routes/swagger/utils/operation-id.ts
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
import camelcase from 'camelcase';
|
||||||
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
|
import pluralize from 'pluralize';
|
||||||
|
|
||||||
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
|
|
||||||
|
import { shouldThrow } from './general.js';
|
||||||
|
|
||||||
|
const chunk = <T>(array: T[], chunkSize: number): T[][] =>
|
||||||
|
Array.from({ length: Math.ceil(array.length / chunkSize) }, (_, i) =>
|
||||||
|
array.slice(i * chunkSize, i * chunkSize + chunkSize)
|
||||||
|
);
|
||||||
|
|
||||||
|
const methodToVerb = Object.freeze({
|
||||||
|
get: 'Get',
|
||||||
|
post: 'Create',
|
||||||
|
put: 'Replace',
|
||||||
|
patch: 'Update',
|
||||||
|
delete: 'Delete',
|
||||||
|
options: 'Options',
|
||||||
|
head: 'Head',
|
||||||
|
trace: 'Trace',
|
||||||
|
} satisfies Record<OpenAPIV3.HttpMethods, string>);
|
||||||
|
|
||||||
|
type RouteDictionary = Record<`${OpenAPIV3.HttpMethods} ${string}`, string>;
|
||||||
|
|
||||||
|
const devFeatureCustomRoutes: RouteDictionary = Object.freeze({
|
||||||
|
// Security
|
||||||
|
'post /security/subject-tokens': 'CreateSubjectToken',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const customRoutes: Readonly<RouteDictionary> = Object.freeze({
|
||||||
|
// Authn
|
||||||
|
'get /authn/hasura': 'GetHasuraAuth',
|
||||||
|
'post /authn/saml/:connectorId': 'AssertSaml',
|
||||||
|
'post /authn/single-sign-on/saml/:connectorId': 'AssertSingleSignOnSaml',
|
||||||
|
// Organization users
|
||||||
|
'post /organizations/:id/users': 'AddOrganizationUsers',
|
||||||
|
'post /organizations/:id/users/roles': 'AssignOrganizationRolesToUsers',
|
||||||
|
'post /organizations/:id/users/:userId/roles': 'AssignOrganizationRolesToUser',
|
||||||
|
// Organization applications
|
||||||
|
'post /organizations/:id/applications': 'AddOrganizationApplications',
|
||||||
|
'post /organizations/:id/applications/roles': 'AssignOrganizationRolesToApplications',
|
||||||
|
'post /organizations/:id/applications/:applicationId/roles':
|
||||||
|
'AssignOrganizationRolesToApplication',
|
||||||
|
// Configs
|
||||||
|
'get /configs/jwt-customizer': 'ListJwtCustomizers',
|
||||||
|
'put /configs/jwt-customizer/:tokenTypePath': 'UpsertJwtCustomizer',
|
||||||
|
'patch /configs/jwt-customizer/:tokenTypePath': 'UpdateJwtCustomizer',
|
||||||
|
'get /configs/jwt-customizer/:tokenTypePath': 'GetJwtCustomizer',
|
||||||
|
'delete /configs/jwt-customizer/:tokenTypePath': 'DeleteJwtCustomizer',
|
||||||
|
'post /configs/jwt-customizer/test': 'TestJwtCustomizer',
|
||||||
|
'get /configs/oidc/:keyType': 'GetOidcKeys',
|
||||||
|
'delete /configs/oidc/:keyType/:keyId': 'DeleteOidcKey',
|
||||||
|
'post /configs/oidc/:keyType/rotate': 'RotateOidcKeys',
|
||||||
|
'get /configs/admin-console': 'GetAdminConsoleConfig',
|
||||||
|
'patch /configs/admin-console': 'UpdateAdminConsoleConfig',
|
||||||
|
// Systems
|
||||||
|
'get /systems/application': 'GetSystemApplicationConfig',
|
||||||
|
// Applications
|
||||||
|
'post /applications/:applicationId/roles': 'AssignApplicationRoles',
|
||||||
|
'get /applications/:id/protected-app-metadata/custom-domains':
|
||||||
|
'ListApplicationProtectedAppMetadataCustomDomains',
|
||||||
|
'post /applications/:id/protected-app-metadata/custom-domains':
|
||||||
|
'CreateApplicationProtectedAppMetadataCustomDomain',
|
||||||
|
'delete /applications/:id/protected-app-metadata/custom-domains/:domain':
|
||||||
|
'DeleteApplicationProtectedAppMetadataCustomDomain',
|
||||||
|
'delete /applications/:applicationId/user-consent-scopes/:scopeType/:scopeId':
|
||||||
|
'DeleteApplicationUserConsentScope',
|
||||||
|
// Users
|
||||||
|
'post /users/:userId/roles': 'AssignUserRoles',
|
||||||
|
'post /users/:userId/password/verify': 'VerifyUserPassword',
|
||||||
|
// Dashboard
|
||||||
|
'get /dashboard/users/total': 'GetTotalUserCount',
|
||||||
|
'get /dashboard/users/new': 'GetNewUserCounts',
|
||||||
|
'get /dashboard/users/active': 'GetActiveUserCounts',
|
||||||
|
// Verification code
|
||||||
|
'post /verification-codes/verify': 'VerifyVerificationCode',
|
||||||
|
// User assets
|
||||||
|
'get /user-assets/service-status': 'GetUserAssetServiceStatus',
|
||||||
|
// Well-known
|
||||||
|
'get /.well-known/phrases': 'GetSignInExperiencePhrases',
|
||||||
|
'get /.well-known/sign-in-exp': 'GetSignInExperienceConfig',
|
||||||
|
...(EnvSet.values.isDevFeaturesEnabled ? devFeatureCustomRoutes : {}),
|
||||||
|
} satisfies RouteDictionary); // Key assertion doesn't work without `satisfies`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a set of built custom routes, throws an error if there are any differences between the
|
||||||
|
* built routes and the routes defined in `customRoutes`.
|
||||||
|
*/
|
||||||
|
export const throwByDifference = (builtCustomRoutes: Set<string>) => {
|
||||||
|
// Unit tests are hard to cover the full list of custom routes, skip the check.
|
||||||
|
if (EnvSet.values.isUnitTest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldThrow() && builtCustomRoutes.size !== Object.keys(customRoutes).length) {
|
||||||
|
const missingRoutes = Object.entries(customRoutes).filter(
|
||||||
|
([path]) => !builtCustomRoutes.has(path)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingRoutes.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
'Not all custom routes are built.\n' +
|
||||||
|
`Missing routes: ${missingRoutes.map(([path]) => path).join(', ')}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraRoutes = [...builtCustomRoutes].filter(
|
||||||
|
(path) => !Object.keys(customRoutes).includes(path)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (extraRoutes.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
'There are extra routes that are built but not defined in `customRoutes`.\n' +
|
||||||
|
`Extra routes: ${extraRoutes.join(', ')}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Path segments that are treated as namespace prefixes. */
|
||||||
|
const namespacePrefixes = Object.freeze(['jit', '.well-known']);
|
||||||
|
|
||||||
|
const isPathParameter = (segment?: string) =>
|
||||||
|
Boolean(segment && (segment.startsWith(':') || segment.startsWith('{')));
|
||||||
|
|
||||||
|
const throwIfNeeded = (method: OpenAPIV3.HttpMethods, path: string) => {
|
||||||
|
if (shouldThrow()) {
|
||||||
|
throw new Error(`Invalid path for generating operation ID: ${method} ${path}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a method and a path, generates an operation ID that is friendly for creating client SDKs.
|
||||||
|
*
|
||||||
|
* The generated operation ID is in the format of `VerbNounNoun...` where `Verb` is translated from
|
||||||
|
* the HTTP method and `Noun` is the path segment in PascalCase. Some exceptions:
|
||||||
|
*
|
||||||
|
* 1. If an override is found in `customRoutes`, it will be used instead.
|
||||||
|
* 2. If the HTTP method is `GET` and the path does not end with a path parameter, the verb will be
|
||||||
|
* `List`.
|
||||||
|
* 3. If the path segment is a namespace prefix, the trailing `/` will be replaced with `-`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* buildOperationId('get', '/foo/:fooId/bar/:barId') // GetFooBar
|
||||||
|
* buildOperationId('post', '/foo/:fooId/bar') // CreateFooBar
|
||||||
|
* buildOperationId('get', '/foo/:fooId/bar') // ListFooBars
|
||||||
|
* buildOperationId('get', '/jit/foo') // GetJitFoo
|
||||||
|
*
|
||||||
|
* @see {@link customRoutes} for the full list of overrides.
|
||||||
|
* @see {@link methodToVerb} for the mapping of HTTP methods to verbs.
|
||||||
|
* @see {@link namespacePrefixes} for the list of namespace prefixes.
|
||||||
|
*/
|
||||||
|
export const buildOperationId = (method: OpenAPIV3.HttpMethods, path: string) => {
|
||||||
|
const customOperationId = customRoutes[`${method} ${path}`];
|
||||||
|
|
||||||
|
if (customOperationId) {
|
||||||
|
return customOperationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip interactions APIs as they are going to replaced by the new APIs soon.
|
||||||
|
if (path.startsWith('/interaction')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitted = namespacePrefixes
|
||||||
|
.reduce((accumulator, prefix) => accumulator.replace(prefix + '/', prefix + '-'), path)
|
||||||
|
.split('/');
|
||||||
|
|
||||||
|
const lastItem = splitted.at(-1);
|
||||||
|
|
||||||
|
if (!lastItem) {
|
||||||
|
throwIfNeeded(method, path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isForSingleItem = isPathParameter(lastItem);
|
||||||
|
const items = chunk(splitted.slice(1, isForSingleItem ? undefined : -1), 2);
|
||||||
|
|
||||||
|
// Check if all items have the pattern of `[name, parameter]`
|
||||||
|
if (
|
||||||
|
!items.every((values): values is [string, string] =>
|
||||||
|
Boolean(values[0] && values[1] && !isPathParameter(values[0]) && isPathParameter(values[1]))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throwIfNeeded(method, path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemTypes = items.map(([name]) =>
|
||||||
|
camelcase(pluralize.singular(name), { pascalCase: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
const verb =
|
||||||
|
!isForSingleItem && method === OpenAPIV3.HttpMethods.GET && pluralize.isPlural(lastItem)
|
||||||
|
? 'List'
|
||||||
|
: methodToVerb[method];
|
||||||
|
|
||||||
|
if (isForSingleItem) {
|
||||||
|
return verb + itemTypes.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
verb +
|
||||||
|
itemTypes.join('') +
|
||||||
|
camelcase(method === OpenAPIV3.HttpMethods.POST ? pluralize.singular(lastItem) : lastItem, {
|
||||||
|
pascalCase: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
|
@ -9,4 +9,4 @@ export * from './interaction.js';
|
||||||
export * from './logto-config.js';
|
export * from './logto-config.js';
|
||||||
export * from './domain.js';
|
export * from './domain.js';
|
||||||
|
|
||||||
export { default as api, authedAdminApi } from './api.js';
|
export { default as api, authedAdminApi, adminTenantApi } from './api.js';
|
||||||
|
|
|
@ -2,22 +2,28 @@ import * as SwaggerParser from '@apidevtools/swagger-parser';
|
||||||
import Validator from 'openapi-schema-validator';
|
import Validator from 'openapi-schema-validator';
|
||||||
import type { OpenAPI } from 'openapi-types';
|
import type { OpenAPI } from 'openapi-types';
|
||||||
|
|
||||||
import { api } from '#src/api/index.js';
|
import * as apis from '#src/api/index.js';
|
||||||
|
|
||||||
const { default: OpenApiSchemaValidator } = Validator;
|
const { default: OpenApiSchemaValidator } = Validator;
|
||||||
|
|
||||||
describe('Swagger check', () => {
|
describe('Swagger check', () => {
|
||||||
it('should provide a valid swagger.json', async () => {
|
it.each(['api', 'adminTenantApi'] as const)(
|
||||||
const response = await api.get('swagger.json');
|
'should provide a valid swagger.json for %s',
|
||||||
expect(response).toHaveProperty('status', 200);
|
async (apiName) => {
|
||||||
expect(response.headers.get('content-type')).toContain('application/json');
|
const api = apis[apiName];
|
||||||
|
const response = await api.get('swagger.json');
|
||||||
|
expect(response).toHaveProperty('status', 200);
|
||||||
|
expect(response.headers.get('content-type')).toContain('application/json');
|
||||||
|
|
||||||
// Use multiple validators to be more confident
|
// Use multiple validators to be more confident
|
||||||
const object: unknown = await response.json();
|
const object: unknown = await response.json();
|
||||||
|
|
||||||
const validator = new OpenApiSchemaValidator({ version: 3 });
|
const validator = new OpenApiSchemaValidator({ version: 3 });
|
||||||
const result = validator.validate(object as OpenAPI.Document);
|
const result = validator.validate(object as OpenAPI.Document);
|
||||||
expect(result.errors).toEqual([]);
|
expect(result.errors).toEqual([]);
|
||||||
await expect(SwaggerParser.default.validate(object as OpenAPI.Document)).resolves.not.toThrow();
|
await expect(
|
||||||
});
|
SwaggerParser.default.validate(object as OpenAPI.Document)
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue