Updating definitions to standardize to express type definitions.

Signed-off-by: Aramis Sennyey <sennyeya@amazon.com>
This commit is contained in:
Aramis Sennyey
2023-02-22 15:30:50 -05:00
committed by Fredrik Adelöw
parent b76999c4df
commit 8eda7722a3
12 changed files with 518 additions and 353 deletions
+49
View File
@@ -480,6 +480,28 @@ components:
- message
- name
description: The serialized form of an Error.
EntitiesQueryResponse:
type: object
properties:
items:
type: array
items:
- $ref: '#/components/schemas/Entity'
description: |-
The list of entities paginated by a specific filter.
totalItems:
type: number
pageInfo:
type: object
properties:
nextCursor:
type: string
description: |-
Base64 encoded database query for the next page.
prevCursor:
type: string
description: |-
Base64 encoded database query for the previous page.
securitySchemes:
JWT:
type: http
@@ -661,6 +683,33 @@ paths:
type: array
items:
type: string
/entities/by-query:
get:
operationId: GetEntitiesByQuery
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/EntitiesQueryResponse'
security:
- {}
- JWT: []
parameters:
- in: query
name: fields
required: false
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
type: string
/entity-facets:
get:
operationId: GetEntityFacets
@@ -352,6 +352,7 @@ export default {
{
type: 'object',
properties: {
properties: null,
links: {
type: 'array',
items: {
@@ -404,9 +405,10 @@ export default {
'A globally unique ID for the entity.\nThis field can not be set by the user at creation time, and the server\nwill reject an attempt to do so. The field will be populated in read\noperations. The field can (optionally) be specified when performing\nupdate or delete operations, but the server is free to reject requests\nthat do so in such a way that it breaks semantics.',
},
},
description:
'Metadata fields common to all versions/kinds of entity.',
},
],
description: 'Metadata fields common to all versions/kinds of entity.',
},
RecursivePartial_Entity_: {
type: 'object',
@@ -443,7 +445,7 @@ export default {
description: {
type: 'string',
description:
'A text to show to the user to inform about the choices made. Like, it could say\n"Found a CODEOWNERS file that covers this target, so we suggest leaving this\nfield empty; which would currently make it owned by X" where X is taken from the\ncodeowners file.',
'A text to show to the user to inform about the choices made. Like, it could say\n"Found a CODEOWNERS file that covers this target, so we suggest leaving this \nfield empty; which would currently make it owned by X" where X is taken from the\ncodeowners file.',
},
value: {
type: 'string',
@@ -549,6 +551,37 @@ export default {
],
description: 'The serialized form of an Error.',
},
EntitiesQueryResponse: {
type: 'object',
properties: {
items: {
type: 'array',
items: [
{
$ref: '#/components/schemas/Entity',
},
],
description: 'The list of entities paginated by a specific filter.',
},
totalItems: {
type: 'number',
},
pageInfo: {
type: 'object',
properties: {
nextCursor: {
type: 'string',
description: 'Base64 encoded database query for the next page.',
},
prevCursor: {
type: 'string',
description:
'Base64 encoded database query for the previous page.',
},
},
},
},
},
},
securitySchemes: {
JWT: {
@@ -855,6 +888,52 @@ export default {
},
},
},
'/entities/by-query': {
get: {
operationId: 'GetEntitiesByQuery',
responses: {
'200': {
description: 'Ok',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/EntitiesQueryResponse',
},
},
},
},
},
security: [
{},
{
JWT: [],
},
],
parameters: [
{
in: 'query',
name: 'fields',
required: false,
schema: {
type: 'string',
},
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'array',
items: {
type: 'string',
},
},
},
},
},
},
},
'/entity-facets': {
get: {
operationId: 'GetEntityFacets',
+1 -1
View File
@@ -30,7 +30,7 @@
"dist"
],
"dependencies": {
"express": "^4.18.2",
"express": "^4.17.1",
"json-schema-to-ts": "^2.6.2",
"openapi-types": "^12.1.0",
"openapi3-ts": "^3.1.2",
@@ -0,0 +1,41 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import express, { Router } from 'express';
import { ApiRouter, DeepWriteable } from './router';
import doc from './schema/petstore';
interface RouterOptions {}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
console.log(options);
const router = Router() as ApiRouter<DeepWriteable<typeof doc>>;
router.get('/pets/:uid', (req, res) => {
res.json({
id: 1,
name: req.params.uid,
});
});
// router.get('/pet') will complain with a TS error
router.post('/pets', (_, res) => {
res.send();
});
return router;
}
+15 -344
View File
@@ -13,356 +13,27 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ErrorRequestHandler, RequestHandler, Router } from 'express';
import core, { ParamsDictionary } from 'express-serve-static-core';
import { FromSchema, JSONSchema7 } from 'json-schema-to-ts';
import {
DocPathMethod,
DocPathTemplate,
MethodAwareDocPath,
PathTemplate,
RequestBodySchema,
RequiredDoc,
ValueOf,
} from './types';
import { ResponseSchemas } from './types/response';
import { Router } from 'express';
import { RequiredDoc } from './types';
import { DocRequestMatcher } from './types/express';
export type DeepWriteable<T> = {
-readonly [P in keyof T]: DeepWriteable<T[P]>;
};
const doc = {
openapi: '3.1.0',
info: {
version: '1.0.0',
title: 'Swagger Petstore',
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT',
},
},
servers: [
{
url: 'http://petstore.swagger.io/v1',
},
],
paths: {
'/pets': {
get: {
summary: 'List all pets',
operationId: 'listPets',
tags: ['pets'],
parameters: [
{
name: 'limit',
in: 'query',
description: 'How many items to return at one time (max 100)',
required: false,
schema: {
type: 'integer',
format: 'int32',
},
},
],
responses: {
'200': {
description: 'A paged array of pets',
headers: {
'x-next': {
description: 'A link to the next page of responses',
schema: {
type: 'string',
},
},
},
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Pets',
},
},
},
},
default: {
description: 'unexpected error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
},
},
post: {
summary: 'Create a pet',
operationId: 'createPets',
tags: ['pets'],
responses: {
'201': {
description: 'Null response',
},
default: {
description: 'unexpected error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
},
},
},
'/pets/{petId}': {
get: {
summary: 'Info for a specific pet',
operationId: 'showPetById',
tags: ['pets'],
parameters: [
{
name: 'petId',
in: 'path',
required: true,
description: 'The id of the pet to retrieve',
schema: {
type: 'string',
},
},
],
responses: {
'200': {
description: 'Expected response to a valid request',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Pet',
},
},
},
},
default: {
description: 'unexpected error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
},
},
},
},
components: {
schemas: {
Pet: {
type: 'object',
required: ['id', 'name'],
properties: {
id: {
type: 'integer',
format: 'int64',
},
name: {
type: 'string',
},
tag: {
type: 'string',
},
},
},
Pets: {
type: 'array',
items: {
$ref: '#/components/schemas/Pet',
},
},
Error: {
type: 'object',
required: ['code', 'message'],
properties: {
code: {
type: 'integer',
format: 'int32',
},
message: {
type: 'string',
},
},
},
},
},
} as const;
type RemoveTail<
S extends string,
Tail extends string,
> = S extends `${infer P}${Tail}` ? P : S;
type GetRouteParameter<S extends string> = RemoveTail<
RemoveTail<RemoveTail<S, `/${string}`>, `-${string}`>,
`.${string}`
>;
export type RouteParameters<Route extends string> = string extends Route
? ParamsDictionary
: Route extends `${string}(${string}`
? ParamsDictionary
: Route extends `${string}:${infer Rest}`
? (GetRouteParameter<Rest> extends never
? ParamsDictionary
: GetRouteParameter<Rest> extends `${infer ParamName}?`
? { [P in ParamName]?: string }
: { [P in GetRouteParameter<Rest>]: string }) &
(Rest extends `${GetRouteParameter<Rest>}${infer Next}`
? RouteParameters<Next>
: unknown)
: {};
interface ParsedQs {
[key: string]: undefined | string | string[] | ParsedQs | ParsedQs[];
}
// oh boy don't do this
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never;
type LastOf<T> = UnionToIntersection<
T extends any ? () => T : never
> extends () => infer R
? R
: never;
// TS4.0+
type Push<T extends any[], V> = [...T, V];
// TS4.1+
type TuplifyUnion<
T,
L = LastOf<T>,
N = [T] extends [never] ? true : false,
> = true extends N ? [] : Push<TuplifyUnion<Exclude<T, L>>, L>;
type ConvertAll<T, R extends ReadonlyArray<unknown> = []> = T extends [
infer First extends JSONSchema7,
...infer Rest,
]
? ConvertAll<Rest, [...R, FromSchema<First>]>
: R;
type ResponseBodyToJsonSchema<
Doc extends RequiredDoc,
Path extends PathTemplate<Extract<keyof Doc['paths'], string>>,
Method extends DocPathMethod<Doc, Path>,
> = ConvertAll<
TuplifyUnion<ValueOf<ResponseSchemas<Doc, Path, Method>>>
>[number];
type RequestBodyToJsonSchema<
Doc extends RequiredDoc,
Path extends PathTemplate<Extract<keyof Doc['paths'], string>>,
Method extends DocPathMethod<Doc, Path>,
> = ConvertAll<
TuplifyUnion<ValueOf<RequestBodySchema<Doc, Path, Method>>>
>[number];
export type RequestHandlerParams<
P,
ResBody,
ReqBody,
ReqQuery,
LocalsObj extends Record<string, any>,
> =
| RequestHandler<P, ResBody, ReqBody, ReqQuery, LocalsObj>
| ErrorRequestHandler<P, ResBody, ReqBody, ReqQuery, LocalsObj>;
type DocRequestHandler<
Doc extends RequiredDoc,
Path extends DocPathTemplate<Doc>,
Method extends keyof Doc['paths'][Path],
> = core.RequestHandler<
core.ParamsDictionary,
// From https://stackoverflow.com/questions/71393738/typescript-intersection-not-union-type-from-json-schema.
ResponseBodyToJsonSchema<Doc, Path, Method>,
RequestBodyToJsonSchema<Doc, Path, Method>,
ParsedQs,
Record<string, string>
>;
export interface ApiRouterMatcher<
Doc extends RequiredDoc,
Path extends DocPathTemplate<Doc>,
Method extends DocPathMethod<Doc, Path>,
T,
> {
(
path: MethodAwareDocPath<Doc, Path, Method>,
...handlers: Array<DocRequestHandler<Doc, Path, Method>>
): T;
}
export interface ApiRouter<Doc extends RequiredDoc> extends Router {
get<Path extends MethodAwareDocPath<Doc, DocPathTemplate<Doc>, 'get'>>(
path: Path,
...handlers: DocRequestHandler<Doc, Path, 'get'>[]
): this;
get: DocRequestMatcher<Doc, this, 'get'>;
post<Path extends MethodAwareDocPath<Doc, DocPathTemplate<Doc>, 'post'>>(
path: Path,
...handlers: DocRequestHandler<Doc, Path, 'post'>[]
): this;
post: DocRequestMatcher<Doc, this, 'get'>;
all<Path extends MethodAwareDocPath<Doc, DocPathTemplate<Doc>, 'all'>>(
path: Path,
...handlers: DocRequestHandler<Doc, Path, 'all'>[]
): this;
all: DocRequestMatcher<Doc, this, 'get'>;
put<Path extends MethodAwareDocPath<Doc, DocPathTemplate<Doc>, 'put'>>(
path: Path,
...handlers: DocRequestHandler<Doc, Path, 'put'>[]
): this;
delete<Path extends MethodAwareDocPath<Doc, DocPathTemplate<Doc>, 'delete'>>(
path: Path,
...handlers: DocRequestHandler<Doc, Path, 'delete'>[]
): this;
patch<Path extends MethodAwareDocPath<Doc, DocPathTemplate<Doc>, 'patch'>>(
path: Path,
...handlers: DocRequestHandler<Doc, Path, 'patch'>[]
): this;
options<
Path extends MethodAwareDocPath<Doc, DocPathTemplate<Doc>, 'options'>,
>(
path: Path,
...handlers: DocRequestHandler<Doc, Path, 'options'>[]
): this;
head<Path extends MethodAwareDocPath<Doc, DocPathTemplate<Doc>, 'head'>>(
path: Path,
...handlers: DocRequestHandler<Doc, Path, 'head'>[]
): this;
}
interface RouterOptions {}
export async function createRouter(options: RouterOptions) {
console.log(options);
const router = Router() as ApiRouter<DeepWriteable<typeof doc>>;
router.get('/pets/:uid', (req, res) => {
res.json({
id: 1,
name: req.params.uid,
});
});
// router.get('/pet') will complain with a TS error
router.post('/pets', (req, res) => {
res.send({
message: req.path,
code: 1,
});
});
return router;
put: DocRequestMatcher<Doc, this, 'get'>;
delete: DocRequestMatcher<Doc, this, 'get'>;
patch: DocRequestMatcher<Doc, this, 'get'>;
options: DocRequestMatcher<Doc, this, 'get'>;
head: DocRequestMatcher<Doc, this, 'get'>;
}
@@ -0,0 +1,181 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export default {
openapi: '3.1.0',
info: {
version: '1.0.0',
title: 'Swagger Petstore',
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT',
},
},
servers: [
{
url: 'http://petstore.swagger.io/v1',
},
],
paths: {
'/pets': {
get: {
summary: 'List all pets',
operationId: 'listPets',
tags: ['pets'],
parameters: [
{
name: 'limit',
in: 'query',
description: 'How many items to return at one time (max 100)',
required: false,
schema: {
type: 'integer',
format: 'int32',
},
},
],
responses: {
'200': {
description: 'A paged array of pets',
headers: {
'x-next': {
description: 'A link to the next page of responses',
schema: {
type: 'string',
},
},
},
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Pets',
},
},
},
},
default: {
description: 'unexpected error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
},
},
post: {
summary: 'Create a pet',
operationId: 'createPets',
tags: ['pets'],
responses: {
'201': {
description: 'Null response',
},
default: {
description: 'unexpected error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
},
},
},
'/pets/{petId}': {
get: {
summary: 'Info for a specific pet',
operationId: 'showPetById',
tags: ['pets'],
parameters: [
{
name: 'petId',
in: 'path',
required: true,
description: 'The id of the pet to retrieve',
schema: {
type: 'string',
},
},
],
responses: {
'200': {
description: 'Expected response to a valid request',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Pet',
},
},
},
},
default: {
description: 'unexpected error',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error',
},
},
},
},
},
},
},
},
components: {
schemas: {
Pet: {
type: 'object',
required: ['id', 'name'],
properties: {
id: {
type: 'integer',
format: 'int64',
},
name: {
type: 'string',
},
tag: {
type: 'string',
},
},
},
Pets: {
type: 'array',
items: {
$ref: '#/components/schemas/Pet',
},
},
Error: {
type: 'object',
required: ['code', 'message'],
properties: {
code: {
type: 'integer',
format: 'int32',
},
message: {
type: 'string',
},
},
},
},
},
} as const;
@@ -17,7 +17,7 @@
import { Server } from 'http';
import { Logger } from 'winston';
import { createServiceBuilder } from '@backstage/backend-common';
import { createRouter } from './router';
import { createRouter } from './example';
export interface ServerOptions {
port: number;
@@ -18,6 +18,7 @@
* Pulled from https://github.com/varanauskas/oatx.
*/
import { FromSchema, JSONSchema7 } from 'json-schema-to-ts';
import type {
ContentObject,
OpenAPIObject,
@@ -25,7 +26,8 @@ import type {
} from 'openapi3-ts';
export type RequiredDoc = Pick<OpenAPIObject, 'paths' | 'components'>;
export type PathDoc = { paths: Record<string, unknown> };
export type PathDoc = Pick<OpenAPIObject, 'paths'>;
/**
* Get value types of `T`
@@ -129,4 +131,36 @@ export type ObjectWithContentSchema<
Object extends { content?: ContentObject },
> = Object['content'] extends ContentObject
? SchemaRef<Doc, Object['content']['application/json']['schema']>
: unknown;
/**
* From https://stackoverflow.com/questions/71393738/typescript-intersection-not-union-type-from-json-schema.
*
* StackOverflow says not to do this, but union types aren't possible any other way.
*/
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never;
type LastOf<T> = UnionToIntersection<
T extends any ? () => T : never
> extends () => infer R
? R
: never;
type Push<T extends any[], V> = [...T, V];
export type TuplifyUnion<
T,
L = LastOf<T>,
N = [T] extends [never] ? true : false,
> = true extends N ? [] : Push<TuplifyUnion<Exclude<T, L>>, L>;
export type ConvertAll<T, R extends ReadonlyArray<unknown> = []> = T extends [
infer First extends JSONSchema7,
...infer Rest,
]
? ConvertAll<Rest, [...R, FromSchema<First>]>
: R;
@@ -0,0 +1,86 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Application } from 'express';
import core from 'express-serve-static-core';
import { DocPathTemplate, MethodAwareDocPath, RequiredDoc } from './common';
import { RequestBodyToJsonSchema } from './requests';
import { ResponseBodyToJsonSchema } from './response';
interface ParsedQs {
[key: string]: undefined | string | string[] | ParsedQs | ParsedQs[];
}
export type DocRequestHandler<
Doc extends RequiredDoc,
Path extends DocPathTemplate<Doc>,
Method extends keyof Doc['paths'][Path],
> = core.RequestHandler<
core.ParamsDictionary,
ResponseBodyToJsonSchema<Doc, Path, Method>,
RequestBodyToJsonSchema<Doc, Path, Method>,
ParsedQs,
Record<string, string>
>;
export type DocRequestHandlerParams<
Doc extends RequiredDoc,
Path extends DocPathTemplate<Doc>,
Method extends keyof Doc['paths'][Path],
> = core.RequestHandlerParams<
core.ParamsDictionary,
ResponseBodyToJsonSchema<Doc, Path, Method>,
RequestBodyToJsonSchema<Doc, Path, Method>,
ParsedQs,
Record<string, string>
>;
export type PathParams = string | RegExp | Array<string | RegExp>;
export interface DocRequestMatcher<
Doc extends RequiredDoc,
T,
Method extends
| 'all'
| 'get'
| 'post'
| 'put'
| 'delete'
| 'patch'
| 'options'
| 'head' = any,
> {
<Path extends MethodAwareDocPath<Doc, DocPathTemplate<Doc>, Method>>(
path: Path,
...handlers: DocRequestHandler<Doc, Path, Method>[]
): T;
<Path extends MethodAwareDocPath<Doc, DocPathTemplate<Doc>, Method>>(
path: Path,
...handlers: Array<DocRequestHandler<Doc, Path, Method>>
): T;
<Path extends MethodAwareDocPath<Doc, DocPathTemplate<Doc>, Method>>(
path: Path,
...handlers: Array<DocRequestHandlerParams<Doc, Path, Method>>
): T;
<Path extends MethodAwareDocPath<Doc, DocPathTemplate<Doc>, Method>>(
path: PathParams,
...handlers: Array<DocRequestHandler<Doc, Path, Method>>
): T;
<Path extends MethodAwareDocPath<Doc, DocPathTemplate<Doc>, Method>>(
path: PathParams,
...handlers: Array<DocRequestHandlerParams<Doc, Path, Method>>
): T;
(path: PathParams, subApplication: Application): T;
}
@@ -28,6 +28,10 @@ import type {
DocPath,
DocPathMethod,
DocPathTemplate,
PathTemplate,
ConvertAll,
TuplifyUnion,
ValueOf,
} from './common';
type RequestBody<
@@ -50,4 +54,12 @@ export type RequestBodySchema<
Method extends DocPathMethod<Doc, Path>,
> = RequestBody<Doc, DocPath<Doc, Path>, Method> extends RequestBodyObject
? ObjectWithContentSchema<Doc, RequestBody<Doc, DocPath<Doc, Path>, Method>>
: never;
: unknown;
export type RequestBodyToJsonSchema<
Doc extends RequiredDoc,
Path extends PathTemplate<Extract<keyof Doc['paths'], string>>,
Method extends DocPathMethod<Doc, Path>,
> = ConvertAll<
TuplifyUnion<ValueOf<RequestBodySchema<Doc, Path, Method>>>
>[number];
@@ -28,6 +28,10 @@ import type {
DocPath,
DocPathMethod,
DocPathTemplate,
PathTemplate,
ConvertAll,
ValueOf,
TuplifyUnion,
} from './common';
type Response<
@@ -81,5 +85,13 @@ export type ResponseSchemas<
Doc,
Responses<Doc, DocPath<Doc, Path>, Method>[StatusCode]
>
: never;
: unknown;
};
export type ResponseBodyToJsonSchema<
Doc extends RequiredDoc,
Path extends PathTemplate<Extract<keyof Doc['paths'], string>>,
Method extends DocPathMethod<Doc, Path>,
> = ConvertAll<
TuplifyUnion<ValueOf<ResponseSchemas<Doc, Path, Method>>>
>[number];
+2 -2
View File
@@ -7809,7 +7809,7 @@ __metadata:
resolution: "@backstage/plugin-openapi-router@workspace:plugins/openapi-router-common"
dependencies:
"@backstage/cli": "workspace:^"
express: ^4.18.2
express: ^4.17.1
json-schema-to-ts: ^2.6.2
openapi-types: ^12.1.0
openapi3-ts: ^3.1.2
@@ -24164,7 +24164,7 @@ __metadata:
languageName: node
linkType: hard
"express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1, express@npm:^4.18.2":
"express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1":
version: 4.18.2
resolution: "express@npm:4.18.2"
dependencies: