Skip to content

Commit ccdd401

Browse files
committed
Add ContentType enum
1 parent 439be0e commit ccdd401

File tree

6 files changed

+318
-11
lines changed

6 files changed

+318
-11
lines changed

src/Header/ContentType.php

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace FastForward\Http\Message\Header;
6+
7+
/**
8+
* Enum ContentType
9+
*
10+
* Represents a comprehensive set of HTTP Content-Type header values.
11+
* Each enum case describes a MIME type that MAY be used when constructing or
12+
* parsing HTTP messages. Implementations interacting with this enum SHOULD
13+
* ensure appropriate handling based on RFC 2119 requirement levels.
14+
*
15+
* This enum MUST be used when normalizing, validating, or comparing Content-Type
16+
* header values in a strict and type-safe manner. It SHALL provide helper
17+
* methods for extracting metadata, ensuring consistent behavior across HTTP
18+
* message handling.
19+
*/
20+
enum ContentType: string
21+
{
22+
// Text Types
23+
case TextPlain = 'text/plain';
24+
case TextHtml = 'text/html';
25+
case TextCss = 'text/css';
26+
case TextCsv = 'text/csv';
27+
case TextXml = 'text/xml';
28+
29+
// Application Types
30+
case ApplicationJson = 'application/json';
31+
case ApplicationXml = 'application/xml';
32+
case ApplicationFormUrlencoded = 'application/x-www-form-urlencoded';
33+
case ApplicationPdf = 'application/pdf';
34+
case ApplicationJavascript = 'application/javascript';
35+
case ApplicationOctetStream = 'application/octet-stream';
36+
37+
// Multipart Types
38+
case MultipartFormData = 'multipart/form-data';
39+
40+
// Image Types
41+
case ImageJpeg = 'image/jpeg';
42+
case ImagePng = 'image/png';
43+
case ImageGif = 'image/gif';
44+
case ImageSvg = 'image/svg+xml';
45+
46+
/**
47+
* Creates a ContentType instance from a full Content-Type header string.
48+
*
49+
* This method SHALL parse header values that include parameters such as
50+
* charsets or boundary markers. Only the primary MIME type SHALL be used
51+
* for determining the enum case. If the base MIME type does not match any
52+
* known ContentType, this method MUST return null.
53+
*
54+
* Example:
55+
* "application/json; charset=utf-8" → ContentType::ApplicationJson
56+
*
57+
* @param string $header The full Content-Type header string.
58+
* @return self|null The derived ContentType case or null if unsupported.
59+
*/
60+
public static function fromHeaderString(string $header): ?self
61+
{
62+
$baseType = strtok($header, ';');
63+
if (false === $baseType) {
64+
return null;
65+
}
66+
67+
return self::tryFrom($baseType);
68+
}
69+
70+
/**
71+
* Extracts the charset parameter from a Content-Type header string.
72+
*
73+
* This method SHOULD be used when charset negotiation or validation is
74+
* required. If no charset is present, this method MUST return null. The
75+
* extracted charset value SHALL be trimmed of surrounding whitespace.
76+
*
77+
* Example:
78+
* "application/json; charset=utf-8" → "utf-8"
79+
*
80+
* @param string $contentTypeHeader The full Content-Type header value.
81+
* @return string|null The charset value or null if absent.
82+
*/
83+
public static function getCharset(string $contentTypeHeader): ?string
84+
{
85+
if (preg_match('/charset=([^;]+)/i', $contentTypeHeader, $matches)) {
86+
return trim($matches[1]);
87+
}
88+
89+
return null;
90+
}
91+
92+
/**
93+
* Returns the Content-Type header value with an appended charset parameter.
94+
*
95+
* The returned string MUST follow the standard "type/subtype; charset=X"
96+
* format. Implementations SHOULD ensure the provided charset is valid
97+
* according to application requirements.
98+
*
99+
* @param string $charset The charset to append to the Content-Type.
100+
* @return string The resulting Content-Type header value.
101+
*/
102+
public function withCharset(string $charset): string
103+
{
104+
return $this->value . '; charset=' . $charset;
105+
}
106+
107+
/**
108+
* Determines whether the content type represents JSON data.
109+
*
110+
* This method SHALL evaluate strictly via enum identity comparison. It MUST
111+
* return true only for application/json.
112+
*
113+
* @return bool True if JSON type, false otherwise.
114+
*/
115+
public function isJson(): bool
116+
{
117+
return $this === self::ApplicationJson;
118+
}
119+
120+
/**
121+
* Determines whether the content type represents XML data.
122+
*
123+
* This method SHALL consider both application/xml and text/xml as valid XML
124+
* content types. It MUST return true for either enumeration case.
125+
*
126+
* @return bool True if XML type, false otherwise.
127+
*/
128+
public function isXml(): bool
129+
{
130+
return $this === self::ApplicationXml || $this === self::TextXml;
131+
}
132+
133+
/**
134+
* Determines whether the content type represents text-based content.
135+
*
136+
* Any MIME type beginning with "text/" SHALL be treated as text content.
137+
* This method MUST use string prefix evaluation according to the enum value.
138+
*
139+
* @return bool True if text-based type, false otherwise.
140+
*/
141+
public function isText(): bool
142+
{
143+
return str_starts_with($this->value, 'text/');
144+
}
145+
146+
/**
147+
* Determines whether the content type represents multipart data.
148+
*
149+
* Multipart types are typically used for form uploads and MUST begin with
150+
* "multipart/". This method SHALL match MIME type prefixes accordingly.
151+
*
152+
* @return bool True if multipart type, false otherwise.
153+
*/
154+
public function isMultipart(): bool
155+
{
156+
return str_starts_with($this->value, 'multipart/');
157+
}
158+
}

