1use anyhow::Result;
8use std::collections::HashMap;
9
10#[derive(Debug, Clone)]
12pub struct DtoField {
13 pub name: String,
14 pub php_doc: String,
15 pub rust_type: String,
16 pub optional: bool,
17 pub description: String,
18}
19
20#[derive(Debug, Clone)]
22pub struct DtoDefinition {
23 pub name: String,
24 pub kind: String,
25 pub fields: Vec<DtoField>,
26}
27
28pub struct PhpDtoGenerator {
30 metadata: Vec<DtoDefinition>,
31}
32
33impl PhpDtoGenerator {
34 #[must_use]
36 pub fn new() -> Self {
37 Self {
38 metadata: Self::default_metadata(),
39 }
40 }
41
42 #[must_use]
44 pub const fn with_metadata(metadata: Vec<DtoDefinition>) -> Self {
45 Self { metadata }
46 }
47
48 pub fn generate_all(&self) -> Result<HashMap<String, String>> {
52 let mut generated = HashMap::new();
53
54 for definition in &self.metadata {
55 let kind = definition.kind.as_str();
56 let code = match kind {
57 "request" => self.render_request(definition)?,
58 "response" => self.render_response(definition)?,
59 _ => continue,
60 };
61
62 let filename = format!("{}.php", definition.name);
63 generated.insert(filename, code);
64 }
65
66 Ok(generated)
67 }
68
69 fn render_request(&self, definition: &DtoDefinition) -> Result<String> {
71 let headers_doc = self.php_doc_for(&definition.fields, "headers");
72 let cookies_doc = self.php_doc_for(&definition.fields, "cookies");
73 let query_doc = self.php_doc_for(&definition.fields, "raw_query_params");
74 let path_doc = self.php_doc_for(&definition.fields, "path_params");
75 let files_doc = self.php_doc_for(&definition.fields, "files");
76 let raw_query_doc = self.php_doc_for(&definition.fields, "raw_query_params");
77 let dependencies_doc = self.php_doc_for(&definition.fields, "dependencies");
78
79 Ok(format!(
80 r"<?php
81
82declare(strict_types=1);
83
84namespace Spikard\Generated;
85
86use Spikard\DI\ResolvedDependencies;
87
88final class Request
89{{
90 public function __construct(
91 public readonly string $method,
92 public readonly string $path,
93 public readonly mixed $body,
94 {headers_doc}public readonly array $headers = [],
95 {cookies_doc}public readonly array $cookies = [],
96 {query_doc}public readonly array $queryParams = [],
97 {path_doc}public readonly array $pathParams = [],
98 {files_doc}public readonly array $files = [],
99 public readonly ?string $rawBody = null,
100 {raw_query_doc}public readonly ?array $rawQueryParams = null,
101 {dependencies_doc}public readonly ?ResolvedDependencies $dependencies = null,
102 ) {{
103 }}
104
105 /** @param array<string, mixed> $options */
106 public static function fromHttp(string $method, string $path, array $options = []): self
107 {{
108 $headers = self::normalizeStringMap($options['headers'] ?? []);
109 $cookies = self::normalizeStringMap($options['cookies'] ?? []);
110 $files = self::normalizeMixedMap($options['files'] ?? []);
111 $queryParams = self::parseQueryParams($path);
112 $pathOnly = \explode('?', $path, 2)[0];
113 $body = $options['body'] ?? null;
114
115 if ($body === null && $files !== []) {{
116 $body = $files;
117 }}
118
119 $rawBody = \is_string($body)
120 ? $body
121 : ((\is_scalar($body) && !\is_bool($body)) ? (string) $body : null);
122
123 return new self(
124 method: \strtoupper($method),
125 path: $pathOnly,
126 body: $body,
127 headers: $headers,
128 cookies: $cookies,
129 queryParams: $queryParams,
130 pathParams: self::normalizeStringMap($options['pathParams'] ?? []),
131 files: $files,
132 rawBody: $rawBody,
133 rawQueryParams: $queryParams,
134 dependencies: $options['dependencies'] ?? null,
135 );
136 }}
137
138 public function query(string $name): ?string
139 {{
140 $values = $this->queryParams[$name] ?? $this->rawQueryParams[$name] ?? null;
141 if (\is_array($values)) {{
142 foreach ($values as $value) {{
143 if (\is_string($value)) {{
144 return $value;
145 }}
146 }}
147 }}
148
149 return null;
150 }}
151
152 /** @return array<string, string> */
153 private static function normalizeStringMap(mixed $input): array
154 {{
155 if (!\is_array($input)) {{
156 return [];
157 }}
158
159 $normalized = [];
160 foreach ($input as $key => $value) {{
161 if (!\is_string($key) || (!\is_string($value) && !\is_numeric($value))) {{
162 continue;
163 }}
164 $normalized[$key] = (string) $value;
165 }}
166
167 return $normalized;
168 }}
169
170 /** @return array<string, mixed> */
171 private static function normalizeMixedMap(mixed $input): array
172 {{
173 if (!\is_array($input)) {{
174 return [];
175 }}
176
177 $normalized = [];
178 foreach ($input as $key => $value) {{
179 if (!\is_string($key)) {{
180 continue;
181 }}
182 $normalized[$key] = $value;
183 }}
184
185 return $normalized;
186 }}
187
188 /** @return array<string, array<int, string>> */
189 private static function parseQueryParams(string $path): array
190 {{
191 $parsed = \parse_url($path, PHP_URL_QUERY);
192 if (!\is_string($parsed) || $parsed === '') {{
193 return [];
194 }}
195
196 $result = [];
197 foreach (\explode('&', $parsed) as $pair) {{
198 if ($pair === '') {{
199 continue;
200 }}
201
202 [$rawKey, $rawValue] = \array_pad(\explode('=', $pair, 2), 2, '');
203 $key = \urldecode($rawKey);
204 $value = \urldecode($rawValue);
205
206 if ($key === '') {{
207 continue;
208 }}
209
210 if (!\array_key_exists($key, $result)) {{
211 $result[$key] = [];
212 }}
213
214 $result[$key][] = $value;
215 }}
216
217 return $result;
218 }}
219}}
220"
221 ))
222 }
223
224 fn render_response(&self, definition: &DtoDefinition) -> Result<String> {
226 let headers_doc = self.php_doc_for(&definition.fields, "headers");
227 let cookies_doc = self.php_doc_for(&definition.fields, "cookies");
228
229 Ok(format!(
230 r"<?php
231
232declare(strict_types=1);
233
234namespace Spikard\Generated;
235
236final class Response
237{{
238 public function __construct(
239 public readonly mixed $body = null,
240 public readonly int $statusCode = 200,
241 {headers_doc}public readonly array $headers = [],
242 {cookies_doc}public readonly array $cookies = [],
243 ) {{
244 }}
245
246 /** @param array<string, string> $headers */
247 public static function json(mixed $data, int $status = 200, array $headers = []): self
248 {{
249 $mergedHeaders = \array_merge(['Content-Type' => 'application/json'], $headers);
250 return new self(body: $data, statusCode: $status, headers: $mergedHeaders);
251 }}
252
253 /** @param array<string, string> $headers */
254 public static function text(string $body, int $status = 200, array $headers = []): self
255 {{
256 $mergedHeaders = \array_merge(['Content-Type' => 'text/plain; charset=utf-8'], $headers);
257 return new self(body: $body, statusCode: $status, headers: $mergedHeaders);
258 }}
259
260 /** @param array<string, string> $cookies */
261 public function withCookies(array $cookies): self
262 {{
263 return new self(
264 body: $this->body,
265 statusCode: $this->statusCode,
266 headers: $this->headers,
267 cookies: $cookies
268 );
269 }}
270
271 public function getStatus(): int
272 {{
273 return $this->statusCode;
274 }}
275
276 public function getStatusCode(): int
277 {{
278 return $this->statusCode;
279 }}
280
281 public function getBody(): string
282 {{
283 if (\is_string($this->body)) {{
284 return $this->body;
285 }}
286
287 return (string) \json_encode($this->body);
288 }}
289
290 /** @return array<string, string> */
291 public function getHeaders(): array
292 {{
293 return $this->headers;
294 }}
295
296 /**
297 * Convenience accessor to decode JSON body when returned as a string.
298 *
299 * @return array<string, mixed>|null
300 */
301 public function jsonBody(): ?array
302 {{
303 if (\is_array($this->body)) {{
304 return $this->body;
305 }}
306
307 if (\is_string($this->body)) {{
308 $decoded = \json_decode($this->body, true);
309 if (\is_array($decoded)) {{
310 return $decoded;
311 }}
312 }}
313
314 return null;
315 }}
316
317 public function __call(string $name, array $args): mixed
318 {{
319 if ($name === 'json') {{
320 return $this->jsonBody();
321 }}
322
323 throw new \BadMethodCallException('Undefined method ' . __CLASS__ . '::' . $name);
324 }}
325}}
326"
327 ))
328 }
329
330 fn php_doc_for(&self, fields: &[DtoField], name: &str) -> String {
332 for field in fields {
333 if field.name == name {
334 let doc = field.php_doc.trim();
335 if !doc.is_empty() {
336 return format!("/** @var {doc} */\n ");
337 }
338 }
339 }
340 String::new()
341 }
342
343 fn default_metadata() -> Vec<DtoDefinition> {
345 vec![
346 DtoDefinition {
347 name: "Request".to_string(),
348 kind: "request".to_string(),
349 fields: vec![
350 DtoField {
351 name: "method".to_string(),
352 php_doc: "string".to_string(),
353 rust_type: "String".to_string(),
354 optional: false,
355 description: "HTTP method in uppercase form".to_string(),
356 },
357 DtoField {
358 name: "path".to_string(),
359 php_doc: "string".to_string(),
360 rust_type: "String".to_string(),
361 optional: false,
362 description: "Route path with query stripped".to_string(),
363 },
364 DtoField {
365 name: "path_params".to_string(),
366 php_doc: "array<string, string>".to_string(),
367 rust_type: "HashMap<String, String>".to_string(),
368 optional: false,
369 description: "Resolved path parameters".to_string(),
370 },
371 DtoField {
372 name: "query_params".to_string(),
373 php_doc: "mixed".to_string(),
374 rust_type: "serde_json::Value".to_string(),
375 optional: false,
376 description: "Parsed query params preserving typed JSON".to_string(),
377 },
378 DtoField {
379 name: "raw_query_params".to_string(),
380 php_doc: "array<string, array<int, string>>".to_string(),
381 rust_type: "HashMap<String, Vec<String>>".to_string(),
382 optional: false,
383 description: "Lossless multi-map query parameters".to_string(),
384 },
385 DtoField {
386 name: "body".to_string(),
387 php_doc: "mixed".to_string(),
388 rust_type: "serde_json::Value".to_string(),
389 optional: false,
390 description: "Validated JSON body".to_string(),
391 },
392 DtoField {
393 name: "raw_body".to_string(),
394 php_doc: "string|null".to_string(),
395 rust_type: "Option<Vec<u8>>".to_string(),
396 optional: true,
397 description: "Raw request body bytes when available".to_string(),
398 },
399 DtoField {
400 name: "headers".to_string(),
401 php_doc: "array<string, string>".to_string(),
402 rust_type: "HashMap<String, String>".to_string(),
403 optional: false,
404 description: "Normalized header map (lowercase keys)".to_string(),
405 },
406 DtoField {
407 name: "cookies".to_string(),
408 php_doc: "array<string, string>".to_string(),
409 rust_type: "HashMap<String, String>".to_string(),
410 optional: false,
411 description: "Incoming cookies".to_string(),
412 },
413 DtoField {
414 name: "files".to_string(),
415 php_doc: "array<string, mixed>".to_string(),
416 rust_type: "HashMap<String, Value>".to_string(),
417 optional: false,
418 description: "Multipart form/file uploads".to_string(),
419 },
420 DtoField {
421 name: "dependencies".to_string(),
422 php_doc: "ResolvedDependencies|null".to_string(),
423 rust_type: "Option<ResolvedDependencies>".to_string(),
424 optional: true,
425 description: "Dependency injection payload".to_string(),
426 },
427 ],
428 },
429 DtoDefinition {
430 name: "Response".to_string(),
431 kind: "response".to_string(),
432 fields: vec![
433 DtoField {
434 name: "status".to_string(),
435 php_doc: "int".to_string(),
436 rust_type: "u16".to_string(),
437 optional: false,
438 description: "HTTP status code".to_string(),
439 },
440 DtoField {
441 name: "body".to_string(),
442 php_doc: "mixed".to_string(),
443 rust_type: "serde_json::Value".to_string(),
444 optional: true,
445 description: "Response body as structured JSON".to_string(),
446 },
447 DtoField {
448 name: "headers".to_string(),
449 php_doc: "array<string, string>".to_string(),
450 rust_type: "HashMap<String, String>".to_string(),
451 optional: false,
452 description: "Outgoing headers".to_string(),
453 },
454 DtoField {
455 name: "cookies".to_string(),
456 php_doc: "array<string, string>".to_string(),
457 rust_type: "HashMap<String, String>".to_string(),
458 optional: false,
459 description: "Outgoing cookies".to_string(),
460 },
461 ],
462 },
463 ]
464 }
465}
466
467impl Default for PhpDtoGenerator {
468 fn default() -> Self {
469 Self::new()
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn test_generates_request_dto() {
479 let generator = PhpDtoGenerator::new();
480 let code = generator.render_request(&generator.metadata[0]).unwrap();
481
482 assert!(code.contains("namespace Spikard\\Generated;"));
483 assert!(code.contains("final class Request"));
484 assert!(code.contains("public readonly string $method"));
485 assert!(code.contains("public readonly string $path"));
486 assert!(code.contains("public readonly mixed $body"));
487 }
488
489 #[test]
490 fn test_generates_response_dto() {
491 let generator = PhpDtoGenerator::new();
492 let code = generator.render_response(&generator.metadata[1]).unwrap();
493
494 assert!(code.contains("namespace Spikard\\Generated;"));
495 assert!(code.contains("final class Response"));
496 assert!(code.contains("public readonly mixed $body"));
497 assert!(code.contains("public readonly int $statusCode"));
498 }
499
500 #[test]
501 fn test_generate_all_returns_both_dtos() {
502 let generator = PhpDtoGenerator::new();
503 let generated = generator.generate_all().unwrap();
504
505 assert!(generated.contains_key("Request.php"));
506 assert!(generated.contains_key("Response.php"));
507 assert_eq!(generated.len(), 2);
508 }
509}