use anyhow::{Result, bail};
use heck::ToPascalCase;
use serde_json::Value;
use super::base::sanitize_identifier;
use super::{AsyncApiGenerator, ChannelInfo, ChannelMessage};
pub struct PhpAsyncApiGenerator;
impl AsyncApiGenerator for PhpAsyncApiGenerator {
fn generate_test_app(&self, channels: &[ChannelInfo], protocol: &str) -> Result<String> {
let mut code = String::new();
code.push_str("<?php\n");
code.push_str("declare(strict_types=1);\n\n");
code.push_str("/**\n");
code.push_str(" * Test application generated from AsyncAPI specification\n");
code.push_str(&format!(" * Protocol: {protocol}\n"));
code.push_str(" * Generated by Spikard CLI\n");
code.push_str(" */\n\n");
code.push_str("/**\n");
code.push_str(" * Load a JSON fixture file\n");
code.push_str(" * @param string $name Fixture name (without .json extension)\n");
code.push_str(" * @return array<string, mixed>\n");
code.push_str(" */\n");
code.push_str("function loadFixture(string $name): array\n");
code.push_str("{\n");
code.push_str(" $path = __DIR__ . \"/../fixtures/{$name}.json\";\n");
code.push_str(" if (!file_exists($path)) {\n");
code.push_str(" throw new RuntimeException(\"Fixture not found: {$path}\");\n");
code.push_str(" }\n");
code.push_str(" $content = file_get_contents($path);\n");
code.push_str(" if ($content === false) {\n");
code.push_str(" throw new RuntimeException(\"Failed to read fixture: {$path}\");\n");
code.push_str(" }\n");
code.push_str(" /** @var array<string, mixed> $data */\n");
code.push_str(" $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);\n");
code.push_str(" return $data;\n");
code.push_str("}\n\n");
code.push_str("$baseUri = getenv('URI') ?: 'http://localhost:8000';\n\n");
match protocol {
"websocket" | "ws" => {
code.push_str("// WebSocket test client\n");
code.push_str("echo \"Testing WebSocket endpoints...\\n\";\n\n");
for channel in channels {
let fixture_name = sanitize_identifier(&channel.name);
code.push_str(&format!("// Test channel: {}\n", channel.path));
code.push_str(&format!(
"$wsUri = str_replace('http://', 'ws://', $baseUri) . '{}';\n",
channel.path
));
code.push_str("echo \"Connecting to {$wsUri}...\\n\";\n\n");
code.push_str("// TODO: Add WebSocket client library (e.g., ratchet/pawl)\n");
code.push_str("// \\Ratchet\\Client\\connect($wsUri)->then(\n");
code.push_str("// function($conn) {\n");
code.push_str(&format!("// $fixture = loadFixture('{fixture_name}');\n"));
code.push_str("// $conn->send(json_encode($fixture, JSON_THROW_ON_ERROR));\n");
code.push_str("// $conn->on('message', function($msg) use ($conn) {\n");
code.push_str("// echo \"Received: {$msg}\\n\";\n");
code.push_str("// $conn->close();\n");
code.push_str("// });\n");
code.push_str("// },\n");
code.push_str("// function($e) {\n");
code.push_str("// echo \"Could not connect: {$e->getMessage()}\\n\";\n");
code.push_str("// }\n");
code.push_str("// );\n\n");
}
code.push_str("echo \"WebSocket tests complete\\n\";\n");
}
"sse" => {
code.push_str("// SSE test client\n");
code.push_str("echo \"Testing SSE endpoints...\\n\";\n\n");
for channel in channels {
let fixture_name = sanitize_identifier(&channel.name);
code.push_str(&format!("// Test channel: {}\n", channel.path));
code.push_str(&format!("$sseUri = $baseUri . '{}';\n", channel.path));
code.push_str("echo \"Connecting to {$sseUri}...\\n\";\n\n");
code.push_str("// Open SSE stream\n");
code.push_str("$context = stream_context_create([\n");
code.push_str(" 'http' => [\n");
code.push_str(" 'method' => 'GET',\n");
code.push_str(" 'header' => 'Accept: text/event-stream',\n");
code.push_str(" ],\n");
code.push_str("]);\n");
code.push_str("$stream = fopen($sseUri, 'r', false, $context);\n");
code.push_str("if ($stream === false) {\n");
code.push_str(" throw new RuntimeException(\"Failed to open SSE stream\");\n");
code.push_str("}\n\n");
code.push_str("// Read first 5 events\n");
code.push_str("$events = [];\n");
code.push_str("while (count($events) < 5 && !feof($stream)) {\n");
code.push_str(" $line = fgets($stream);\n");
code.push_str(" if ($line !== false && str_starts_with($line, 'data:')) {\n");
code.push_str(" $data = trim(substr($line, 5));\n");
code.push_str(" $events[] = json_decode($data, true, 512, JSON_THROW_ON_ERROR);\n");
code.push_str(" echo \"Received event: {$data}\\n\";\n");
code.push_str(" }\n");
code.push_str("}\n");
code.push_str("fclose($stream);\n\n");
code.push_str("// Load expected fixture for validation\n");
code.push_str(&format!("$expectedFixture = loadFixture('{fixture_name}');\n"));
code.push_str("echo \"Collected \" . count($events) . \" events\\n\\n\";\n");
}
code.push_str("echo \"SSE tests complete\\n\";\n");
}
_ => {
return Err(anyhow::anyhow!("Unsupported protocol for PHP test app: {protocol}"));
}
}
Ok(code)
}
fn generate_handler_app(&self, channels: &[ChannelInfo], protocol: &str) -> Result<String> {
if channels.is_empty() {
bail!("AsyncAPI spec does not define any channels");
}
match protocol {
"websocket" | "ws" | "sse" => {}
other => bail!("Protocol {other} is not supported for PHP handler generation"),
}
let mut code = String::new();
code.push_str("<?php\n");
code.push_str("declare(strict_types=1);\n\n");
code.push_str("/**\n");
code.push_str(" * AsyncAPI handler skeleton generated by Spikard CLI\n");
code.push_str(&format!(" * Protocol: {protocol}\n"));
code.push_str(" * Generated handler classes for each channel\n");
code.push_str(" */\n\n");
code.push_str("use DateTimeImmutable;\n");
code.push_str("use DateTimeInterface;\n");
code.push_str("use JsonException;\n");
code.push_str("use RuntimeException;\n");
match protocol {
"websocket" | "ws" => {
code.push_str("use Spikard\\App;\n");
code.push_str("use Spikard\\Handlers\\WebSocketHandlerInterface;\n\n");
code.push_str(&generate_php_asyncapi_assertions());
for channel in channels {
code.push_str(&generate_channel_message_models(channel));
let class_name = camel_identifier(&channel.name);
let message_description = if channel.messages.is_empty() {
"messages".to_string()
} else {
channel.messages.join(", ")
};
let payload_type = php_channel_payload_type(channel).unwrap_or_else(|| "array".to_string());
code.push_str(&format!("/**\n * WebSocket handler for {}\n", channel.path));
code.push_str(&format!(" * Handles: {message_description}\n */\n"));
code.push_str(&format!(
"final class {class_name}Handler implements WebSocketHandlerInterface\n"
));
code.push_str("{\n");
code.push_str(" /**\n");
code.push_str(" * Called when WebSocket connection is established\n");
code.push_str(" */\n");
code.push_str(" public function onConnect(): void\n");
code.push_str(" {\n");
code.push_str(" // TODO: Initialize connection state\n");
code.push_str(" error_log(\"WebSocket connection established\");\n");
code.push_str(" }\n\n");
code.push_str(" /**\n");
code.push_str(" * Handle incoming WebSocket message\n");
code.push_str(" */\n");
code.push_str(" public function onMessage(string $message): void\n");
code.push_str(" {\n");
code.push_str(" $payload = $this->parseMessage($message);\n");
code.push_str(&format!(
" // TODO: Handle {} received on {}\n",
message_description, channel.path
));
code.push_str(" unset($payload);\n");
code.push_str(" }\n\n");
code.push_str(" /**\n");
code.push_str(" * Called when WebSocket connection closes\n");
code.push_str(" */\n");
code.push_str(" public function onClose(int $code, ?string $reason = null): void\n");
code.push_str(" {\n");
code.push_str(" // TODO: Clean up connection resources\n");
code.push_str(" unset($code, $reason);\n");
code.push_str(" error_log(\"WebSocket connection closed\");\n");
code.push_str(" }\n\n");
code.push_str(" /**\n");
code.push_str(" * @throws JsonException|RuntimeException\n");
code.push_str(" */\n");
code.push_str(&format!(
" private function parseMessage(string $message): {payload_type}\n"
));
code.push_str(" {\n");
code.push_str(" $decoded = json_decode($message, true, 512, JSON_THROW_ON_ERROR);\n");
code.push_str(" if (!is_array($decoded)) {\n");
code.push_str(" throw new RuntimeException('Expected JSON object payload');\n");
code.push_str(" }\n");
code.push_str(" /** @var array<string, mixed> $payload */\n");
code.push_str(" $payload = $decoded;\n");
match channel.message_definitions.as_slice() {
[] => {
code.push_str(" return $payload;\n");
}
[message] => {
let payload_class = php_message_type_name(channel, message);
code.push_str(&format!(" return {payload_class}::fromArray($payload);\n"));
}
messages => {
for message in messages.iter().filter(|message| message.schema.is_some()) {
let payload_class = php_message_type_name(channel, message);
code.push_str(&format!(" if ({payload_class}::matches($payload)) {{\n"));
code.push_str(&format!(" return {payload_class}::fromArray($payload);\n"));
code.push_str(" }\n");
}
code.push_str(&format!(
" throw new RuntimeException('Unsupported message payload for {}');\n",
channel.path
));
}
}
code.push_str(" }\n");
code.push_str("}\n\n");
}
}
"sse" => {
code.push_str("use Generator;\n");
code.push_str("use Spikard\\App;\n");
code.push_str("use Spikard\\Handlers\\SseEventProducerInterface;\n\n");
code.push_str(&generate_php_asyncapi_assertions());
for channel in channels {
code.push_str(&generate_channel_message_models(channel));
let class_name = camel_identifier(&channel.name);
let message_description = if channel.messages.is_empty() {
"events".to_string()
} else {
channel.messages.join(", ")
};
code.push_str(&format!("/**\n * SSE event producer for {}\n", channel.path));
code.push_str(&format!(" * Produces: {message_description}\n */\n"));
code.push_str(&format!(
"final class {class_name}Producer implements SseEventProducerInterface\n"
));
code.push_str("{\n");
code.push_str(" /**\n");
code.push_str(" * Produce SSE events\n");
code.push_str(" * @return Generator<int, string, mixed, void>\n");
code.push_str(" */\n");
code.push_str(" public function __invoke(): Generator\n");
code.push_str(" {\n");
code.push_str(&format!(
" // TODO: Implement event generation logic for {}\n",
channel.path
));
if let Some(message) = channel
.message_definitions
.iter()
.find(|message| message.schema.is_some())
{
let payload_class = php_message_type_name(channel, message);
code.push_str(&format!(" $event = {payload_class}::example();\n"));
code.push_str(
" yield \"data: \" . json_encode($event->toArray(), JSON_THROW_ON_ERROR) . \"\\n\\n\";\n",
);
} else {
code.push_str(&format!(
" yield \"data: \" . json_encode(['channel' => '{}', 'event' => 'replace-me'], JSON_THROW_ON_ERROR) . \"\\n\\n\";\n",
channel.path
));
}
code.push_str(" }\n");
code.push_str("}\n\n");
}
}
_ => {}
}
code.push_str("/**\n");
code.push_str(" * Helper class to register all AsyncAPI handlers with the application\n");
code.push_str(" */\n");
code.push_str("final class AsyncApiHandlers\n{\n");
code.push_str(" /**\n");
code.push_str(" * Register all AsyncAPI handlers with the Spikard application\n");
code.push_str(" * @param object $app Spikard\\App instance\n");
code.push_str(" */\n");
code.push_str(" public static function register(object $app): void\n");
code.push_str(" {\n");
code.push_str(" if (!$app instanceof App) {\n");
code.push_str(" throw new RuntimeException('Expected Spikard\\\\App instance');\n");
code.push_str(" }\n");
for channel in channels {
let class_name = camel_identifier(&channel.name);
match protocol {
"websocket" | "ws" => {
code.push_str(&format!(
" $app = $app->addWebSocket('{}', new {}Handler());\n",
channel.path, class_name
));
}
"sse" => {
code.push_str(&format!(
" $app = $app->addSse('{}', new {}Producer());\n",
channel.path, class_name
));
}
_ => {}
}
}
code.push_str(" unset($app);\n");
code.push_str(" }\n");
code.push_str("}\n\n");
code.push_str("// Application entry point\n");
code.push_str("$script = $_SERVER['SCRIPT_FILENAME'] ?? null;\n");
code.push_str("if (PHP_SAPI === 'cli' && is_string($script) && __FILE__ === realpath($script)) {\n");
code.push_str(" // Uncomment to run the server:\n");
code.push_str(" // $app = new Spikard\\App();\n");
code.push_str(" // AsyncApiHandlers::register($app);\n");
code.push_str(" // $app->listen(3000);\n");
code.push_str(" // echo \"Server listening on http://localhost:3000\\n\";\n");
code.push_str("}\n");
Ok(code)
}
}
fn camel_identifier(name: &str) -> String {
let base = sanitize_identifier(name);
let mut result = String::new();
for part in base.split('_').filter(|segment| !segment.is_empty()) {
let mut chars = part.chars();
if let Some(first) = chars.next() {
result.push(first.to_ascii_uppercase());
result.push_str(chars.as_str());
}
}
if result.is_empty() {
"Handler".to_string()
} else {
result
}
}
fn generate_php_asyncapi_assertions() -> String {
let mut code = String::new();
code.push_str("/**\n");
code.push_str(" * Shared runtime assertions for generated AsyncAPI payload objects.\n");
code.push_str(" */\n");
code.push_str("trait AsyncApiTypeAssertions\n");
code.push_str("{\n");
code.push_str(" /** @param array<string, mixed> $payload */\n");
code.push_str(" private static function expectString(array $payload, string $key): string\n");
code.push_str(" {\n");
code.push_str(" $value = $payload[$key] ?? null;\n");
code.push_str(" if (!is_string($value)) {\n");
code.push_str(" throw new RuntimeException(\"Expected string for {$key}\");\n");
code.push_str(" }\n");
code.push_str(" return $value;\n");
code.push_str(" }\n\n");
code.push_str(" /** @param array<string, mixed> $payload */\n");
code.push_str(" private static function expectInt(array $payload, string $key): int\n");
code.push_str(" {\n");
code.push_str(" $value = $payload[$key] ?? null;\n");
code.push_str(" if (!is_int($value)) {\n");
code.push_str(" throw new RuntimeException(\"Expected integer for {$key}\");\n");
code.push_str(" }\n");
code.push_str(" return $value;\n");
code.push_str(" }\n\n");
code.push_str(" /** @param array<string, mixed> $payload */\n");
code.push_str(" private static function expectFloat(array $payload, string $key): float\n");
code.push_str(" {\n");
code.push_str(" $value = $payload[$key] ?? null;\n");
code.push_str(" if (!is_float($value) && !is_int($value)) {\n");
code.push_str(" throw new RuntimeException(\"Expected number for {$key}\");\n");
code.push_str(" }\n");
code.push_str(" return (float) $value;\n");
code.push_str(" }\n\n");
code.push_str(" /** @param array<string, mixed> $payload */\n");
code.push_str(" private static function expectBool(array $payload, string $key): bool\n");
code.push_str(" {\n");
code.push_str(" $value = $payload[$key] ?? null;\n");
code.push_str(" if (!is_bool($value)) {\n");
code.push_str(" throw new RuntimeException(\"Expected boolean for {$key}\");\n");
code.push_str(" }\n");
code.push_str(" return $value;\n");
code.push_str(" }\n\n");
code.push_str(" /** @param array<string, mixed> $payload\n");
code.push_str(" * @return array<string, mixed>\n");
code.push_str(" */\n");
code.push_str(" private static function expectHash(array $payload, string $key): array\n");
code.push_str(" {\n");
code.push_str(" $value = $payload[$key] ?? null;\n");
code.push_str(" if (!is_array($value)) {\n");
code.push_str(" throw new RuntimeException(\"Expected object for {$key}\");\n");
code.push_str(" }\n");
code.push_str(" if (array_is_list($value)) {\n");
code.push_str(" throw new RuntimeException(\"Expected object for {$key}\");\n");
code.push_str(" }\n");
code.push_str(" /** @var array<string, mixed> $value */\n");
code.push_str(" return $value;\n");
code.push_str(" }\n\n");
code.push_str(" /** @param array<string, mixed> $payload\n");
code.push_str(" * @return list<mixed>\n");
code.push_str(" */\n");
code.push_str(" private static function expectList(array $payload, string $key): array\n");
code.push_str(" {\n");
code.push_str(" $value = $payload[$key] ?? null;\n");
code.push_str(" if (!is_array($value)) {\n");
code.push_str(" throw new RuntimeException(\"Expected list for {$key}\");\n");
code.push_str(" }\n");
code.push_str(" if (!array_is_list($value)) {\n");
code.push_str(" throw new RuntimeException(\"Expected list for {$key}\");\n");
code.push_str(" }\n");
code.push_str(" /** @var list<mixed> $value */\n");
code.push_str(" return $value;\n");
code.push_str(" }\n\n");
code.push_str(" /** @param array<string, mixed> $payload\n");
code.push_str(" * @return list<array<string, mixed>>\n");
code.push_str(" */\n");
code.push_str(" private static function expectHashList(array $payload, string $key): array\n");
code.push_str(" {\n");
code.push_str(" $items = self::expectList($payload, $key);\n");
code.push_str(" $result = [];\n");
code.push_str(" foreach ($items as $item) {\n");
code.push_str(" if (!is_array($item) || array_is_list($item)) {\n");
code.push_str(" throw new RuntimeException(\"Expected object list for {$key}\");\n");
code.push_str(" }\n");
code.push_str(" /** @var array<string, mixed> $item */\n");
code.push_str(" $result[] = $item;\n");
code.push_str(" }\n");
code.push_str(" /** @var list<array<string, mixed>> $result */\n");
code.push_str(" return $result;\n");
code.push_str(" }\n\n");
code.push_str(" /** @param array<string, mixed> $payload */\n");
code.push_str(" private static function expectDateTime(array $payload, string $key): DateTimeImmutable\n");
code.push_str(" {\n");
code.push_str(" return new DateTimeImmutable(self::expectString($payload, $key));\n");
code.push_str(" }\n\n");
code.push_str(" /** @param array<string, mixed> $payload\n");
code.push_str(" * @param list<string> $allowed\n");
code.push_str(" */\n");
code.push_str(
" private static function expectStringEnum(array $payload, string $key, array $allowed): string\n",
);
code.push_str(" {\n");
code.push_str(" $value = self::expectString($payload, $key);\n");
code.push_str(" if (!in_array($value, $allowed, true)) {\n");
code.push_str(" throw new RuntimeException(\"Unexpected value for {$key}\");\n");
code.push_str(" }\n");
code.push_str(" return $value;\n");
code.push_str(" }\n");
code.push_str("}\n\n");
code
}
fn generate_channel_message_models(channel: &ChannelInfo) -> String {
let mut code = String::new();
if channel.message_definitions.len() > 1 {
code.push_str(&format!("interface {} {{}}\n\n", php_channel_union_name(channel)));
}
for message in &channel.message_definitions {
if let Some(schema) = &message.schema {
let class_name = php_message_type_name(channel, message);
code.push_str(&generate_named_schema(
channel,
&class_name,
schema,
channel.message_definitions.len() > 1,
));
code.push('\n');
}
}
code
}
fn php_channel_payload_type(channel: &ChannelInfo) -> Option<String> {
match channel.message_definitions.as_slice() {
[] => None,
[message] => message.schema.as_ref().map(|_| php_message_type_name(channel, message)),
_ => Some(php_channel_union_name(channel)),
}
}
fn php_channel_union_name(channel: &ChannelInfo) -> String {
format!("{}Message", channel.name.to_pascal_case())
}
fn php_message_type_name(channel: &ChannelInfo, message: &ChannelMessage) -> String {
format!(
"{}Payload",
format!("{}_{}", channel.name, message.schema_name).to_pascal_case()
)
}
fn generate_named_schema(channel: &ChannelInfo, class_name: &str, schema: &Value, implements_union: bool) -> String {
let mut code = String::new();
for (field_name, field_schema) in object_properties(schema) {
if schema_has_named_object_shape(field_schema) {
let nested_name = format!("{class_name}{}", field_name.to_pascal_case());
code.push_str(&generate_named_schema(channel, &nested_name, field_schema, false));
code.push('\n');
} else if let Some(items) = field_schema.get("items")
&& schema_has_named_object_shape(items)
{
let nested_name = format!("{class_name}{}Item", field_name.to_pascal_case());
code.push_str(&generate_named_schema(channel, &nested_name, items, false));
code.push('\n');
}
}
code.push_str("final readonly class ");
code.push_str(class_name);
if implements_union {
code.push_str(" implements ");
code.push_str(&php_channel_union_name(channel));
}
code.push_str("\n{\n");
code.push_str(" use AsyncApiTypeAssertions;\n\n");
let properties = object_properties(schema);
let required = required_field_names(schema);
let mut constructor_properties = properties.clone();
constructor_properties
.sort_by_key(|(field_name, _)| !required.iter().any(|required_name| required_name == field_name));
code.push_str(" /**\n");
for (field_name, field_schema) in &properties {
let doc_type = schema_to_php_doc_type(class_name, field_name, field_schema, true);
code.push_str(&format!(" * @param {doc_type} ${field_name}\n"));
}
code.push_str(" */\n");
code.push_str(" public function __construct(\n");
for (index, (field_name, field_schema)) in constructor_properties.iter().enumerate() {
let is_required = required.iter().any(|required_name| required_name == field_name);
let native_type = schema_to_php_native_type(class_name, field_name, field_schema, is_required);
let comma = if index + 1 == constructor_properties.len() {
""
} else {
","
};
if is_required {
code.push_str(&format!(" public {native_type} ${field_name}{comma}\n"));
} else {
code.push_str(&format!(" public {native_type} ${field_name} = null{comma}\n"));
}
}
code.push_str(" ) {}\n\n");
code.push_str(" /** @param array<string, mixed> $payload */\n");
code.push_str(" public static function fromArray(array $payload): self\n");
code.push_str(" {\n");
code.push_str(" return new self(\n");
for (index, (field_name, field_schema)) in properties.iter().enumerate() {
let is_required = required.iter().any(|required_name| required_name == field_name);
let comma = if index + 1 == properties.len() { "" } else { "," };
let expr = schema_to_php_value_expr(class_name, field_name, field_schema, is_required);
code.push_str(&format!(" {field_name}: {expr}{comma}\n"));
}
code.push_str(" );\n");
code.push_str(" }\n\n");
code.push_str(" /** @param array<string, mixed> $payload */\n");
code.push_str(" public static function matches(array $payload): bool\n");
code.push_str(" {\n");
code.push_str(" try {\n");
code.push_str(" self::fromArray($payload);\n");
code.push_str(" return true;\n");
code.push_str(" } catch (RuntimeException) {\n");
code.push_str(" return false;\n");
code.push_str(" }\n");
code.push_str(" }\n\n");
code.push_str(" /** @return array<string, mixed> */\n");
code.push_str(" public function toArray(): array\n");
code.push_str(" {\n");
code.push_str(" return [\n");
for (field_name, field_schema) in &properties {
let is_required = required.iter().any(|required_name| required_name == field_name);
let expr = schema_to_php_serialize_expr(field_name, field_schema, is_required);
code.push_str(&format!(" '{field_name}' => {expr},\n"));
}
code.push_str(" ];\n");
code.push_str(" }\n\n");
code.push_str(" public static function example(): self\n");
code.push_str(" {\n");
code.push_str(" return new self(\n");
for (index, (field_name, field_schema)) in properties.iter().enumerate() {
let comma = if index + 1 == properties.len() { "" } else { "," };
let expr = schema_to_php_example_expr(class_name, field_name, field_schema);
code.push_str(&format!(" {field_name}: {expr}{comma}\n"));
}
code.push_str(" );\n");
code.push_str(" }\n");
code.push_str("}\n");
code
}
fn schema_to_php_native_type(parent_name: &str, field_name: &str, schema: &Value, required: bool) -> String {
let base = match schema.get("type").and_then(Value::as_str) {
Some("string") => match schema.get("format").and_then(Value::as_str) {
Some("date") | Some("date-time") => "DateTimeImmutable".to_string(),
_ => "string".to_string(),
},
Some("integer") => "int".to_string(),
Some("number") => "float".to_string(),
Some("boolean") => "bool".to_string(),
Some("array") => "array".to_string(),
Some("object") => {
if schema_has_named_object_shape(schema) {
format!("{parent_name}{}", field_name.to_pascal_case())
} else {
"array".to_string()
}
}
_ => "mixed".to_string(),
};
if required || base == "mixed" {
base
} else {
format!("?{base}")
}
}
fn schema_to_php_doc_type(parent_name: &str, field_name: &str, schema: &Value, required: bool) -> String {
let base = match schema.get("type").and_then(Value::as_str) {
Some("string") => match schema.get("format").and_then(Value::as_str) {
Some("date") | Some("date-time") => "DateTimeImmutable".to_string(),
_ => "string".to_string(),
},
Some("integer") => "int".to_string(),
Some("number") => "float".to_string(),
Some("boolean") => "bool".to_string(),
Some("array") => {
let item_type = schema
.get("items")
.map(|items| {
if schema_has_named_object_shape(items) {
format!("{parent_name}{}Item", field_name.to_pascal_case())
} else {
schema_to_php_doc_type(parent_name, field_name, items, true)
}
})
.unwrap_or_else(|| "mixed".to_string());
format!("list<{item_type}>")
}
Some("object") => {
if schema_has_named_object_shape(schema) {
format!("{parent_name}{}", field_name.to_pascal_case())
} else {
"array<string, mixed>".to_string()
}
}
_ => "mixed".to_string(),
};
if required || base == "mixed" {
base
} else {
format!("{base}|null")
}
}
fn schema_to_php_value_expr(parent_name: &str, field_name: &str, schema: &Value, required: bool) -> String {
let inner = if let Some(enum_values) = schema.get("enum").and_then(Value::as_array) {
let literals = enum_values
.iter()
.filter_map(Value::as_str)
.map(|value| format!("'{value}'"))
.collect::<Vec<_>>();
if literals.is_empty() {
format!("self::expectString($payload, '{field_name}')")
} else {
format!(
"self::expectStringEnum($payload, '{field_name}', [{}])",
literals.join(", ")
)
}
} else if let Some(const_value) = schema.get("const").and_then(Value::as_str) {
format!("self::expectStringEnum($payload, '{field_name}', ['{const_value}'])")
} else {
match schema.get("type").and_then(Value::as_str) {
Some("string") => match schema.get("format").and_then(Value::as_str) {
Some("date") | Some("date-time") => format!("self::expectDateTime($payload, '{field_name}')"),
_ => format!("self::expectString($payload, '{field_name}')"),
},
Some("integer") => format!("self::expectInt($payload, '{field_name}')"),
Some("number") => format!("self::expectFloat($payload, '{field_name}')"),
Some("boolean") => format!("self::expectBool($payload, '{field_name}')"),
Some("array") => {
if let Some(items) = schema.get("items")
&& schema_has_named_object_shape(items)
{
let item_class = format!("{parent_name}{}Item", field_name.to_pascal_case());
format!(
"array_map(static fn(array $item): {item_class} => {item_class}::fromArray($item), self::expectHashList($payload, '{field_name}'))"
)
} else {
format!("self::expectList($payload, '{field_name}')")
}
}
Some("object") => {
if schema_has_named_object_shape(schema) {
let nested_class = format!("{parent_name}{}", field_name.to_pascal_case());
format!("{nested_class}::fromArray(self::expectHash($payload, '{field_name}'))")
} else {
format!("self::expectHash($payload, '{field_name}')")
}
}
_ => format!("$payload['{field_name}'] ?? null"),
}
};
if required {
inner
} else {
format!("array_key_exists('{field_name}', $payload) ? {inner} : null")
}
}
fn schema_to_php_serialize_expr(field_name: &str, schema: &Value, required: bool) -> String {
match schema.get("type").and_then(Value::as_str) {
Some("string") => match schema.get("format").and_then(Value::as_str) {
Some("date") => {
if required {
format!("$this->{field_name}->format('Y-m-d')")
} else {
format!("$this->{field_name}?->format('Y-m-d')")
}
}
Some("date-time") => {
if required {
format!("$this->{field_name}->format(DateTimeInterface::ATOM)")
} else {
format!("$this->{field_name}?->format(DateTimeInterface::ATOM)")
}
}
_ => format!("$this->{field_name}"),
},
Some("array") => {
if let Some(items) = schema.get("items")
&& schema_has_named_object_shape(items)
{
return if required {
format!("array_map(static fn($item) => $item->toArray(), $this->{field_name})")
} else {
format!(
"$this->{field_name} === null ? null : array_map(static fn($item) => $item->toArray(), $this->{field_name})"
)
};
}
format!("$this->{field_name}")
}
Some("object") if schema_has_named_object_shape(schema) => {
if required {
format!("$this->{field_name}->toArray()")
} else {
format!("$this->{field_name}?->toArray()")
}
}
_ => format!("$this->{field_name}"),
}
}
fn schema_to_php_example_expr(parent_name: &str, field_name: &str, schema: &Value) -> String {
if let Some(const_value) = schema.get("const") {
return literal_php_value(const_value);
}
if let Some(enum_values) = schema.get("enum").and_then(Value::as_array)
&& let Some(first) = enum_values.first()
{
return literal_php_value(first);
}
match schema.get("type").and_then(Value::as_str) {
Some("string") => match schema.get("format").and_then(Value::as_str) {
Some("date") => "new DateTimeImmutable('2025-01-01')".to_string(),
Some("date-time") => "new DateTimeImmutable('2025-01-01T00:00:00Z')".to_string(),
_ => format!("'{field_name}'"),
},
Some("integer") => "1".to_string(),
Some("number") => "1.0".to_string(),
Some("boolean") => "true".to_string(),
Some("array") => {
if let Some(items) = schema.get("items")
&& schema_has_named_object_shape(items)
{
let item_class = format!("{parent_name}{}Item", field_name.to_pascal_case());
return format!("[{}::example()]", item_class);
}
"[]".to_string()
}
Some("object") => {
if schema_has_named_object_shape(schema) {
format!("{}{}::example()", parent_name, field_name.to_pascal_case())
} else {
"[]".to_string()
}
}
_ => "null".to_string(),
}
}
fn literal_php_value(value: &Value) -> String {
match value {
Value::String(value) => format!("'{value}'"),
Value::Bool(value) => value.to_string(),
Value::Number(value) => value.to_string(),
Value::Null => "null".to_string(),
_ => "null".to_string(),
}
}
fn object_properties(schema: &Value) -> Vec<(&str, &Value)> {
schema
.get("properties")
.and_then(Value::as_object)
.map(|properties| properties.iter().map(|(key, value)| (key.as_str(), value)).collect())
.unwrap_or_default()
}
fn required_field_names(schema: &Value) -> Vec<String> {
schema
.get("required")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|value| value.as_str().map(str::to_string))
.collect()
}
fn schema_has_named_object_shape(schema: &Value) -> bool {
schema
.get("type")
.and_then(Value::as_str)
.is_some_and(|schema_type| schema_type == "object")
&& schema
.get("properties")
.and_then(Value::as_object)
.is_some_and(|properties| !properties.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_php_generator_test_app() {
let generator = PhpAsyncApiGenerator;
let channels = vec![ChannelInfo {
name: "chat".to_string(),
path: "/chat".to_string(),
messages: vec!["message".to_string()],
message_definitions: vec![],
}];
let code = generator.generate_test_app(&channels, "websocket").unwrap();
assert!(code.contains("<?php"));
assert!(code.contains("getenv"));
assert!(code.contains("/chat"));
}
#[test]
fn test_php_generator_handler_app() {
let generator = PhpAsyncApiGenerator;
let channels = vec![ChannelInfo {
name: "chat".to_string(),
path: "/chat".to_string(),
messages: vec!["message".to_string()],
message_definitions: vec![ChannelMessage {
name: "chatEvent".to_string(),
schema_name: "chatEvent".to_string(),
schema: Some(serde_json::json!({
"type": "object",
"properties": {
"type": { "const": "chatEvent" },
"body": { "type": "string" }
},
"required": ["type", "body"]
})),
examples: vec![],
}],
}];
let code = generator.generate_handler_app(&channels, "websocket").unwrap();
assert!(code.contains("AsyncApiHandlers"));
assert!(code.contains("addWebSocket"));
assert!(code.contains("fromArray($payload)"));
assert!(code.contains("expectStringEnum"));
}
#[test]
fn test_php_generator_rejects_unsupported_test_app_protocol() {
let generator = PhpAsyncApiGenerator;
let channels = vec![ChannelInfo {
name: "chat".to_string(),
path: "/chat".to_string(),
messages: vec!["message".to_string()],
message_definitions: vec![],
}];
let err = generator.generate_test_app(&channels, "mqtt").unwrap_err().to_string();
assert!(err.contains("Unsupported protocol for PHP test app"));
}
}