Skip to content

Commit 148d44e

Browse files
authored
Merge pull request #16 from danielxheld/main
feat: improve parameter handling for nested objects and HTTP method semantics
2 parents 3c4e507 + fbc8819 commit 148d44e

File tree

6 files changed

+156
-13
lines changed

6 files changed

+156
-13
lines changed

src/Attributes/DataResponse.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,11 @@
99
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD)]
1010
class DataResponse
1111
{
12-
public function __construct(public int $status, public string $description = '', public null|string|array $resource = [], public array $headers = []) {}
12+
public function __construct(
13+
public int $status,
14+
public string $description = '',
15+
public null|string|array $resource = [],
16+
public array $headers = [],
17+
public bool $isCollection = false,
18+
) {}
1319
}

src/Services/EnhancedResponseAnalyzer.php

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,15 @@ public function analyzeResourceNew(New_ $node, string $controller, string $metho
564564

565565
$resourceClass = $node->class->toString();
566566

567+
if (!class_exists($resourceClass)) {
568+
$resolvedClass = $this->resolveClassNameFromController($resourceClass, $controller);
569+
if ($resolvedClass && class_exists($resolvedClass)) {
570+
$resourceClass = $resolvedClass;
571+
} else {
572+
return;
573+
}
574+
}
575+
567576
// Use existing ResponseAnalyzer for resource analysis
568577
$analysis = $this->responseAnalyzer->analyzeJsonResourceResponse($resourceClass);
569578

@@ -579,6 +588,50 @@ public function analyzeResourceNew(New_ $node, string $controller, string $metho
579588
}
580589
}
581590

591+
private function resolveClassNameFromController(string $shortClassName, string $controllerClass): ?string
592+
{
593+
if (!class_exists($controllerClass)) {
594+
return null;
595+
}
596+
597+
try {
598+
$reflection = new \ReflectionClass($controllerClass);
599+
$filename = $reflection->getFileName();
600+
601+
if (!$filename || !file_exists($filename)) {
602+
return null;
603+
}
604+
605+
$content = file_get_contents($filename);
606+
$namespace = $reflection->getNamespaceName();
607+
608+
if (preg_match('/^use\s+([^\s;]+\\\\' . preg_quote($shortClassName, '/') . ')\s*;/m', $content, $matches)) {
609+
return $matches[1];
610+
}
611+
612+
$sameNamespaceClass = $namespace . '\\' . $shortClassName;
613+
if (class_exists($sameNamespaceClass)) {
614+
return $sameNamespaceClass;
615+
}
616+
617+
$resourceNamespaces = [
618+
str_replace('\\Controllers\\', '\\Resources\\', $namespace),
619+
str_replace('\\Http\\Controllers\\', '\\Http\\Resources\\', $namespace),
620+
];
621+
622+
foreach ($resourceNamespaces as $resourceNamespace) {
623+
$potentialClass = $resourceNamespace . '\\' . $shortClassName;
624+
if (class_exists($potentialClass)) {
625+
return $potentialClass;
626+
}
627+
}
628+
629+
return null;
630+
} catch (\Throwable $e) {
631+
return null;
632+
}
633+
}
634+
582635
/**
583636
* Detect resource wrapping configuration with inheritance support
584637
*/
@@ -735,6 +788,7 @@ private function processDataResponseAttributes(ReflectionMethod $method): void
735788
$description = $instance->description ?: $this->getStatusDescription($statusCode);
736789
$resource = $instance->resource;
737790
$headers = $instance->headers;
791+
$isCollection = $instance->isCollection;
738792

