Skip to content

Commit

Permalink
[Versioned HTTP] Add response runtime and type-level validation (#153011
Browse files Browse the repository at this point in the history
)

## Summary

This PR restructures the `validation` object on the `.addVersion` method
by:

1. Adding an `in` object for the `body`, `params` and `query`
validations
2. Adding `out` so that we can have both runtime and TS type checking
our responses

To reviewers: easiest way to interpret these changes is to read the
`example.ts` file.

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
jloleysens and kibanamachine authored Mar 13, 2023
1 parent eb60253 commit 3c7bf58
Show file tree
Hide file tree
Showing 9 changed files with 103 additions and 87 deletions.
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>,
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> & {
[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,
}: 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));
}, durationInSeconds * 1000 + 10000 /* wait extra 10s to drain queue */);
}),
});
Expand Down

0 comments on commit 3c7bf58

Please sign in to comment.