Skip to content

Commit 305fd28

Browse files
authored
Merge pull request #26 from xp-forge/feature/conversions
Add possibility to convert input parameters via annotations
2 parents 6e58635 + 30db324 commit 305fd28

File tree

11 files changed

+316
-26
lines changed

11 files changed

+316
-26
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php namespace web\rest;
2+
3+
/** @test web.rest.unittest.ConversionsTest */
4+
interface Conversion {
5+
6+
/**
7+
* Converts a given input value.
8+
*
9+
* @param var $input
10+
* @return var
11+
*/
12+
public function convert($input);
13+
}

src/main/php/web/rest/Delegate.class.php

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,34 @@ public function __construct($instance, $method, $source) {
3939
$this->method= $method instanceof Method ? $method : Reflection::type($instance)->method($method);
4040
foreach ($this->method->parameters() as $param) {
4141

42-
// Source explicitely set by annotation
42+
// Check for source being explicitely set by annotation
43+
$accessor= null;
44+
$conversions= [];
4345
foreach ($param->annotations() as $annotation) {
44-
if ($accessor= self::$SOURCES[$annotation->name()] ?? null) {
45-
$this->param($param, $annotation->argument(0) ?? $param->name(), $accessor);
46-
continue 2;
46+
if (null === $accessor && $accessor= self::$SOURCES[$annotation->name()] ?? null) {
47+
$name= $annotation->argument(0) ?? $param->name();
48+
} else if ($annotation->is(Conversion::class)) {
49+
$conversions[]= $annotation->newInstance();
4750
}
4851
}
4952

50-
// Source derived from parameter type
51-
$type= $param->constraint()->type();
52-
if (Type::$VAR === $type) {
53-
// NOOP
54-
} else if ($type->isAssignableFrom(InputStream::class)) {
55-
$source= 'stream';
56-
} else if ($type->isAssignableFrom(Request::class)) {
57-
$source= 'request';
53+
// Otherwise try to derive source from parameter type, falling
54+
// back to the one supplied via constructor parameter.
55+
if (null === $accessor) {
56+
$name= $param->name();
57+
$type= $param->constraint()->type();
58+
if (Type::$VAR === $type) {
59+
goto supplied;
60+
} else if ($type->isAssignableFrom(InputStream::class)) {
61+
$accessor= self::$SOURCES['stream'];
62+
} else if ($type->isAssignableFrom(Request::class)) {
63+
$accessor= self::$SOURCES['request'];
64+
} else {
65+
supplied: $accessor= self::$SOURCES[$source];
66+
}
5867
}
59-
$this->param($param, $param->name(), self::$SOURCES[$source]);
68+
69+
$this->param($param, $name, $accessor, $conversions);
6070
}
6171
}
6272

@@ -66,10 +76,11 @@ public function __construct($instance, $method, $source) {
6676
* @param lang.reflection.Parameter $param
6777
* @param string $name
6878
* @param function(web.Request, web.rest.format.EntityFormat, string): var $accessor
79+
* @param web.rest.Conversion[] $conversions
6980
* @return void
7081
* @throws lang.IllegalArgumentException
7182
*/
72-
private function param($param, $name, $accessor) {
83+
private function param($param, $name, $accessor, $conversions= []) {
7384
if ($param->optional()) {
7485
$default= $param->default();
7586
$read= function($req, $format) use($accessor, $name, $default) {
@@ -83,7 +94,7 @@ private function param($param, $name, $accessor) {
8394
return $value;
8495
};
8596
}
86-
$this->params[$name]= ['type' => $param->constraint()->type(), 'read' => $read];
97+
$this->params[$name]= ['type' => $param->constraint()->type(), 'read' => $read, 'conv' => $conversions];
8798
}
8899

89100
/** @return string */
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php namespace web\rest;
2+
3+
/** @see https://doriantaylor.com/policy/http-url-path-parameter-syntax */
4+
class Matrix implements Conversion {
5+
6+
/**
7+
* Converts a given input value.
8+
*
9+
* @param var $input
10+
* @return var
11+
*/
12+
public function convert($input) {
13+
if (is_string($input)) {
14+
$matrix= [];
15+
for ($o= 0, $l= strlen($input); $o < $l; $o+= $s + 1) {
16+
$s= strcspn($input, ';', $o);
17+
if (1 === sscanf(substr($input, $o, $s), '%[^=]=%[^;]', $key, $value)) {
18+
$matrix[$key]= null;
19+
} else {
20+
$matrix[$key]= false === strpos($value, ',') ? $value : explode(',', $value);
21+
}
22+
}
23+
return $matrix;
24+
}
25+
return $input;
26+
}
27+
}

src/main/php/web/rest/RestApi.class.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,11 @@ public function handle($req, $res) {
110110
try {
111111
$args= [];
112112
foreach ($delegate->params() as $name => $definition) {
113-
if (isset($matches[$name])) {
114-
$args[]= $this->marshalling->unmarshal($matches[$name], $definition['type']);
115-
} else {
116-
$args[]= $this->marshalling->unmarshal($definition['read']($req, $in), $definition['type']);
113+
$value= $matches[$name] ?? $definition['read']($req, $in);
114+
foreach ($definition['conv'] as $conversion) {
115+
$value= $conversion->convert($value);
117116
}
117+
$args[]= $this->marshalling->unmarshal($value, $definition['type']);
118118
}
119119
} catch (IllegalArgumentException $e) {
120120
return $this->transmit($res, Response::error(400, $e), $out);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php namespace web\rest;
2+
3+
class SeparatedBy implements Conversion {
4+
private $separator;
5+
6+
/** @param string $separator */
7+
public function __construct($separator) {
8+
$this->separator= $separator;
9+
}
10+
11+
/**
12+
* Converts a given input value.
13+
*
14+
* @param var $input
15+
* @return var
16+
*/
17+
public function convert($input) {
18+
return is_string($input) ? explode($this->separator, $input) : $input;
19+
}
20+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php namespace web\rest\unittest;
2+
3+
use lang\IllegalArgumentException;
4+
use web\rest\Conversion;
5+
6+
class Branches implements Conversion {
7+
8+
public function convert($input) {
9+
if (2 === sscanf($input, '%[^.]...%[^.]', $lo, $hi)) {
10+
return [$lo, $hi];
11+
}
12+
throw new IllegalArgumentException('Malformed input "'. $input.'"');
13+
}
14+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php namespace web\rest\unittest;
2+
3+
use test\{Assert, Test, Values};
4+
use web\rest\{RestApi, Get, Param, SeparatedBy, Matrix};
5+
6+
class ConversionsTest extends RunTest {
7+
8+
#[Test, Values([['', '[]'], ['select=one', '["one"]'], ['select=one,two', '["one","two"]'], ['select[]=one', '["one"]'], ['select[]=one&select[]=two', '["one","two"]']])]
9+
public function array_separated_by($query, $output) {
10+
$api= new class() {
11+
12+
#[Get('/')]
13+
public function test(
14+
#[Param, SeparatedBy(',')]
15+
array $select= []
16+
) {
17+
return $select;
18+
}
19+
};
20+
21+
$this->assertPayload(
22+
200,
23+
self::JSON,
24+
$output,
25+
$this->run(new RestApi($api), 'GET', '/?'.$query)
26+
);
27+
}
28+
29+
#[Test]
30+
public function generic_range_separated_by() {
31+
$api= new class() {
32+
33+
/** @param web.rest.unittest.Range<int> $pages */
34+
#[Get('/')]
35+
public function test(
36+
#[Param, SeparatedBy('..')]
37+
$pages
38+
) {
39+
return $pages;
40+
}
41+
};
42+
43+
$this->assertPayload(
44+
200,
45+
self::JSON,
46+
'{"begin":1,"end":10}',
47+
$this->run(new RestApi($api), 'GET', '/?pages=1..10')
48+
);
49+
}
50+
51+
#[Test, Values([['uid=0', '{"uid":"0"}'], ['variants=a%26b;owned=true;colors=green,blue', '{"variants":"a&b","owned":"true","colors":["green","blue"]}']])]
52+
public function matrix_parameter($path, $output) {
53+
$api= new class() {
54+
55+
#[Get('/{filter}')]
56+
public function test(
57+
#[Matrix]
58+
array $filter= []
59+
) {
60+
return $filter;
61+
}
62+
};
63+
64+
$this->assertPayload(
65+
200,
66+
self::JSON,
67+
$output,
68+
$this->run(new RestApi($api), 'GET', '/'.$path)
69+
);
70+
}
71+
72+
#[Test]
73+
public function conversions_can_be_combined_with_type_hinting() {
74+
$api= new class() {
75+
76+
#[Get('/{filter}/authors')]
77+
public function test(
78+
#[Matrix]
79+
Filters $filter
80+
) {
81+
return $filter;
82+
}
83+
};
84+
85+
$this->assertPayload(
86+
200,
87+
self::JSON,
88+
'{"status":"COMPLETED","orgunits":["a","b"]}',
89+
$this->run(new RestApi($api), 'GET', '/status=COMPLETED;orgunits=a,b/authors')
90+
);
91+
}
92+
93+
#[Test]
94+
public function branches() {
95+
$api= new class() {
96+
97+
#[Get('/compare/{branches}')]
98+
public function test(
99+
#[Branches]
100+
array $branches
101+
) {
102+
return $branches;
103+
}
104+
};
105+
106+
$this->assertPayload(
107+
200,
108+
self::JSON,
109+
'["main","feature"]',
110+
$this->run(new RestApi($api), 'GET', '/compare/main...feature')
111+
);
112+
}
113+
114+
#[Test]
115+
public function exceptions_raised_during_conversion_yield_bad_request() {
116+
$api= new class() {
117+
118+
#[Get('/compare/{branches}')]
119+
public function test(
120+
#[Branches]
121+
array $branches
122+
) {
123+
return $branches;
124+
}
125+
};
126+
127+
$this->assertPayload(
128+
400,
129+
self::JSON,
130+
'{"status":400,"message":"Malformed input \"main\""}',
131+
$this->run(new RestApi($api), 'GET', '/compare/main')
132+
);
133+
}
134+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php namespace web\rest\unittest;
2+
3+
class Filters {
4+
5+
/** @type string */
6+
public $status;
7+
8+
/** @type array<string> */
9+
public $orgunits;
10+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php namespace web\rest\unittest;
2+
3+
use lang\{Generic, IllegalArgumentException};
4+
5+
#[Generic(self: 'T')]
6+
class Range {
7+
private $begin, $end;
8+
9+
/**
10+
* Creates a range from a given two-element array, casting if necessary.
11+
*
12+
* @throws lang.IllegalArgumentException
13+
* @throws lang.ClassCastException
14+
*/
15+
public function __construct(array $range) {
16+
if (2 !== sizeof($range)) {
17+
throw new IllegalArgumentException('Given array must have 2 elements');
18+
}
19+
list($this->begin, $this->end)= array_map([$T, 'cast'], $range);
20+
}
21+
22+
/** Returns begin of range */
23+
#[Generic(return: 'T')]
24+
public function begin() { return $this->begin; }
25+
26+
/** Returns end of range */
27+
#[Generic(return: 'T')]
28+
public function end() { return $this->end; }
29+
}

0 commit comments

Comments
 (0)