Skip to content

Commit 709826e

Browse files
authored
Merge pull request #134 from szhajdu/RFC-6839
feat: RFC 6839 support #133
2 parents 6239ec5 + 0fc1b2b commit 709826e

12 files changed

Lines changed: 565 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
55
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
66

7+
## [11.2.0] - 2026-02-19
8+
### Added
9+
- RFC 6839 support: Content types with `+json` suffix (e.g., `application/vnd.api+json`, `application/hal+json`, `application/problem+json`) are now recognized as JSON-based formats
10+
711
## [11.1.3] - 2025.12.08
812
### Fixed
913
- Fixed content type construction parameter generation - issue #128, fixed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ API client generator is a console application capable of auto-generating a [PSR1
2727
* application/json
2828
* application/x-www-form-urlencoded
2929
* application/xml
30+
* Any content type with `+json` suffix ([RFC 6839](https://datatracker.ietf.org/doc/html/rfc6839)), e.g., `application/hal+json`, `application/problem+json`, `application/vnd.sdmx.data+json`
3031
- Supports new PHP versions syntax features.
3132
- It is base client independent, you are free to choose any [existing PSR-18 compliant client](https://packagist.org/providers/psr/http-client-implementation). Just choose the one which you already use, so generated client would not cause any conflicts with your dependencies. Although not recommended, you can also use or build your own PSR-18 implementation, as the generated client depends on PSR interfaces only.
3233
- Applies code style rules to generated code, you can specify your own.

src/Command/GenerateCommand.php

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace DoclerLabs\ApiClientGenerator\Command;
66

77
use DoclerLabs\ApiClientGenerator\CodeGeneratorFacade;
8+
use DoclerLabs\ApiClientGenerator\Entity\ContentType;
89
use DoclerLabs\ApiClientGenerator\Generator\Security\BasicAuthenticationSecurityStrategy;
910
use DoclerLabs\ApiClientGenerator\Input\Configuration;
1011
use DoclerLabs\ApiClientGenerator\Input\FileReader;
@@ -253,22 +254,33 @@ static function (): bool {
253254

254255
private function getUnusedSerializers(Specification $specification): array
255256
{
256-
$contentTypeMapping = [
257-
XmlContentTypeSerializer::MIME_TYPE => XmlContentTypeSerializer::class,
258-
FormUrlencodedContentTypeSerializer::MIME_TYPE => FormUrlencodedContentTypeSerializer::class,
259-
JsonContentTypeSerializer::MIME_TYPE => JsonContentTypeSerializer::class,
260-
VdnApiJsonContentTypeSerializer::MIME_TYPE => VdnApiJsonContentTypeSerializer::class,
261-
];
262-
263-
$allContentTypes = $specification->getAllContentTypes();
264-
265-
return array_values(
266-
array_filter(
267-
$contentTypeMapping,
268-
static fn (string $key) => !in_array($key, $allContentTypes, true),
269-
ARRAY_FILTER_USE_KEY
270-
)
257+
$allContentTypes = $specification->getAllContentTypes();
258+
$unusedSerializers = [];
259+
260+
// JSON serializer is needed if any JSON-based content type is used (RFC 6839)
261+
$needsJsonSerializer = array_reduce(
262+
$allContentTypes,
263+
static fn (bool $carry, string $ct): bool => $carry || ContentType::isJsonBased($ct),
264+
false
271265
);
266+
267+
if (!$needsJsonSerializer) {
268+
$unusedSerializers[] = JsonContentTypeSerializer::class;
269+
}
270+
271+
if (!in_array(VdnApiJsonContentTypeSerializer::MIME_TYPE, $allContentTypes, true)) {
272+
$unusedSerializers[] = VdnApiJsonContentTypeSerializer::class;
273+
}
274+
275+
if (!in_array(FormUrlencodedContentTypeSerializer::MIME_TYPE, $allContentTypes, true)) {
276+
$unusedSerializers[] = FormUrlencodedContentTypeSerializer::class;
277+
}
278+
279+
if (!in_array(XmlContentTypeSerializer::MIME_TYPE, $allContentTypes, true)) {
280+
$unusedSerializers[] = XmlContentTypeSerializer::class;
281+
}
282+
283+
return $unusedSerializers;
272284
}
273285

274286
/**

src/Entity/ContentType.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoclerLabs\ApiClientGenerator\Entity;
6+
7+
use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\FormUrlencodedContentTypeSerializer;
8+
use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\JsonContentTypeSerializer;
9+
use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\VdnApiJsonContentTypeSerializer;
10+
use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\XmlContentTypeSerializer;
11+
12+
final class ContentType
13+
{
14+
private const ALLOWED_CONTENT_TYPES = [
15+
JsonContentTypeSerializer::MIME_TYPE,
16+
FormUrlencodedContentTypeSerializer::MIME_TYPE,
17+
XmlContentTypeSerializer::MIME_TYPE,
18+
VdnApiJsonContentTypeSerializer::MIME_TYPE,
19+
];
20+
21+
private const JSON_SUFFIX = '+json';
22+
23+
/**
24+
* Checks if a content type is supported by the generator.
25+
*
26+
* Supports:
27+
* - Standard content types (application/json, application/xml, etc.)
28+
* - RFC 6839 structured syntax suffixes (+json indicates JSON format)
29+
* - Content types with parameters (charset, version, etc.)
30+
*/
31+
public static function isSupported(string $contentType): bool
32+
{
33+
$normalizedContentType = self::normalize($contentType);
34+
35+
if (in_array($normalizedContentType, self::ALLOWED_CONTENT_TYPES, true)) {
36+
return true;
37+
}
38+
39+
// RFC 6839: +json suffix indicates JSON-based format
40+
return str_ends_with($normalizedContentType, self::JSON_SUFFIX);
41+
}
42+
43+
/**
44+
* Normalizes a content type by removing parameters and converting to lowercase.
45+
*
46+
* Example: "Application/JSON; charset=utf-8" becomes "application/json"
47+
*/
48+
public static function normalize(string $contentType): string
49+
{
50+
return strtolower(trim(explode(';', $contentType)[0]));
51+
}
52+
53+
/**
54+
* Checks if a content type is JSON-based (either standard or +json suffix).
55+
*/
56+
public static function isJsonBased(string $contentType): bool
57+
{
58+
$normalizedContentType = self::normalize($contentType);
59+
60+
return $normalizedContentType === JsonContentTypeSerializer::MIME_TYPE
61+
|| str_ends_with($normalizedContentType, self::JSON_SUFFIX);
62+
}
63+
64+
/**
65+
* Filters an array of content types and returns only unsupported ones.
66+
*
67+
* @param string[] $contentTypes
68+
*
69+
* @return string[]
70+
*/
71+
public static function filterUnsupported(array $contentTypes): array
72+
{
73+
return array_values(
74+
array_filter(
75+
$contentTypes,
76+
static fn (string $contentType): bool => !self::isSupported($contentType)
77+
)
78+
);
79+
}
80+
}

src/Entity/Request.php

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,6 @@
55
namespace DoclerLabs\ApiClientGenerator\Entity;
66

77
use DoclerLabs\ApiClientGenerator\Input\InvalidSpecificationException;
8-
use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\FormUrlencodedContentTypeSerializer;
9-
use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\JsonContentTypeSerializer;
10-
use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\VdnApiJsonContentTypeSerializer;
11-
use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\XmlContentTypeSerializer;
128

139
class Request
1410
{
@@ -36,13 +32,6 @@ class Request
3632
self::HEAD,
3733
];
3834

39-
private const ALLOWED_CONTENT_TYPES = [
40-
JsonContentTypeSerializer::MIME_TYPE,
41-
FormUrlencodedContentTypeSerializer::MIME_TYPE,
42-
XmlContentTypeSerializer::MIME_TYPE,
43-
VdnApiJsonContentTypeSerializer::MIME_TYPE,
44-
];
45-
4635
public function __construct(
4736
public readonly string $path,
4837
public readonly string $method,
@@ -55,7 +44,8 @@ public function __construct(
5544
);
5645
}
5746

58-
$unsupportedContentTypes = array_diff($bodyContentTypes, Request::ALLOWED_CONTENT_TYPES);
47+
$unsupportedContentTypes = ContentType::filterUnsupported($bodyContentTypes);
48+
5949
if (!empty($unsupportedContentTypes)) {
6050
throw new InvalidSpecificationException(
6151
sprintf('Request content-type %s is not currently supported.', json_encode($unsupportedContentTypes))

src/Entity/Response.php

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,16 @@
55
namespace DoclerLabs\ApiClientGenerator\Entity;
66

77
use DoclerLabs\ApiClientGenerator\Input\InvalidSpecificationException;
8-
use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\FormUrlencodedContentTypeSerializer;
9-
use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\JsonContentTypeSerializer;
10-
use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\VdnApiJsonContentTypeSerializer;
11-
use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\XmlContentTypeSerializer;
128

139
class Response
1410
{
15-
private const ALLOWED_CONTENT_TYPES = [
16-
JsonContentTypeSerializer::MIME_TYPE,
17-
FormUrlencodedContentTypeSerializer::MIME_TYPE,
18-
XmlContentTypeSerializer::MIME_TYPE,
19-
VdnApiJsonContentTypeSerializer::MIME_TYPE,
20-
];
21-
2211
public function __construct(
2312
public readonly int $statusCode,
2413
public readonly ?Field $body = null,
2514
public readonly array $bodyContentTypes = []
2615
) {
27-
$unsupportedContentTypes = array_diff($bodyContentTypes, self::ALLOWED_CONTENT_TYPES);
16+
$unsupportedContentTypes = ContentType::filterUnsupported($bodyContentTypes);
17+
2818
if (!empty($unsupportedContentTypes)) {
2919
throw new InvalidSpecificationException(
3020
sprintf('Response content-type %s is not currently supported.', json_encode($unsupportedContentTypes))

src/Generator/ServiceProviderGenerator.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use DoclerLabs\ApiClientGenerator\Ast\Builder\CodeBuilder;
99
use DoclerLabs\ApiClientGenerator\Ast\Builder\MethodBuilder;
1010
use DoclerLabs\ApiClientGenerator\Ast\PhpVersion;
11+
use DoclerLabs\ApiClientGenerator\Entity\ContentType;
1112
use DoclerLabs\ApiClientGenerator\Entity\Field;
1213
use DoclerLabs\ApiClientGenerator\Generator\Implementation\ContainerImplementationStrategy;
1314
use DoclerLabs\ApiClientGenerator\Generator\Implementation\HttpMessageImplementationStrategy;
@@ -192,7 +193,14 @@ private function generateBodySerializerClosure(Specification $specification): Cl
192193
$serializer = $this->builder->new('BodySerializer');
193194
$allContentTypes = $specification->getAllContentTypes();
194195

195-
if (in_array(JsonContentTypeSerializer::MIME_TYPE, $allContentTypes, true)) {
196+
// Register JSON serializer if any JSON-based content type is used (RFC 6839)
197+
$needsJsonSerializer = array_reduce(
198+
$allContentTypes,
199+
static fn (bool $carry, string $ct): bool => $carry || ContentType::isJsonBased($ct),
200+
false
201+
);
202+
203+
if ($needsJsonSerializer) {
196204
$serializer = $this->builder->methodCall(
197205
$serializer,
198206
'add',

src/Output/Copy/Serializer/BodySerializer.php

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
use DoclerLabs\ApiClientException\UnexpectedResponseException;
99
use DoclerLabs\ApiClientGenerator\Output\Copy\Request\RequestInterface;
1010
use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\ContentTypeSerializerInterface;
11+
use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\JsonContentTypeSerializer;
1112
use InvalidArgumentException;
1213
use Psr\Http\Message\ResponseInterface;
1314
use Throwable;
1415

1516
class BodySerializer
1617
{
18+
private const JSON_SUFFIX = '+json';
19+
1720
/** @var ContentTypeSerializerInterface[] */
1821
private $contentTypeSerializers = [];
1922

@@ -60,18 +63,39 @@ public function unserializeResponse(ResponseInterface $response): array
6063

6164
private function getContentTypeSerializer(string $contentType): ContentTypeSerializerInterface
6265
{
63-
$contentType = strtolower(trim(explode(';', $contentType)[0]));
64-
65-
if (!isset($this->contentTypeSerializers[$contentType])) {
66-
throw new InvalidArgumentException(
67-
sprintf(
68-
'Serializer for `%s` is not found. Supported: %s',
69-
$contentType,
70-
json_encode(array_keys($this->contentTypeSerializers))
71-
)
72-
);
66+
$normalizedContentType = $this->normalizeContentType($contentType);
67+
68+
if (isset($this->contentTypeSerializers[$normalizedContentType])) {
69+
return $this->contentTypeSerializers[$normalizedContentType];
70+
}
71+
72+
// RFC 6839: +json suffix indicates JSON-based format, fall back to JSON serializer
73+
if ($this->isJsonBasedContentType($normalizedContentType)) {
74+
return $this->contentTypeSerializers[JsonContentTypeSerializer::MIME_TYPE];
7375
}
7476

75-
return $this->contentTypeSerializers[$contentType];
77+
throw new InvalidArgumentException(
78+
sprintf(
79+
'Serializer for `%s` is not found. Supported: %s',
80+
$normalizedContentType,
81+
json_encode(array_keys($this->contentTypeSerializers))
82+
)
83+
);
84+
}
85+
86+
private function normalizeContentType(string $contentType): string
87+
{
88+
return strtolower(trim(explode(';', $contentType)[0]));
89+
}
90+
91+
private function isJsonBasedContentType(string $normalizedContentType): bool
92+
{
93+
return $this->endsWith($normalizedContentType, self::JSON_SUFFIX)
94+
&& isset($this->contentTypeSerializers[JsonContentTypeSerializer::MIME_TYPE]);
95+
}
96+
97+
private function endsWith(string $haystack, string $needle): bool
98+
{
99+
return $needle === '' || substr($haystack, -strlen($needle)) === $needle;
76100
}
77101
}

0 commit comments

Comments
 (0)