diff --git a/packages/core/http/core-http-resources-server-internal/src/http_resources_service.test.ts b/packages/core/http/core-http-resources-server-internal/src/http_resources_service.test.ts index efce905e6564f..1a7757d4e1eaa 100644 --- a/packages/core/http/core-http-resources-server-internal/src/http_resources_service.test.ts +++ b/packages/core/http/core-http-resources-server-internal/src/http_resources_service.test.ts @@ -69,6 +69,23 @@ describe('HttpResources service', () => { expect(registeredRouteConfig.options?.access).toBe('internal'); }); + it('registration defaults to excluded from OAS', () => { + register({ ...routeConfig, options: { access: 'internal' } }, async (ctx, req, res) => + res.ok() + ); + const [[registeredRouteConfig]] = router.get.mock.calls; + expect(registeredRouteConfig.options?.excludeFromOAS).toBe(true); + }); + + it('registration allows being included in OAS', () => { + register( + { ...routeConfig, options: { access: 'internal', excludeFromOAS: false } }, + async (ctx, req, res) => res.ok() + ); + const [[registeredRouteConfig]] = router.get.mock.calls; + expect(registeredRouteConfig.options?.excludeFromOAS).toBe(false); + }); + describe('renderCoreApp', () => { it('formats successful response', async () => { register(routeConfig, async (ctx, req, res) => { diff --git a/packages/core/http/core-http-resources-server-internal/src/http_resources_service.ts b/packages/core/http/core-http-resources-server-internal/src/http_resources_service.ts index d9e75d49e72cf..29114c0dffc07 100644 --- a/packages/core/http/core-http-resources-server-internal/src/http_resources_service.ts +++ b/packages/core/http/core-http-resources-server-internal/src/http_resources_service.ts @@ -89,6 +89,7 @@ export class HttpResourcesService implements CoreService !route.isVersioned); } - return [...this.routes]; + return this.routes; } public handleLegacyErrors = wrapErrors; diff --git a/packages/core/http/core-http-server-internal/src/http_service.ts b/packages/core/http/core-http-server-internal/src/http_service.ts index e5a82f0abefb0..3f803b06f15fd 100644 --- a/packages/core/http/core-http-server-internal/src/http_service.ts +++ b/packages/core/http/core-http-server-internal/src/http_service.ts @@ -9,9 +9,13 @@ import { Observable, Subscription, combineLatest, firstValueFrom, of, mergeMap } from 'rxjs'; import { map } from 'rxjs'; +import { schema, TypeOf } from '@kbn/config-schema'; import { pick, Semaphore } from '@kbn/std'; -import { generateOpenApiDocument } from '@kbn/router-to-openapispec'; +import { + generateOpenApiDocument, + type GenerateOpenApiDocumentOptionsFilters, +} from '@kbn/router-to-openapispec'; import { Logger } from '@kbn/logging'; import { Env } from '@kbn/config'; import type { CoreContext, CoreService } from '@kbn/core-base-server-internal'; @@ -254,49 +258,55 @@ export class HttpService const baseUrl = basePath.publicBaseUrl ?? `http://localhost:${config.port}${basePath.serverBasePath}`; + const stringOrStringArraySchema = schema.oneOf([ + schema.string(), + schema.arrayOf(schema.string()), + ]); + const querySchema = schema.object({ + access: schema.maybe(schema.oneOf([schema.literal('public'), schema.literal('internal')])), + excludePathsMatching: schema.maybe(stringOrStringArraySchema), + pathStartsWith: schema.maybe(stringOrStringArraySchema), + pluginId: schema.maybe(schema.string()), + version: schema.maybe(schema.string()), + }); + server.route({ path: '/api/oas', method: 'GET', handler: async (req, h) => { - const version = req.query?.version; - - let pathStartsWith: undefined | string[]; - if (typeof req.query?.pathStartsWith === 'string') { - pathStartsWith = [req.query.pathStartsWith]; - } else { - pathStartsWith = req.query?.pathStartsWith; - } - - let excludePathsMatching: undefined | string[]; - if (typeof req.query?.excludePathsMatching === 'string') { - excludePathsMatching = [req.query.excludePathsMatching]; - } else { - excludePathsMatching = req.query?.excludePathsMatching; + let filters: GenerateOpenApiDocumentOptionsFilters; + let query: TypeOf; + try { + query = querySchema.validate(req.query); + filters = { + ...query, + excludePathsMatching: + typeof query.excludePathsMatching === 'string' + ? [query.excludePathsMatching] + : query.excludePathsMatching, + pathStartsWith: + typeof query.pathStartsWith === 'string' + ? [query.pathStartsWith] + : query.pathStartsWith, + }; + } catch (e) { + return h.response({ message: e.message }).code(400); } - - const pluginId = req.query?.pluginId; - - const access = req.query?.access as 'public' | 'internal' | undefined; - if (access && !['public', 'internal'].some((a) => a === access)) { - return h - .response({ - message: 'Invalid access query parameter. Must be one of "public" or "internal".', - }) - .code(400); - } - return await firstValueFrom( of(1).pipe( HttpService.generateOasSemaphore.acquire(), mergeMap(async () => { try { // Potentially quite expensive - const result = generateOpenApiDocument(this.httpServer.getRouters({ pluginId }), { - baseUrl, - title: 'Kibana HTTP APIs', - version: '0.0.0', // TODO get a better version here - filters: { pathStartsWith, excludePathsMatching, access, version }, - }); + const result = generateOpenApiDocument( + this.httpServer.getRouters({ pluginId: query.pluginId }), + { + baseUrl, + title: 'Kibana HTTP APIs', + version: '0.0.0', // TODO get a better version here + filters, + } + ); return h.response(result); } catch (e) { this.log.error(e); diff --git a/packages/core/http/core-http-server/src/router/route.ts b/packages/core/http/core-http-server/src/router/route.ts index bdf4f9f03c784..194191e6f423f 100644 --- a/packages/core/http/core-http-server/src/router/route.ts +++ b/packages/core/http/core-http-server/src/router/route.ts @@ -215,7 +215,7 @@ export interface RouteConfigOptions { /** * Defines intended request origin of the route: * - public. The route is public, declared stable and intended for external access. - * In the future, may require an incomming request to contain a specified header. + * In the future, may require an incoming request to contain a specified header. * - internal. The route is internal and intended for internal access only. * * Defaults to 'internal' If not declared, @@ -284,6 +284,14 @@ export interface RouteConfigOptions { */ deprecated?: boolean; + /** + * Whether this route should be treated as "invisible" and excluded from router + * OAS introspection. + * + * @default false + */ + excludeFromOAS?: boolean; + /** * Release version or date that this route will be removed * Use with `deprecated: true` @@ -292,6 +300,7 @@ export interface RouteConfigOptions { * @example 9.0.0 */ discontinued?: string; + /** * Defines the security requirements for a route, including authorization and authentication. * diff --git a/packages/kbn-router-to-openapispec/index.ts b/packages/kbn-router-to-openapispec/index.ts index 17f8253348ab3..1869167db0323 100644 --- a/packages/kbn-router-to-openapispec/index.ts +++ b/packages/kbn-router-to-openapispec/index.ts @@ -7,4 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { generateOpenApiDocument } from './src/generate_oas'; +export { + generateOpenApiDocument, + type GenerateOpenApiDocumentOptionsFilters, +} from './src/generate_oas'; diff --git a/packages/kbn-router-to-openapispec/src/util.test.ts b/packages/kbn-router-to-openapispec/src/util.test.ts index 79b4ddf8eba84..abbb605df79e5 100644 --- a/packages/kbn-router-to-openapispec/src/util.test.ts +++ b/packages/kbn-router-to-openapispec/src/util.test.ts @@ -163,6 +163,15 @@ describe('prepareRoutes', () => { output: [{ path: '/api/foo', options: { access: pub } }], filters: { excludePathsMatching: ['/api/b'], access: pub }, }, + { + input: [ + { path: '/api/foo', options: { access: pub, excludeFromOAS: true } }, + { path: '/api/bar', options: { access: internal } }, + { path: '/api/baz', options: { access: pub } }, + ], + output: [{ path: '/api/baz', options: { access: pub } }], + filters: { excludePathsMatching: ['/api/bar'], access: pub }, + }, ])('returns the expected routes #%#', ({ input, output, filters }) => { expect(prepareRoutes(input, filters)).toEqual(output); }); diff --git a/packages/kbn-router-to-openapispec/src/util.ts b/packages/kbn-router-to-openapispec/src/util.ts index 1aa2a080ccc18..55f7348dc199a 100644 --- a/packages/kbn-router-to-openapispec/src/util.ts +++ b/packages/kbn-router-to-openapispec/src/util.ts @@ -105,13 +105,14 @@ export const getVersionedHeaderParam = ( }); export const prepareRoutes = < - R extends { path: string; options: { access?: 'public' | 'internal' } } + R extends { path: string; options: { access?: 'public' | 'internal'; excludeFromOAS?: boolean } } >( routes: R[], filters: GenerateOpenApiDocumentOptionsFilters = {} ): R[] => { if (Object.getOwnPropertyNames(filters).length === 0) return routes; return routes.filter((route) => { + if (route.options.excludeFromOAS) return false; if ( filters.excludePathsMatching && filters.excludePathsMatching.some((ex) => route.path.startsWith(ex)) diff --git a/src/core/server/integration_tests/http/oas.test.ts b/src/core/server/integration_tests/http/oas.test.ts index 6c7c191f68204..5e165d221c782 100644 --- a/src/core/server/integration_tests/http/oas.test.ts +++ b/src/core/server/integration_tests/http/oas.test.ts @@ -191,7 +191,9 @@ it('only accepts "public" or "internal" for "access" query param', async () => { const server = await startService({ config: { server: { oas: { enabled: true } } } }); const result = await supertest(server.listener).get('/api/oas').query({ access: 'invalid' }); expect(result.body.message).toBe( - 'Invalid access query parameter. Must be one of "public" or "internal".' + `[access]: types that failed validation: +- [access.0]: expected value to equal [public] +- [access.1]: expected value to equal [internal]` ); expect(result.status).toBe(400); });