diff --git a/src/ControllerMethod.php b/src/ControllerMethod.php index 7ee1bf8..0dad47e 100644 --- a/src/ControllerMethod.php +++ b/src/ControllerMethod.php @@ -26,6 +26,262 @@ class ControllerMethod { private const STATUS_CODE_DESCRIPTION_PATTERN = '/^(\d{3}): (.+)$/'; + // Generate the list using this command: + // curl https://www.iana.org/assignments/http-fields/field-names.csv | cut -d ',' -f 1 | tail -n +2 | head -n -1 | tr '[:upper:]' '[:lower:]' | grep -E '^[a-z]' | sed -e "s/^/'/g" | sed -e "s/$/',/g" + private const HTTP_STANDARD_HEADERS = [ + 'a-im', + 'accept', + 'accept-additions', + 'accept-ch', + 'accept-charset', + 'accept-datetime', + 'accept-encoding', + 'accept-features', + 'accept-language', + 'accept-patch', + 'accept-post', + 'accept-ranges', + 'accept-signature', + 'access-control', + 'access-control-allow-credentials', + 'access-control-allow-headers', + 'access-control-allow-methods', + 'access-control-allow-origin', + 'access-control-expose-headers', + 'access-control-max-age', + 'access-control-request-headers', + 'access-control-request-method', + 'activate-storage-access', + 'age', + 'allow', + 'alpn', + 'alt-svc', + 'alt-used', + 'alternates', + 'amp-cache-transform', + 'apply-to-redirect-ref', + 'authentication-control', + 'authentication-info', + 'authorization', + 'available-dictionary', + 'c-ext', + 'c-man', + 'c-opt', + 'c-pep', + 'c-pep-info', + 'cache-control', + 'cache-group-invalidation', + 'cache-groups', + 'cache-status', + 'cal-managed-id', + 'caldav-timezones', + 'capsule-protocol', + 'cdn-cache-control', + 'cdn-loop', + 'cert-not-after', + 'cert-not-before', + 'clear-site-data', + 'client-cert', + 'client-cert-chain', + 'close', + 'cmcd-object', + 'cmcd-request', + 'cmcd-session', + 'cmcd-status', + 'cmsd-dynamic', + 'cmsd-static', + 'concealed-auth-export', + 'configuration-context', + 'connection', + 'content-base', + 'content-digest', + 'content-disposition', + 'content-encoding', + 'content-id', + 'content-language', + 'content-length', + 'content-location', + 'content-md5', + 'content-range', + 'content-script-type', + 'content-security-policy', + 'content-security-policy-report-only', + 'content-style-type', + 'content-type', + 'content-version', + 'cookie', + 'cookie2', + 'cross-origin-embedder-policy', + 'cross-origin-embedder-policy-report-only', + 'cross-origin-opener-policy', + 'cross-origin-opener-policy-report-only', + 'cross-origin-resource-policy', + 'cta-common-access-token', + 'dasl', + 'date', + 'dav', + 'default-style', + 'delta-base', + 'deprecation', + 'depth', + 'derived-from', + 'destination', + 'detached-jws', + 'differential-id', + 'dictionary-id', + 'digest', + 'dpop', + 'dpop-nonce', + 'early-data', + 'ediint-features', + 'etag', + 'expect', + 'expect-ct', + 'expires', + 'ext', + 'forwarded', + 'from', + 'getprofile', + 'hobareg', + 'host', + 'http2-settings', + 'if', + 'if-match', + 'if-modified-since', + 'if-none-match', + 'if-range', + 'if-schedule-tag-match', + 'if-unmodified-since', + 'im', + 'include-referred-token-binding-id', + 'isolation', + 'keep-alive', + 'label', + 'last-event-id', + 'last-modified', + 'link', + 'link-template', + 'location', + 'lock-token', + 'man', + 'max-forwards', + 'memento-datetime', + 'meter', + 'method-check', + 'method-check-expires', + 'mime-version', + 'negotiate', + 'nel', + 'odata-entityid', + 'odata-isolation', + 'odata-maxversion', + 'odata-version', + 'opt', + 'optional-www-authenticate', + 'ordering-type', + 'origin', + 'origin-agent-cluster', + 'oscore', + 'oslc-core-version', + 'overwrite', + 'p3p', + 'pep', + 'pep-info', + 'permissions-policy', + 'pics-label', + 'ping-from', + 'ping-to', + 'position', + 'pragma', + 'prefer', + 'preference-applied', + 'priority', + 'profileobject', + 'protocol', + 'protocol-info', + 'protocol-query', + 'protocol-request', + 'proxy-authenticate', + 'proxy-authentication-info', + 'proxy-authorization', + 'proxy-features', + 'proxy-instruction', + 'proxy-status', + 'public', + 'public-key-pins', + 'public-key-pins-report-only', + 'range', + 'redirect-ref', + 'referer', + 'referer-root', + 'referrer-policy', + 'refresh', + 'repeatability-client-id', + 'repeatability-first-sent', + 'repeatability-request-id', + 'repeatability-result', + 'replay-nonce', + 'reporting-endpoints', + 'repr-digest', + 'retry-after', + 'safe', + 'schedule-reply', + 'schedule-tag', + 'sec-fetch-dest', + 'sec-fetch-mode', + 'sec-fetch-site', + 'sec-fetch-storage-access', + 'sec-fetch-user', + 'sec-gpc', + 'sec-purpose', + 'sec-token-binding', + 'sec-websocket-accept', + 'sec-websocket-extensions', + 'sec-websocket-key', + 'sec-websocket-protocol', + 'sec-websocket-version', + 'security-scheme', + 'server', + 'server-timing', + 'set-cookie', + 'set-cookie2', + 'setprofile', + 'signature', + 'signature-input', + 'slug', + 'soapaction', + 'status-uri', + 'strict-transport-security', + 'sunset', + 'surrogate-capability', + 'surrogate-control', + 'tcn', + 'te', + 'timeout', + 'timing-allow-origin', + 'topic', + 'traceparent', + 'tracestate', + 'trailer', + 'transfer-encoding', + 'ttl', + 'upgrade', + 'urgency', + 'uri', + 'use-as-dictionary', + 'user-agent', + 'variant-vary', + 'vary', + 'via', + 'want-content-digest', + 'want-digest', + 'want-repr-digest', + 'warning', + 'www-authenticate', + 'x-content-type-options', + 'x-frame-options', + ]; + /** * @param ControllerMethodParameter[] $parameters * @param array $requestHeaders @@ -301,7 +557,15 @@ public static function parse(string $context, $methodCall->var->var->name === 'this' && $methodCall->var->name->name === 'request') { if ($methodCall->name->name === 'getHeader') { - $codeRequestHeaders[] = $methodCall->args[0]->value->value; + $headerName = self::cleanHeaderName($methodCall->args[0]->value->value); + + if ($headerName !== $methodCall->args[0]->value->value) { + Logger::error($context, 'Request header "' . $methodCall->args[0]->value->value . '" should be "' . $headerName . '".'); + } + + self::checkCustomHeaderName($context, $headerName); + + $codeRequestHeaders[] = $headerName; } if ($methodCall->name->name === 'getParam') { $name = $methodCall->args[0]->value->value; @@ -352,11 +616,18 @@ public static function parse(string $context, $args[$attrName] = $arg->value->value; } - if (array_key_exists($args['name'], $attributeRequestHeaders)) { - Logger::error($context, 'Request header "' . $args['name'] . '" already documented.'); + $headerName = self::cleanHeaderName($args['name']); + if ($headerName !== $args['name']) { + Logger::error($context, 'Request header "' . $args['name'] . '" should be "' . $headerName . '".'); + } + + self::checkCustomHeaderName($context, $headerName); + + if (array_key_exists($headerName, $attributeRequestHeaders)) { + Logger::error($context, 'Request header "' . $headerName . '" already documented.'); } - $attributeRequestHeaders[$args['name']] = $args['description']; + $attributeRequestHeaders[$headerName] = $args['description']; } } } @@ -377,4 +648,17 @@ public static function parse(string $context, return new ControllerMethod($parameters, $attributeRequestHeaders, $responses, $responseDescriptions, $methodDescription, $methodSummary, $isDeprecated); } + private static function cleanHeaderName(string $header): string { + return str_replace('_', '-', strtolower($header)); + } + + private static function checkCustomHeaderName(string $context, string $header): void { + if (in_array($header, self::HTTP_STANDARD_HEADERS, true)) { + return; + } + + if (!str_starts_with($header, 'x-')) { + Logger::warning($context, 'Request header "' . $header . '" should start with "x-" to denote a custom header.'); + } + } } diff --git a/tests/lib/Controller/SettingsController.php b/tests/lib/Controller/SettingsController.php index e370757..6498ca0 100644 --- a/tests/lib/Controller/SettingsController.php +++ b/tests/lib/Controller/SettingsController.php @@ -760,16 +760,18 @@ public function samePathPost(): DataResponse { * * 200: Admin settings updated */ - #[RequestHeader('X-Custom-Header-1', 'A custom header 1')] - #[RequestHeader('X-Custom-Header-2', description: 'A custom header 2')] - #[RequestHeader(name: 'X-Custom-Header-3', description: 'A custom header 3')] - #[RequestHeader(description: 'A custom header 4', name: 'X-Custom-Header-4')] + #[RequestHeader('x-custom-header-1', 'A custom header 1')] + #[RequestHeader('x-custom-header-2', description: 'A custom header 2')] + #[RequestHeader(name: 'x-custom-header-3', description: 'A custom header 3')] + #[RequestHeader(description: 'A custom header 4', name: 'x-custom-header-4')] public function requestHeader(): DataResponse { - $this->request->getHeader('X-Custom-Header-1'); - $this->request->getHeader('X-Custom-Header-2'); - $this->request->getHeader('X-Custom-Header-3'); - $this->request->getHeader('X-Custom-Header-4'); - $this->request->getHeader('X-Custom-Header-5'); + $this->request->getHeader('x-custom-header-1'); + $this->request->getHeader('x-custom-header-2'); + $this->request->getHeader('x-custom-header-3'); + $this->request->getHeader('x-custom-header-4'); + $this->request->getHeader('x-custom-header-5'); + $this->request->getHeader('custom-header-6'); + $this->request->getHeader('user-agent'); return new DataResponse(); } diff --git a/tests/openapi-administration.json b/tests/openapi-administration.json index 70a66d1..24f9a83 100644 --- a/tests/openapi-administration.json +++ b/tests/openapi-administration.json @@ -5472,7 +5472,7 @@ } }, { - "name": "X-Custom-Header-1", + "name": "x-custom-header-1", "in": "header", "description": "A custom header 1", "schema": { @@ -5480,7 +5480,7 @@ } }, { - "name": "X-Custom-Header-2", + "name": "x-custom-header-2", "in": "header", "description": "A custom header 2", "schema": { @@ -5488,7 +5488,7 @@ } }, { - "name": "X-Custom-Header-3", + "name": "x-custom-header-3", "in": "header", "description": "A custom header 3", "schema": { @@ -5496,7 +5496,7 @@ } }, { - "name": "X-Custom-Header-4", + "name": "x-custom-header-4", "in": "header", "description": "A custom header 4", "schema": { @@ -5504,7 +5504,21 @@ } }, { - "name": "X-Custom-Header-5", + "name": "x-custom-header-5", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "custom-header-6", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "user-agent", "in": "header", "schema": { "type": "string" diff --git a/tests/openapi-full.json b/tests/openapi-full.json index d9cc871..4e6955f 100644 --- a/tests/openapi-full.json +++ b/tests/openapi-full.json @@ -5629,7 +5629,7 @@ } }, { - "name": "X-Custom-Header-1", + "name": "x-custom-header-1", "in": "header", "description": "A custom header 1", "schema": { @@ -5637,7 +5637,7 @@ } }, { - "name": "X-Custom-Header-2", + "name": "x-custom-header-2", "in": "header", "description": "A custom header 2", "schema": { @@ -5645,7 +5645,7 @@ } }, { - "name": "X-Custom-Header-3", + "name": "x-custom-header-3", "in": "header", "description": "A custom header 3", "schema": { @@ -5653,7 +5653,7 @@ } }, { - "name": "X-Custom-Header-4", + "name": "x-custom-header-4", "in": "header", "description": "A custom header 4", "schema": { @@ -5661,7 +5661,21 @@ } }, { - "name": "X-Custom-Header-5", + "name": "x-custom-header-5", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "custom-header-6", + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "user-agent", "in": "header", "schema": { "type": "string"