diff --git a/src/Analyzers/Error/ValidationErrorAnalyzer.php b/src/Analyzers/Error/ValidationErrorAnalyzer.php index 096c670..db4087d 100644 --- a/src/Analyzers/Error/ValidationErrorAnalyzer.php +++ b/src/Analyzers/Error/ValidationErrorAnalyzer.php @@ -38,9 +38,10 @@ public function extract(AnalysisContext $ctx): array } } - // Also check for inline validation in AST + // Also check for inline validation or container-resolved FormRequest in AST if (! $hasFormRequest && $ctx->hasAst()) { - $hasFormRequest = $this->hasInlineValidation($ctx); + $hasFormRequest = $this->hasInlineValidation($ctx) + || $this->hasContainerResolvedFormRequest($ctx); } if (! $hasFormRequest) { @@ -82,6 +83,152 @@ private function hasInlineValidation(AnalysisContext $ctx): bool return false; } + /** + * Detect resolve(FormRequest::class) or app(FormRequest::class) in the method body, + * or $this->method() calls that lead to a FormRequest (up to 3 levels deep). + */ + private function hasContainerResolvedFormRequest(AnalysisContext $ctx): bool + { + if ($ctx->controllerClass() === null) { + return $this->stmtsContainContainerResolve($ctx->astNode->stmts ?? []); + } + + return $this->hasValidationInStmts( + $ctx->astNode->stmts ?? [], + $ctx->controllerClass(), + depth: 0, + ); + } + + private const MAX_DEPTH = 3; + + /** + * Check if statements contain resolve()/app() calls or $this->method() calls + * that eventually lead to FormRequest resolution. + * + * @param \PhpParser\Node[] $stmts + */ + private function hasValidationInStmts(array $stmts, string $controllerClass, int $depth): bool + { + if ($depth >= self::MAX_DEPTH) { + return false; + } + + // Direct resolve()/app() calls + if ($this->stmtsContainContainerResolve($stmts)) { + return true; + } + + // $this->method() calls — check return type or trace into body + $nodeFinder = new \PhpParser\NodeFinder; + $methodCalls = $nodeFinder->findInstanceOf($stmts, \PhpParser\Node\Expr\MethodCall::class); + + foreach ($methodCalls as $call) { + if (! $call->var instanceof \PhpParser\Node\Expr\Variable + || $call->var->name !== 'this' + || ! $call->name instanceof \PhpParser\Node\Identifier + ) { + continue; + } + + try { + $refClass = new \ReflectionClass($controllerClass); + $methodName = $call->name->toString(); + if (! $refClass->hasMethod($methodName)) { + continue; + } + + // Shortcut: return type is FormRequest + $returnType = $refClass->getMethod($methodName)->getReturnType(); + if ($returnType instanceof \ReflectionNamedType + && ! $returnType->isBuiltin() + && is_subclass_of($returnType->getName(), FormRequest::class) + ) { + return true; + } + + // Trace into helper method body (recursive) + $declaringClass = $refClass->getMethod($methodName)->getDeclaringClass(); + $fileName = $declaringClass->getFileName(); + if (! $fileName || ! file_exists($fileName)) { + continue; + } + + $helperStmts = $this->getMethodStmts($fileName, $methodName); + if ($helperStmts !== null && $this->hasValidationInStmts($helperStmts, $controllerClass, $depth + 1)) { + return true; + } + } catch (\Throwable) { + continue; + } + } + + return false; + } + + /** + * Check if statements contain resolve(SomeClass::class) or app(SomeClass::class) calls. + * + * @param \PhpParser\Node[] $stmts + */ + private function stmtsContainContainerResolve(array $stmts): bool + { + $nodeFinder = new \PhpParser\NodeFinder; + $funcCalls = $nodeFinder->findInstanceOf($stmts, \PhpParser\Node\Expr\FuncCall::class); + + foreach ($funcCalls as $call) { + if (! $call->name instanceof \PhpParser\Node\Name) { + continue; + } + $funcName = $call->name->toString(); + if (in_array($funcName, ['resolve', 'app'], true) && ! empty($call->args)) { + $firstArg = $call->args[0]->value ?? null; + if ($firstArg instanceof \PhpParser\Node\Expr\ClassConstFetch + && $firstArg->name instanceof \PhpParser\Node\Identifier + && $firstArg->name->toString() === 'class' + ) { + return true; + } + } + } + + return false; + } + + /** + * Parse a file and extract a specific method's statements. + * + * @return \PhpParser\Node[]|null + */ + private function getMethodStmts(string $filePath, string $methodName): ?array + { + try { + $code = file_get_contents($filePath); + if ($code === false) { + return null; + } + + $parser = (new \PhpParser\ParserFactory)->createForNewestSupportedVersion(); + $stmts = $parser->parse($code); + if ($stmts === null) { + return null; + } + + $nodeFinder = new \PhpParser\NodeFinder; + $classMethods = $nodeFinder->findInstanceOf($stmts, \PhpParser\Node\Stmt\ClassMethod::class); + + foreach ($classMethods as $method) { + if ($method->name->toString() === $methodName) { + return $method->stmts ?? []; + } + } + } catch (\Throwable) { + // Parsing failed + } + + return null; + } + private function validationErrorSchema(): SchemaObject { $custom = $this->handlerAnalyzer?->getErrorSchema(422, includeValidationErrors: true); diff --git a/src/Analyzers/Request/ContainerFormRequestAnalyzer.php b/src/Analyzers/Request/ContainerFormRequestAnalyzer.php new file mode 100644 index 0000000..46487a8 --- /dev/null +++ b/src/Analyzers/Request/ContainerFormRequestAnalyzer.php @@ -0,0 +1,351 @@ +validateCreateRequest(); + * // where validateCreateRequest() calls resolve(SomeFormRequest::class) + */ +class ContainerFormRequestAnalyzer implements RequestBodyExtractor +{ + private ValidationRuleMapper $ruleMapper; + + private const QUERY_METHODS = ['GET', 'HEAD']; + + private const CONTAINER_FUNCTIONS = ['resolve', 'app']; + + public function __construct( + private readonly FormRequestAnalyzer $formRequestAnalyzer, + array $config = [], + private readonly ?AstCache $astCache = null, + ) { + $this->ruleMapper = new ValidationRuleMapper($config); + } + + public function extract(AnalysisContext $ctx): ?SchemaResult + { + if (in_array($ctx->route->httpMethod(), self::QUERY_METHODS, true)) { + return null; + } + + if (! $ctx->hasAst()) { + return null; + } + + // First: check directly in the action method body + $formRequestClass = $this->findContainerResolveInStmts( + $ctx->astNode->stmts ?? [], + $ctx->sourceFilePath, + $ctx, + ); + + // Second: trace into $this->helperMethod() calls + if ($formRequestClass === null && $ctx->controllerClass() !== null) { + $formRequestClass = $this->traceHelperMethods($ctx); + } + + if ($formRequestClass === null) { + return null; + } + + $rules = $this->formRequestAnalyzer->extractRules($formRequestClass); + if (empty($rules)) { + return null; + } + + $schema = $this->ruleMapper->mapAllRules($rules); + $contentType = $this->ruleMapper->hasFileUpload($rules) + ? 'multipart/form-data' + : 'application/json'; + + return new SchemaResult( + schema: $schema, + description: 'Request body', + contentType: $contentType, + source: 'container_form_request:'.class_basename($formRequestClass), + ); + } + + /** + * Scan AST statements for resolve(FormRequest::class) or app(FormRequest::class) calls. + * + * @param Node[] $stmts + */ + private function findContainerResolveInStmts(array $stmts, ?string $sourceFilePath, AnalysisContext $ctx): ?string + { + $nodeFinder = new NodeFinder; + $funcCalls = $nodeFinder->findInstanceOf($stmts, FuncCall::class); + + foreach ($funcCalls as $call) { + if (! $call->name instanceof Name) { + continue; + } + + $funcName = $call->name->toString(); + if (! in_array($funcName, self::CONTAINER_FUNCTIONS, true)) { + continue; + } + + if (empty($call->args)) { + continue; + } + + $firstArg = $call->args[0]->value ?? null; + + // resolve(SomeFormRequest::class) or app(SomeFormRequest::class) + if ($firstArg instanceof ClassConstFetch + && $firstArg->name instanceof Node\Identifier + && $firstArg->name->toString() === 'class' + && $firstArg->class instanceof Name + ) { + $className = $firstArg->class->toString(); + $resolved = $this->resolveClassNameFromFile($className, $sourceFilePath, $ctx); + + if ($resolved !== null) { + try { + if (is_subclass_of($resolved, FormRequest::class)) { + return $resolved; + } + } catch (\Throwable) { + continue; + } + } + } + } + + return null; + } + + /** + * Find $this->method() calls in the action body, then trace into those helper methods. + */ + private function traceHelperMethods(AnalysisContext $ctx): ?string + { + return $this->findInThisMethodCalls( + $ctx->astNode->stmts ?? [], + $ctx->controllerClass(), + $ctx, + depth: 0, + ); + } + + private const MAX_TRACE_DEPTH = 3; + + /** + * Scan statements for $this->method() calls and trace into their bodies recursively. + * + * @param Node[] $stmts + */ + private function findInThisMethodCalls(array $stmts, string $controllerClass, AnalysisContext $ctx, int $depth): ?string + { + if ($depth >= self::MAX_TRACE_DEPTH) { + return null; + } + + $nodeFinder = new NodeFinder; + $methodCalls = $nodeFinder->findInstanceOf($stmts, MethodCall::class); + + foreach ($methodCalls as $call) { + // Only $this->method() calls + if (! $call->var instanceof Expr\Variable + || $call->var->name !== 'this' + || ! $call->name instanceof Node\Identifier + ) { + continue; + } + + $helperName = $call->name->toString(); + $result = $this->traceIntoHelperMethod($controllerClass, $helperName, $ctx, $depth + 1); + if ($result !== null) { + return $result; + } + } + + return null; + } + + /** + * Parse a helper method's body and look for container-resolved FormRequests. + * If not found directly, recursively traces into $this->method() calls. + */ + private function traceIntoHelperMethod(string $controllerClass, string $methodName, AnalysisContext $ctx, int $depth): ?string + { + try { + $refClass = new \ReflectionClass($controllerClass); + if (! $refClass->hasMethod($methodName)) { + return null; + } + + // Quick check: if the return type is a FormRequest subclass, use it directly + $refMethod = $refClass->getMethod($methodName); + $returnType = $refMethod->getReturnType(); + if ($returnType instanceof \ReflectionNamedType && ! $returnType->isBuiltin()) { + try { + if (is_subclass_of($returnType->getName(), FormRequest::class)) { + return $returnType->getName(); + } + } catch (\Throwable) { + // Continue with AST tracing + } + } + + $declaringClass = $refMethod->getDeclaringClass(); + $fileName = $declaringClass->getFileName(); + + if (! $fileName || ! file_exists($fileName)) { + return null; + } + + $stmts = $this->parseFile($fileName); + if ($stmts === null) { + return null; + } + + // Find the target method in the AST + $nodeFinder = new NodeFinder; + $classMethods = $nodeFinder->findInstanceOf($stmts, ClassMethod::class); + + $targetMethod = null; + foreach ($classMethods as $method) { + if ($method->name->toString() === $methodName) { + $targetMethod = $method; + + break; + } + } + + if ($targetMethod === null) { + return null; + } + + $helperStmts = $targetMethod->stmts ?? []; + + // Look for resolve()/app() directly inside the helper method + $found = $this->findContainerResolveInStmts($helperStmts, $fileName, $ctx); + if ($found !== null) { + return $found; + } + + // Recurse: trace $this->method() calls inside this helper + return $this->findInThisMethodCalls($helperStmts, $controllerClass, $ctx, $depth); + } catch (\Throwable) { + return null; + } + } + + /** + * Parse a file using AstCache if available, otherwise use a fresh parser. + * + * @return Node\Stmt[]|null + */ + private function parseFile(string $filePath): ?array + { + if ($this->astCache !== null) { + return $this->astCache->parseFile($filePath); + } + + try { + $code = file_get_contents($filePath); + if ($code === false) { + return null; + } + $parser = (new \PhpParser\ParserFactory)->createForNewestSupportedVersion(); + + return $parser->parse($code); + } catch (\Throwable) { + return null; + } + } + + /** + * Resolve a short class name to a FQCN using the given file's use-statements. + */ + private function resolveClassNameFromFile(string $shortName, ?string $filePath, AnalysisContext $ctx): ?string + { + if (class_exists($shortName)) { + return $shortName; + } + + // Check use-statement map from the source file + if ($filePath !== null) { + $useMap = $this->resolveUseStatements($filePath); + if (isset($useMap[$shortName])) { + $fqcn = $useMap[$shortName]; + if (class_exists($fqcn)) { + return $fqcn; + } + } + } + + // Try with controller's namespace + if ($ctx->controllerClass()) { + $namespace = substr($ctx->controllerClass(), 0, (int) strrpos($ctx->controllerClass(), '\\')); + $fqcn = $namespace.'\\'.$shortName; + if (class_exists($fqcn)) { + return $fqcn; + } + } + + return null; + } + + /** + * Parse use statements from a PHP file. + * + * @return array + */ + private function resolveUseStatements(string $filePath): array + { + static $cache = []; + + if (isset($cache[$filePath])) { + return $cache[$filePath]; + } + + $map = []; + + try { + $code = file_get_contents($filePath); + if ($code === false) { + return $cache[$filePath] = $map; + } + + // Simple regex-based use-statement extraction (fast, no AST needed) + if (preg_match_all('/^use\s+([\w\\\\]+?)(?:\s+as\s+(\w+))?\s*;/m', $code, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $fqcn = $match[1]; + $alias = $match[2] ?? substr($fqcn, (int) strrpos($fqcn, '\\') + 1); + $map[$alias] = $fqcn; + } + } + } catch (\Throwable) { + // Ignore + } + + return $cache[$filePath] = $map; + } +} diff --git a/src/Analyzers/Response/ReturnTypeAnalyzer.php b/src/Analyzers/Response/ReturnTypeAnalyzer.php index 7aeb578..406d530 100644 --- a/src/Analyzers/Response/ReturnTypeAnalyzer.php +++ b/src/Analyzers/Response/ReturnTypeAnalyzer.php @@ -575,6 +575,7 @@ private function detectAbortCalls(AnalysisContext $ctx): array if ($statusCode !== null) { $results[$statusCode] = new ResponseResult( statusCode: $statusCode, + schema: $this->abortSchema($statusCode), description: $this->descriptionForStatus($statusCode), source: 'ast:abort', ); @@ -1199,9 +1200,27 @@ private function descriptionForStatus(int $status): string 401 => 'Unauthorized', 403 => 'Forbidden', 404 => 'Not Found', + 405 => 'Method Not Allowed', 422 => 'Unprocessable Entity', 500 => 'Internal Server Error', + 501 => 'Not Implemented', default => 'Response', }; } + + /** + * Build a standard error schema for abort() responses. + */ + private function abortSchema(int $statusCode): SchemaObject + { + return SchemaObject::object( + properties: [ + 'message' => new SchemaObject( + type: 'string', + example: $this->descriptionForStatus($statusCode), + ), + ], + required: ['message'], + ); + } } diff --git a/src/LaravelApiDocumentationServiceProvider.php b/src/LaravelApiDocumentationServiceProvider.php index d0f8333..85919cd 100644 --- a/src/LaravelApiDocumentationServiceProvider.php +++ b/src/LaravelApiDocumentationServiceProvider.php @@ -20,6 +20,7 @@ use JkBennemann\LaravelApiDocumentation\Analyzers\QueryParam\QueryParameterAttributeAnalyzer; use JkBennemann\LaravelApiDocumentation\Analyzers\QueryParam\RequestMethodCallAnalyzer; use JkBennemann\LaravelApiDocumentation\Analyzers\QueryParam\RuntimeCaptureQueryAnalyzer; +use JkBennemann\LaravelApiDocumentation\Analyzers\Request\ContainerFormRequestAnalyzer; use JkBennemann\LaravelApiDocumentation\Analyzers\Request\FormRequestAnalyzer; use JkBennemann\LaravelApiDocumentation\Analyzers\Request\InlineValidationAnalyzer; use JkBennemann\LaravelApiDocumentation\Analyzers\Request\RequestBodyAttributeAnalyzer; @@ -174,6 +175,7 @@ private function registerCoreAnalyzers(PluginRegistry $registry, array $config, $registry->addRequestExtractor(new RequestBodyAttributeAnalyzer, 100); $formRequestAnalyzer = new FormRequestAnalyzer($config, $astCache); $registry->addRequestExtractor($formRequestAnalyzer, 90); + $registry->addRequestExtractor(new ContainerFormRequestAnalyzer($formRequestAnalyzer, $config, $astCache), 85); $registry->addRequestExtractor(new InlineValidationAnalyzer($config), 80); $registry->addRequestExtractor(new RuntimeCaptureRequestAnalyzer($capturedRepo), 60); diff --git a/src/Plugins/PaginationPlugin.php b/src/Plugins/PaginationPlugin.php index 4435856..f6a4034 100644 --- a/src/Plugins/PaginationPlugin.php +++ b/src/Plugins/PaginationPlugin.php @@ -191,32 +191,32 @@ private function addPaginationMetadata(array $schema, string $type): array $schema['properties']['meta'] = [ 'type' => 'object', 'properties' => [ - 'path' => ['type' => 'string'], - 'per_page' => ['type' => 'integer'], - 'next_cursor' => ['type' => 'string', 'nullable' => true], - 'prev_cursor' => ['type' => 'string', 'nullable' => true], + 'path' => ['type' => 'string', 'example' => 'https://example.com/api/resource'], + 'per_page' => ['type' => 'integer', 'example' => 15], + 'next_cursor' => ['type' => 'string', 'nullable' => true, 'example' => 'eyJpZCI6MTUsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0'], + 'prev_cursor' => ['type' => 'string', 'nullable' => true, 'example' => null], ], ]; } else { $schema['properties']['links'] = [ 'type' => 'object', 'properties' => [ - 'first' => ['type' => 'string', 'format' => 'uri'], - 'last' => ['type' => 'string', 'format' => 'uri', 'nullable' => true], - 'prev' => ['type' => 'string', 'format' => 'uri', 'nullable' => true], - 'next' => ['type' => 'string', 'format' => 'uri', 'nullable' => true], + 'first' => ['type' => 'string', 'format' => 'uri', 'example' => 'https://example.com/api/resource?page=1'], + 'last' => ['type' => 'string', 'format' => 'uri', 'nullable' => true, 'example' => 'https://example.com/api/resource?page=10'], + 'prev' => ['type' => 'string', 'format' => 'uri', 'nullable' => true, 'example' => null], + 'next' => ['type' => 'string', 'format' => 'uri', 'nullable' => true, 'example' => 'https://example.com/api/resource?page=2'], ], ]; $schema['properties']['meta'] = [ 'type' => 'object', 'properties' => [ - 'current_page' => ['type' => 'integer'], - 'from' => ['type' => 'integer', 'nullable' => true], - 'last_page' => ['type' => 'integer'], - 'per_page' => ['type' => 'integer'], - 'to' => ['type' => 'integer', 'nullable' => true], - 'total' => ['type' => 'integer'], - 'path' => ['type' => 'string'], + 'current_page' => ['type' => 'integer', 'example' => 1], + 'from' => ['type' => 'integer', 'nullable' => true, 'example' => 1], + 'last_page' => ['type' => 'integer', 'example' => 10], + 'per_page' => ['type' => 'integer', 'example' => 15], + 'to' => ['type' => 'integer', 'nullable' => true, 'example' => 15], + 'total' => ['type' => 'integer', 'example' => 150], + 'path' => ['type' => 'string', 'example' => 'https://example.com/api/resource'], ], ]; } diff --git a/src/Schema/ExampleGenerator.php b/src/Schema/ExampleGenerator.php index 538229b..02a5e74 100644 --- a/src/Schema/ExampleGenerator.php +++ b/src/Schema/ExampleGenerator.php @@ -19,8 +19,8 @@ public function generate(SchemaObject $schema): SchemaObject return $schema; } - // For objects, recurse into properties (don't set example on the object itself) - if ($schema->type === 'object' && ! empty($schema->properties)) { + // Schemas with properties — recurse into each property (don't set example on the object itself) + if (! empty($schema->properties)) { $modified = false; $newProperties = []; @@ -64,6 +64,11 @@ public function generate(SchemaObject $schema): SchemaObject return $schema; } + // Composition schemas — recurse into sub-schemas + if ($schema->oneOf !== null || $schema->anyOf !== null || $schema->allOf !== null) { + return $this->generateForComposition($schema); + } + // For scalar leaves, generate an example return $this->generateForSchema($schema, null); } @@ -77,8 +82,8 @@ private function generateForSchema(SchemaObject $schema, ?string $fieldName): Sc return $schema; } - // Recurse into objects and arrays first - if ($schema->type === 'object' && ! empty($schema->properties)) { + // Recurse into schemas with properties (regardless of type) + if (! empty($schema->properties)) { return $this->generate($schema); } @@ -86,6 +91,11 @@ private function generateForSchema(SchemaObject $schema, ?string $fieldName): Sc return $this->generate($schema); } + // Recurse into composition schemas + if ($schema->oneOf !== null || $schema->anyOf !== null || $schema->allOf !== null) { + return $this->generate($schema); + } + // Already has an example — skip if ($schema->example !== null) { return $schema; @@ -103,6 +113,33 @@ private function generateForSchema(SchemaObject $schema, ?string $fieldName): Sc return $clone; } + /** + * Recurse into oneOf/anyOf/allOf sub-schemas to fill in examples. + */ + private function generateForComposition(SchemaObject $schema): SchemaObject + { + $modified = false; + $clone = clone $schema; + + foreach (['oneOf', 'anyOf', 'allOf'] as $key) { + if ($schema->{$key} === null) { + continue; + } + + $newSchemas = []; + foreach ($schema->{$key} as $subSchema) { + $generated = $this->generate($subSchema); + if ($generated !== $subSchema) { + $modified = true; + } + $newSchemas[] = $generated; + } + $clone->{$key} = $newSchemas; + } + + return $modified ? $clone : $schema; + } + /** * Produce a synthetic example value from schema metadata. * @@ -151,7 +188,12 @@ private function generateValue(SchemaObject $schema, ?string $fieldName): mixed } } - // 6. Type-based fallback + // 6. String constraint heuristics (maxLength gives a reasonable-length example) + if ($schema->type === 'string' && $schema->maxLength !== null && $schema->maxLength <= 10) { + return 'abc'; + } + + // 7. Type-based fallback return $this->generateFromType($schema->type); } @@ -169,23 +211,25 @@ private function guessFromFieldName(string $name, ?string $type): mixed return 'password123'; } - // Token - if (str_contains($lower, 'token')) { + // Token / secret / key (auth-related) + if (str_contains($lower, 'token') || str_contains($lower, 'secret') || $lower === 'api_key') { return 'abc123def456'; } // Phone - if (str_contains($lower, 'phone')) { + if (str_contains($lower, 'phone') || str_contains($lower, 'mobile') || str_contains($lower, 'fax')) { return '+1-555-555-5555'; } - // URL/link/href - if (str_contains($lower, 'url') || str_contains($lower, 'link') || str_contains($lower, 'href')) { + // URL/link/href/website/homepage + if (str_contains($lower, 'url') || str_contains($lower, 'link') || str_contains($lower, 'href') + || str_contains($lower, 'website') || str_contains($lower, 'homepage')) { return 'https://example.com'; } - // Image/avatar/photo - if (str_contains($lower, 'image') || str_contains($lower, 'avatar') || str_contains($lower, 'photo')) { + // Image/avatar/photo/logo/icon/thumbnail + if (str_contains($lower, 'image') || str_contains($lower, 'avatar') || str_contains($lower, 'photo') + || str_contains($lower, 'logo') || str_contains($lower, 'icon') || str_contains($lower, 'thumbnail')) { return 'https://example.com/image.jpg'; } @@ -203,17 +247,44 @@ private function guessFromFieldName(string $name, ?string $type): mixed if (str_ends_with($lower, '_at') || str_ends_with($lower, 'datetime')) { return '2025-01-15T10:30:00Z'; } - if (str_contains($lower, 'date')) { + if (str_contains($lower, 'date') || str_ends_with($lower, '_on')) { return '2025-01-15'; } + // Person name fields (before generic 'name' match and before 'first'/'last') + if (str_contains($lower, 'first_name') || str_contains($lower, 'firstname') || $lower === 'given_name') { + return 'John'; + } + if (str_contains($lower, 'last_name') || str_contains($lower, 'lastname') + || $lower === 'surname' || $lower === 'family_name') { + return 'Doe'; + } + if ($lower === 'full_name' || $lower === 'fullname' || $lower === 'display_name') { + return 'John Doe'; + } + if ($lower === 'username' || $lower === 'user_name' || $lower === 'login') { + return 'johndoe'; + } + if ($lower === 'nickname') { + return 'Johnny'; + } + // Address fields (before count/quantity to avoid "country" matching "count") - if (str_contains($lower, 'address')) { + if (str_contains($lower, 'address') || $lower === 'line1' || $lower === 'street') { return '123 Main St'; } + if ($lower === 'line2') { + return 'Suite 100'; + } + if ($lower === 'line3' || $lower === 'line4') { + return ''; + } if (str_contains($lower, 'city')) { return 'New York'; } + if (str_contains($lower, 'state') || str_contains($lower, 'province') || str_contains($lower, 'region')) { + return 'NY'; + } if (str_contains($lower, 'country')) { return 'US'; } @@ -221,9 +292,38 @@ private function guessFromFieldName(string $name, ?string $type): mixed return '10001'; } - // Amount/price/cost - if (str_contains($lower, 'amount') || str_contains($lower, 'price') || str_contains($lower, 'cost')) { - return 99.99; + // Company/organization + if (str_contains($lower, 'company') || str_contains($lower, 'organization') || str_contains($lower, 'organisation')) { + return 'Acme Inc.'; + } + + // Currency + if ($lower === 'currency' || $lower === 'currency_code') { + return 'USD'; + } + if ($lower === 'locale' || $lower === 'language' || $lower === 'lang') { + return 'en'; + } + + // Amount/price/cost/fee/total/balance/rate + if (str_contains($lower, 'amount') || str_contains($lower, 'price') || str_contains($lower, 'cost') + || str_contains($lower, 'fee') || str_contains($lower, 'balance') || str_contains($lower, 'rate') + || str_contains($lower, 'subtotal') || str_contains($lower, 'discount')) { + return $type === 'integer' ? 100 : 99.99; + } + + // Percentage/ratio + if (str_contains($lower, 'percent') || str_contains($lower, 'ratio')) { + return $type === 'integer' ? 50 : 0.5; + } + + // Weight/size/width/height/length/depth/duration + if (str_contains($lower, 'weight') || str_contains($lower, 'width') || str_contains($lower, 'height') + || str_contains($lower, 'length') || str_contains($lower, 'depth') || str_contains($lower, 'size')) { + return $type === 'integer' ? 100 : 10.5; + } + if (str_contains($lower, 'duration') || str_contains($lower, 'timeout') || str_contains($lower, 'interval')) { + return 30; } // Latitude/longitude @@ -240,18 +340,53 @@ private function guessFromFieldName(string $name, ?string $type): mixed } // Pagination - if ($lower === 'page') { + if ($lower === 'page' || $lower === 'current_page') { return 1; } - if ($lower === 'per_page' || $lower === 'limit') { + if ($lower === 'per_page' || $lower === 'limit' || $lower === 'page_size') { return 15; } + if ($lower === 'total' || $lower === 'total_count' || $lower === 'total_items') { + return 100; + } + if ($lower === 'last_page' || $lower === 'total_pages') { + return 10; + } + if ($lower === 'from') { + return $type === 'integer' ? 1 : null; + } + if ($lower === 'to') { + return $type === 'integer' ? 15 : null; + } - // Count/quantity - if (str_contains($lower, 'count') || str_contains($lower, 'quantity')) { + // Count/quantity/number + if (str_contains($lower, 'count') || str_contains($lower, 'quantity') || $lower === 'qty') { return 1; } + // Priority/position/rank/level + if (str_contains($lower, 'priority') || str_contains($lower, 'position') || str_contains($lower, 'rank') + || str_contains($lower, 'level') || $lower === 'order' || $lower === 'sort_order') { + return 1; + } + + // Version + if ($lower === 'version') { + return '1.0.0'; + } + + // Path (filesystem or URL path) + if ($lower === 'path') { + return '/api/resource'; + } + + // Content/body/text/message/note/comment/summary + if ($lower === 'body' || $lower === 'content' || $lower === 'text' || $lower === 'message' + || $lower === 'note' || $lower === 'comment' || $lower === 'summary' || $lower === 'bio' + || $lower === 'excerpt' || $lower === 'reason') { + return 'Example text content'; + } + // ID fields if ($lower === 'id' || str_ends_with($lower, '_id') || str_ends_with($lower, 'id')) { return $type === 'string' ? '1' : 1; @@ -262,29 +397,56 @@ private function guessFromFieldName(string $name, ?string $type): mixed return 'active'; } - // Type - if ($lower === 'type') { + // Type/kind/category + if ($lower === 'type' || $lower === 'kind' || $lower === 'category') { return 'default'; } - // Sort/order - if (str_contains($lower, 'sort') || str_contains($lower, 'order')) { + // Method (HTTP or payment) + if ($lower === 'method') { + return 'GET'; + } + + // Format/mime_type + if ($lower === 'format' || $lower === 'mime_type' || $lower === 'content_type') { + return 'application/json'; + } + + // Sort/order direction + if (str_contains($lower, 'sort') || $lower === 'direction') { return 'asc'; } - // Title - if (str_contains($lower, 'title')) { + // Title/subject/label/headline + if (str_contains($lower, 'title') || str_contains($lower, 'subject') || str_contains($lower, 'headline')) { return 'Example title'; } - // Name fields + // Label/tag + if ($lower === 'label' || $lower === 'tag') { + return 'example-label'; + } + + // Name fields (generic, after specific name matches) if (str_contains($lower, 'name')) { return 'Example name'; } // Description if (str_contains($lower, 'description')) { - return 'A description'; + return 'A detailed description of the resource'; + } + + // Value (generic) + if ($lower === 'value' || $lower === 'data' || $lower === 'result') { + return 'string'; + } + + // Boolean-like field names + if (str_starts_with($lower, 'is_') || str_starts_with($lower, 'has_') || str_starts_with($lower, 'can_') + || str_starts_with($lower, 'should_') || str_starts_with($lower, 'allow') + || $lower === 'active' || $lower === 'enabled' || $lower === 'visible' || $lower === 'published') { + return true; } return null; @@ -297,11 +459,15 @@ private function generateFromFormat(string $type, string $format): mixed 'uuid' => '550e8400-e29b-41d4-a716-446655440000', 'date' => '2025-01-15', 'date-time' => '2025-01-15T10:30:00Z', + 'time' => '10:30:00', 'uri', 'url' => 'https://example.com', + 'hostname' => 'example.com', 'ipv4' => '192.168.1.1', 'ipv6' => '2001:0db8:85a3::8a2e:0370:7334', + 'password' => 'password123', + 'byte' => 'U3dhZ2dlciByb2Nrcw==', 'json' => '{}', - 'binary' => null, // No useful example for binary + 'binary' => null, default => null, }; } diff --git a/tests/Feature/ContainerFormRequestTest.php b/tests/Feature/ContainerFormRequestTest.php new file mode 100644 index 0000000..b652e64 --- /dev/null +++ b/tests/Feature/ContainerFormRequestTest.php @@ -0,0 +1,159 @@ +reset(); + + $discovery = app(RouteDiscovery::class); + $contexts = $discovery->discover(); + + $emitter = app(OpenApiEmitter::class); + + return $emitter->emit($contexts, config('api-documentation')); + } + + public function test_resolve_form_request_extracts_request_body(): void + { + Route::post('api/store-resolve', [ContainerResolvedController::class, 'storeWithResolve']); + + $spec = $this->generateSpec(); + + $operation = $spec['paths']['/api/store-resolve']['post'] ?? null; + expect($operation)->not()->toBeNull(); + expect($operation)->toHaveKey('requestBody'); + + $schema = $operation['requestBody']['content']['application/json']['schema'] ?? null; + expect($schema)->not()->toBeNull(); + expect($schema['properties'])->toHaveKey('parameter_1'); + expect($schema['properties'])->toHaveKey('parameter_2'); + expect($schema['required'])->toContain('parameter_1'); + } + + public function test_app_form_request_extracts_request_body(): void + { + Route::post('api/store-app', [ContainerResolvedController::class, 'storeWithApp']); + + $spec = $this->generateSpec(); + + $operation = $spec['paths']['/api/store-app']['post'] ?? null; + expect($operation)->not()->toBeNull(); + expect($operation)->toHaveKey('requestBody'); + + $schema = $operation['requestBody']['content']['application/json']['schema'] ?? null; + expect($schema)->not()->toBeNull(); + expect($schema['properties'])->toHaveKey('parameter_1'); + expect($schema['properties'])->toHaveKey('parameter_2'); + } + + public function test_helper_method_with_resolve_extracts_request_body(): void + { + Route::post('api/store-helper', [ContainerResolvedController::class, 'storeViaHelper']); + + $spec = $this->generateSpec(); + + $operation = $spec['paths']['/api/store-helper']['post'] ?? null; + expect($operation)->not()->toBeNull(); + expect($operation)->toHaveKey('requestBody'); + + $schema = $operation['requestBody']['content']['application/json']['schema'] ?? null; + expect($schema)->not()->toBeNull(); + expect($schema['properties'])->toHaveKey('parameter_1'); + expect($schema['properties'])->toHaveKey('parameter_2'); + expect($schema['required'])->toContain('parameter_1'); + } + + public function test_nested_helper_with_resolve_extracts_request_body(): void + { + Route::post('api/store-nested', [ContainerResolvedController::class, 'storeViaNested']); + + $spec = $this->generateSpec(); + + $operation = $spec['paths']['/api/store-nested']['post'] ?? null; + expect($operation)->not()->toBeNull(); + expect($operation)->toHaveKey('requestBody'); + + $schema = $operation['requestBody']['content']['application/json']['schema'] ?? null; + expect($schema)->not()->toBeNull(); + expect($schema['properties'])->toHaveKey('parameter_1'); + expect($schema['properties'])->toHaveKey('parameter_2'); + expect($schema['required'])->toContain('parameter_1'); + } + + public function test_resolve_form_request_triggers_422_response(): void + { + Route::post('api/store-resolve', [ContainerResolvedController::class, 'storeWithResolve']); + + $spec = $this->generateSpec(); + + $operation = $spec['paths']['/api/store-resolve']['post'] ?? null; + expect($operation)->not()->toBeNull(); + expect($operation['responses'])->toHaveKey('422'); + } + + public function test_nested_helper_triggers_422_response(): void + { + Route::post('api/store-nested', [ContainerResolvedController::class, 'storeViaNested']); + + $spec = $this->generateSpec(); + + $operation = $spec['paths']['/api/store-nested']['post'] ?? null; + expect($operation)->not()->toBeNull(); + expect($operation['responses'])->toHaveKey('422'); + } + + public function test_abort_only_method_produces_error_response_with_schema(): void + { + Route::put('api/abort-only', [ContainerResolvedController::class, 'abortOnly']); + + $spec = $this->generateSpec(); + + $operation = $spec['paths']['/api/abort-only']['put'] ?? null; + expect($operation)->not()->toBeNull(); + expect($operation['responses'])->toHaveKey('405'); + + // Should have an error schema with message property + $schema = $operation['responses']['405']['content']['application/json']['schema'] ?? null; + expect($schema)->not()->toBeNull(); + expect($schema['properties'])->toHaveKey('message'); + } + + public function test_abort_only_method_does_not_produce_default_200(): void + { + Route::put('api/abort-only', [ContainerResolvedController::class, 'abortOnly']); + + $spec = $this->generateSpec(); + + $operation = $spec['paths']['/api/abort-only']['put'] ?? null; + expect($operation)->not()->toBeNull(); + + // Should NOT have a fallback 200 response + expect($operation['responses'])->not()->toHaveKey('200'); + } + + public function test_abort_501_produces_not_implemented_response(): void + { + Route::get('api/not-implemented', [ContainerResolvedController::class, 'abortNotImplemented']); + + $spec = $this->generateSpec(); + + $operation = $spec['paths']['/api/not-implemented']['get'] ?? null; + expect($operation)->not()->toBeNull(); + expect($operation['responses'])->toHaveKey('501'); + + $response = $operation['responses']['501']; + expect($response['description'])->toBe('Not Implemented'); + } +} diff --git a/tests/Feature/SyntheticExampleGenerationTest.php b/tests/Feature/SyntheticExampleGenerationTest.php index fbbb497..b9f26cd 100644 --- a/tests/Feature/SyntheticExampleGenerationTest.php +++ b/tests/Feature/SyntheticExampleGenerationTest.php @@ -279,6 +279,166 @@ public function test_existing_property_examples_preserved_in_object(): void expect($result->properties['email']->example)->toBe('user@example.com'); } + // --- Composition schema tests --- + + public function test_one_of_sub_schemas_get_examples(): void + { + $schema = new SchemaObject( + oneOf: [ + SchemaObject::object([ + 'email' => SchemaObject::string(), + 'role' => SchemaObject::string(), + ]), + SchemaObject::object([ + 'name' => SchemaObject::string(), + ]), + ], + ); + + $result = $this->generator->generate($schema); + + expect($result->oneOf[0]->properties['email']->example)->toBe('user@example.com'); + expect($result->oneOf[0]->properties['role']->example)->toBeString(); + expect($result->oneOf[1]->properties['name']->example)->toBe('Example name'); + } + + public function test_all_of_sub_schemas_get_examples(): void + { + $schema = new SchemaObject( + allOf: [ + SchemaObject::object([ + 'id' => SchemaObject::integer(), + ]), + SchemaObject::object([ + 'title' => SchemaObject::string(), + ]), + ], + ); + + $result = $this->generator->generate($schema); + + expect($result->allOf[0]->properties['id']->example)->toBe(1); + expect($result->allOf[1]->properties['title']->example)->toBe('Example title'); + } + + public function test_any_of_sub_schemas_get_examples(): void + { + $schema = new SchemaObject( + anyOf: [ + SchemaObject::string(), + SchemaObject::integer(), + ], + ); + + $result = $this->generator->generate($schema); + + expect($result->anyOf[0]->example)->toBe('string'); + expect($result->anyOf[1]->example)->toBe(1); + } + + public function test_properties_with_non_object_type_get_examples(): void + { + // Simulates wildcard validation rules where type is "array" but properties exist + $schema = new SchemaObject( + type: 'array', + properties: [ + 'first_name' => SchemaObject::string(), + 'city' => SchemaObject::string(), + ], + ); + + $result = $this->generator->generate($schema); + + expect($result->properties['first_name']->example)->toBe('John'); + expect($result->properties['city']->example)->toBe('New York'); + } + + public function test_person_name_heuristics(): void + { + $schema = SchemaObject::object([ + 'first_name' => SchemaObject::string(), + 'last_name' => SchemaObject::string(), + 'company' => SchemaObject::string(), + 'username' => SchemaObject::string(), + ]); + + $result = $this->generator->generate($schema); + + expect($result->properties['first_name']->example)->toBe('John'); + expect($result->properties['last_name']->example)->toBe('Doe'); + expect($result->properties['company']->example)->toBe('Acme Inc.'); + expect($result->properties['username']->example)->toBe('johndoe'); + } + + public function test_address_heuristics(): void + { + $schema = SchemaObject::object([ + 'line1' => SchemaObject::string(), + 'line2' => SchemaObject::string(), + 'state' => SchemaObject::string(), + 'currency' => SchemaObject::string(), + ]); + + $result = $this->generator->generate($schema); + + expect($result->properties['line1']->example)->toBe('123 Main St'); + expect($result->properties['line2']->example)->toBe('Suite 100'); + expect($result->properties['state']->example)->toBe('NY'); + expect($result->properties['currency']->example)->toBe('USD'); + } + + public function test_pagination_heuristics(): void + { + $schema = SchemaObject::object([ + 'current_page' => SchemaObject::integer(), + 'last_page' => SchemaObject::integer(), + 'total' => SchemaObject::integer(), + 'from' => new SchemaObject(type: 'integer', nullable: true), + 'to' => new SchemaObject(type: 'integer', nullable: true), + 'path' => SchemaObject::string(), + ]); + + $result = $this->generator->generate($schema); + + expect($result->properties['current_page']->example)->toBe(1); + expect($result->properties['last_page']->example)->toBe(10); + expect($result->properties['total']->example)->toBe(100); + expect($result->properties['from']->example)->toBe(1); + expect($result->properties['to']->example)->toBe(15); + expect($result->properties['path']->example)->toBe('/api/resource'); + } + + public function test_boolean_field_name_heuristics(): void + { + $schema = SchemaObject::object([ + 'is_active' => SchemaObject::boolean(), + 'has_permission' => SchemaObject::boolean(), + 'enabled' => SchemaObject::boolean(), + ]); + + $result = $this->generator->generate($schema); + + expect($result->properties['is_active']->example)->toBe(true); + expect($result->properties['has_permission']->example)->toBe(true); + expect($result->properties['enabled']->example)->toBe(true); + } + + public function test_time_format_gets_example(): void + { + $schema = new SchemaObject(type: 'string', format: 'time'); + $result = $this->generator->generate($schema); + + expect($result->example)->toBe('10:30:00'); + } + + public function test_byte_format_gets_example(): void + { + $schema = new SchemaObject(type: 'string', format: 'byte'); + $result = $this->generator->generate($schema); + + expect($result->example)->toBe('U3dhZ2dlciByb2Nrcw=='); + } + // --- Integration tests --- public function test_full_pipeline_request_body_has_examples(): void diff --git a/tests/Stubs/Controllers/ContainerResolvedController.php b/tests/Stubs/Controllers/ContainerResolvedController.php new file mode 100644 index 0000000..a62b1bb --- /dev/null +++ b/tests/Stubs/Controllers/ContainerResolvedController.php @@ -0,0 +1,69 @@ +json($validated->validated()); + } + + public function storeWithApp(Request $request): JsonResponse + { + $validated = app(SimpleRequest::class); + + return response()->json($validated->validated()); + } + + public function storeViaHelper(Request $request): JsonResponse + { + $validated = $this->validateRequest(); + + return response()->json($validated); + } + + public function storeViaNested(Request $request): JsonResponse + { + $validated = $this->validateNestedRequest(); + + return response()->json($validated); + } + + protected function validateRequest(): array + { + $request = resolve(SimpleRequest::class); + + return $request->validated(); + } + + protected function validateNestedRequest(): array + { + $request = $this->getRequestValidator(); + + return $request->validated(); + } + + public function getRequestValidator(): SimpleRequest + { + return resolve(SimpleRequest::class); + } + + public function abortOnly(): JsonResponse + { + abort(405); + } + + public function abortNotImplemented(): JsonResponse + { + abort(501); + } +}