src/HtmlResponse.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
namespace FastForward\Http\Message;
1717

18+
use FastForward\Http\Message\Header\ContentType;
1819
use Nyholm\Psr7\Response;
1920
use Nyholm\Psr7\Stream;
2021

@@ -44,7 +45,7 @@ final class HtmlResponse extends Response
4445
*/
4546
public function __construct(string $html, string $charset = 'utf-8', array $headers = [])
4647
{
47-
$headers['Content-Type'] = 'text/html; charset=' . $charset;
48+
$headers['Content-Type'] = ContentType::TextHtml->withCharset($charset);
4849

4950
parent::__construct(
5051
status: StatusCode::Ok->value,

src/JsonResponse.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
namespace FastForward\Http\Message;
1717

18+
use FastForward\Http\Message\Header\ContentType;
1819
use Nyholm\Psr7\Response;
1920

2021
/**
@@ -39,10 +40,13 @@ final class JsonResponse extends Response implements PayloadResponseInterface
3940
*/
4041
public function __construct(
4142
mixed $payload = [],
42-
string $charset = 'utf-8'
43+
string $charset = 'utf-8',
44+
array $headers = [],
4345
) {
46+
$headers['Content-Type'] = ContentType::ApplicationJson->withCharset($charset);
47+
4448
parent::__construct(
45-
headers: ['Content-Type' => 'application/json; charset=' . $charset],
49+
headers: $headers,
4650
body: new JsonStream($payload),
4751
);
4852
}

tests/Header/ContentTypeTest.php

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace FastForward\Http\Message\Tests\Header;
6+
7+
use FastForward\Http\Message\Header\ContentType;
8+
use PHPUnit\Framework\Attributes\CoversClass;
9+
use PHPUnit\Framework\Attributes\DataProvider;
10+
use PHPUnit\Framework\TestCase;
11+
12+
#[CoversClass(ContentType::class)]
13+
class ContentTypeTest extends TestCase
14+
{
15+
#[DataProvider('providerHeaderStrings')]
16+
public function testFromHeaderString(string $header, ?ContentType $expected): void
17+
{
18+
$this->assertSame($expected, ContentType::fromHeaderString($header));
19+
}
20+
21+
#[DataProvider('providerCharsets')]
22+
public function testGetCharset(string $header, ?string $expectedCharset): void
23+
{
24+
$this->assertSame($expectedCharset, ContentType::getCharset($header));
25+
}
26+
27+
public function testWithCharset(): void
28+
{
29+
$expected = 'application/json; charset=utf-8';
30+
$this->assertSame($expected, ContentType::ApplicationJson->withCharset('utf-8'));
31+
}
32+
33+
#[DataProvider('providerJsonCases')]
34+
public function testIsJson(ContentType $case, bool $expected): void
35+
{
36+
$this->assertSame($expected, $case->isJson());
37+
}
38+
39+
#[DataProvider('providerXmlCases')]
40+
public function testIsXml(ContentType $case, bool $expected): void
41+
{
42+
$this->assertSame($expected, $case->isXml());
43+
}
44+
45+
#[DataProvider('providerTextCases')]
46+
public function testIsText(ContentType $case, bool $expected): void
47+
{
48+
$this->assertSame($expected, $case->isText());
49+
}
50+
51+
#[DataProvider('providerMultipartCases')]
52+
public function testIsMultipart(ContentType $case, bool $expected): void
53+
{
54+
$this->assertSame($expected, $case->isMultipart());
55+
}
56+
57+
public static function providerHeaderStrings(): array
58+
{
59+
return [
60+
'json with charset' => ['application/json; charset=utf-8', ContentType::ApplicationJson],
61+
'html without charset' => ['text/html', ContentType::TextHtml],
62+
'invalid type' => ['application/invalid', null],
63+
'empty string' => ['', null],
64+
];
65+
}
66+
67+
public static function providerCharsets(): array
68+
{
69+
return [
70+
'json with utf-8' => ['application/json; charset=utf-8', 'utf-8'],
71+
'html with iso' => ['text/html; charset=ISO-8859-1', 'ISO-8859-1'],
72+
'plain text without charset' => ['text/plain', null],
73+
'header with extra spaces' => [' application/json ; charset=UTF-8 ', 'UTF-8'],
74+
'case insensitive' => ['application/json; CharSet=UTF-8', 'UTF-8'],
75+
'empty header' => ['', null],
76+
];
77+
}
78+
79+
public static function providerJsonCases(): array
80+
{
81+
return [
82+
[ContentType::ApplicationJson, true],
83+
[ContentType::TextHtml, false],
84+
[ContentType::ApplicationXml, false],
85+
];
86+
}
87+
88+
public static function providerXmlCases(): array
89+
{
90+
return [
91+
[ContentType::ApplicationXml, true],
92+
[ContentType::TextXml, true],
93+
[ContentType::ApplicationJson, false],
94+
[ContentType::TextHtml, false],
95+
];
96+
}
97+
98+
public static function providerTextCases(): array
99+
{
100+
return [
101+
[ContentType::TextPlain, true],
102+
[ContentType::TextHtml, true],
103+
[ContentType::TextCss, true],
104+
[ContentType::TextCsv, true],
105+
[ContentType::TextXml, true],
106+
[ContentType::ApplicationJson, false],
107+
[ContentType::ImagePng, false],
108+
];
109+
}
110+
111+
public static function providerMultipartCases(): array
112+
{
113+
return [
114+
[ContentType::MultipartFormData, true],
115+
[ContentType::ApplicationFormUrlencoded, false],
116+
[ContentType::ApplicationJson, false],
117+
];
118+
}
119+
}

tests/HtmlResponseTest.php

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
namespace FastForward\Http\Message\Tests;
1717

18+
use FastForward\Http\Message\Header\ContentType;
1819
use FastForward\Http\Message\HtmlResponse;
1920
use FastForward\Http\Message\StatusCode;
2021
use PHPUnit\Framework\Attributes\CoversClass;
@@ -36,17 +37,25 @@ public function testConstructorWillSetHtmlBodyAndContentType(): void
3637

3738
self::assertSame(StatusCode::Ok->value, $response->getStatusCode());
3839
self::assertSame(StatusCode::Ok->getReasonPhrase(), $response->getReasonPhrase());
39-
self::assertSame('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
40+
self::assertSame(
41+
ContentType::TextHtml,
42+
ContentType::fromHeaderString($response->getHeaderLine('Content-Type')),
43+
);
4044
self::assertSame($html, (string) $response->getBody());
4145
}
4246

4347
public function testConstructorWillRespectCustomCharset(): void
4448
{
4549
$html = '<p>Charset Test</p>';
50+
$charset = 'iso-8859-1';
4651

47-
$response = new HtmlResponse($html, charset: 'iso-8859-1');
52+
$response = new HtmlResponse($html, charset: $charset);
4853

49-
self::assertSame('text/html; charset=iso-8859-1', $response->getHeaderLine('Content-Type'));
54+
self::assertSame(
55+
ContentType::TextHtml,
56+
ContentType::fromHeaderString($response->getHeaderLine('Content-Type')),
57+
);
58+
self::assertSame($charset, ContentType::getCharset($response->getHeaderLine('Content-Type')));
5059
self::assertSame($html, (string) $response->getBody());
5160
}
5261

@@ -60,7 +69,11 @@ public function testConstructorWillPreserveAdditionalHeaders(): void
6069

6170
$response = new HtmlResponse($html, headers: $headers);
6271

63-
self::assertSame('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
72+
self::assertSame(
73+
ContentType::TextHtml,
74+
ContentType::fromHeaderString($response->getHeaderLine('Content-Type')),
75+
);
76+
self::assertSame('utf-8', ContentType::getCharset($response->getHeaderLine('Content-Type')));
6477
self::assertSame('test-value', $response->getHeaderLine('X-Test'));
6578
self::assertSame('one, two', $response->getHeaderLine('X-Array'));
6679
self::assertSame($html, (string) $response->getBody());

0 commit comments

Comments
 (0)