Repository: cdimascio/express-openapi-validator
Pull request #854 (2023-06-22 13:40:04)

chore: apiSpec may be const literal
## 🐸  Problem to Solve

The middleware may be instantiated with a `string` (path-on-disk to schema document) or with an object literal of the schema.

In Typescript, it is possible to import `.json` files as modules; however the types of their fields are "loose" (eg; the file `['foo', bar']` would be typed as `string[]`
In order to get stronger typing on imported schemas, they can also be re-emitted as Typescript variables with an `as const` declaration.

```ts
const json = ['foo', 'bar'] as const;
```

In this case, the `json` const is typed as its literal value `readonly ['foo', 'bar']`.
This is great, and can be really helpful both when developing, and at run-time.

However, the express-openapi-validator middleware is unhappy with such `as const` schemas, since the `OpenAPIV3.Document` type has many mutable fields.

<img width="860" alt="image" src="https://github.com/cdimascio/express-openapi-validator/assets/7367/2327731f-999d-4d99-9143-057b857b85dd">

## 👑 Proposed Solution

Through the type system, promise that the `express-openapi-validator` won't mangle your `as const` schema.

The middleware is already gentle with the schema, so this is a type-only change.

The change was motivated by the discovery that passing the schema object to other utilities (eg; [openapi-typescript](https://openapi-ts.pages.dev/node/)) may cause it to be mutated.

The implementation of `DeepImmutable` is lifted directly from [this comment](https://github.com/Microsoft/TypeScript/issues/13923#issuecomment-402901005) on the TypeScript repo.


Patch:
diff --git a/src/framework/types.ts b/src/framework/types.ts
index 420f42d..9d4d0c6 100644
--- a/src/framework/types.ts
+++ b/src/framework/types.ts
@@ -113,8 +113,26 @@ export type SerDesMap = {
   [format: string]: SerDes
 };
 
+type Primitive = undefined | null | boolean | string | number | Function
+
+type Immutable<T> =
+  T extends Primitive ? T :
+    T extends Array<infer U> ? ReadonlyArray<U> :
+      T extends Map<infer K, infer V> ? ReadonlyMap<K, V> : Readonly<T>
+
+type DeepImmutable<T> =
+  T extends Primitive ? T :
+    T extends Array<infer U> ? DeepImmutableArray<U> :
+      T extends Map<infer K, infer V> ? DeepImmutableMap<K, V> : DeepImmutableObject<T>
+
+interface DeepImmutableArray<T> extends ReadonlyArray<DeepImmutable<T>> {}
+interface DeepImmutableMap<K, V> extends ReadonlyMap<DeepImmutable<K>, DeepImmutable<V>> {}
+type DeepImmutableObject<T> = {
+  readonly [K in keyof T]: DeepImmutable<T[K]>
+}
+
 export interface OpenApiValidatorOpts {
-  apiSpec: OpenAPIV3.Document | string;
+  apiSpec: DeepImmutable<OpenAPIV3.Document> | string;
   validateApiSpec?: boolean;
   validateResponses?: boolean | ValidateResponseOpts;
   validateRequests?: boolean | ValidateRequestOpts;


Test patch:
diff --git a/test/default-export.spec.ts b/test/default-export.spec.ts
index 6b4cd2f..cacff56 100644
--- a/test/default-export.spec.ts
+++ b/test/default-export.spec.ts
@@ -4,6 +4,21 @@ import { expect } from 'chai';
 import * as request from 'supertest';
 import * as path from 'path';
 
+const schema = {
+  openapi: '3.0.0',
+  info: { version: '1.0.0', title: 'test bug OpenApiValidator' },
+  servers: [],
+  paths: {
+    '/': {
+      get: {
+        operationId: 'anything',
+        'x-eov-operation-handler': 'controller-with-default',
+        responses: { 200: { description: 'home api' } }
+      }
+    },
+  },
+} as const;
+
 describe('default export resolver', () => {
   let server = null;
   let app = express();
@@ -11,20 +26,7 @@ describe('default export resolver', () => {
   before(async () => {
     app.use(
       OpenApiValidator.middleware({
-        apiSpec: {
-          openapi: '3.0.0',
-          info: { version: '1.0.0', title: 'test bug OpenApiValidator' },
-          paths: {
-            '/': {
-              get: {
-                operationId: 'anything',
-                // @ts-ignore
-                'x-eov-operation-handler': 'controller-with-default',
-                responses: { 200: { description: 'home api' } }
-              }
-            },
-          },
-        },
+        apiSpec: schema,
         operationHandlers: path.join(__dirname, 'resources'),
       }),
     );