diff --git a/CHANGELOG.md b/CHANGELOG.md index 8abea4e..709b304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +## [8.1.1] - 2026-06-29 +### Fixed +- Request header keys are now normalized to lowercase, so headers sent in camelCase over HTTP/1 (e.g. `x-janis-Page`) are read consistently by every API instead of falling back to defaults + ## [8.1.0] - 2024-08-20 ### Changed - Reimplemented omitRecursive function to support pattern-based field exclusion instead of simple field name matching diff --git a/README.md b/README.md index 7287594..b694546 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ The following methods will be inherited from the base API Class: Returns the path parameters of the request. For example: /store/10/schedules will generate the following path parameters: ['10'] - **headers**. *object*. -Returns the the headers of the request as a key-value object. +Returns the the headers of the request as a key-value object. Header keys are normalized to lowercase (HTTP header names are case-insensitive per RFC 7230), so you can always read them in lowercase regardless of the casing sent by the client (e.g. `this.headers['x-janis-page']`). - **cookies**. *object*. Returns the the cookies of the request as a key-value object. diff --git a/lib/dispatcher.js b/lib/dispatcher.js index ebe44f3..2e9511e 100644 --- a/lib/dispatcher.js +++ b/lib/dispatcher.js @@ -5,7 +5,7 @@ const { ApiSession } = require('@janiscommerce/api-session'); const cloneObj = require('lodash.clonedeep'); const Fetcher = require('./fetcher'); -const { isObject, trimObjectValues } = require('./utils'); +const { isObject, trimObjectValues, lowerCaseKeys } = require('./utils'); const API = require('./api'); const APIError = require('./error'); @@ -41,7 +41,10 @@ module.exports = class Dispatcher { this.pristineData = request.data; Object.freeze(this.pristineData); this.rawData = request.rawData; - this.headers = request.headers || {}; + // Normalize header keys to lowercase. HTTP header names are case-insensitive (RFC 7230) and HTTP/2 + // forces them to lowercase, but over HTTP/1 a consumer may send them in any case (e.g. 'x-janis-Page'). + // Normalizing here lets every API read headers consistently regardless of the casing received. + this.headers = lowerCaseKeys(request.headers || {}); this.cookies = request.cookies || {}; this.authenticationData = request.authenticationData || {}; this.executionStarted = Date.now(); diff --git a/lib/utils.js b/lib/utils.js index f3493c6..427a1b0 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -121,6 +121,25 @@ const omitRecursive = (object, pathPatterns) => { return clonedObject; }; +/** + * Returns a new object with all the top-level keys lowercased. + * Useful to normalize HTTP header names, which are case-insensitive (RFC 7230), + * so they can be read consistently regardless of the casing sent by the consumer. + * + * @param {object} object - The input object + * @returns {object} A new object with its keys lowercased + */ +const lowerCaseKeys = object => { + + if(!isObject(object)) + return object; + + return Object.entries(object).reduce((normalized, [key, value]) => { + normalized[key.toLowerCase()] = value; + return normalized; + }, {}); +}; + const trimObjectValues = value => { if(typeof value !== 'object' || !value) @@ -141,5 +160,6 @@ const trimObjectValues = value => { module.exports = { isObject, omitRecursive, - trimObjectValues + trimObjectValues, + lowerCaseKeys }; diff --git a/package.json b/package.json index 4626164..7939d2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@janiscommerce/api", - "version": "8.1.0", + "version": "8.1.1", "description": "A package for managing API from any origin", "main": "lib/index.js", "scripts": { diff --git a/tests/dispatcher-test.js b/tests/dispatcher-test.js index 6f0eb31..ed3aab1 100644 --- a/tests/dispatcher-test.js +++ b/tests/dispatcher-test.js @@ -543,6 +543,26 @@ describe('Dispatcher', () => { }, 200); }); + it('Should normalize header keys to lowercase regardless of the casing received', async function() { + + extraProcess = api => { + assert.deepStrictEqual(api.headers, { + 'x-janis-page': '3', + 'x-janis-page-size': '20', + 'my-header': 'foo' + }); + }; + + await test({ + endpoint: 'api/valid-endpoint', + headers: { + 'x-janis-Page': '3', + 'X-Janis-Page-Size': '20', + 'My-Header': 'foo' + } + }, 200); + }); + it('Should response with a custom HTTP Code when given', async function() { httpCode = 201; diff --git a/tests/utils-test.js b/tests/utils-test.js new file mode 100644 index 0000000..4318544 --- /dev/null +++ b/tests/utils-test.js @@ -0,0 +1,29 @@ +'use strict'; + +const assert = require('assert'); + +const { lowerCaseKeys } = require('../lib/utils'); + +describe('Utils', () => { + + describe('lowerCaseKeys()', () => { + + it('Should lowercase every top-level key', () => { + assert.deepStrictEqual(lowerCaseKeys({ + 'x-janis-Page': '3', + 'X-Janis-Page-Size': '20', + 'My-Header': 'foo' + }), { + 'x-janis-page': '3', + 'x-janis-page-size': '20', + 'my-header': 'foo' + }); + }); + + it('Should return the value as-is when it is not an object', () => { + [null, undefined, 'string', 1, true, ['a', 'b']].forEach(value => { + assert.deepStrictEqual(lowerCaseKeys(value), value); + }); + }); + }); +});