Skip to content

Commit 72aa176

Browse files
committed
feat: allow mapping HTTP request without attributes
1 parent 02d82cb commit 72aa176

File tree

8 files changed

+321
-151
lines changed

8 files changed

+321
-151
lines changed

src/Library/Container.php

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,19 +121,18 @@ public function __construct(Settings $settings)
121121
),
122122
);
123123

124-
$builder = new HttpRequestNodeBuilder($builder);
125-
126124
if ($settings->keyConverters !== []) {
127125
$builder = new KeyConverterNodeBuilder(
128126
$builder,
129-
new KeyConverterContainer(
130-
$this->get(FunctionDefinitionRepository::class),
131-
$settings->keyConverters,
132-
),
133-
$settings->exceptionFilter,
127+
$this->get(KeyConverterContainer::class),
134128
);
135129
}
136130

131+
$builder = new HttpRequestNodeBuilder(
132+
$builder,
133+
$this->get(KeyConverterContainer::class),
134+
);
135+
137136
return new ValueConverterNodeBuilder(
138137
$builder,
139138
$this->get(ConverterContainer::class),
@@ -148,6 +147,12 @@ public function __construct(Settings $settings)
148147
$settings->convertersSortedByPriority(),
149148
),
150149

150+
KeyConverterContainer::class => fn () => new KeyConverterContainer(
151+
$this->get(FunctionDefinitionRepository::class),
152+
$settings->keyConverters,
153+
$settings->exceptionFilter,
154+
),
155+
151156
InterfaceInferringContainer::class => fn () => new InterfaceInferringContainer(
152157
new FunctionsContainer(
153158
$this->get(FunctionDefinitionRepository::class),

src/Mapper/Tree/Builder/HttpRequestNodeBuilder.php

Lines changed: 122 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,26 @@
88
use CuyZ\Valinor\Mapper\Http\FromQuery;
99
use CuyZ\Valinor\Mapper\Http\FromRoute;
1010
use CuyZ\Valinor\Mapper\Http\HttpRequest;
11-
use CuyZ\Valinor\Mapper\Tree\Exception\CannotMapHttpRequestElement;
1211
use CuyZ\Valinor\Mapper\Tree\Exception\CannotMapHttpRequestToUnsealedShapedArray;
1312
use CuyZ\Valinor\Mapper\Tree\Exception\CannotUseBothFromBodyAttributes;
1413
use CuyZ\Valinor\Mapper\Tree\Exception\CannotUseBothFromQueryAttributes;
14+
use CuyZ\Valinor\Mapper\Tree\Exception\KeysCollision;
15+
use CuyZ\Valinor\Mapper\Tree\Exception\MissingHttpBodyValue;
16+
use CuyZ\Valinor\Mapper\Tree\Exception\MissingHttpQueryValue;
17+
use CuyZ\Valinor\Mapper\Tree\Exception\MissingHttpRouteValue;
1518
use CuyZ\Valinor\Mapper\Tree\Shell;
1619
use CuyZ\Valinor\Type\Types\ShapedArrayType;
20+
use CuyZ\Valinor\Type\Types\UnresolvableType;
1721

1822
use function array_intersect_key;
19-
use function array_replace;
20-
use function count;
21-
use function is_a;
23+
use function array_key_exists;
24+
use function array_keys;
2225

23-
/** @internal */
2426
final class HttpRequestNodeBuilder implements NodeBuilder
2527
{
2628
public function __construct(
2729
private NodeBuilder $delegate,
30+
private KeyConverterContainer $keyConverterContainer,
2831
) {}
2932

3033
public function build(Shell $shell): Node
@@ -43,126 +46,154 @@ public function build(Shell $shell): Node
4346
throw new CannotMapHttpRequestToUnsealedShapedArray();
4447
}
4548

46-
$routeElements = [];
47-
$queryElements = [];
48-
$bodyElements = [];
49+
$route = $request->routeParameters;
50+
$query = $request->queryParameters;
51+
$body = $request->bodyValues;
52+
$errors = [];
53+
54+
if ($this->keyConverterContainer->hasConverters()) {
55+
// Key converters (e.g. camelCase to snake_case) are applied to all
56+
// three sources independently. Each conversion returns the renamed
57+
// values, a name map for error reporting, and any conversion errors.
58+
[$route, $routeNameMap, $routeErrors] = $this->keyConverterContainer->convert($request->routeParameters);
59+
[$query, $queryNameMap, $queryErrors] = $this->keyConverterContainer->convert($request->queryParameters);
60+
[$body, $bodyNameMap, $bodyErrors] = $this->keyConverterContainer->convert($request->bodyValues);
61+
62+
foreach ([...$routeErrors, ...$queryErrors, ...$bodyErrors] as $key => $error) {
63+
$errors[] = $shell->child((string)$key, UnresolvableType::forInvalidKey())->error($error);
64+
}
65+
66+
$shell = $shell->withNameMap([...$routeNameMap, ...$queryNameMap, ...$bodyNameMap]);
67+
}
68+
69+
$collisions = array_intersect_key($route, $query) + array_intersect_key($route, $body) + array_intersect_key($query, $body);
70+
71+
foreach (array_keys($collisions) as $key) {
72+
$errors[] = $shell->child($key, UnresolvableType::forInvalidKey())->error(new KeysCollision($key, $key)); // @todo double $key
73+
}
4974

50-
$mapAllQueryKey = null;
51-
$mapAllBodyKey = null;
75+
if ($errors !== []) {
76+
return $shell->errors($errors);
77+
}
5278

53-
$values = [];
79+
$elements = [];
80+
$result = [];
81+
82+
$queryAttributes = 0;
83+
$bodyAttributes = 0;
84+
$queryMapAll = false;
85+
$bodyMapAll = false;
5486

5587
foreach ($shell->type->elements as $key => $element) {
5688
$attributes = $element->attributes();
5789

5890
if ($attributes->has(FromRoute::class)) {
59-
$routeElements[$key] = $element;
91+
// This element must be resolved exclusively from route params.
92+
if (array_key_exists($key, $query) || array_key_exists($key, $body)) {
93+
// The value must *NEVER* come from query or body.
94+
unset($query[$key], $body[$key]);
95+
96+
$errors[] = $shell->child($key, $element->type())->error(new MissingHttpRouteValue($key));
97+
} else {
98+
$elements[$key] = $element;
99+
}
60100
} elseif ($attributes->has(FromQuery::class)) {
61-
$queryElements[$key] = $element;
62-
63101
/** @var FromQuery $attribute */
64102
$attribute = $attributes->firstOf(FromQuery::class)->instantiate();
65103

104+
// This element must be resolved exclusively from query
105+
// parameters. When `mapAll` is true, the entire query array is
106+
// mapped to this single element.
66107
if ($attribute->mapAll) {
67-
$mapAllQueryKey = $key;
108+
$queryMapAll = true;
109+
110+
$node = $shell->withType($element->type())->withValue($query)->build();
111+
$query = [];
112+
113+
if ($node->isValid()) {
114+
$result[$element->key()->value()] = $node->value();
115+
} else {
116+
$errors[] = $node;
117+
}
118+
} elseif (array_key_exists($key, $route) || array_key_exists($key, $body)) {
119+
// The value must *NEVER* come from route or body.
120+
unset($route[$key], $body[$key]);
121+
122+
$errors[] = $shell->child($key, $element->type())->error(new MissingHttpQueryValue($key));
123+
} else {
124+
$elements[$key] = $element;
68125
}
69-
} elseif ($attributes->has(FromBody::class)) {
70-
$bodyElements[$key] = $element;
71126

127+
$queryAttributes++;
128+
129+
// No other `#[FromQuery]` element is allowed alongside.
130+
if ($queryMapAll && $queryAttributes > 1) {
131+
throw new CannotUseBothFromQueryAttributes();
132+
}
133+
} elseif ($attributes->has(FromBody::class)) {
72134
/** @var FromBody $attribute */
73135
$attribute = $attributes->firstOf(FromBody::class)->instantiate();
74136

137+
// This element must be resolved exclusively from body values.
138+
// When `mapAll` is true, the entire body array is mapped to
139+
// this single element.
75140
if ($attribute->mapAll) {
76-
$mapAllBodyKey = $key;
141+
$bodyMapAll = true;
142+
143+
$node = $shell->withType($element->type())->withValue($body)->build();
144+
$body = [];
145+
146+
if ($node->isValid()) {
147+
$result[$element->key()->value()] = $node->value();
148+
} else {
149+
$errors[] = $node;
150+
}
151+
} elseif (array_key_exists($key, $route) || array_key_exists($key, $query)) {
152+
// The value must *NEVER* come from route or query.
153+
unset($route[$key], $query[$key]);
154+
155+
$errors[] = $shell->child($key, $element->type())->error(new MissingHttpBodyValue($key));
156+
} else {
157+
$elements[$key] = $element;
77158
}
78-
} elseif ($request->requestObject && is_a($request->requestObject, $element->type()->toString(), true)) {
79-
$values[$key] = $request->requestObject;
80-
} else {
81-
throw new CannotMapHttpRequestElement($key);
82-
}
83-
}
84159

85-
$errors = [];
86-
87-
// -----------------//
88-
// Route parameters //
89-
// -----------------//
90-
$routeNode = $shell
91-
->withType(new ShapedArrayType($routeElements))
92-
->withValue($request->routeParameters)
93-
// Allows converting string values to integers, for example.
94-
->allowScalarValueCasting()
95-
// Some given route parameters might be optional.
96-
->allowSuperfluousKeys()
97-
->build();
98-
99-
if ($routeNode->isValid()) {
100-
$values += $routeNode->value(); // @phpstan-ignore assignOp.invalid (we know value is an array)
101-
} else {
102-
$errors[] = $routeNode;
103-
}
160+
$bodyAttributes++;
104161

105-
// -----------------//
106-
// Query parameters //
107-
// -----------------//
108-
if ($mapAllQueryKey !== null) {
109-
if (count($queryElements) > 1) {
110-
throw new CannotUseBothFromQueryAttributes();
162+
// No other `#[FromBody]` element is allowed alongside.
163+
if ($bodyMapAll && $bodyAttributes > 1) {
164+
throw new CannotUseBothFromBodyAttributes();
165+
}
166+
} elseif ($request->requestObject && $element->type()->accepts($request->requestObject)) {
167+
$result[$key] = $request->requestObject;
168+
} else {
169+
$elements[$key] = $element;
111170
}
112-
113-
$queryType = $queryElements[$mapAllQueryKey]->type();
114-
} else {
115-
$queryType = new ShapedArrayType($queryElements);
116171
}
117172

118-
$queryNode = $shell
119-
->withType($queryType)
120-
->withValue($request->queryParameters)
121-
// Allows converting string values to integers, for example.
122-
->allowScalarValueCasting()
123-
->build();
173+
// Route values may contain extra values, we won't block these.
174+
$shell = $shell->withAllowedSuperfluousKeys(array_keys($route));
124175

125-
if (! $queryNode->isValid()) {
126-
$errors[] = $queryNode;
127-
} elseif ($mapAllQueryKey !== null) {
128-
$values[$mapAllQueryKey] = $queryNode->value();
129-
} else {
130-
$values += $queryNode->value(); // @phpstan-ignore assignOp.invalid (we know value is an array)
176+
if (! $shell->allowScalarValueCasting) {
177+
// Route and query values are all string values, so we enable scalar
178+
// value casting for them.
179+
$shell = $shell->allowScalarValueCastingForChildren(array_keys($route + $query));
131180
}
132181

133-
// ------------//
134-
// Body values //
135-
// ------------//
136-
if ($mapAllBodyKey !== null) {
137-
$bodyType = $bodyElements[$mapAllBodyKey]->type();
138-
} else {
139-
$bodyType = new ShapedArrayType($bodyElements);
140-
}
141-
142-
$bodyNode = $shell
143-
->withType($bodyType)
144-
->withValue($request->bodyValues)
182+
// Build the remaining elements (those not handled by `mapAll` or
183+
// request object injection) using the merged values from all sources.
184+
$node = $shell
185+
->withType(new ShapedArrayType($elements))
186+
->withValue($route + $query + $body)
145187
->build();
146188

147-
if (! $bodyNode->isValid()) {
148-
$errors[] = $bodyNode;
149-
} elseif ($mapAllBodyKey !== null) {
150-
if (count($bodyElements) > 1) {
151-
throw new CannotUseBothFromBodyAttributes();
152-
}
153-
154-
$values[$mapAllBodyKey] = $bodyNode->value();
155-
} else {
156-
$values += $bodyNode->value(); // @phpstan-ignore assignOp.invalid (we know value is an array)
189+
if (! $node->isValid()) {
190+
$errors[] = $node;
157191
}
158192

159193
if ($errors !== []) {
160194
return $shell->errors($errors);
161195
}
162196

163-
// Reorder values to match the original parameter order.
164-
$values = array_replace(array_intersect_key($shell->type->elements, $values), $values);
165-
166-
return $shell->node($values);
197+
return $shell->node($result + $node->value());
167198
}
168199
}

0 commit comments

Comments
 (0)