Skip to main content

spikard_cli/codegen/
php_dto.rs

1//! PHP DTO generator for Request/Response types from Rust-exported metadata
2//!
3//! This module generates PHP Data Transfer Objects (DTOs) from structured metadata,
4//! typically exported from the Rust ext-php-rs binding. It produces readonly classes
5//! matching the Spikard Request and Response types.
6
7use anyhow::Result;
8use std::collections::HashMap;
9
10/// Field metadata for DTO generation
11#[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/// DTO definition (Request or Response)
21#[derive(Debug, Clone)]
22pub struct DtoDefinition {
23    pub name: String,
24    pub kind: String,
25    pub fields: Vec<DtoField>,
26}
27
28/// PHP DTO generator
29pub struct PhpDtoGenerator {
30    metadata: Vec<DtoDefinition>,
31}
32
33impl PhpDtoGenerator {
34    /// Create a new PHP DTO generator with default Request/Response metadata
35    #[must_use]
36    pub fn new() -> Self {
37        Self {
38            metadata: Self::default_metadata(),
39        }
40    }
41
42    /// Create a PHP DTO generator with custom metadata
43    #[must_use]
44    pub const fn with_metadata(metadata: Vec<DtoDefinition>) -> Self {
45        Self { metadata }
46    }
47
48    /// Generate all DTOs and write to directory
49    ///
50    /// Returns a map of filename -> generated PHP code
51    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    /// Render Request DTO class
70    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    /// Render Response DTO class
225    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    /// Get PHP doc annotation for a specific field
331    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    /// Create default Request and Response metadata
344    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}