Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Versioned HTTP] Add response runtime and type-level validation #153011

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/http/core-http-server/src/router/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import type { ResponseHeaders } from './headers';
* HTTP response parameters
* @public
*/
export interface HttpResponseOptions {
export interface HttpResponseOptions<T extends HttpResponsePayload | ResponseError = any> {
/** HTTP message to send to the client */
body?: HttpResponsePayload;
body?: T;
/** HTTP Headers with additional information about response */
headers?: ResponseHeaders;
/** Bypass the default error formatting */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,18 @@ export interface KibanaSuccessResponseFactory {
* Status code: `200`.
* @param options - {@link HttpResponseOptions} configures HTTP response body & headers.
*/
ok(options?: HttpResponseOptions): IKibanaResponse;
ok<T extends HttpResponsePayload | ResponseError = any>(
options?: HttpResponseOptions<T>
): IKibanaResponse<T>;

/**
* The request has been accepted for processing.
* Status code: `202`.
* @param options - {@link HttpResponseOptions} configures HTTP response body & headers.
*/
accepted(options?: HttpResponseOptions): IKibanaResponse;
accepted<T extends HttpResponsePayload | ResponseError = any>(
options?: HttpResponseOptions<T>
): IKibanaResponse<T>;

/**
* The server has successfully fulfilled the request and that there is no additional content to send in the response payload body.
Expand Down
57 changes: 36 additions & 21 deletions packages/core/versioning/core-version-http-server/src/example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,18 @@ const versionedRoute = versionedRouter
{
version: '1',
validate: {
query: schema.object({
name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })),
}),
params: schema.object({
id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })),
}),
body: schema.object({ foo: schema.string() }),
request: {
query: schema.object({
name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })),
}),
params: schema.object({
id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })),
}),
body: schema.object({ foo: schema.string() }),
},
response: {
body: schema.object({ foo: schema.string() }),
},
},
},
async (ctx, req, res) => {
Expand All @@ -47,13 +52,18 @@ const versionedRoute = versionedRouter
{
version: '2',
validate: {
query: schema.object({
name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })),
}),
params: schema.object({
id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })),
}),
body: schema.object({ fooString: schema.string() }),
request: {
query: schema.object({
name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })),
}),
params: schema.object({
id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })),
}),
body: schema.object({ fooString: schema.string() }),
},
response: {
body: schema.object({ fooName: schema.string() }),
},
},
},
async (ctx, req, res) => {
Expand All @@ -66,13 +76,18 @@ const versionedRoute = versionedRouter
{
version: '3',
validate: {
query: schema.object({
name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })),
}),
params: schema.object({
id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })),
}),
body: schema.object({ fooString: schema.string({ minLength: 0, maxLength: 1000 }) }),
request: {
query: schema.object({
name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })),
}),
params: schema.object({
id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })),
}),
body: schema.object({ fooString: schema.string({ minLength: 0, maxLength: 1000 }) }),
},
response: {
body: schema.object({ fooName: schema.string() }),
},
},
},
async (ctx, req, res) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@
* Side Public License, v 1.
*/

import { Type } from '@kbn/config-schema';
import type { WithRequiredProperty } from '@kbn/utility-types';
import type {
IRouter,
RouteConfig,
RouteMethod,
RequestHandler,
IKibanaResponse,
RouteConfigOptions,
RouteValidatorFullConfig,
RequestHandlerContextBase,
RouteConfigOptions,
RouteValidationFunction,
} from '@kbn/core-http-server';

type RqCtx = RequestHandlerContextBase;
Expand All @@ -39,53 +43,8 @@ export interface CreateVersionedRouterArgs<Ctx extends RqCtx = RqCtx> {
/**
* This interface is the starting point for creating versioned routers and routes
*
* @example
* const versionedRouter = vtk.createVersionedRouter({ router });
* @example see ./example.ts
*
* ```ts
* const versionedRoute = versionedRouter
* .post({
* path: '/api/my-app/foo/{id?}',
* options: { timeout: { payload: 60000 }, access: 'public' },
* })
* .addVersion(
* {
* version: '1',
* validate: {
* query: schema.object({
* name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })),
* }),
* params: schema.object({
* id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })),
* }),
* body: schema.object({ foo: schema.string() }),
* },
* },
* async (ctx, req, res) => {
* await ctx.fooService.create(req.body.foo, req.params.id, req.query.name);
* return res.ok({ body: { foo: req.body.foo } });
* }
* )
* // BREAKING CHANGE: { foo: string } => { fooString: string } in body
* .addVersion(
* {
* version: '2',
* validate: {
* query: schema.object({
* name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })),
* }),
* params: schema.object({
* id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })),
* }),
* body: schema.object({ fooString: schema.string() }),
* },
* },
* async (ctx, req, res) => {
* await ctx.fooService.create(req.body.fooString, req.params.id, req.query.name);
* return res.ok({ body: { fooName: req.body.fooString } });
* }
* )
* ```
* @experimental
*/
export interface VersionHTTPToolkit {
Expand All @@ -100,13 +59,6 @@ export interface VersionHTTPToolkit {
): VersionedRouter<Ctx>;
}

/**
* Converts an input property from optional to required. Needed for making RouteConfigOptions['access'] required.
*/
type WithRequiredProperty<Type, Key extends keyof Type> = Type & {
[Property in Key]-?: Type[Property];
};

/**
* Versioned route access flag, required
* - '/api/foo' is 'public'
Expand Down Expand Up @@ -154,12 +106,39 @@ export interface VersionedRouter<Ctx extends RqCtx = RqCtx> {
options: VersionedRouteRegistrar<'options', Ctx>;
}

/** @experimental */
export type RequestValidation<P, Q, B> = RouteValidatorFullConfig<P, Q, B>;

/** @experimental */
export interface ResponseValidation<R> {
body: RouteValidationFunction<R> | Type<R>;
}

/**
* Versioned route validation
* @experimental
*/
interface FullValidationConfig<P, Q, B, R> {
/**
* Validation to run against route inputs: params, query and body
* @experimental
*/
request?: RequestValidation<P, Q, B>;
/**
* Validation to run against route output
* @note This validation is only intended to run in development. Do not use this
* for setting default values!
* @experimental
*/
response?: ResponseValidation<R>;
}

/**
* Options for a versioned route. Probably needs a lot more options like sunsetting
* of an endpoint etc.
* @experimental
*/
export interface AddVersionOpts<P, Q, B, Method extends RouteMethod = RouteMethod> {
export interface AddVersionOpts<P, Q, B, R> {
/**
* Version to assign to this route
* @experimental
Expand All @@ -169,7 +148,7 @@ export interface AddVersionOpts<P, Q, B, Method extends RouteMethod = RouteMetho
* Validation for this version of a route
* @experimental
*/
validate: false | RouteValidatorFullConfig<P, Q, B>;
validate: false | FullValidationConfig<P, Q, B, R>;
}

/**
Expand All @@ -187,8 +166,8 @@ export interface VersionedRoute<
* @returns A versioned route, allows for fluent chaining of version declarations
* @experimental
*/
addVersion<P, Q, B>(
opts: AddVersionOpts<P, Q, B>,
handler: RequestHandler<P, Q, B, Ctx>
addVersion<P, Q, B, R>(
options: AddVersionOpts<P, Q, B, R>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

handler: (...params: Parameters<RequestHandler<P, Q, B, Ctx>>) => Promise<IKibanaResponse<R>>
): VersionedRoute<Method, Ctx>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"kbn_references": [
"@kbn/config-schema",
"@kbn/core-http-server",
"@kbn/utility-types",
],
"exclude": [
"target/**/*",
Expand Down
11 changes: 11 additions & 0 deletions packages/kbn-utility-types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,14 @@ export type DeepPartialObject<T> = { [P in keyof T]+?: DeepPartial<T[P]> };
export type { DotObject, DedotObject } from './src/dot';

export type ArrayElement<A> = A extends ReadonlyArray<infer T> ? T : never;

/**
* Takes a type and makes selected properties required.
*
* @example
* interface Foo { bar?: string }
* const foo: WithRequiredProperty<Foo, 'bar'> = { bar: 'baz' }
*/
export type WithRequiredProperty<Type, Key extends keyof Type> = Omit<Type, Key> & {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need to add a test if the type starts getting used a lot.

[Property in Key]-?: Type[Property];
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Side Public License, v 1.
*/

import { HttpResponseOptions } from '@kbn/core-http-server';
import { DataView, DataViewField } from '../../../common';
import { SERVICE_KEY_LEGACY, SERVICE_KEY_TYPE, SERVICE_KEY } from '../../constants';

Expand All @@ -15,7 +16,11 @@ interface ResponseFormatterArgs {
dataView: DataView;
}

export const responseFormatter = ({ serviceKey, fields, dataView }: ResponseFormatterArgs) => {
export const responseFormatter = ({
serviceKey,
fields,
dataView,
jloleysens marked this conversation as resolved.
Show resolved Hide resolved
}: ResponseFormatterArgs): HttpResponseOptions => {
const response = {
body: {
fields: fields.map((field) => field.toSpec()),
Expand Down
1 change: 1 addition & 0 deletions src/plugins/data_views/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@kbn/config-schema",
"@kbn/utility-types-jest",
"@kbn/safer-lodash-set",
"@kbn/core-http-server",
],
"exclude": [
"target/**/*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ export function initRoutes(
}, Promise.resolve<ConcreteTaskInstance[] | undefined>(undefined));

return res.ok({
body: await new Promise((resolve) => {
body: await new Promise<PerfResult>((resolve) => {
setTimeout(() => {
performanceApi.endCapture().then((perf: PerfResult) => resolve(perf));
performanceApi.endCapture().then((perf) => resolve(perf));
jloleysens marked this conversation as resolved.
Show resolved Hide resolved
}, durationInSeconds * 1000 + 10000 /* wait extra 10s to drain queue */);
}),
});
Expand Down