88use CuyZ \Valinor \Mapper \Http \FromQuery ;
99use CuyZ \Valinor \Mapper \Http \FromRoute ;
1010use CuyZ \Valinor \Mapper \Http \HttpRequest ;
11- use CuyZ \Valinor \Mapper \Tree \Exception \CannotMapHttpRequestElement ;
1211use CuyZ \Valinor \Mapper \Tree \Exception \CannotMapHttpRequestToUnsealedShapedArray ;
1312use CuyZ \Valinor \Mapper \Tree \Exception \CannotUseBothFromBodyAttributes ;
1413use 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 ;
1518use CuyZ \Valinor \Mapper \Tree \Shell ;
1619use CuyZ \Valinor \Type \Types \ShapedArrayType ;
20+ use CuyZ \Valinor \Type \Types \UnresolvableType ;
1721
1822use 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 */
2426final 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