diff --git a/CHANGELOG.md b/CHANGELOG.md index 9995477..df90a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.2.0 (2026-04-20) + +### Features + +- **content-type:** Add optional `contentType` to `ValidatorOptions` for `validateResponse` and `validateRequest`. Defaults to `application/json` (backwards compatible). Media-type resolution order is exact match → family wildcard (`image/*`) → `*/*`. Unmatched binary content types (`image/*`, `video/*`, `audio/*`, `application/octet-stream`, `application/pdf`, `application/zip`) are silently bypassed — no more false-positive `MISSING_SCHEMA` warnings when a mock returns binary data like a QR code. + +### Internal + +- **normalize:** `normalizeAllSchemas` now rewrites OpenAPI 3.0 → 3.1 schemas under every media-type entry in `content`, not only `application/json`. Previously, schemas declared under e.g. `multipart/form-data` or `image/jpeg` missed the rewrite and could throw at validation time. + ## 0.1.4 (2026-04-08) ### Bug Fixes diff --git a/package-lock.json b/package-lock.json index 7379525..728f2c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openapi-mock-validator", - "version": "0.1.4", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openapi-mock-validator", - "version": "0.1.4", + "version": "0.2.0", "license": "MIT", "dependencies": { "@apidevtools/json-schema-ref-parser": "^15.3.4", @@ -99,22 +99,22 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", "optional": true, "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "dev": true, "license": "MIT", "optional": true, @@ -124,9 +124,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -163,9 +163,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", - "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, @@ -182,9 +182,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", - "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", + "version": "0.126.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz", + "integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==", "dev": true, "license": "MIT", "funding": { @@ -192,9 +192,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz", - "integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", "cpu": [ "arm64" ], @@ -209,9 +209,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz", - "integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", "cpu": [ "arm64" ], @@ -226,9 +226,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz", - "integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", "cpu": [ "x64" ], @@ -243,9 +243,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz", - "integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", "cpu": [ "x64" ], @@ -260,9 +260,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz", - "integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", + "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", "cpu": [ "arm" ], @@ -277,9 +277,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz", - "integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", "cpu": [ "arm64" ], @@ -294,9 +294,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz", - "integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", "cpu": [ "arm64" ], @@ -311,9 +311,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz", - "integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", "cpu": [ "ppc64" ], @@ -328,9 +328,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz", - "integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", "cpu": [ "s390x" ], @@ -345,9 +345,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz", - "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==", "cpu": [ "x64" ], @@ -362,9 +362,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz", - "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", "cpu": [ "x64" ], @@ -379,9 +379,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz", - "integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", "cpu": [ "arm64" ], @@ -396,9 +396,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz", - "integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", + "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", "cpu": [ "wasm32" ], @@ -406,18 +406,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.9.1", - "@emnapi/runtime": "1.9.1", - "@napi-rs/wasm-runtime": "^1.1.2" + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz", - "integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", "cpu": [ "arm64" ], @@ -432,9 +432,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz", - "integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==", "cpu": [ "x64" ], @@ -449,9 +449,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", - "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz", + "integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==", "dev": true, "license": "MIT" }, @@ -506,15 +506,15 @@ "peer": true }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.3.tgz", - "integrity": "sha512-/MBdrkA8t6hbdCWFKs09dPik774xvs4Z6L4bycdCxYNLHM8oZuRyosumQMG19LUlBsB6GeVpL1q4kFFazvyKGA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", + "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.3", + "@vitest/utils": "4.1.4", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -528,8 +528,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.3", - "vitest": "4.1.3" + "@vitest/browser": "4.1.4", + "vitest": "4.1.4" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -538,16 +538,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", - "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.3", - "@vitest/utils": "4.1.3", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -556,13 +556,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", - "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.3", + "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -583,9 +583,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", - "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", "dev": true, "license": "MIT", "dependencies": { @@ -596,13 +596,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", - "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.3", + "@vitest/utils": "4.1.4", "pathe": "^2.0.3" }, "funding": { @@ -610,14 +610,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", - "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.3", - "@vitest/utils": "4.1.3", + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -626,9 +626,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", - "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", "dev": true, "license": "MIT", "funding": { @@ -636,13 +636,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", - "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.3", + "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1269,9 +1269,9 @@ } }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -1307,14 +1307,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz", - "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", + "integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.123.0", - "@rolldown/pluginutils": "1.0.0-rc.13" + "@oxc-project/types": "=0.126.0", + "@rolldown/pluginutils": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" @@ -1323,21 +1323,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.13", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", - "@rolldown/binding-darwin-x64": "1.0.0-rc.13", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" + "@rolldown/binding-android-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-x64": "1.0.0-rc.16", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" } }, "node_modules/semver": { @@ -1378,9 +1378,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -1464,18 +1464,18 @@ } }, "node_modules/vite": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz", - "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==", + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", + "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.13", - "tinyglobby": "^0.2.15" + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.16", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -1543,20 +1543,20 @@ } }, "node_modules/vitest": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", - "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/expect": "4.1.3", - "@vitest/mocker": "4.1.3", - "@vitest/pretty-format": "4.1.3", - "@vitest/runner": "4.1.3", - "@vitest/snapshot": "4.1.3", - "@vitest/spy": "4.1.3", - "@vitest/utils": "4.1.3", + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -1584,12 +1584,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.3", - "@vitest/browser-preview": "4.1.3", - "@vitest/browser-webdriverio": "4.1.3", - "@vitest/coverage-istanbul": "4.1.3", - "@vitest/coverage-v8": "4.1.3", - "@vitest/ui": "4.1.3", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 793d9ce..87bb32d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openapi-mock-validator", - "version": "0.1.4", + "version": "0.2.0", "description": "Validate JSON payloads against OpenAPI 3.0/3.1 specs — catch mock drift before it hits production", "type": "module", "main": "./dist/index.js", diff --git a/src/schemas.ts b/src/schemas.ts index 38c734b..84ef2d8 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1,5 +1,34 @@ import type { OpenAPISpec, ValidationWarning } from './types.js'; +const BINARY_PREFIXES = ['image/', 'video/', 'audio/'] as const; +const BINARY_EXACT = new Set([ + 'application/octet-stream', + 'application/pdf', + 'application/zip', +]); + +export function isBinaryContentType(contentType: string): boolean { + return BINARY_PREFIXES.some((prefix) => contentType.startsWith(prefix)) + || BINARY_EXACT.has(contentType); +} + +export function resolveMediaType( + content: Record>, + contentType: string, +): Record | null { + if (content[contentType]) return content[contentType]; + + const slashIndex = contentType.indexOf('/'); + if (slashIndex > 0) { + const family = `${contentType.slice(0, slashIndex)}/*`; + if (content[family]) return content[family]; + } + + if (content['*/*']) return content['*/*']; + + return null; +} + interface SchemaExtractionResult { schema: Record | null; warnings: ValidationWarning[]; @@ -10,6 +39,7 @@ export function extractResponseSchema( path: string, method: string, status: number, + contentType: string = 'application/json', ): SchemaExtractionResult { const warnings: ValidationWarning[] = []; const normalizedMethod = method.toLowerCase(); @@ -48,11 +78,14 @@ export function extractResponseSchema( return { schema: null, warnings }; } - const mediaType = content['application/json']; + const mediaType = resolveMediaType(content, contentType); if (!mediaType) { + if (isBinaryContentType(contentType)) { + return { schema: null, warnings: [] }; + } warnings.push({ type: 'MISSING_SCHEMA', - message: `No application/json content for ${method.toUpperCase()} ${path} (${status})`, + message: `No ${contentType} content for ${method.toUpperCase()} ${path} (${status})`, }); return { schema: null, warnings }; } @@ -73,6 +106,7 @@ export function extractRequestSchema( spec: OpenAPISpec, path: string, method: string, + contentType: string = 'application/json', ): SchemaExtractionResult { const warnings: ValidationWarning[] = []; const normalizedMethod = method.toLowerCase(); @@ -105,11 +139,14 @@ export function extractRequestSchema( return { schema: null, warnings }; } - const mediaType = content['application/json']; + const mediaType = resolveMediaType(content, contentType); if (!mediaType) { + if (isBinaryContentType(contentType)) { + return { schema: null, warnings: [] }; + } warnings.push({ type: 'MISSING_SCHEMA', - message: `No application/json content in requestBody for ${method.toUpperCase()} ${path}`, + message: `No ${contentType} content in requestBody for ${method.toUpperCase()} ${path}`, }); return { schema: null, warnings }; } diff --git a/src/types.ts b/src/types.ts index aab6ba0..92dc7b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,12 @@ export interface ValidatorOptions { strict?: boolean; + /** + * Content-Type of the response or request being validated. + * Default: `"application/json"`. + * Accepts exact types (`"image/jpeg"`) or is matched against wildcard + * content-type entries in the spec (`"image/*"`, `"*\/*"`). + */ + contentType?: string; } export interface PathMatch { diff --git a/src/validator.ts b/src/validator.ts index d08be2a..2cc65d4 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -21,7 +21,7 @@ interface InternalError extends ValidationError { export class OpenAPIMockValidator { private spec: OpenAPISpec; - private options: Required; + private options: { strict: boolean }; private compiledPaths: CompiledPath[] | null = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Ajv2020 lacks proper type exports private ajv: any = null; @@ -86,7 +86,8 @@ export class OpenAPIMockValidator { ): ValidationResult { this.ensureInitialized(); - const { schema, warnings } = extractResponseSchema(this.spec, path, method, status); + const contentType = options?.contentType ?? 'application/json'; + const { schema, warnings } = extractResponseSchema(this.spec, path, method, status, contentType); if (!schema) { return { valid: true, errors: [], warnings }; } @@ -103,7 +104,8 @@ export class OpenAPIMockValidator { ): ValidationResult { this.ensureInitialized(); - const { schema, warnings } = extractRequestSchema(this.spec, path, method); + const contentType = options?.contentType ?? 'application/json'; + const { schema, warnings } = extractRequestSchema(this.spec, path, method, contentType); if (!schema) { return { valid: true, errors: [], warnings }; } @@ -260,29 +262,37 @@ export class OpenAPIMockValidator { if (key.startsWith('x-') || typeof value !== 'object' || value === null) continue; const operation = value as Record; - // Normalize response schemas + // Normalize response schemas across all content types const responses = operation.responses as Record> | undefined; if (responses) { for (const response of Object.values(responses)) { const content = response?.content as Record> | undefined; - if (content?.['application/json']?.schema) { - content['application/json'].schema = normalizeSpec( - content['application/json'].schema as Record, - spec.openapi, - ); + if (content) { + for (const mediaTypeObj of Object.values(content)) { + if (mediaTypeObj?.schema) { + mediaTypeObj.schema = normalizeSpec( + mediaTypeObj.schema as Record, + spec.openapi, + ); + } + } } } } - // Normalize request body schemas + // Normalize request body schemas across all content types const requestBody = operation.requestBody as Record | undefined; if (requestBody) { const content = requestBody.content as Record> | undefined; - if (content?.['application/json']?.schema) { - content['application/json'].schema = normalizeSpec( - content['application/json'].schema as Record, - spec.openapi, - ); + if (content) { + for (const mediaTypeObj of Object.values(content)) { + if (mediaTypeObj?.schema) { + mediaTypeObj.schema = normalizeSpec( + mediaTypeObj.schema as Record, + spec.openapi, + ); + } + } } } } diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts index f80b55a..7a8594c 100644 --- a/tests/schemas.test.ts +++ b/tests/schemas.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { extractResponseSchema, extractRequestSchema } from '../src/schemas.js'; +import { extractResponseSchema, extractRequestSchema, isBinaryContentType, resolveMediaType } from '../src/schemas.js'; import type { OpenAPISpec } from '../src/types.js'; import petstore from './fixtures/petstore-3.0.json'; @@ -74,3 +74,193 @@ describe('extractRequestSchema', () => { expect(result.warnings[0].type).toBe('MISSING_SCHEMA'); }); }); + +describe('isBinaryContentType', () => { + it('returns true for image/* types', () => { + expect(isBinaryContentType('image/png')).toBe(true); + expect(isBinaryContentType('image/jpeg')).toBe(true); + expect(isBinaryContentType('image/svg+xml')).toBe(true); + }); + + it('returns true for video/* and audio/* types', () => { + expect(isBinaryContentType('video/mp4')).toBe(true); + expect(isBinaryContentType('audio/mpeg')).toBe(true); + }); + + it('returns true for application binary types', () => { + expect(isBinaryContentType('application/octet-stream')).toBe(true); + expect(isBinaryContentType('application/pdf')).toBe(true); + expect(isBinaryContentType('application/zip')).toBe(true); + }); + + it('returns false for JSON and text types', () => { + expect(isBinaryContentType('application/json')).toBe(false); + expect(isBinaryContentType('application/xml')).toBe(false); + expect(isBinaryContentType('text/plain')).toBe(false); + expect(isBinaryContentType('text/html')).toBe(false); + }); +}); + +describe('resolveMediaType', () => { + it('returns exact match when content-type is in the spec', () => { + const content = { 'image/png': { schema: { type: 'string' } } }; + expect(resolveMediaType(content, 'image/png')).toEqual({ schema: { type: 'string' } }); + }); + + it('falls back to family wildcard (image/*) when no exact match', () => { + const content = { 'image/*': { schema: { type: 'string' } } }; + expect(resolveMediaType(content, 'image/png')).toEqual({ schema: { type: 'string' } }); + }); + + it('falls back to */* when neither exact nor family match', () => { + const content = { '*/*': { schema: { type: 'string' } } }; + expect(resolveMediaType(content, 'image/png')).toEqual({ schema: { type: 'string' } }); + }); + + it('prefers exact match over wildcard', () => { + const content = { + 'image/png': { schema: { const: 'exact' } }, + 'image/*': { schema: { const: 'family' } }, + '*/*': { schema: { const: 'any' } }, + }; + expect(resolveMediaType(content, 'image/png')).toEqual({ schema: { const: 'exact' } }); + }); + + it('prefers family wildcard over */*', () => { + const content = { + 'image/*': { schema: { const: 'family' } }, + '*/*': { schema: { const: 'any' } }, + }; + expect(resolveMediaType(content, 'image/png')).toEqual({ schema: { const: 'family' } }); + }); + + it('returns null when nothing matches', () => { + const content = { 'application/xml': { schema: {} } }; + expect(resolveMediaType(content, 'image/png')).toBeNull(); + }); +}); + +describe('extractRequestSchema (content-type aware)', () => { + const specWithMultipart: OpenAPISpec = { + openapi: '3.0.0', + paths: { + '/upload': { + post: { + requestBody: { + content: { + 'multipart/form-data': { schema: { type: 'object', properties: { file: { type: 'string' } } } }, + 'application/json': { schema: { type: 'object', properties: { url: { type: 'string' } } } }, + }, + }, + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }; + + it('defaults to application/json when contentType is not passed', () => { + const result = extractRequestSchema(specWithMultipart, '/upload', 'post'); + expect(result.schema).toEqual({ type: 'object', properties: { url: { type: 'string' } } }); + expect(result.warnings).toEqual([]); + }); + + it('returns schema for exact content-type match', () => { + const result = extractRequestSchema(specWithMultipart, '/upload', 'post', 'multipart/form-data'); + expect(result.schema).toEqual({ type: 'object', properties: { file: { type: 'string' } } }); + expect(result.warnings).toEqual([]); + }); + + it('silently bypasses binary content-type when no match', () => { + const result = extractRequestSchema(specWithMultipart, '/upload', 'post', 'image/png'); + expect(result.schema).toBeNull(); + expect(result.warnings).toEqual([]); + }); + + it('emits MISSING_SCHEMA when non-binary content-type has no match', () => { + const result = extractRequestSchema(specWithMultipart, '/upload', 'post', 'application/xml'); + expect(result.schema).toBeNull(); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].type).toBe('MISSING_SCHEMA'); + expect(result.warnings[0].message).toContain('application/xml'); + }); +}); + +describe('extractResponseSchema (content-type aware)', () => { + const specWithImage: OpenAPISpec = { + openapi: '3.0.0', + paths: { + '/qr': { + get: { + responses: { + '200': { + description: 'OK', + content: { + 'image/jpeg': { schema: { type: 'string', format: 'binary' } }, + }, + }, + }, + }, + }, + }, + }; + + const specWithImageWildcard: OpenAPISpec = { + openapi: '3.0.0', + paths: { + '/qr': { + get: { + responses: { + '200': { + description: 'OK', + content: { + 'image/*': { schema: { type: 'string', format: 'binary' } }, + }, + }, + }, + }, + }, + }, + }; + + it('defaults to application/json when contentType is not passed', () => { + // petstore fixture defines application/json for /v1/pets GET 200 + const result = extractResponseSchema(spec, '/v1/pets', 'get', 200); + expect(result.schema).toBeDefined(); + expect(result.warnings).toEqual([]); + }); + + it('returns schema for exact content-type match', () => { + const result = extractResponseSchema(specWithImage, '/qr', 'get', 200, 'image/jpeg'); + expect(result.schema).toEqual({ type: 'string', format: 'binary' }); + expect(result.warnings).toEqual([]); + }); + + it('returns schema for family wildcard match (image/*)', () => { + const result = extractResponseSchema(specWithImageWildcard, '/qr', 'get', 200, 'image/png'); + expect(result.schema).toEqual({ type: 'string', format: 'binary' }); + expect(result.warnings).toEqual([]); + }); + + it('silently bypasses binary content-type when no match (no warning)', () => { + // spec declares image/jpeg; consumer sends image/png — no wildcard, but binary so silent + const result = extractResponseSchema(specWithImage, '/qr', 'get', 200, 'image/png'); + expect(result.schema).toBeNull(); + expect(result.warnings).toEqual([]); + }); + + it('emits MISSING_SCHEMA when JSON requested but spec only has image', () => { + const result = extractResponseSchema(specWithImage, '/qr', 'get', 200, 'application/json'); + expect(result.schema).toBeNull(); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].type).toBe('MISSING_SCHEMA'); + expect(result.warnings[0].message).toContain('application/json'); + }); + + it('emits MISSING_SCHEMA when non-binary non-JSON requested and no match', () => { + const result = extractResponseSchema(specWithImage, '/qr', 'get', 200, 'application/xml'); + expect(result.schema).toBeNull(); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].type).toBe('MISSING_SCHEMA'); + expect(result.warnings[0].message).toContain('application/xml'); + }); +}); diff --git a/tests/validateRequest.test.ts b/tests/validateRequest.test.ts index 199ddd1..7a9569f 100644 --- a/tests/validateRequest.test.ts +++ b/tests/validateRequest.test.ts @@ -78,3 +78,65 @@ describe('validateRequest', () => { }); }); }); + +describe('validateRequest — content-type support', () => { + const spec = { + openapi: '3.0.0', + info: { title: 'test', version: '1.0.0' }, + paths: { + '/upload': { + post: { + requestBody: { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + required: ['file'], + properties: { file: { type: 'string' } }, + }, + }, + 'application/json': { + schema: { + type: 'object', + required: ['url'], + properties: { url: { type: 'string' } }, + }, + }, + }, + }, + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }; + + it('validates against the content-type specified in options', async () => { + const validator = new OpenAPIMockValidator(spec as never); + await validator.init(); + + // multipart branch requires "file" + const result = validator.validateRequest('/upload', 'post', { file: 'data' }, { + contentType: 'multipart/form-data', + }); + expect(result.valid).toBe(true); + }); + + it('defaults to application/json when contentType omitted', async () => { + const validator = new OpenAPIMockValidator(spec as never); + await validator.init(); + + const result = validator.validateRequest('/upload', 'post', { url: 'http://x' }); + expect(result.valid).toBe(true); + }); + + it('silently bypasses binary content-type with no schema match', async () => { + const validator = new OpenAPIMockValidator(spec as never); + await validator.init(); + + const result = validator.validateRequest('/upload', 'post', 'raw-bytes', { + contentType: 'image/png', + }); + expect(result.valid).toBe(true); + expect(result.warnings).toEqual([]); + }); +}); diff --git a/tests/validateResponse.test.ts b/tests/validateResponse.test.ts index 0133395..07c144b 100644 --- a/tests/validateResponse.test.ts +++ b/tests/validateResponse.test.ts @@ -126,3 +126,84 @@ describe('validateResponse', () => { }); }); }); + +describe('validateResponse — content-type support', () => { + const imageSpec = { + openapi: '3.0.0', + info: { title: 'test', version: '1.0.0' }, + paths: { + '/qr': { + get: { + responses: { + '200': { + description: 'OK', + content: { + 'image/jpeg': { schema: { type: 'string', format: 'binary' } }, + }, + }, + }, + }, + }, + }, + }; + + const wildcardSpec = { + openapi: '3.0.0', + info: { title: 'test', version: '1.0.0' }, + paths: { + '/qr': { + get: { + responses: { + '200': { + description: 'OK', + content: { + 'image/*': { schema: { type: 'string', format: 'binary' } }, + }, + }, + }, + }, + }, + }, + }; + + it('returns valid with no warnings for binary content-type mismatch (acceptance #1)', async () => { + const validator = new OpenAPIMockValidator(imageSpec as never); + await validator.init(); + const result = validator.validateResponse('/qr', 'get', 200, 'fake-png-bytes', { + contentType: 'image/png', + }); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + }); + + it('resolves family wildcard image/* (acceptance #2 & #5)', async () => { + const validator = new OpenAPIMockValidator(wildcardSpec as never); + await validator.init(); + const result = validator.validateResponse('/qr', 'get', 200, 'fake-png-bytes', { + contentType: 'image/png', + }); + expect(result.valid).toBe(true); + expect(result.warnings).toEqual([]); + }); + + it('emits MISSING_SCHEMA when JSON requested but spec only has image (acceptance #3)', async () => { + const validator = new OpenAPIMockValidator(imageSpec as never); + await validator.init(); + const result = validator.validateResponse('/qr', 'get', 200, { url: 'x' }, { + contentType: 'application/json', + }); + expect(result.valid).toBe(true); // no schema means no validation, valid but warned + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].type).toBe('MISSING_SCHEMA'); + }); + + it('defaults to application/json when contentType is omitted (acceptance #4)', async () => { + const validator = new OpenAPIMockValidator(imageSpec as never); + await validator.init(); + const result = validator.validateResponse('/qr', 'get', 200, { url: 'x' }); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].type).toBe('MISSING_SCHEMA'); + expect(result.warnings[0].message).toContain('application/json'); + }); +}); diff --git a/tests/validator.test.ts b/tests/validator.test.ts index 45418d6..df4c147 100644 --- a/tests/validator.test.ts +++ b/tests/validator.test.ts @@ -77,3 +77,91 @@ describe('OpenAPIMockValidator', () => { }); }); }); + +describe('normalizeAllSchemas — non-JSON content types', () => { + // `exclusiveMinimum: true + minimum: 5` is a 3.0-only form that normalizeSpec + // rewrites to 3.1's `exclusiveMinimum: 5`. It is a discriminating transform: + // if normalization runs, value 5 is rejected; if it does not, Ajv 2020 in + // strict:false silently ignores the boolean form and value 5 is accepted. + + const discriminatingResponseSpec = { + openapi: '3.0.0', + info: { title: 'test', version: '1.0.0' }, + paths: { + '/widget': { + get: { + responses: { + '200': { + description: 'OK', + content: { + 'application/xml': { + schema: { type: 'number', minimum: 5, exclusiveMinimum: true }, + }, + }, + }, + }, + }, + }, + }, + }; + + const discriminatingRequestSpec = { + openapi: '3.0.0', + info: { title: 'test', version: '1.0.0' }, + paths: { + '/upload': { + post: { + requestBody: { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + required: ['count'], + properties: { + count: { type: 'number', minimum: 5, exclusiveMinimum: true }, + }, + }, + }, + }, + }, + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }; + + it('normalizes response schemas under non-JSON content types', async () => { + const validator = new OpenAPIMockValidator(discriminatingResponseSpec as never); + await validator.init(); + + // Value 5 violates exclusiveMinimum:5 (post-normalization). If normalization + // did not run, Ajv would accept 5 as >= minimum:5. + const boundary = validator.validateResponse('/widget', 'get', 200, 5, { + contentType: 'application/xml', + }); + expect(boundary.valid).toBe(false); + expect(boundary.errors.some((e) => e.keyword === 'exclusiveMinimum')).toBe(true); + + // Sanity: a value strictly greater than 5 passes. + const above = validator.validateResponse('/widget', 'get', 200, 6, { + contentType: 'application/xml', + }); + expect(above.valid).toBe(true); + }); + + it('normalizes request body schemas under non-JSON content types', async () => { + const validator = new OpenAPIMockValidator(discriminatingRequestSpec as never); + await validator.init(); + + const boundary = validator.validateRequest('/upload', 'post', { count: 5 }, { + contentType: 'multipart/form-data', + }); + expect(boundary.valid).toBe(false); + expect(boundary.errors.some((e) => e.keyword === 'exclusiveMinimum')).toBe(true); + + const above = validator.validateRequest('/upload', 'post', { count: 6 }, { + contentType: 'multipart/form-data', + }); + expect(above.valid).toBe(true); + }); +});