739793
// Create response schema based on resource if specified
740794
$schema = null;
@@ -752,7 +806,8 @@ private function processDataResponseAttributes(ReflectionMethod $method): void
752806
'application/json',
753807
$schema,
754808
$headers,
755-
is_string($resource) ? $resource : null
809+
is_string($resource) ? $resource : null,
810+
$isCollection
756811
);
757812
} else {
758813
$this->addResponse($statusCode, $description, 'application/json', $schema, $headers);
@@ -1426,22 +1481,45 @@ private function addResponse(string $statusCode, string $description, ?string $c
14261481
/**
14271482
* Add success response with detailed schema analysis
14281483
*/
1429-
private function addSuccessResponse(string $statusCode, string $description, string $controller, string $method, ?string $contentType = 'application/json', ?array $additionalSchema = null, array $headers = [], ?string $resourceClass = null): void
1484+
private function addSuccessResponse(string $statusCode, string $description, string $controller, string $method, ?string $contentType = 'application/json', ?array $additionalSchema = null, array $headers = [], ?string $resourceClass = null, bool $isCollection = false): void
14301485
{
14311486
if (! isset($this->detectedResponses[$statusCode])) {
14321487
// Use existing ResponseAnalyzer for detailed schema analysis
14331488
$detailedSchema = $this->responseAnalyzer->analyzeControllerMethod($controller, $method);
14341489

1435-
// Merge with any additional schema information
1436-
if ($additionalSchema) {
1490+
// Prefer additionalSchema (from resource class) if provided and has properties
1491+
if ($additionalSchema && !empty($additionalSchema['properties'])) {
1492+
$detailedSchema = $additionalSchema;
1493+
} elseif ($additionalSchema) {
14371494
$detailedSchema = array_merge($detailedSchema ?: [], $additionalSchema);
14381495
}
14391496

14401497
// Apply Parameter attributes to enhance the schema
14411498
$detailedSchema = $this->applyParameterAttributesToSchema($detailedSchema, $controller, $method);
14421499

1500+
// Wrap schema in array if isCollection is true
1501+
if ($isCollection) {
1502+
// Ensure we have at least a basic schema for collections
1503+
if (empty($detailedSchema)) {
1504+
$detailedSchema = [
1505+
'type' => 'object',
1506+
'properties' => [],
1507+
];
1508+
}
1509+
1510+
$detailedSchema = [
1511+
'type' => 'array',
1512+
'items' => $detailedSchema,
1513+
];
1514+
}
1515+
14431516
// Generate MediaType-level example from schema property examples
1444-
$responseExample = $this->generateResponseExampleFromSchema($detailedSchema);
1517+
$responseExample = $this->generateResponseExampleFromSchema($isCollection ? ($detailedSchema['items'] ?? null) : $detailedSchema);
1518+
1519+
// Wrap example in array if isCollection is true
1520+
if ($isCollection && $responseExample) {
1521+
$responseExample = [$responseExample];
1522+
}
14451523

14461524
$response = [
14471525
'description' => $description,

src/Services/OpenApi.php

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ private function findQueryParamValue(string $paramName, array $queryParams): mix
470470
$baseName = $matches[1];
471471
$subKey = $matches[2];
472472

473-
if (isset($queryParams[$baseName][$subKey])) {
473+
if (isset($queryParams[$baseName]) && is_array($queryParams[$baseName]) && isset($queryParams[$baseName][$subKey])) {
474474
return $queryParams[$baseName][$subKey];
475475
}
476476
}
@@ -510,10 +510,8 @@ private function processOperationRequestBody(Operation $operation, array $route,
510510
]);
511511
}
512512

513-
// Fall back to smart detection if no explicit definition
514-
// Only create request body for non-GET/DELETE methods
515-
// (GET requests should use query parameters, DELETE shouldn't have request bodies per OpenAPI spec)
516-
if (! $requestBody && ! empty($route['parameters']) && !in_array(strtoupper($route['method']), ['GET', 'DELETE'])) {
513+
$methodsWithBody = ['POST', 'PUT', 'PATCH', 'DELETE'];
514+
if (! $requestBody && ! empty($route['parameters']) && in_array(strtoupper($route['method']), $methodsWithBody, true)) {
517515
// Enhanced schema building with AST analysis if available
518516
$schema = $this->buildRequestBodySchema($route['parameters']);
519517

@@ -841,6 +839,12 @@ private function buildRequestBodySchema(array $parameters): Schema
841839
if (!empty($nestedSchema->required)) {
842840
$nestedSchemaData['required'] = $nestedSchema->required;
843841
}
842+
if (!empty($param['example'])) {
843+
$nestedSchemaData['example'] = $param['example'];
844+
}
845+
if (!empty($param['description'])) {
846+
$nestedSchemaData['description'] = $param['description'];
847+
}
844848
$properties[$name] = new Schema($nestedSchemaData);
845849
}
846850
// Handle nested objects with 'parameters' key (legacy structure)

src/Services/RequestAnalyzer.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,11 +282,31 @@ private function mergeParameterAttributes(array $existingParameters, array $attr
282282
return $attributeParameters;
283283
}
284284

285+
foreach (array_keys($attributeParameters) as $attrName) {
286+
$normalizedName = str_replace(['[', ']'], ['.', ''], $attrName);
287+
if (strpos($normalizedName, '.') !== false) {
288+
$parentName = explode('.', $normalizedName)[0];
289+
if (isset($existingParameters[$parentName]) && !isset($attributeParameters[$parentName])) {
290+
unset($existingParameters[$parentName]);
291+
}
292+
}
293+
}
294+
285295
// Merge parameters, with Parameter attributes taking precedence
286296
foreach ($attributeParameters as $name => $attributeParam) {
287297
if (isset($existingParameters[$name])) {
288298
// Merge with existing parameter, Parameter attribute values take precedence
289299
$existingParameters[$name] = array_merge($existingParameters[$name], $attributeParam);
300+
301+
if (isset($attributeParam['example']) && is_array($attributeParam['example'])
302+
&& isset($existingParameters[$name]['properties']) && is_array($existingParameters[$name]['properties'])) {
303+
foreach ($existingParameters[$name]['properties'] as $childKey => &$childProp) {
304+
if (!isset($childProp['example']) && array_key_exists($childKey, $attributeParam['example'])) {
305+
$childProp['example'] = $attributeParam['example'][$childKey];
306+
}
307+
}
308+
unset($childProp);
309+
}
290310
} else {
291311
// Add new parameter from attribute
292312
$existingParameters[$name] = $attributeParam;
@@ -1216,6 +1236,10 @@ private function transformNestedParameters(array $parameters): array
12161236
}
12171237
if (isset($value['example'])) {
12181238
$nestedGroups[$parentKey]['properties'][$childKey]['example'] = $value['example'];
1239+
} elseif (isset($nestedGroups[$parentKey]['example'])
1240+
&& is_array($nestedGroups[$parentKey]['example'])
1241+
&& array_key_exists($childKey, $nestedGroups[$parentKey]['example'])) {
1242+
$nestedGroups[$parentKey]['properties'][$childKey]['example'] = $nestedGroups[$parentKey]['example'][$childKey];
12191243
}
12201244
if (isset($value['minimum'])) {
12211245
$nestedGroups[$parentKey]['properties'][$childKey]['minimum'] = $value['minimum'];
@@ -1254,6 +1278,10 @@ private function transformNestedParameters(array $parameters): array
12541278
'deprecated' => $value['deprecated'] ?? false,
12551279
];
12561280

1281+
if (isset($value['example']) && is_array($value['example'])) {
1282+
$nestedGroups[$key]['example'] = $value['example'];
1283+
}
1284+
12571285
// Will be filled by nested children processing
12581286
// Don't pre-create properties/items here - let the wildcard handling do it
12591287
}

src/Services/ResponseAnalyzer.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,19 @@ public function analyzeJsonResourceResponse(string $resourceClass): array
896896
'enhanced_analysis' => true,
897897
];
898898
}
899+
900+
// Fallback for classes that are neither ResourceCollection nor JsonResource
901+
return [
902+
'type' => 'object',
903+
'properties' => [
904+
'data' => [
905+
'type' => 'object',
906+
'description' => 'Resource data',
907+
],
908+
],
909+
'example' => ['data' => []],
910+
'enhanced_analysis' => true,
911+
];
899912
} catch (Throwable $e) {
900913
return [
901914
'type' => 'object',

src/Services/RouteComposition.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,19 +114,29 @@ public function process(?string $docName = null): array
114114

115115
$additionalDocs = $this->processAdditionalDocumentation($controllerClass, $actionMethod);
116116

117+
$requestParameters = $this->processRequestParameters($controllerClass, $actionMethod, $route);
118+
$bodyParameters = [];
119+
$queryParametersFromRequest = [];
120+
121+
if (in_array(strtoupper($httpMethod), ['GET', 'HEAD'], true)) {
122+
$queryParametersFromRequest = $requestParameters;
123+
} else {
124+
$bodyParameters = $requestParameters;
125+
}
126+
117127
$routes[] = [
118128
'method' => $httpMethod,
119129
'uri' => $uri,
120130
'summary' => $this->processSummary($controllerClass, $actionMethod),
121131
'description' => $description,
122132
'middlewares' => $middlewares,
123133
'is_vendor' => $isVendorClass,
124-
'parameters' => $this->processRequestParameters($controllerClass, $actionMethod, $route),
134+
'parameters' => $bodyParameters,
125135
'request_parameters' => $this->processPathParameters($route, $uri, $controllerClass, $actionMethod),
126136
'tags' => array_filter($tags),
127137
'documentation' => null,
128138
'additional_documentation' => $additionalDocs,
129-
'query_parameters' => $this->mergeQueryParameters($controllerClass, $actionMethod, $reflectionMethod, $route),
139+
'query_parameters' => array_merge($this->mergeQueryParameters($controllerClass, $actionMethod, $reflectionMethod, $route), $queryParametersFromRequest),
130140
'request_body' => $reflectionMethod ? $this->attributeAnalyzer->extractRequestBody($reflectionMethod) : null,
131141
'response_headers' => $reflectionMethod ? $this->attributeAnalyzer->extractResponseHeaders($reflectionMethod) : [],
132142
'response_bodies' => $reflectionMethod ? $this->attributeAnalyzer->extractResponseBodies($reflectionMethod) : [],
@@ -580,6 +590,10 @@ private function normalizeEnhancedResponses(array $enhancedResponses): array
580590
'detection_method' => 'enhanced_ast_analysis',
581591
];
582592

593+
if (isset($response['schema']['items'])) {
594+
$normalizedResponses[$statusCode]['items'] = $response['schema']['items'];
595+
}
596+
583597
// Include resource if available
584598
if (isset($response['resource'])) {
585599
$normalizedResponses[$statusCode]['resource'] = $response['resource'];

0 commit comments

Comments
 (0)