Ubah dari Swagger ke Dedoc lalu buat API Ticket dan TicketType

This commit is contained in:
Wian Drs
2026-06-20 00:12:19 +07:00
parent 2f805a233d
commit 2dc461ae89
1283 changed files with 36405 additions and 89470 deletions
+3
View File
@@ -0,0 +1,3 @@
# Changelog
All notable changes to `scramble` will be documented in this file.
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) dedoc <litvinenko95@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+41
View File
@@ -0,0 +1,41 @@
<p>
<a href="https://scramble.dedoc.co" target="_blank">
<img src="./.github/gh-img.png?v=1" alt="Scramble Laravel API documentation generator"/>
</a>
</p>
# Scramble
Scramble generates API documentation for Laravel project. Without requiring you to manually write PHPDoc annotations. Docs are generated in OpenAPI 3.1.0 format.
## Documentation
You can find full documentation at [scramble.dedoc.co](https://scramble.dedoc.co).
## Introduction
The main motto of the project is generating your API documentation without requiring you to annotate your code.
This allows you to focus on code and avoid annotating every possible param/field as it may result in outdated documentation. By generating docs automatically from the code your API will always have up-to-date docs which you can trust.
## Installation
You can install the package via composer:
```shell
composer require dedoc/scramble
```
## Usage
After install you will have 2 routes added to your application:
- `/docs/api` - UI viewer for your documentation
- `/docs/api.json` - Open API document in JSON format describing your API.
By default, these routes are available only in `local` environment. You can change this behavior [by defining `viewApiDocs` gate](https://scramble.dedoc.co/usage/getting-started#docs-authorization).
---
<p>
<a href="https://savelife.in.ua/en/donate-en/" target="_blank">
<img src="./.github/gh-promo.svg?v=1" alt="Donate"/>
</a>
</p>
+73
View File
@@ -0,0 +1,73 @@
{
"name": "dedoc/scramble",
"description": "Automatic generation of API documentation for Laravel applications.",
"keywords": [
"laravel",
"documentation",
"openapi"
],
"homepage": "https://github.com/dedoc/scramble",
"license": "MIT",
"authors": [
{
"name": "Roman Lytvynenko",
"email": "litvinenko95@gmail.com",
"role": "Developer"
}
],
"require": {
"php": "^8.1",
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
"myclabs/deep-copy": "^1.12",
"nikic/php-parser": "^5.0",
"phpstan/phpdoc-parser": "^1.0|^2.0",
"spatie/laravel-package-tools": "^1.9.2"
},
"require-dev": {
"larastan/larastan": "^3.3",
"laravel/pint": "^v1.1.0",
"nunomaduro/collision": "^7.0|^8.0",
"orchestra/testbench": "^8.0|^9.0|^10.0|^11.0",
"pestphp/pest": "^2.34|^3.7|^4.4",
"pestphp/pest-plugin-laravel": "^2.3|^3.1|^4.1",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^10.5|^11.5.3|^12.5.12",
"spatie/laravel-permission": "^6.10|^7.2",
"spatie/pest-plugin-snapshots": "^2.1"
},
"autoload": {
"psr-4": {
"Dedoc\\Scramble\\": "src",
"Dedoc\\Scramble\\Database\\Factories\\": "database/factories"
}
},
"autoload-dev": {
"psr-4": {
"Dedoc\\Scramble\\Tests\\": "tests"
}
},
"scripts": {
"analyse": "vendor/bin/phpstan analyse",
"test": "vendor/bin/pest",
"test-coverage": "vendor/bin/pest --coverage",
"format": "vendor/bin/pint"
},
"config": {
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"phpstan/extension-installer": true
}
},
"extra": {
"laravel": {
"providers": [
"Dedoc\\Scramble\\ScrambleServiceProvider"
]
}
},
"minimum-stability": "dev",
"prefer-stable": true
}
+174
View File
@@ -0,0 +1,174 @@
<?php
use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess;
return [
/*
* Which routes to document. String or array form; use Scramble::routes() for custom selection.
*
* 'api_path' => [
* 'include' => 'api',
* 'exclude' => ['api/internal'],
* ],
*
* Without *, patterns match path segments (api matches api and api/users, not apiary).
* With *, Str::is is used (e.g. api/v*).
*
* One static include default server is /{include} and paths are stripped (/users).
* Multiple includes or wildcards server defaults to / and paths stay full (/api/users).
* Override with `servers`, or use Scramble::registerApi() for separate bases.
*/
'api_path' => 'api',
/*
* Your API domain. By default, app domain is used. This is also a part of the default API routes
* matcher, so when implementing your own, make sure you use this config if needed.
*/
'api_domain' => null,
/*
* The path where your OpenAPI specification will be exported.
*/
'export_path' => 'api.json',
/*
* Cache configuration for the generated OpenAPI document.
*
* Use `scramble:cache` to warm the cache and `scramble:clear` to invalidate it.
*/
'cache' => [
'key' => 'scramble.openapi',
'store' => 'file',
],
'info' => [
/*
* API version.
*/
'version' => env('API_VERSION', '0.0.1'),
/*
* Description rendered on the home page of the API documentation (`/docs/api`).
*/
'description' => '',
],
'ui' => [
'title' => null,
],
'renderer' => 'elements',
'renderers' => [
/*
* Stoplight Elements config options: https://docs.stoplight.io/docs/elements/b074dc47b2826-elements-configuration-options
*/
'elements' => [
'view' => 'scramble::docs',
'theme' => 'light',
'hideTryIt' => false,
'hideSchemas' => false,
'logo' => '',
'tryItCredentialsPolicy' => 'include',
'layout' => 'responsive',
'router' => 'hash',
],
/*
* Scalar API reference config options: https://scalar.com/products/api-references/configuration
*/
'scalar' => [
'view' => 'scramble::scalar',
'cdn' => 'https://cdn.jsdelivr.net/npm/@scalar/api-reference',
'theme' => 'laravel',
'proxyUrl' => 'https://proxy.scalar.com',
'darkMode' => false,
'showDeveloperTools' => 'never',
'agent' => ['disabled' => true],
'credentials' => 'include',
],
],
/*
* The list of servers of the API. By default, when `null`, server URL will be created from
* `scramble.api_path` and `scramble.api_domain` config variables. When providing an array, you
* will need to specify the local server URL manually (if needed).
*
* Example of non-default config (final URLs are generated using Laravel `url` helper):
*
* ```php
* 'servers' => [
* 'Live' => 'api',
* 'Prod' => 'https://scramble.dedoc.co/api',
* ],
* ```
*/
'servers' => null,
/**
* Determines how Scramble stores the descriptions of enum cases.
* Available options:
* - 'description' Case descriptions are stored as the enum schema's description using table formatting.
* - 'extension' Case descriptions are stored in the `x-enumDescriptions` enum schema extension.
*
* @see https://redocly.com/docs-legacy/api-reference-docs/specification-extensions/x-enum-descriptions
* - false - Case descriptions are ignored.
*/
'enum_cases_description_strategy' => 'description',
/**
* Determines how Scramble stores the names of enum cases.
* Available options:
* - 'names' Case names are stored in the `x-enumNames` enum schema extension.
* - 'varnames' - Case names are stored in the `x-enum-varnames` enum schema extension.
* - false - Case names are not stored.
*/
'enum_cases_names_strategy' => false,
/**
* When Scramble encounters deep objects in query parameters, it flattens the parameters so the generated
* OpenAPI document correctly describes the API. Flattening deep query parameters is relevant until
* OpenAPI 3.2 is released and query string structure can be described properly.
*
* For example, this nested validation rule describes the object with `bar` property:
* `['foo.bar' => ['required', 'int']]`.
*
* When `flatten_deep_query_parameters` is `true`, Scramble will document the parameter like so:
* `{"name":"foo[bar]", "schema":{"type":"int"}, "required":true}`.
*
* When `flatten_deep_query_parameters` is `false`, Scramble will document the parameter like so:
* `{"name":"foo", "schema": {"type":"object", "properties":{"bar":{"type": "int"}}, "required": ["bar"]}, "required":true}`.
*/
'flatten_deep_query_parameters' => true,
'middleware' => [
'web',
RestrictedDocsAccess::class,
],
'extensions' => [],
/*
* Automatically document API security (OpenAPI `security` / `securitySchemes`) based on route
* middleware.
*
* Disabled by default. Uncomment the line below to enable `MiddlewareAuthSecurityStrategy`.
* When at least one documented route uses middleware matching the configured patterns (by default
* `auth` and `auth:*`), bearer auth is applied globally. Routes without matching middleware are
* marked as public (`security: []`).
*
* Set to `null` explicitly to disable. If you already configure security manually via
* `afterOpenApiGenerated` / `extendOpenApi`, keep this disabled to avoid duplicate schemes.
*
* Customize with a class-string or [class, options]:
*
* 'security_strategy' => [
* \Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy::class,
* [
* 'middleware' => ['auth', 'auth:*'],
* 'scheme' => \Dedoc\Scramble\Support\Generator\SecurityScheme::http('bearer'),
* ],
* ],
*/
// 'security_strategy' => \Dedoc\Scramble\SecurityDocumentation\MiddlewareAuthSecurityStrategy::class,
'security_strategy' => null,
];
File diff suppressed because one or more lines are too long
+46
View File
@@ -0,0 +1,46 @@
<?php
use Dedoc\Scramble\Infer\Scope\Index;
use Dedoc\Scramble\Infer\Services\FileParser;
use PhpParser\ParserFactory;
include 'vendor/autoload.php';
$classes = [
Exception::class,
RuntimeException::class,
\Symfony\Component\HttpKernel\Exception\HttpException::class,
];
app()->singleton(FileParser::class, function () {
return new FileParser(
(new ParserFactory)->createForHostVersion()
);
});
app()->singleton(Index::class);
$classesDefinitions = [];
foreach ($classes as $className) {
$classesDefinitions[$className] = generateClassDefinitionInitialization($className);
}
function generateClassDefinitionInitialization(string $name)
{
$classAnalyzer = app(\Dedoc\Scramble\Infer\Analyzer\ClassAnalyzer::class);
$classDefinition = $classAnalyzer->analyze($name);
foreach ($classDefinition->methods as $methodName => $method) {
$classDefinition->getMethodDefinition($methodName);
}
return serialize($classAnalyzer->analyze($name));
}
$def = var_export($classesDefinitions, true);
file_put_contents(__DIR__.'/../classMap.php', <<<EOL
<?php
/*
* Do not change! This file is generated via scripts/generate.php.
*/
return {$def};
EOL);
+17
View File
@@ -0,0 +1,17 @@
includes:
- phpstan-baseline.neon
parameters:
level: 9
paths:
- src
- config
stubFiles:
- stubs/DeepCopy.stub
tmpDir: build/phpstan
checkOctaneCompatibility: true
checkModelProperties: true
typeAliases:
Rule: 'string|\Illuminate\Contracts\Validation\ValidationRule'
RuleSet: 'Rule|Rule[]'
#reportUnmatchedIgnoredErrors: false
+6
View File
@@ -0,0 +1,6 @@
{
"preset": "laravel",
"rules": {
"fully_qualified_strict_types": false
}
}
View File
+112
View File
@@ -0,0 +1,112 @@
<!doctype html>
<html lang="en" data-theme="{{ $config->renderer()->get('theme', 'light') }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="color-scheme" content="{{ $config->renderer()->get('theme', 'light') }}">
<title>{{ $config->get('ui.title') ?? config('app.name') . ' - API Docs' }}</title>
<script src="https://unpkg.com/@stoplight/elements@8.4.2/web-components.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@stoplight/elements@8.4.2/styles.min.css">
<script>
const originalFetch = window.fetch;
// intercept TryIt requests and add the XSRF-TOKEN header,
// which is necessary for Sanctum cookie-based authentication to work correctly
window.fetch = (url, options) => {
const CSRF_TOKEN_COOKIE_KEY = "XSRF-TOKEN";
const CSRF_TOKEN_HEADER_KEY = "X-XSRF-TOKEN";
const getCookieValue = (key) => {
const cookie = document.cookie.split(';').find((cookie) => cookie.trim().startsWith(key));
return cookie?.split("=")[1];
};
const updateFetchHeaders = (
headers,
headerKey,
headerValue,
) => {
if (headers instanceof Headers) {
headers.set(headerKey, headerValue);
} else if (Array.isArray(headers)) {
headers.push([headerKey, headerValue]);
} else if (headers) {
headers[headerKey] = headerValue;
}
};
const csrfToken = getCookieValue(CSRF_TOKEN_COOKIE_KEY);
if (csrfToken) {
const { headers = new Headers() } = options || {};
updateFetchHeaders(headers, CSRF_TOKEN_HEADER_KEY, decodeURIComponent(csrfToken));
return originalFetch(url, {
...options,
headers,
});
}
return originalFetch(url, options);
};
</script>
<style>
html, body { margin:0; height:100%; }
body { background-color: var(--color-canvas); }
/* issues about the dark theme of stoplight/mosaic-code-viewer using web component:
* https://github.com/stoplightio/elements/issues/2188#issuecomment-1485461965
*/
[data-theme="dark"] .token.property {
color: rgb(128, 203, 196) !important;
}
[data-theme="dark"] .token.operator {
color: rgb(255, 123, 114) !important;
}
[data-theme="dark"] .token.number {
color: rgb(247, 140, 108) !important;
}
[data-theme="dark"] .token.string {
color: rgb(165, 214, 255) !important;
}
[data-theme="dark"] .token.boolean {
color: rgb(121, 192, 255) !important;
}
[data-theme="dark"] .token.punctuation {
color: #dbdbdb !important;
}
</style>
</head>
<body style="height: 100vh; overflow-y: hidden">
<elements-api
id="docs"
@foreach($config->renderer()->all(except: ['theme']) as $key => $value)
@continue(! $value)
{{ $key }}="{{ $value === true ? 'true' : ($value === false ? 'false' : $value) }}"
@endforeach
/>
<script>
(async () => {
const docs = document.getElementById('docs');
docs.apiDescriptionDocument = @json($spec);
})();
</script>
@if($config->renderer()->get('theme', 'light') === 'system')
<script>
var mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
function updateTheme(e) {
if (e.matches) {
window.document.documentElement.setAttribute('data-theme', 'dark');
window.document.getElementsByName('color-scheme')[0].setAttribute('content', 'dark');
} else {
window.document.documentElement.setAttribute('data-theme', 'light');
window.document.getElementsByName('color-scheme')[0].setAttribute('content', 'light');
}
}
mediaQuery.addEventListener('change', updateTheme);
updateTheme(mediaQuery);
</script>
@endif
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>{{ $config->get('ui.title') ?? config('app.name') . ' - API Docs' }}</title>
</head>
<body>
<div id="app"></div>
<script src="{{ $config->renderer()->get('cdn', 'https://cdn.jsdelivr.net/npm/@scalar/api-reference') }}"></script>
<script>
const CSRF_TOKEN_COOKIE_KEY = "XSRF-TOKEN";
const CSRF_TOKEN_HEADER_KEY = "X-XSRF-TOKEN";
const getCookieValue = (key) => {
const cookie = document.cookie.split(';').find((cookie) => cookie.trim().startsWith(key));
return cookie?.split("=")[1];
};
Scalar.createApiReference('#app', {
content: @json($spec),
...@json($config->renderer()->all(except: ['cdn', 'credentials'])),
onBeforeRequest: ({ requestBuilder }) => {
requestBuilder.headers.set(CSRF_TOKEN_HEADER_KEY, decodeURIComponent(getCookieValue(CSRF_TOKEN_COOKIE_KEY)))
},
customFetch: (input, init) => {
return window.fetch(input, { ...init, credentials: @json($config->renderer()->get('credentials', 'include')) })
}
})
</script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
<?php
use Dedoc\Scramble\Scramble;
Scramble::registerUiRoute(path: 'docs/api')->name('scramble.docs.ui');
Scramble::registerJsonSpecificationRoute(path: 'docs/api.json')->name('scramble.docs.document');
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Dedoc\Scramble;
class AbstractOpenApiVisitor implements OpenApiVisitor
{
public function enter(mixed $object, array $path = []) {}
public function leave(mixed $object, array $path = []) {}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_ALL)]
class BodyParameter extends Parameter
{
public function __construct(
string $name,
?string $description = null,
?bool $required = null,
$deprecated = false,
?string $type = null,
?string $format = null,
bool $infer = true,
mixed $default = new MissingValue,
mixed $example = new MissingValue,
array $examples = [],
) {
parent::__construct('body', $name, $description, $required, $deprecated, $type, $format, $infer, $default, $example, $examples);
}
}
@@ -0,0 +1,24 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_ALL)]
class CookieParameter extends Parameter
{
public function __construct(
string $name,
?string $description = null,
?bool $required = null,
$deprecated = false,
?string $type = null,
?string $format = null,
bool $infer = true,
mixed $default = new MissingValue,
mixed $example = new MissingValue,
array $examples = [],
) {
parent::__construct('cookie', $name, $description, $required, $deprecated, $type, $format, $infer, $default, $example, $examples);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
/**
* Adds metadata to endpoints.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
class Endpoint
{
public function __construct(
/**
* Assigns an OperationID to a controller method.
*/
public readonly ?string $operationId = null,
/**
* Sets the title (summary) of the endpoint.
*/
public readonly ?string $title = null,
/**
* Sets the description of the endpoint.
*/
public readonly ?string $description = null,
/**
* Allows to override the method of the endpoint in the documentation. Useful when you want to document the
* `PUT|PATCH` endpoint as `PATCH`. The method provided here MUST be the actual method the API will reply to.
*/
public readonly ?string $method = null,
) {}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Dedoc\Scramble\Support\Generator\Example as OpenApiExample;
use Dedoc\Scramble\Support\Generator\MissingValue as OpenApiMissingValue;
class Example
{
public function __construct(
public mixed $value = new MissingValue,
public ?string $summary = null,
public ?string $description = null,
public ?string $externalValue = null,
) {}
public static function toOpenApiExample(Example $example): OpenApiExample
{
return new OpenApiExample(
value: $example->value instanceof MissingValue ? new OpenApiMissingValue : $example->value,
summary: $example->summary,
description: $example->description,
externalValue: $example->externalValue,
);
}
}
@@ -0,0 +1,11 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
/**
* Excludes all routes of a controller from the API documentation. Applies to controller's methods.
*/
#[Attribute(Attribute::TARGET_CLASS)]
class ExcludeAllRoutesFromDocs {}
@@ -0,0 +1,11 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
/**
* Excludes a route from API documentation. Applies to controller's methods.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
class ExcludeRouteFromDocs {}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
/**
* Groups endpoints.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
class Group
{
public function __construct(
public readonly ?string $name = null,
public readonly ?string $description = null,
/**
* Determines the ordering of the groups. Groups with the same weight, are sorted
* by the name (with `SORT_LOCALE_STRING` sorting flag).
*/
public readonly int $weight = PHP_INT_MAX,
) {}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper;
use Dedoc\Scramble\Support\Generator\Header as OpenApiHeader;
use Dedoc\Scramble\Support\Generator\MissingValue as OpenApiMissingValue;
use Dedoc\Scramble\Support\Generator\Schema;
use Dedoc\Scramble\Support\Generator\Types\MixedType;
use Dedoc\Scramble\Support\Generator\Types\StringType;
use Dedoc\Scramble\Support\Generator\Types\Type as OpenApiType;
use Dedoc\Scramble\Support\Generator\TypeTransformer;
use Dedoc\Scramble\Support\PhpDoc;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
class Header
{
/**
* @param scalar|array<mixed>|object|MissingValue $example
*/
public function __construct(
public readonly string $name,
public readonly ?string $description = null,
public readonly ?string $type = null,
public readonly ?string $format = null,
public readonly ?bool $required = null,
/** @var array<mixed>|scalar|null|MissingValue */
public readonly mixed $default = new MissingValue,
public readonly mixed $example = new MissingValue,
/** @var array<string, Example> $examples */
public readonly array $examples = [],
public readonly ?bool $deprecated = null,
public readonly ?bool $explode = null,
public readonly int|string $status = 200,
) {}
public static function toOpenApiHeader(Header $header, TypeTransformer $openApiTransformer): OpenApiHeader
{
$type = self::getType($header, $openApiTransformer);
$default = $header->default instanceof MissingValue ? new OpenApiMissingValue : $header->default;
$format = $header->format ?: '';
if ($type instanceof MixedType && ($format || ! $default instanceof OpenApiMissingValue)) {
$type = new StringType;
}
return new OpenApiHeader(
description: $header->description,
required: $header->required,
deprecated: $header->deprecated,
explode: $header->explode,
schema: Schema::fromType(
$type->default($default)->format($format)
),
example: $header->example instanceof MissingValue ? new OpenApiMissingValue : $header->example,
examples: array_map(fn ($e) => Example::toOpenApiExample($e), $header->examples),
);
}
public static function getType(Header $header, TypeTransformer $openApiTransformer): OpenApiType
{
return $header->type ? $openApiTransformer->transform(
PhpDocTypeHelper::toType(
PhpDoc::parse("/** @return $header->type */")->getReturnTagValues()[0]->type ?? new IdentifierTypeNode('mixed')
)
) : new MixedType;
}
}
@@ -0,0 +1,24 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_ALL)]
class HeaderParameter extends Parameter
{
public function __construct(
string $name,
?string $description = null,
?bool $required = null,
$deprecated = false,
?string $type = null,
?string $format = null,
bool $infer = true,
mixed $default = new MissingValue,
mixed $example = new MissingValue,
array $examples = [],
) {
parent::__construct('header', $name, $description, $required, $deprecated, $type, $format, $infer, $default, $example, $examples);
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
/**
* Marks a class property as hidden from OpenAPI object schema.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
class Hidden {}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
class IgnoreParam
{
/**
* @param 'query'|'path'|'header'|'cookie'|'body'|null $in
*/
public function __construct(
public readonly string $name,
public readonly ?string $in = null,
) {}
}
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace Dedoc\Scramble\Attributes;
class MissingValue {}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_ALL)]
class Parameter
{
public readonly bool $required;
/**
* @param 'query'|'path'|'header'|'cookie'|'body' $in
* @param scalar|array|object|MissingValue $example
* @param array<string, Example> $examples The key is a distinct name and the value is an example object.
*/
public function __construct(
public readonly string $in,
public readonly string $name,
public readonly ?string $description = null,
?bool $required = null,
public bool $deprecated = false,
public ?string $type = null,
public ?string $format = null,
public bool $infer = true,
public mixed $default = new MissingValue,
public mixed $example = new MissingValue,
public array $examples = [],
) {
$this->required = $required !== null ? $required : $this->in === 'path';
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_ALL)]
class PathParameter extends Parameter
{
public function __construct(
string $name,
?string $description = null,
?bool $required = null,
$deprecated = false,
?string $type = null,
?string $format = null,
bool $infer = true,
mixed $default = new MissingValue,
mixed $example = new MissingValue,
array $examples = [],
) {
parent::__construct('path', $name, $description, $required, $deprecated, $type, $format, $infer, $default, $example, $examples);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_ALL)]
class QueryParameter extends Parameter
{
public function __construct(
string $name,
?string $description = null,
?bool $required = null,
$deprecated = false,
?string $type = null,
?string $format = null,
bool $infer = true,
mixed $default = new MissingValue,
mixed $example = new MissingValue,
array $examples = [],
) {
parent::__construct('query', $name, $description, $required, $deprecated, $type, $format, $infer, $default, $example, $examples);
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
use Dedoc\Scramble\Infer\Services\FileNameResolver;
use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper;
use Dedoc\Scramble\Support\Generator\Reference;
use Dedoc\Scramble\Support\Generator\Response as OpenApiResponse;
use Dedoc\Scramble\Support\Generator\Schema;
use Dedoc\Scramble\Support\Generator\Types\StringType;
use Dedoc\Scramble\Support\Generator\Types\Type;
use Dedoc\Scramble\Support\Generator\TypeTransformer;
use Dedoc\Scramble\Support\PhpDoc;
use Illuminate\Support\Str;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
use function DeepCopy\deep_copy;
/**
* @phpstan-type BaseExample array<mixed>|scalar|null
*/
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
class Response
{
public function __construct(
public readonly int|string $status = 200,
public readonly ?string $description = null,
public readonly string $mediaType = 'application/json',
public readonly ?string $type = null,
public readonly ?string $format = null,
/** @var BaseExample|BaseExample[] $examples */
public readonly mixed $examples = [],
) {}
public static function toOpenApiResponse(Response $responseAttribute, ?OpenApiResponse $originalResponse, TypeTransformer $openApiTransformer, ?FileNameResolver $nameResolver): OpenApiResponse
{
$response = $originalResponse ? deep_copy($originalResponse) : OpenApiResponse::make($responseAttribute->status);
$response = self::applyResponseMediaType($responseAttribute, $response, $openApiTransformer, $nameResolver);
$response->setDescription(self::getDescription($responseAttribute, $response));
return $response;
}
private static function applyResponseMediaType(Response $responseAttribute, OpenApiResponse $response, TypeTransformer $openApiTransformer, ?FileNameResolver $nameResolver): OpenApiResponse
{
if (! $responseAttribute->type) {
return $response
->setContent(
$responseAttribute->mediaType,
self::getMediaType($responseAttribute, $response),
);
}
$responseFromType = $openApiTransformer->toResponse(
PhpDocTypeHelper::toType(
PhpDoc::parse("/** @return $responseAttribute->type */", $nameResolver)->getReturnTagValues()[0]->type ?? new IdentifierTypeNode('mixed')
)
);
if ($responseFromType instanceof Reference) {
$responseFromType = deep_copy($responseFromType->resolve());
}
/** @var OpenApiResponse|null $responseFromType */
if (! $responseFromType) {
return $response->setContent(
$responseAttribute->mediaType,
self::getMediaType($responseAttribute, $response),
);
}
return $response
->setDescription($responseFromType->description ?: self::getDescription($responseAttribute, $response))
->setContent(
$responseAttribute->mediaType,
self::getMediaType($responseAttribute, $responseFromType),
);
}
private static function getDescription(Response $responseAttribute, OpenApiResponse $response): string
{
if ($responseAttribute->description === null) {
return $response->description;
}
return Str::replace('$0', $response->description, $responseAttribute->description);
}
private static function getMediaType(Response $responseAttribute, OpenApiResponse $response): Schema|Reference
{
return self::getSchema($responseAttribute, $response->content[$responseAttribute->mediaType] ?? null);
}
private static function getSchema(Response $responseAttribute, Schema|Reference|null $schema): Schema|Reference
{
if (! $responseAttribute->format && ! $responseAttribute->examples) {
return $schema ?: Schema::fromType(new StringType);
}
$schemaType = $schema
? clone ($schema instanceof Reference ? $schema->resolve()->type : $schema->type)
: new StringType;
/** @var Type $schemaType */
if ($responseAttribute->format) {
$schemaType->format($responseAttribute->format);
}
if ($responseAttribute->examples) {
$schemaType->examples($responseAttribute->examples); // @phpstan-ignore argument.type
}
return Schema::fromType($schemaType);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
/**
* Allows naming class based schemas for different contexts.
*/
#[Attribute(Attribute::TARGET_CLASS)]
class SchemaName
{
public function __construct(
public readonly string $name,
/**
* Some classes can be used both as input and output schemas. So this property is used to
* explicitly name the schema when is in input context.
*/
public readonly ?string $input = null,
) {}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace Dedoc\Scramble;
class CacheableGenerator
{
public function __construct(
private Generator $generator,
) {}
public function __invoke(?GeneratorConfig $config = null): array
{
$config ??= Scramble::getGeneratorConfig(Scramble::DEFAULT_API);
$store = config('scramble.cache.store');
$keyBase = config('scramble.cache.key');
if (! $store || ! $keyBase) {
return ($this->generator)($config);
}
$key = $keyBase.':'.$this->resolveApi($config);
if ($cached = cache()->store($store)->get($key)) {
return $cached;
}
return ($this->generator)($config);
}
private function resolveApi(GeneratorConfig $config): string
{
foreach (Scramble::getConfigurationsInstance()->all() as $api => $generatorConfig) {
if ($generatorConfig === $config) {
return $api;
}
}
return Scramble::DEFAULT_API;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use InvalidArgumentException;
class ApiPath
{
/**
* @param list<string> $includes
* @param list<string> $excludes
*/
private function __construct(
private readonly array $includes,
private readonly array $excludes,
) {}
/**
* @param string|array{include?: string|string[], exclude?: string|string[]}|null $config
*/
public static function from(mixed $config, string $default = 'api'): self
{
if ($config === null || is_string($config)) {
return new self(self::normalizePrefixes($config ?? $default), []);
}
if (is_array($config) && (array_key_exists('include', $config) || array_key_exists('exclude', $config))) {
return new self(
self::normalizePrefixes($config['include'] ?? $default),
self::normalizePrefixes($config['exclude'] ?? []),
);
}
throw new InvalidArgumentException(
'Invalid scramble.api_path config. Expected a string or an array with `include` and/or `exclude` keys.'
);
}
/**
* @return list<string>
*/
private static function normalizePrefixes(mixed $prefixes): array
{
if ($prefixes === '' || $prefixes === null) {
return [];
}
return array_values(array_filter(Arr::wrap($prefixes), fn ($prefix) => is_string($prefix) && $prefix !== ''));
}
public function matches(string $uri): bool
{
if ($this->includes !== [] && ! $this->matchesAny($uri, $this->includes)) {
return false;
}
return ! $this->matchesAny($uri, $this->excludes);
}
public function stripPrefix(string $uri): string
{
if (! $this->usesSingleBase()) {
return $uri;
}
return (string) Str::of($uri)
->replaceStart($this->includes[0], '')
->trim('/');
}
public function serverPath(): string
{
return $this->usesSingleBase() ? $this->includes[0] : '';
}
private function usesSingleBase(): bool
{
return count($this->includes) === 1 && ! str_contains($this->includes[0], '*');
}
/**
* @param list<string> $patterns
*/
private function matchesAny(string $uri, array $patterns): bool
{
foreach ($patterns as $pattern) {
if (str_contains($pattern, '*') ? Str::is($pattern, $uri) : ($uri === $pattern || Str::startsWith($uri, $pattern.'/'))) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,56 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\Contracts\DocumentTransformer;
use Illuminate\Support\Arr;
class DocumentTransformers
{
protected array $transformers = [];
protected array $appends = [];
protected array $prepends = [];
public function append(array|callable|string $transformers)
{
$this->appends = array_merge(
$this->appends,
Arr::wrap($transformers)
);
return $this;
}
public function prepend(array|callable|string $transformers)
{
$this->prepends = array_merge(
$this->prepends,
Arr::wrap($transformers)
);
return $this;
}
public function use(array $transformers)
{
$this->transformers = $transformers;
return $this;
}
/**
* @return (callable|class-string<DocumentTransformer>)[]
*/
public function all(): array
{
$base = $this->transformers;
return array_values(array_unique([
...$this->prepends,
...$base,
...$this->appends,
], SORT_REGULAR));
}
}
@@ -0,0 +1,53 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\GeneratorConfig;
use Dedoc\Scramble\Scramble;
use Illuminate\Routing\Router;
use LogicException;
class GeneratorConfigCollection
{
/**
* @var array<string, GeneratorConfig>
*/
private array $apis = [];
public function __construct()
{
$this->apis[Scramble::DEFAULT_API] = $this->buildDefaultApiConfiguration();
}
private function buildDefaultApiConfiguration(): GeneratorConfig
{
return (new GeneratorConfig)
->expose(
ui: fn (Router $router, $action) => $router->get('docs/api', $action)->name('scramble.docs.ui'),
document: fn (Router $router, $action) => $router->get('docs/api.json', $action)->name('scramble.docs.document'),
);
}
public function get(string $name): GeneratorConfig
{
if (! array_key_exists($name, $this->apis)) {
throw new LogicException("$name API is not registered. Register the API using `Scramble::registerApi` first.");
}
return $this->apis[$name];
}
public function register(string $name, array $config): GeneratorConfig
{
$this->apis[$name] = $generatorConfig = $this->apis[Scramble::DEFAULT_API]
->cloneWithoutExposing()
->useConfig(array_merge(config('scramble') ?: [], $config));
return $generatorConfig;
}
public function all(): array
{
return $this->apis;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\Infer\Configuration\ClassLike;
use Dedoc\Scramble\Infer\Configuration\DefinitionMatcher;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
/**
* @internal
*/
class InferConfig
{
/** @var DefinitionMatcher[] */
private array $forcedAstSourcedDefinitionsMatchers = [];
/** @var DefinitionMatcher[] */
private array $forcedReflectionSourcedDefinitionsMatchers = [];
/**
* @param (string|DefinitionMatcher)[] $items
* @return $this
*/
public function buildDefinitionsUsingAstFor(array $items): static
{
$this->forcedAstSourcedDefinitionsMatchers = [
...$this->forcedAstSourcedDefinitionsMatchers,
...array_map(
fn ($item) => is_string($item) ? new ClassLike($item) : $item,
Arr::wrap($items),
),
];
return $this;
}
/**
* @param (string|DefinitionMatcher)[] $items
* @return $this
*/
public function buildDefinitionsUsingReflectionFor(array $items): static
{
$this->forcedReflectionSourcedDefinitionsMatchers = [
...$this->forcedReflectionSourcedDefinitionsMatchers,
...array_map(
fn ($item) => is_string($item) ? new ClassLike($item) : $item,
Arr::wrap($items),
),
];
return $this;
}
/**
* @param DefinitionMatcher[] $matchers
*/
private function matchesAnyDefinitionMatcher(string $class, array $matchers): bool
{
foreach ($matchers as $item) {
if ($item->matches($class)) {
return true;
}
}
return false;
}
public function shouldAnalyzeAst(string $class): bool
{
if ($this->matchesAnyDefinitionMatcher($class, $this->forcedReflectionSourcedDefinitionsMatchers)) {
return false;
}
if ($this->matchesAnyDefinitionMatcher($class, $this->forcedAstSourcedDefinitionsMatchers)) {
return true;
}
// Ignoring static analysis error due to it is fine to exception to be thrown here if bad input.
$reflection = new \ReflectionClass($class); // @phpstan-ignore argument.type
$path = $reflection->getFileName();
if (! $path) {
return true; // Keep in mind the internal classes are analyzed via AST analyzer
}
return ! Str::contains($path, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR);
}
}
@@ -0,0 +1,24 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\Enums\JsonApiArraySerialization;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;
class JsonApiConfig
{
public function __construct(
public readonly JsonApiArraySerialization $arraySerialization = JsonApiArraySerialization::Comma,
public readonly ?int $maxRelationshipDepth = null,
) {}
/** @return non-negative-int */
public function maxRelationshipDepth(): int
{
if ($this->maxRelationshipDepth === null) {
return JsonApiResource::$maxRelationshipDepth; // @phpstan-ignore return.type
}
return max(0, min($this->maxRelationshipDepth, JsonApiResource::$maxRelationshipDepth));
}
}
@@ -0,0 +1,69 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\Contracts\OperationTransformer;
use Dedoc\Scramble\Support\OperationExtensions\DeprecationExtension;
use Dedoc\Scramble\Support\OperationExtensions\ErrorResponsesExtension;
use Dedoc\Scramble\Support\OperationExtensions\RequestBodyExtension;
use Dedoc\Scramble\Support\OperationExtensions\RequestEssentialsExtension;
use Dedoc\Scramble\Support\OperationExtensions\ResponseExtension;
use Dedoc\Scramble\Support\OperationExtensions\ResponseHeadersExtension;
use Illuminate\Support\Arr;
class OperationTransformers
{
protected array $transformers = [];
protected array $appends = [];
protected array $prepends = [];
public function append(array|callable|string $transformers)
{
$this->appends = array_merge(
$this->appends,
Arr::wrap($transformers)
);
return $this;
}
public function prepend(array|callable|string $transformers)
{
$this->prepends = array_merge(
$this->prepends,
Arr::wrap($transformers)
);
return $this;
}
public function use(array $transformers)
{
$this->transformers = $transformers;
return $this;
}
/**
* @return list<callable|class-string<OperationTransformer>>
*/
public function all(): array
{
$base = $this->transformers ?: [
RequestEssentialsExtension::class,
RequestBodyExtension::class,
ErrorResponsesExtension::class,
ResponseExtension::class,
ResponseHeadersExtension::class,
DeprecationExtension::class,
];
return array_values(array_unique([
...$this->prepends,
...$base,
...$this->appends,
], SORT_REGULAR));
}
}
@@ -0,0 +1,88 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\AttributesParametersExtractor;
use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\FormRequestParametersExtractor;
use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\JsonApiResourceParametersExtractor;
use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\MethodCallsParametersExtractor;
use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\ParameterExtractor;
use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\PathParametersExtractor;
use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\ValidateCallParametersExtractor;
use Illuminate\Support\Arr;
class ParametersExtractors
{
/** @var class-string<ParameterExtractor>[] */
protected array $extractors = [];
/** @var class-string<ParameterExtractor>[] */
protected array $appends = [];
/** @var class-string<ParameterExtractor>[] */
protected array $prepends = [];
/**
* @param class-string<ParameterExtractor>[]|class-string<ParameterExtractor> $extractor
* @return $this
*/
public function append(array|string $extractor): self
{
$this->appends = array_merge(
$this->appends,
Arr::wrap($extractor)
);
return $this;
}
/**
* @param class-string<ParameterExtractor>[]|class-string<ParameterExtractor> $extractor
* @return $this
*/
public function prepend(array|string $extractor): self
{
$this->prepends = array_merge(
$this->prepends,
Arr::wrap($extractor)
);
return $this;
}
/**
* @param class-string<ParameterExtractor>[] $extractors
* @return $this
*/
public function use(array $extractors): self
{
$this->extractors = $extractors;
return $this;
}
/**
* @return class-string<ParameterExtractor>[]
*/
public function all(): array
{
$base = $this->extractors ?: [
PathParametersExtractor::class,
FormRequestParametersExtractor::class,
ValidateCallParametersExtractor::class,
JsonApiResourceParametersExtractor::class,
];
$defaultAppends = [
MethodCallsParametersExtractor::class,
AttributesParametersExtractor::class,
];
return array_values(array_unique([
...$this->prepends,
...$base,
...$this->appends,
...$defaultAppends,
]));
}
}
@@ -0,0 +1,32 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Illuminate\Support\Arr;
class RendererConfig
{
public readonly string $view;
private array $config;
/**
* @param array{view: string} $config
*/
public function __construct(
array $config = []
) {
$this->config = Arr::except($config, ['view']);
$this->view = $config['view'];
}
public function get(string $key, mixed $default = null): mixed
{
return Arr::get($this->config, $key, $default);
}
public function all(array $except = []): array
{
return Arr::except($this->config, $except);
}
}
@@ -0,0 +1,97 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\Contracts\AllRulesSchemasTransformer;
use Dedoc\Scramble\Contracts\RuleTransformer;
use Dedoc\Scramble\RuleTransformers\ConfirmedRule;
use Dedoc\Scramble\RuleTransformers\EnumRule;
use Dedoc\Scramble\RuleTransformers\ExistsRule;
use Dedoc\Scramble\RuleTransformers\InRule;
use Dedoc\Scramble\RuleTransformers\RegexRule;
use Dedoc\Scramble\Support\ContainerUtils;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
class RuleTransformers
{
/** @var list<class-string<RuleTransformer|AllRulesSchemasTransformer>> */
protected array $transformers = [];
/** @var list<class-string<RuleTransformer|AllRulesSchemasTransformer>> */
protected array $appends = [];
/** @var list<class-string<RuleTransformer|AllRulesSchemasTransformer>> */
protected array $prepends = [];
/**
* @param class-string<RuleTransformer|AllRulesSchemasTransformer>|list<class-string<RuleTransformer|AllRulesSchemasTransformer>> $transformers
*/
public function append(array|string $transformers): self
{
$this->appends = array_values(array_merge( // @phpstan-ignore arrayValues.list
$this->appends,
Arr::wrap($transformers)
));
return $this;
}
/**
* @param class-string<RuleTransformer|AllRulesSchemasTransformer>|list<class-string<RuleTransformer|AllRulesSchemasTransformer>> $transformers
*/
public function prepend(array|string $transformers): self
{
$this->prepends = array_values(array_merge( // @phpstan-ignore arrayValues.list
$this->prepends,
Arr::wrap($transformers)
));
return $this;
}
/**
* @param list<class-string<RuleTransformer|AllRulesSchemasTransformer>> $transformers
*/
public function use(array $transformers): self
{
$this->transformers = $transformers;
return $this;
}
/**
* @template TExtensionType of object
*
* @param class-string<TExtensionType> $type
* @param array<string, mixed> $contextfulBindings
* @return Collection<int, TExtensionType>
*/
public function instances(string $type, array $contextfulBindings): Collection
{
return collect($this->all()) // @phpstan-ignore return.type
->filter(fn ($class) => is_a($class, $type, true))
->map(fn ($class) => ContainerUtils::makeContextable($class, $contextfulBindings))
->values();
}
/**
* @return list<class-string<RuleTransformer|AllRulesSchemasTransformer>>
*/
public function all(): array
{
$base = $this->transformers ?: [
EnumRule::class,
InRule::class,
ConfirmedRule::class,
ExistsRule::class,
RegexRule::class,
];
return array_values(array_unique([
...$this->prepends,
...$base,
...$this->appends,
], SORT_REGULAR));
}
}
@@ -0,0 +1,17 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\GeneratorConfig;
use Illuminate\Support\Collection;
class SecurityDocumentationContext
{
/**
* @param Collection<int, \Illuminate\Routing\Route> $routes
*/
public function __construct(
public readonly Collection $routes,
public readonly GeneratorConfig $config,
) {}
}
@@ -0,0 +1,32 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\Support\Generator\ServerVariable;
class ServerVariables
{
/**
* @param array<string, ServerVariable> $items
*/
public function __construct(public array $items = []) {}
public function use(array $items)
{
$this->items = $items;
return $this;
}
public function all()
{
return $this->items;
}
public function set(string $name, ServerVariable $serverVariable)
{
$this->items[$name] = $serverVariable;
return $this;
}
}
@@ -0,0 +1,141 @@
<?php
namespace Dedoc\Scramble\Console\Commands;
use Dedoc\Scramble\Console\Commands\Components\TermsOfContentItem;
use Dedoc\Scramble\Exceptions\ConsoleRenderable;
use Dedoc\Scramble\Exceptions\RouteAware;
use Dedoc\Scramble\Generator;
use Dedoc\Scramble\Scramble;
use Illuminate\Console\Command;
use Illuminate\Routing\Route;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Throwable;
class AnalyzeDocumentation extends Command
{
protected $signature = 'scramble:analyze
{--api=default : The API to analyze}
';
protected $description = 'Analyzes the documentation generation process to surface any issues.';
public function handle(Generator $generator): int
{
$generator->setThrowExceptions(false);
$generator(Scramble::getGeneratorConfig($this->option('api')));
$i = 1;
$this->groupExceptions($generator->exceptions)->each(function (Collection $exceptions, string $group) use (&$i) {
$this->renderExceptionsGroup($exceptions, $group, $i);
});
if (count($generator->exceptions)) {
$this->error('[ERROR] Found '.count($generator->exceptions).' errors.');
return static::FAILURE;
}
$this->info('Everything is fine! Documentation is generated without any errors 🍻');
return static::SUCCESS;
}
/**
* @return Collection<string, Collection<int, Throwable>>
*/
private function groupExceptions(array $exceptions): Collection
{
return collect($exceptions)
->groupBy(fn ($e) => $e instanceof RouteAware ? $this->getRouteKey($e->getRoute()) : '');
}
/**
* @param Collection<int, Throwable> $exceptions
*/
private function renderExceptionsGroup(Collection $exceptions, string $group, int &$i): void
{
// when route key is set, then the exceptions in the group are route aware.
if ($group) {
$this->renderRouteExceptionsGroupLine($exceptions);
}
$exceptions->each(function ($exception) use (&$i) {
$this->renderException($exception, $i);
$i++;
$this->line('');
});
}
private function getRouteKey(?Route $route): string
{
if (! $route) {
return '';
}
$method = implode('|', $route->methods());
$action = $route->getAction('uses');
return "$method.$action";
}
/**
* @param Collection<int, RouteAware> $exceptions
*/
private function renderRouteExceptionsGroupLine(Collection $exceptions): void
{
$firstException = $exceptions->first();
$route = $firstException->getRoute();
$method = implode('|', $route->methods());
$errorsMessage = ($count = $exceptions->count()).' '.Str::plural('error', $count);
$tocComponent = new TermsOfContentItem(
right: '<options=bold;fg='.$this->getHttpMethodColor($method).'>'.$method."</> $route->uri <fg=red>$errorsMessage</>",
left: $this->getRouteAction($route),
);
$tocComponent->render($this->output);
$this->line('');
}
private function getHttpMethodColor(string $method): string
{
return match ($method) {
'POST', 'PUT' => 'blue',
'DELETE' => 'red',
default => 'yellow',
};
}
public function getRouteAction(?Route $route): ?string
{
if (! $uses = $route->getAction('uses')) {
return null;
}
if (count($parts = explode('@', $uses)) !== 2 || ! method_exists(...$parts)) {
return null;
}
[$class, $method] = $parts;
$eloquentClassName = Str::replace(['App\Http\Controllers\\', 'App\Http\\'], '', $class);
return "<fg=gray>{$eloquentClassName}@{$method}</>";
}
private function renderException(Throwable $exception, int $i): void
{
$message = Str::replace('Dedoc\Scramble\Support\Generator\Types\\', '', property_exists($exception, 'originalMessage') ? $exception->originalMessage : $exception->getMessage()); // @phpstan-ignore argument.templateType
$this->output->writeln("<options=bold>$i. {$message}</>");
if ($exception instanceof ConsoleRenderable) {
$exception->renderInConsole($this->output);
}
}
}
@@ -0,0 +1,41 @@
<?php
namespace Dedoc\Scramble\Console\Commands;
use Dedoc\Scramble\Console\Commands\Concerns\ManagesDocumentationCache;
use Dedoc\Scramble\Generator;
use Dedoc\Scramble\Scramble;
use Illuminate\Console\Command;
class CacheDocumentation extends Command
{
use ManagesDocumentationCache;
protected $signature = 'scramble:cache
{--api=* : The API to cache a documentation for (by default, caches all APIs)}
';
protected $description = 'Cache the generated OpenAPI document.';
public function handle(Generator $generator): int
{
if (! $this->ensureDocumentationCacheConfigured()) {
return self::SUCCESS;
}
$store = config('scramble.cache.store');
foreach ($this->resolveApis() as $api) {
$config = Scramble::getGeneratorConfig($api);
cache()->store($store)->forever(
$this->cacheKey($api),
$generator($config),
);
$this->info("OpenAPI document cached for [{$api}] API.");
}
return self::SUCCESS;
}
}
@@ -0,0 +1,34 @@
<?php
namespace Dedoc\Scramble\Console\Commands;
use Dedoc\Scramble\Console\Commands\Concerns\ManagesDocumentationCache;
use Illuminate\Console\Command;
class ClearDocumentationCache extends Command
{
use ManagesDocumentationCache;
protected $signature = 'scramble:clear
{--api=* : The API to clear a documentation cache for (by default, clears all APIs)}
';
protected $description = 'Clear the cached OpenAPI document.';
public function handle(): int
{
if (! $this->ensureDocumentationCacheConfigured()) {
return self::SUCCESS;
}
$store = config('scramble.cache.store');
foreach ($this->resolveApis() as $api) {
cache()->store($store)->forget($this->cacheKey($api));
$this->info("OpenAPI document cache cleared for [{$api}] API.");
}
return self::SUCCESS;
}
}
@@ -0,0 +1,21 @@
<?php
namespace Dedoc\Scramble\Console\Commands\Components;
use Illuminate\Console\OutputStyle;
use NunoMaduro\Collision\Highlighter;
class Code implements Component
{
public function __construct(
public string $filePath,
public int $line,
) {}
public function render(OutputStyle $style): void
{
$code = (new Highlighter)->highlight(file_get_contents($this->filePath), $this->line);
$style->writeln($code);
}
}
@@ -0,0 +1,10 @@
<?php
namespace Dedoc\Scramble\Console\Commands\Components;
use Illuminate\Console\OutputStyle;
interface Component
{
public function render(OutputStyle $style): void;
}
@@ -0,0 +1,40 @@
<?php
namespace Dedoc\Scramble\Console\Commands\Components;
use Illuminate\Console\OutputStyle;
use Illuminate\Support\Str;
use Symfony\Component\Console\Terminal;
class TermsOfContentItem implements Component
{
public function __construct(
public string $right,
public ?string $left = null,
) {}
public function render(OutputStyle $style): void
{
$width = (new Terminal)->getWidth();
$rightWidth = $this->getLineWidth($this->right ?: '');
$leftWidth = $this->getLineWidth($this->left ?: '');
if (! $leftWidth) {
$style->writeln($this->right);
return;
}
$dotsCount = max($width - $rightWidth - $leftWidth - 2, 0);
$style->writeln("{$this->right}<fg=gray> ".Str::repeat('.', $dotsCount)." </>{$this->left}");
}
private function getLineWidth(string $string)
{
$re = '/<.*?>/m';
return mb_strlen(preg_replace($re, '', $string));
}
}
@@ -0,0 +1,42 @@
<?php
namespace Dedoc\Scramble\Console\Commands\Concerns;
use Dedoc\Scramble\Scramble;
trait ManagesDocumentationCache
{
protected function ensureDocumentationCacheConfigured(): bool
{
if (config('scramble.cache.store') === null) {
$this->info('Documentation cache store is not configured. Set `scramble.cache.store` in your config.');
return false;
}
if (config('scramble.cache.key') === null) {
$this->info('Documentation cache key is not configured. Set `scramble.cache.key` in your config.');
return false;
}
return true;
}
/** @return string[] */
protected function resolveApis(): array
{
$apis = array_filter($this->option('api') ?: []);
if ($apis === [] || $apis === ['*']) {
return array_keys(Scramble::getConfigurationsInstance()->all());
}
return $apis;
}
protected function cacheKey(string $api): string
{
return config('scramble.cache.key').':'.$api;
}
}
@@ -0,0 +1,35 @@
<?php
namespace Dedoc\Scramble\Console\Commands;
use Dedoc\Scramble\Generator;
use Dedoc\Scramble\Scramble;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class ExportDocumentation extends Command
{
protected $signature = 'scramble:export
{--path= : The path to save the exported JSON file}
{--api=default : The API to export a documentation for}
';
protected $description = 'Export the OpenAPI document to a JSON file.';
public function handle(Generator $generator): void
{
$api = $this->option('api');
$path = $this->option('path');
$config = Scramble::getGeneratorConfig($api);
$specification = json_encode($generator($config), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
/** @var string $filename */
$filename = $path ?: $config->get('export_path') ?? 'api'.($api === 'default' ? '' : "-$api").'.json';
File::put($filename, $specification);
$this->info("OpenAPI document exported to {$filename}.");
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Dedoc\Scramble;
/** @internal */
class ContextReferences
{
public function __construct(
public readonly ContextReferencesCollection $schemas = new ContextReferencesCollection,
public readonly ContextReferencesCollection $responses = new ContextReferencesCollection,
public readonly ContextReferencesCollection $parameters = new ContextReferencesCollection,
public readonly ContextReferencesCollection $examples = new ContextReferencesCollection,
public readonly ContextReferencesCollection $requestBodies = new ContextReferencesCollection,
public readonly ContextReferencesCollection $headers = new ContextReferencesCollection,
public readonly ContextReferencesCollection $securitySchemes = new ContextReferencesCollection,
public readonly ContextReferencesCollection $links = new ContextReferencesCollection,
public readonly ContextReferencesCollection $callbacks = new ContextReferencesCollection,
public readonly ContextReferencesCollection $pathItems = new ContextReferencesCollection,
) {}
}
@@ -0,0 +1,79 @@
<?php
namespace Dedoc\Scramble;
use Dedoc\Scramble\Support\Generator\Reference;
use Illuminate\Support\Str;
/** @internal */
class ContextReferencesCollection
{
private array $tempNames = [];
/**
* @param array<string, Reference[]> $items The key is reference ID and the value is a list of such references.
*/
public function __construct(
public array $items = [],
) {}
public function has(string $referenceId): bool
{
return array_key_exists($referenceId, $this->items);
}
/**
* @return Reference[]
*/
public function get(string $referenceId): array
{
return $this->items[$referenceId] ?? [];
}
public function add(string $referenceId, Reference $reference): Reference
{
$this->items[$referenceId] ??= [];
$this->items[$referenceId][] = $this->setUniqueName($reference);
return $reference;
}
public function setUniqueName(Reference $reference): Reference
{
$reference->fullName = $reference->shortName ?: $this->uniqueSchemaName($reference->fullName);
return $reference;
}
public function uniqueName(string $referenceId): string
{
if ($this->has($referenceId)) {
$reference = $this->get($referenceId)[0];
return $reference->shortName ?: $reference->fullName;
}
return $this->uniqueSchemaName($referenceId);
}
private function uniqueSchemaName(string $fullName)
{
$shortestPossibleName = class_basename($fullName);
if (
($this->tempNames[$shortestPossibleName] ?? null) === null
|| ($this->tempNames[$shortestPossibleName] ?? null) === $fullName
) {
$this->tempNames[$shortestPossibleName] = $fullName;
return static::slug($shortestPossibleName);
}
return static::slug($fullName);
}
private static function slug(string $name)
{
return Str::replace('\\', '.', $name);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Dedoc\Scramble\Contracts;
use Dedoc\Scramble\Support\RuleTransforming\NormalizedRule;
use Dedoc\Scramble\Support\RuleTransforming\RuleTransformerContext;
use Dedoc\Scramble\Support\RuleTransforming\SchemaBag;
interface AllRulesSchemasTransformer
{
public function shouldHandle(NormalizedRule $rule): bool;
public function transformAll(SchemaBag $schemaBag, NormalizedRule $rule, RuleTransformerContext $context): void;
}
@@ -0,0 +1,11 @@
<?php
namespace Dedoc\Scramble\Contracts;
use Dedoc\Scramble\OpenApiContext;
use Dedoc\Scramble\Support\Generator\OpenApi;
interface DocumentTransformer
{
public function handle(OpenApi $document, OpenApiContext $context);
}
@@ -0,0 +1,11 @@
<?php
namespace Dedoc\Scramble\Contracts;
use Dedoc\Scramble\Support\Generator\Operation;
use Dedoc\Scramble\Support\RouteInfo;
interface OperationTransformer
{
public function handle(Operation $operation, RouteInfo $routeInfo);
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Dedoc\Scramble\Contracts;
use Dedoc\Scramble\Support\Generator\Types\Type;
use Dedoc\Scramble\Support\RuleTransforming\NormalizedRule;
use Dedoc\Scramble\Support\RuleTransforming\RuleTransformerContext;
interface RuleTransformer
{
public function shouldHandle(NormalizedRule $rule): bool;
public function toSchema(Type $previous, NormalizedRule $rule, RuleTransformerContext $context): Type;
}
@@ -0,0 +1,11 @@
<?php
namespace Dedoc\Scramble\Contracts;
use Dedoc\Scramble\Configuration\SecurityDocumentationContext;
use Dedoc\Scramble\GeneratorConfig;
interface SecurityDocumentationStrategy
{
public function configure(SecurityDocumentationContext $context): GeneratorConfig;
}
@@ -0,0 +1,57 @@
<?php
namespace Dedoc\Scramble\DocumentTransformers;
use Dedoc\Scramble\Attributes\Group;
use Dedoc\Scramble\Contracts\DocumentTransformer;
use Dedoc\Scramble\OpenApiContext;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\Tag;
use Illuminate\Support\Collection;
use ReflectionAttribute;
class AddDocumentTags implements DocumentTransformer
{
public function handle(OpenApi $document, OpenApiContext $context): void
{
$document->tags = $this->makeTagsFromGroupAttributes($context->groups);
}
/**
* @param Collection<int, ReflectionAttribute<Group>> $groupsAttributes
* @return Tag[]
*/
private function makeTagsFromGroupAttributes(Collection $groupsAttributes)
{
/** @var Collection<string, Tag> $tags */
$tags = $groupsAttributes->reduce(function (Collection $acc, ReflectionAttribute $attribute) {
$arguments = $attribute->getArguments();
$name = $arguments['name'] ?? $arguments[0] ?? null;
if (! $name) {
return $acc;
}
$description = $arguments['description'] ?? $arguments[1] ?? null;
$weight = $arguments['weight'] ?? $arguments[2] ?? null;
/** @var Tag $tag */
$tag = $acc->get($name, new Tag($name));
if ($description !== null && $tag->description === null) {
$tag->description = $description;
}
if ($weight !== null && $tag->getAttribute('weight') === null) {
$tag->setAttribute('weight', $weight);
}
$acc->offsetSet($name, $tag);
return $acc;
}, collect());
return $tags->sortBy(fn (Tag $t) => $t->getAttribute('weight', INF))->values()->all();
}
}
@@ -0,0 +1,36 @@
<?php
namespace Dedoc\Scramble\DocumentTransformers;
use Dedoc\Scramble\Contracts\DocumentTransformer;
use Dedoc\Scramble\OpenApiContext;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Illuminate\Support\Str;
class CleanupUnusedResponseReferencesTransformer implements DocumentTransformer
{
private string $serializedDocumentJson;
public function handle(OpenApi $document, OpenApiContext $context): void
{
$components = $document->components;
$responses = $components->responses;
foreach ($responses as $responseName => $reference) {
if (! $this->isResponseReferenceUsed($document, $context->references->responses->uniqueName($responseName))) {
$components->removeResponse($responseName);
}
}
}
private function isResponseReferenceUsed(OpenApi $document, string $responseName): bool
{
if (! isset($this->serializedDocumentJson)) {
$this->serializedDocumentJson = json_encode($document->toArray(), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
$referencePath = "#/components/responses/{$responseName}";
return Str::contains($this->serializedDocumentJson, $referencePath);
}
}
@@ -0,0 +1,9 @@
<?php
namespace Dedoc\Scramble\Enums;
enum JsonApiArraySerialization
{
case String;
case Comma;
}
@@ -0,0 +1,10 @@
<?php
namespace Dedoc\Scramble\Exceptions;
use Illuminate\Console\OutputStyle;
interface ConsoleRenderable
{
public function renderInConsole(OutputStyle $outputStyle): void;
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace Dedoc\Scramble\Exceptions;
use Dedoc\Scramble\Console\Commands\Components\Code;
use Dedoc\Scramble\Support\Generator\Types\Type;
use Exception;
use Illuminate\Console\OutputStyle;
use Illuminate\Routing\Route;
class InvalidSchema extends Exception implements ConsoleRenderable, RouteAware
{
use RouteAwareTrait;
public ?string $jsonPointer = null;
public string $originalMessage = '';
public ?string $originFile = null;
public ?int $originLine = null;
public static function createForSchema(string $message, string $path, Type $schema)
{
/** @var string|null $file */
$file = $schema->getAttribute('file');
/** @var int|null $line */
$line = $schema->getAttribute('line');
$originalMessage = $message;
if ($file) {
$message = rtrim($message, '.').'. Got when analyzing an expression in file ['.$file.'] on line '.$line;
}
$exception = new static($path.': '.$message);
$exception->originalMessage = $originalMessage;
$exception->originFile = $file;
$exception->originLine = $line;
$exception->jsonPointer = $path;
return $exception;
}
public function getRouteAwareMessage(Route $route, string $msg): string
{
$method = $route->methods()[0];
$action = $route->getAction('uses');
return "'$method $route->uri' ($action): ".$msg;
}
public function renderInConsole(OutputStyle $outputStyle): void
{
$codeSample = null;
$tableCells = [];
if ($this->jsonPointer) {
$tableCells[] = ['<fg=gray>Found at</>', $this->jsonPointer];
}
if ($this->originFile) {
$line = $this->originLine ? ":$this->originLine" : '';
$tableCells[] = ['<fg=gray>Inferred at</>', "{$this->originFile}{$line}"];
if ($line) {
$path = class_exists($this->originFile)
? (new \ReflectionClass($this->originFile))->getFileName()
: $this->originFile;
$codeSample = new Code($path, $this->originLine);
}
}
$outputStyle->createTable()->setRows($tableCells)->setStyle('compact')->render();
$codeSample?->render($outputStyle);
}
}
@@ -0,0 +1,5 @@
<?php
namespace Dedoc\Scramble\Exceptions;
class OpenApiReferenceTargetNotFoundException extends \Exception {}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Dedoc\Scramble\Exceptions;
use Illuminate\Routing\Route;
interface RouteAware
{
public function setRoute(Route $route): static;
public function getRoute(): ?Route;
public function getRouteAwareMessage(Route $route, string $msg): string;
}
@@ -0,0 +1,30 @@
<?php
namespace Dedoc\Scramble\Exceptions;
use Exception;
use Illuminate\Routing\Route;
/**
* @mixin Exception
*/
trait RouteAwareTrait
{
protected ?Route $route = null;
public function setRoute(Route $route): static
{
$this->route = $route;
if (method_exists($this, 'getRouteAwareMessage')) {
$this->message = $this->getRouteAwareMessage($route, $this->getMessage());
}
return $this;
}
public function getRoute(): ?Route
{
return $this->route;
}
}
@@ -0,0 +1,64 @@
<?php
namespace Dedoc\Scramble\Exceptions;
use Exception;
use Illuminate\Support\Arr;
use Throwable;
class RulesEvaluationException extends Exception
{
/** @var array<string, Throwable> */
public array $exceptions = [];
/**
* @param array<string, Throwable> $exceptions
*/
public static function fromExceptions(array $exceptions): self
{
$exceptions = collect(Arr::wrap($exceptions))
->reduce(function (array $acc, Throwable $e, string $key) {
if ($e instanceof self) {
return array_merge($acc, $e->exceptions);
}
return [...$acc, $key => $e];
}, []);
$previous = reset($exceptions) ?: null;
$exception = new self(self::buildMessage($exceptions), previous: $previous);
$exception->exceptions = $exceptions;
return $exception;
}
/**
* @param array<string, Throwable> $exceptions
*/
private static function buildMessage(array $exceptions): string
{
$lines = [
'Cannot evaluate validation rules ('.count($exceptions).' evaluators failed):',
];
foreach ($exceptions as $key => $e) {
$name = class_basename($key);
$context = self::extractFileLocation($e);
$lines[] = " [$name] {$e->getMessage()}".($context ? " {$context}" : '');
}
return implode("\n", $lines);
}
private static function extractFileLocation(Throwable $e): ?string
{
if ($e->getFile() && $e->getLine()) {
return "(at {$e->getFile()}:{$e->getLine()})";
}
return null;
}
}
@@ -0,0 +1,29 @@
<?php
namespace Dedoc\Scramble\Extensions;
use Dedoc\Scramble\Infer;
use Dedoc\Scramble\Support\Generator\Components;
use Dedoc\Scramble\Support\Generator\TypeTransformer;
use Dedoc\Scramble\Support\Type\Type;
abstract class ExceptionToResponseExtension
{
protected Infer $infer;
protected TypeTransformer $openApiTransformer;
protected Components $components;
public function __construct(Infer $infer, TypeTransformer $openApiTransformer, Components $components)
{
$this->infer = $infer;
$this->openApiTransformer = $openApiTransformer;
$this->components = $components;
}
public function toResponse(Type $type)
{
return null;
}
}
@@ -0,0 +1,21 @@
<?php
namespace Dedoc\Scramble\Extensions;
use Dedoc\Scramble\Contracts\OperationTransformer;
use Dedoc\Scramble\GeneratorConfig;
use Dedoc\Scramble\Infer;
use Dedoc\Scramble\Support\Generator\Operation;
use Dedoc\Scramble\Support\Generator\TypeTransformer;
use Dedoc\Scramble\Support\RouteInfo;
abstract class OperationExtension implements OperationTransformer
{
public function __construct(
protected Infer $infer,
protected TypeTransformer $openApiTransformer,
protected GeneratorConfig $config
) {}
abstract public function handle(Operation $operation, RouteInfo $routeInfo);
}
@@ -0,0 +1,34 @@
<?php
namespace Dedoc\Scramble\Extensions;
use Dedoc\Scramble\Infer;
use Dedoc\Scramble\Support\Generator\Components;
use Dedoc\Scramble\Support\Generator\Response;
use Dedoc\Scramble\Support\Generator\Types\Type as OpenApiType;
use Dedoc\Scramble\Support\Generator\Types\UnknownType;
use Dedoc\Scramble\Support\Generator\TypeTransformer;
use Dedoc\Scramble\Support\Type\Type;
abstract class TypeToSchemaExtension
{
public function __construct(protected Infer $infer, protected TypeTransformer $openApiTransformer, protected Components $components) {}
/**
* @param Type $type The type being transformed to schema.
* @param ?OpenApiType $previousExtensionResult The resulting schema from a previous extension.
*/
public function toSchema(Type $type)
{
return new UnknownType;
}
/**
* @param Type $type The type being transformed to response.
* @param ?Response $previousExtensionResult The resulting response from a previous extension.
*/
public function toResponse(Type $type)
{
return null;
}
}
+373
View File
@@ -0,0 +1,373 @@
<?php
namespace Dedoc\Scramble;
use Closure;
use Dedoc\Scramble\Attributes\ExcludeAllRoutesFromDocs;
use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs;
use Dedoc\Scramble\Configuration\SecurityDocumentationContext;
use Dedoc\Scramble\Contracts\DocumentTransformer;
use Dedoc\Scramble\Exceptions\RouteAware;
use Dedoc\Scramble\OpenApiVisitor\SchemaEnforceVisitor;
use Dedoc\Scramble\Support\ContainerUtils;
use Dedoc\Scramble\Support\Generator\Components;
use Dedoc\Scramble\Support\Generator\InfoObject;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\Operation;
use Dedoc\Scramble\Support\Generator\Path;
use Dedoc\Scramble\Support\Generator\Reference;
use Dedoc\Scramble\Support\Generator\Server;
use Dedoc\Scramble\Support\Generator\TypeTransformer;
use Dedoc\Scramble\Support\Generator\UniqueNameOptions;
use Dedoc\Scramble\Support\Generator\UniqueNamesOptionsCollection;
use Dedoc\Scramble\Support\OperationBuilder;
use Dedoc\Scramble\Support\RouteInfo;
use Dedoc\Scramble\Support\ServerFactory;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Str;
use InvalidArgumentException;
use ReflectionException;
use ReflectionMethod;
use Throwable;
class Generator
{
public array $exceptions = [];
protected bool $throwExceptions = true;
public function __construct(
private OperationBuilder $operationBuilder,
) {}
public function setThrowExceptions(bool $throwExceptions): static
{
$this->throwExceptions = $throwExceptions;
return $this;
}
public function __invoke(?GeneratorConfig $config = null)
{
$config ??= Scramble::getGeneratorConfig(Scramble::DEFAULT_API);
$routes = $this->getRoutes($config);
$config = $this->configureSecurityStrategy($routes, $config);
$openApi = $this->makeOpenApi($config);
$context = new OpenApiContext($openApi, $config);
$typeTransformer = $this->buildTypeTransformer($context);
$routes
->flatMap(function (Route $route, int $index) use ($openApi, $config, $typeTransformer) {
try {
$operations = $this->routeToOperations($openApi, $route, $config, $typeTransformer);
foreach ($operations as $i => $operation) {
if ($route->getAction('uses') instanceof Closure) {
$operation->setAttribute('isClosure', true);
}
$operation->setAttribute('index', $index + $i);
}
return $operations;
} catch (Throwable $e) {
if ($e instanceof RouteAware) {
$e->setRoute($route);
}
if (config('app.debug', false)) {
$method = implode('|', $route->methods());
$action = $route->getAction('uses');
if ($action instanceof Closure) {
$action = '{closure}';
}
dump("Error when analyzing route '$method $route->uri' ($action): {$e->getMessage()} ".($e->getFile().' on line '.$e->getLine()));
logger()->error("Error when analyzing route '$method $route->uri' ($action): {$e->getMessage()} ".($e->getFile().' on line '.$e->getLine()));
}
throw $e;
}
})
->filter()
->sortBy($this->createOperationsSorter())
->each(fn (Operation $operation) => $openApi->addPath(
Path::make(
$config->apiPath()->stripPrefix($operation->path)
)->addOperation($operation)
))
->toArray();
$this->setUniqueOperationId($openApi);
$this->moveSameAlternativeServersToPath($openApi);
foreach ($config->documentTransformers->all() as $openApiTransformer) {
$openApiTransformer = is_callable($openApiTransformer)
? $openApiTransformer
: ContainerUtils::makeContextable($openApiTransformer, [
TypeTransformer::class => $typeTransformer,
]);
if (is_callable($openApiTransformer)) {
$openApiTransformer($openApi, $context);
continue;
}
if ($openApiTransformer instanceof DocumentTransformer) {
$openApiTransformer->handle($openApi, $context);
continue;
}
// @phpstan-ignore deadCode.unreachable
throw new InvalidArgumentException('(callable(OpenApi, OpenApiContext): void)|DocumentTransformer type for document transformer expected, received '.$openApiTransformer::class);
}
return $openApi->toArray();
}
private function configureSecurityStrategy(Collection $routes, GeneratorConfig $config): GeneratorConfig
{
$strategy = $config->securityStrategy();
if (! $strategy) {
return $config;
}
return $strategy->configure(new SecurityDocumentationContext($routes, $config->cloneWithoutExposing()));
}
private function createOperationsSorter(): array
{
$defaultSortValue = fn (Operation $o) => $o->tags[0] ?? null;
return [
fn (Operation $a, Operation $b) => $a->getAttribute('groupWeight', INF) <=> $b->getAttribute('groupWeight', INF),
fn (Operation $a, Operation $b) => $a->getAttribute('weight', INF) <=> $b->getAttribute('weight', INF), // @todo manual endpoint sorting
fn (Operation $a, Operation $b) => $defaultSortValue($a) <=> $defaultSortValue($b),
fn (Operation $a, Operation $b) => $a->getAttribute('index', INF) <=> $b->getAttribute('index', INF),
];
}
private function makeOpenApi(GeneratorConfig $config)
{
$openApi = OpenApi::make('3.1.0')
->setComponents(new Components)
->setInfo(
InfoObject::make($config->get('ui.title', $default = config('app.name')) ?: $default)
->setVersion($config->get('info.version', '0.0.1'))
->setDescription($config->get('info.description', ''))
);
[$defaultProtocol] = explode('://', url('/'));
$servers = $config->get('servers') ?: [
'' => ($domain = $config->get('api_domain'))
? $defaultProtocol.'://'.$domain.'/'.$config->apiPath()->serverPath()
: $config->apiPath()->serverPath(),
];
foreach ($servers as $description => $url) {
$openApi->addServer(
(new ServerFactory($config->serverVariables->all()))->make(url($url ?: '/'), $description)
);
}
return $openApi;
}
/**
* @return Collection<int, Route>
*/
private function getRoutes(GeneratorConfig $config): Collection
{
return collect(RouteFacade::getRoutes())
->pipe(function (Collection $c) {
$onlyRoutes = $c->filter(function (Route $route) {
if (! is_string($route->getAction('controller'))) {
return false;
}
if (! is_string($route->getAction('uses'))) {
return false;
}
try {
$reflection = new ReflectionMethod(...explode('@', $route->getAction('uses')));
if (str_contains($reflection->getDocComment() ?: '', '@only-docs')) {
return true;
}
} catch (Throwable) {
}
return false;
});
return $onlyRoutes->count() ? $onlyRoutes : $c;
})
->filter(function (Route $route) {
return ! ($name = $route->getAction('as')) || ! Str::startsWith($name, 'scramble');
})
->filter($config->routes())
->filter(function (Route $route) {
if (! is_string($route->getAction('uses'))) {
return true;
}
try {
$reflection = new ReflectionMethod(...explode('@', $route->getAction('uses')));
} catch (ReflectionException) {
/*
* If route is registered but route method doesn't exist, it will not be included
* in the resulting documentation.
*/
return false;
}
if (count($reflection->getAttributes(ExcludeRouteFromDocs::class))) {
return false;
}
if (count($reflection->getDeclaringClass()->getAttributes(ExcludeAllRoutesFromDocs::class))) {
return false;
}
return true;
})
->values();
}
private function buildTypeTransformer(OpenApiContext $context): TypeTransformer
{
return app()->make(TypeTransformer::class, [
'context' => $context,
]);
}
/** @return Operation[] */
private function routeToOperations(OpenApi $openApi, Route $route, GeneratorConfig $config, TypeTransformer $typeTransformer): array
{
$methods = array_map('strtolower', Arr::wrap(($config->operationMethodsResolver)($route)));
$operations = [];
foreach ($methods as $method) {
$routeInfo = new RouteInfo($route, $method);
$operation = $this->operationBuilder->build($routeInfo, $openApi, $config, $typeTransformer);
$this->ensureSchemaTypes($route, $operation);
$operations[] = $operation;
}
return $operations;
}
private function ensureSchemaTypes(Route $route, Operation $operation): void
{
if (! Scramble::getSchemaValidator()->hasRules()) {
return;
}
[$traverser, $visitor] = $this->createSchemaEnforceTraverser($route);
$traverser->traverse($operation, ['', 'paths', $operation->path, $operation->method]);
$references = $visitor->popReferences();
/** @var Reference $ref */
foreach ($references as $ref) {
if ($resolvedType = $ref->resolve()) {
$traverser->traverse($resolvedType, ['', 'components', $ref->referenceType, $ref->getUniqueName()]);
}
}
}
private function createSchemaEnforceTraverser(Route $route)
{
$traverser = new OpenApiTraverser([$visitor = new SchemaEnforceVisitor($route, $this->throwExceptions, $this->exceptions)]);
return [$traverser, $visitor];
}
private function moveSameAlternativeServersToPath(OpenApi $openApi)
{
foreach (collect($openApi->paths)->groupBy('path') as $pathsGroup) {
if ($pathsGroup->isEmpty()) {
continue;
}
$operations = collect($pathsGroup->pluck('operations')->flatten());
$operationsHaveSameAlternativeServers = $operations->count()
&& $operations->every(fn (Operation $o) => count($o->servers))
&& $operations->unique(function (Operation $o) {
return collect($o->servers)->map(fn (Server $s) => $s->url)->join('.');
})->count() === 1;
if (! $operationsHaveSameAlternativeServers) {
continue;
}
$pathsGroup->every->servers($operations->first()->servers);
foreach ($operations as $operation) {
$operation->servers([]);
}
}
}
private function setUniqueOperationId(OpenApi $openApi)
{
$names = new UniqueNamesOptionsCollection;
$this->foreachOperation($openApi, function (Operation $operation) use ($names) {
if ($operation->operationId) {
return;
}
$names->push($operation->getAttribute('operationId')); // @phpstan-ignore argument.type
});
$this->foreachOperation($openApi, function (Operation $operation, $index) use ($names) {
if ($operation->operationId) {
return;
}
$name = $operation->getAttribute('operationId');
if (! $name instanceof UniqueNameOptions) {
return;
}
if (! $name->eloquent && $operation->getAttribute('isClosure')) {
return;
}
$operation->setOperationId($names->getUniqueName($name, function (string $fallback) use ($index) {
return "{$fallback}_{$index}";
}));
});
}
private function foreachOperation(OpenApi $openApi, callable $callback)
{
foreach (collect($openApi->paths)->groupBy('path') as $pathsGroup) {
if ($pathsGroup->isEmpty()) {
continue;
}
$operations = collect($pathsGroup->pluck('operations')->flatten());
foreach ($operations as $index => $operation) {
$callback($operation, $index);
}
}
}
}
+319
View File
@@ -0,0 +1,319 @@
<?php
namespace Dedoc\Scramble;
use Closure;
use Dedoc\Scramble\Configuration\ApiPath;
use Dedoc\Scramble\Configuration\DocumentTransformers;
use Dedoc\Scramble\Configuration\JsonApiConfig;
use Dedoc\Scramble\Configuration\OperationTransformers;
use Dedoc\Scramble\Configuration\ParametersExtractors;
use Dedoc\Scramble\Configuration\RendererConfig;
use Dedoc\Scramble\Configuration\RuleTransformers;
use Dedoc\Scramble\Configuration\ServerVariables;
use Dedoc\Scramble\Contracts\AllRulesSchemasTransformer;
use Dedoc\Scramble\Contracts\RuleTransformer;
use Dedoc\Scramble\Contracts\SecurityDocumentationStrategy;
use Dedoc\Scramble\Enums\JsonApiArraySerialization;
use Dedoc\Scramble\Support\Generator\ServerVariable;
use Illuminate\Routing\Route;
use Illuminate\Routing\Router;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use ReflectionFunction;
use ReflectionNamedType;
class GeneratorConfig
{
/**
* @var (Closure(Router, mixed): Route)|string|null
*/
public Closure|string|null $uiRoute = null;
/**
* @var (Closure(Router, mixed): Route)|string|null
*/
public Closure|string|null $documentRoute = null;
/**
* @var Closure(Route): (string|string[])
*/
public Closure $operationMethodsResolver;
public function __construct(
private array $config = [],
private ?Closure $routeResolver = null,
public readonly ParametersExtractors $parametersExtractors = new ParametersExtractors,
public readonly OperationTransformers $operationTransformers = new OperationTransformers,
public readonly DocumentTransformers $documentTransformers = new DocumentTransformers,
public readonly RuleTransformers $ruleTransformers = new RuleTransformers,
public readonly ServerVariables $serverVariables = new ServerVariables,
?Closure $operationMethodsResolver = null,
public JsonApiConfig $jsonApi = new JsonApiConfig,
private ?ApiPath $resolvedApiPath = null,
) {
$this->operationMethodsResolver = $operationMethodsResolver ?: fn (Route $r) => $r->methods()[0];
}
public function config(array $config)
{
$this->config = $config;
$this->resolvedApiPath = null;
return $this;
}
public function routes(?Closure $routeResolver = null)
{
if (count(func_get_args()) === 0) {
return $this->routeResolver ?: $this->defaultRoutesFilter(...);
}
if ($routeResolver) {
$this->routeResolver = $routeResolver;
}
return $this;
}
public function renderer(): RendererConfig
{
if (Arr::has($this->config, 'ui.logo')) {
return new RendererConfig(array_merge(
$this->get('renderers.elements', []),
$this->get('ui'),
));
}
return new RendererConfig($this->get('renderers.'.$this->get('renderer'), []));
}
/**
* @param (Closure(Router, mixed): Route)|string|false $ui
* @param (Closure(Router, mixed): Route)|string|false $document
*/
public function expose(Closure|string|false $ui = false, Closure|string|false $document = false): static
{
if (count(func_get_args()) === 1 && isset(func_get_args()[0]) && func_get_args()[0] === false) {
$this->uiRoute = null;
$this->documentRoute = null;
return $this;
}
$this->uiRoute = $ui ?: null;
$this->documentRoute = $document ?: null;
return $this;
}
private function defaultRoutesFilter(Route $route)
{
$expectedDomain = $this->get('api_domain');
return $this->apiPath()->matches($route->uri)
&& (! $expectedDomain || $route->getDomain() === $expectedDomain);
}
public function apiPath(): ApiPath
{
return $this->resolvedApiPath ??= ApiPath::from($this->get('api_path'), 'api');
}
public function afterOpenApiGenerated(?callable $afterOpenApiGenerated = null)
{
if (count(func_get_args()) === 0) {
return $this->documentTransformers->all();
}
if ($afterOpenApiGenerated) {
$this->documentTransformers->append($afterOpenApiGenerated);
}
return $this;
}
public function useConfig(array $config): static
{
$this->config = $config;
$this->resolvedApiPath = null;
return $this;
}
public function cloneWithoutExposing(): static
{
return new GeneratorConfig(
config: $this->config,
routeResolver: $this->routeResolver,
parametersExtractors: clone $this->parametersExtractors,
operationTransformers: clone $this->operationTransformers,
documentTransformers: clone $this->documentTransformers,
ruleTransformers: clone $this->ruleTransformers,
serverVariables: clone $this->serverVariables,
operationMethodsResolver: $this->operationMethodsResolver,
jsonApi: clone $this->jsonApi,
);
}
public function withParametersExtractors(callable $callback): static
{
$callback($this->parametersExtractors);
return $this;
}
public function withOperationTransformers(array|string|callable $cb): static
{
if ($this->isOperationTransformerMapper($cb)) {
$cb($this->operationTransformers);
return $this;
}
$this->operationTransformers->append($cb);
return $this;
}
/**
* @param list<class-string<RuleTransformer|AllRulesSchemasTransformer>>|class-string<RuleTransformer|AllRulesSchemasTransformer>|(callable(RuleTransformers): void) $cb
*/
public function withRuleTransformers(array|string|callable $cb): self
{
if (is_callable($cb)) {
$cb($this->ruleTransformers);
return $this;
}
$this->ruleTransformers->append($cb);
return $this;
}
private function isOperationTransformerMapper($cb): bool
{
if (! $cb instanceof Closure) {
return false;
}
$reflection = new ReflectionFunction($cb);
return count($reflection->getParameters()) === 1
&& $reflection->getParameters()[0]->getType() instanceof ReflectionNamedType
&& is_a($reflection->getParameters()[0]->getType()->getName(), OperationTransformers::class, true);
}
public function withDocumentTransformers(array|string|callable $cb): static
{
if ($this->isDocumentTransformerMapper($cb)) {
$cb($this->documentTransformers);
return $this;
}
$this->documentTransformers->append($cb);
return $this;
}
private function isDocumentTransformerMapper($cb): bool
{
if (! $cb instanceof Closure) {
return false;
}
$reflection = new ReflectionFunction($cb);
return count($reflection->getParameters()) === 1
&& $reflection->getParameters()[0]->getType() instanceof ReflectionNamedType
&& is_a($reflection->getParameters()[0]->getType()->getName(), DocumentTransformers::class, true);
}
/**
* @param (callable(ServerVariables): void)|array<string, ServerVariable> $variables
*/
public function withServerVariables(callable|array $variables)
{
if (is_callable($variables)) {
$variables($this->serverVariables);
return $this;
}
$this->serverVariables->use($variables);
return $this;
}
/**
* Force Scramble to use `PATCH` for `PUT|PATCH` routes.
*/
public function preferPatchMethod(): static
{
return $this->resolveOperationMethodsUsing(function (Route $route): string {
$methods = array_map('strtolower', $route->methods());
return in_array('patch', $methods) && in_array('put', $methods) ? 'patch' : $methods[0];
});
}
/**
* @param Closure(Route): (string|string[]) $resolver
*/
public function resolveOperationMethodsUsing(Closure $resolver): static
{
$this->operationMethodsResolver = $resolver;
return $this;
}
public function jsonApi(
JsonApiArraySerialization $arraySerialization = JsonApiArraySerialization::Comma,
?int $maxRelationshipDepth = null,
): static {
$this->jsonApi = new JsonApiConfig(
arraySerialization: $arraySerialization,
maxRelationshipDepth: $maxRelationshipDepth,
);
return $this;
}
public function get(string $key, mixed $default = null)
{
return Arr::get($this->config, $key, $default);
}
public function securityStrategy(): ?SecurityDocumentationStrategy
{
$value = $this->get('security_strategy');
if ($value === null) {
return null;
}
[$class, $options] = is_string($value)
? [$value, []]
: (is_array($value) && count($value) === 2 && is_string($value[0])
? [$value[0], $value[1]]
: [null, []]);
if ($class === null) {
throw new InvalidArgumentException(
'Invalid scramble.security_strategy config. Expected null, a class-string, or [class-string, options array].'
);
}
$strategy = app($class, $options);
if (! $strategy instanceof SecurityDocumentationStrategy) {
throw new InvalidArgumentException(
"Security strategy [{$class}] must implement ".SecurityDocumentationStrategy::class.'.'
);
}
return $strategy;
}
}
@@ -0,0 +1,21 @@
<?php
namespace Dedoc\Scramble\Http\Middleware;
use Illuminate\Support\Facades\Gate;
class RestrictedDocsAccess
{
public function handle($request, \Closure $next)
{
if (app()->environment('local')) {
return $next($request);
}
if (Gate::allows('viewApiDocs')) {
return $next($request);
}
abort(403);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Dedoc\Scramble;
use Dedoc\Scramble\Configuration\InferConfig;
use Dedoc\Scramble\Infer\Analyzer\ClassAnalyzer;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Scope\Index;
class Infer
{
public function __construct(
public Index $index,
public InferConfig $config,
) {}
public function analyzeClass(string $class): ClassDefinition
{
if (! $this->index->getClassDefinition($class)) {
$this->index->registerClassDefinition(
(new ClassAnalyzer($this->index))->analyze($class)
);
}
return $this->index->getClassDefinition($class);
}
public function configure(): InferConfig
{
return $this->config;
}
}
@@ -0,0 +1,109 @@
<?php
namespace Dedoc\Scramble\Infer\Analyzer;
use Dedoc\Scramble\Infer\Context;
use Dedoc\Scramble\Infer\Definition\AttributeDefinition;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Definition\ClassPropertyDefinition;
use Dedoc\Scramble\Infer\Definition\PendingDocComment;
use Dedoc\Scramble\Infer\Definition\PropertyVisibility;
use Dedoc\Scramble\Infer\Extensions\Event\ClassDefinitionCreatedEvent;
use Dedoc\Scramble\Infer\Scope\Index;
use Dedoc\Scramble\Support\Type\TemplateType;
use Dedoc\Scramble\Support\Type\TypeHelper;
use Dedoc\Scramble\Support\Type\UnknownType;
use Illuminate\Support\Str;
use ReflectionClass;
class ClassAnalyzer
{
public function __construct(private Index $index) {}
/**
* @throws \ReflectionException
*/
public function analyze(string $name): ClassDefinition
{
$classReflection = new ReflectionClass($name); // @phpstan-ignore argument.type
$parentName = ($classReflection->getParentClass() ?: null)?->name;
$parentDefinition = $parentName ? $this->index->getClass($parentName) : null;
$classDefinition = new ClassDefinition(
name: $name,
templateTypes: $parentDefinition?->templateTypes ?: [],
properties: array_map(fn ($pd) => clone $pd, $parentDefinition?->properties ?: []),
methods: array_map(fn ($md) => $md->copyFromParent(), $parentDefinition?->methods ?: []),
parentFqn: $parentName,
);
/*
* Traits get analyzed by embracing default behavior of PHP reflection: reflection properties and
* reflection methods get copied into the class that uses the trait.
*/
foreach ($classReflection->getProperties() as $reflectionProperty) {
if ($reflectionProperty->class !== $name) {
continue;
}
if (array_key_exists($reflectionProperty->name, $classDefinition->properties)) {
$classDefinition->properties[$reflectionProperty->name]->defaultType = $reflectionProperty->hasDefaultValue()
? PropertyAnalyzer::from($reflectionProperty)->getDefaultType()
: null;
continue;
}
if ($reflectionProperty->isStatic()) {
$classDefinition->properties[$reflectionProperty->name] = new ClassPropertyDefinition(
type: $reflectionProperty->hasDefaultValue()
? (TypeHelper::createTypeFromValue($reflectionProperty->getDefaultValue()) ?: new UnknownType)
: new UnknownType,
isStatic: $reflectionProperty->isStatic(),
visibility: PropertyVisibility::fromReflectionProperty($reflectionProperty),
attributes: AttributeDefinition::fromReflectionAttributesArray($reflectionProperty->getAttributes()),
pendingDocComment: ($docComment = $reflectionProperty->getDocComment() ?: null)
? new PendingDocComment($docComment, declaringClass: $name)
: null,
);
} else {
$expectedTemplateTypeName = 'T'.Str::studly($reflectionProperty->name);
$existingPropertyTemplateType = collect($classDefinition->templateTypes)
->first(fn (TemplateType $t) => $t->name === $expectedTemplateTypeName);
$propertyTemplateType = $existingPropertyTemplateType ?: new TemplateType(
$expectedTemplateTypeName,
is: ($reflectionPropertyType = $reflectionProperty->getType()) ? TypeHelper::createTypeFromReflectionType($reflectionPropertyType) : new UnknownType,
);
$classDefinition->properties[$reflectionProperty->name] = new ClassPropertyDefinition(
type: $propertyTemplateType,
defaultType: $reflectionProperty->hasDefaultValue()
? PropertyAnalyzer::from($reflectionProperty)->getDefaultType()
: null,
visibility: PropertyVisibility::fromReflectionProperty($reflectionProperty),
attributes: AttributeDefinition::fromReflectionAttributesArray($reflectionProperty->getAttributes()),
pendingDocComment: ($docComment = $reflectionProperty->getDocComment() ?: null)
? new PendingDocComment($docComment, declaringClass: $name)
: null,
);
if (! $existingPropertyTemplateType) {
$classDefinition->templateTypes[] = $propertyTemplateType;
}
}
}
$classDefinition->setIndex($this->index);
$this->index->registerClassDefinition($classDefinition);
Context::getInstance()->extensionsBroker->afterClassDefinitionCreated(new ClassDefinitionCreatedEvent($classDefinition->name, $classDefinition));
return $classDefinition;
}
}
@@ -0,0 +1,46 @@
<?php
namespace Dedoc\Scramble\Infer\Analyzer;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
use Dedoc\Scramble\Infer\DefinitionBuilders\FunctionLikeAstDefinitionBuilder;
use Dedoc\Scramble\Infer\Reflector\ClassReflector;
use Dedoc\Scramble\Infer\Scope\Index;
use Dedoc\Scramble\Infer\Services\FileNameResolver;
class MethodAnalyzer
{
public function __construct(
private Index $index,
private ClassDefinition $classDefinition,
) {}
public function analyze(FunctionLikeDefinition $methodDefinition, array $indexBuilders = [], bool $withSideEffects = false)
{
try {
$node = $this->getClassReflector()->getMethod($methodDefinition->type->name)->getAstNode();
} catch (\ReflectionException) {
return null;
}
if (! $node) {
return $methodDefinition;
}
return $this->classDefinition->methods[$methodDefinition->type->name] = (new FunctionLikeAstDefinitionBuilder(
$methodDefinition->type->name,
$node,
$this->index,
new FileNameResolver($this->getClassReflector()->getNameContext()),
$this->classDefinition,
$indexBuilders,
$withSideEffects
))->build();
}
private function getClassReflector(): ClassReflector
{
return ClassReflector::make($this->classDefinition->name);
}
}
@@ -0,0 +1,74 @@
<?php
namespace Dedoc\Scramble\Infer\Analyzer;
use Dedoc\Scramble\Infer\Context;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Reflector\PropertyReflector;
use Dedoc\Scramble\Infer\Scope\Index;
use Dedoc\Scramble\Infer\Scope\NodeTypesResolver;
use Dedoc\Scramble\Infer\Scope\Scope;
use Dedoc\Scramble\Infer\Scope\ScopeContext;
use Dedoc\Scramble\Infer\Services\FileNameResolver;
use Dedoc\Scramble\Infer\TypeInferer;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\TypeHelper;
use Illuminate\Support\Arr;
use PhpParser\Node\PropertyItem;
use PhpParser\NodeTraverser;
use ReflectionProperty;
class PropertyAnalyzer
{
public function __construct(private ReflectionProperty $reflectionProperty) {}
public static function from(ReflectionProperty $reflectionProperty): self
{
return new self($reflectionProperty);
}
public function getDefaultType(): ?Type
{
if (! $this->reflectionProperty->hasDefaultValue()) {
return null;
}
if ($astType = $this->getDefaultTypeFromAst()) {
return $astType;
}
return TypeHelper::createTypeFromValue($this->reflectionProperty->getDefaultValue());
}
private function getDefaultTypeFromAst(): ?Type
{
$reflector = PropertyReflector::make($this->reflectionProperty->getDeclaringClass()->name, $this->reflectionProperty->name);
$astNode = $reflector->getAstNode();
if (! $astNode) {
return null;
}
$propertyItem = collect($astNode->props)->first(fn (PropertyItem $p) => $p->name->name === $this->reflectionProperty->name);
if (! $propertyItem instanceof PropertyItem) {
return null;
}
$index = app(Index::class);
$traverser = new NodeTraverser;
$nameResolver = new FileNameResolver($reflector->getClassReflector()->getNameContext());
$traverser->addVisitor($inferer = new TypeInferer(
$index,
$nameResolver,
$scope = new Scope($index, new NodeTypesResolver, new ScopeContext(new ClassDefinition($this->reflectionProperty->getDeclaringClass()->name)), $nameResolver),
Context::getInstance()->extensionsBroker->extensions,
));
$traverser->traverse(Arr::wrap($propertyItem));
if (! $propertyItem->default) {
return null;
}
return $scope->getType($propertyItem->default);
}
}
@@ -0,0 +1,53 @@
<?php
namespace Dedoc\Scramble\Infer;
use Dedoc\Scramble\Infer\Contracts\ArgumentTypeBag;
use Dedoc\Scramble\Infer\Scope\Scope;
use Dedoc\Scramble\Infer\Services\ReferenceTypeResolver;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\UnknownType;
class AutoResolvingArgumentTypeBag implements ArgumentTypeBag
{
/**
* @param array<array-key, Type> $arguments
*/
public function __construct(private Scope $scope, private array $arguments) {}
public function get(string $name, int $position, ?Type $default = new UnknownType): ?Type
{
if ($argumentType = $this->arguments[$name] ?? $this->arguments[$position] ?? null) {
return ReferenceTypeResolver::getInstance()->resolve($this->scope, $argumentType);
}
return $default;
}
public function map(callable $cb): ArgumentTypeBag
{
return new self(
$this->scope,
collect($this->arguments)->map(fn ($t, $key) => $cb($t, $key))->all(),
);
}
public function all(): array
{
return array_map(
fn ($t) => ReferenceTypeResolver::getInstance()->resolve($this->scope, $t),
$this->arguments,
);
}
public function count(): int
{
return count($this->arguments);
}
/** @return array<array-key, Type> */
public function allUnresolved(): array
{
return $this->arguments;
}
}
@@ -0,0 +1,13 @@
<?php
namespace Dedoc\Scramble\Infer\Configuration;
class ClassLike implements DefinitionMatcher
{
public function __construct(public readonly string $class) {}
public function matches(string $class): bool
{
return $class === $this->class;
}
}
@@ -0,0 +1,13 @@
<?php
namespace Dedoc\Scramble\Infer\Configuration;
class ClassLikeAndChildren implements DefinitionMatcher
{
public function __construct(public readonly string $class) {}
public function matches(string $class): bool
{
return is_a($class, $this->class, true);
}
}
@@ -0,0 +1,8 @@
<?php
namespace Dedoc\Scramble\Infer\Configuration;
interface DefinitionMatcher
{
public function matches(string $class): bool;
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Dedoc\Scramble\Infer;
use Dedoc\Scramble\Infer\Extensions\ExtensionsBroker;
class Context
{
private static $instance = null;
public function __construct(
public readonly ExtensionsBroker $extensionsBroker,
) {}
public static function configure(
ExtensionsBroker $extensionsBroker,
) {
static::$instance = new static(
$extensionsBroker,
);
}
public static function getInstance(): static
{
if (! static::$instance) {
static::$instance = new static(
app(ExtensionsBroker::class),
);
}
return static::$instance;
}
public static function reset()
{
static::$instance = null;
}
}
@@ -0,0 +1,20 @@
<?php
namespace Dedoc\Scramble\Infer\Contracts;
use Countable;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\UnknownType;
interface ArgumentTypeBag extends Countable
{
public function get(string $name, int $position, ?Type $default = new UnknownType): ?Type;
/** @return array<array-key, Type> */
public function all(): array;
/**
* @param callable(Type, string|int): Type $cb
*/
public function map(callable $cb): self;
}
@@ -0,0 +1,13 @@
<?php
namespace Dedoc\Scramble\Infer\Contracts;
use Dedoc\Scramble\Infer\Definition\ClassDefinition as ClassDefinitionData;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
interface ClassDefinition
{
public function getMethod(string $name): ?FunctionLikeDefinition;
public function getData(): ClassDefinitionData;
}
@@ -0,0 +1,8 @@
<?php
namespace Dedoc\Scramble\Infer\Contracts;
interface ClassDefinitionBuilder
{
public function build(): ClassDefinition;
}
@@ -0,0 +1,10 @@
<?php
namespace Dedoc\Scramble\Infer\Contracts;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
interface FunctionLikeDefinitionBuilder
{
public function build(): FunctionLikeDefinition;
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Dedoc\Scramble\Infer\Contracts;
use Dedoc\Scramble\Infer\Contracts\ClassDefinition as ClassDefinitionContract;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
interface Index
{
public function getFunction(string $name): ?FunctionLikeDefinition;
public function getClass(string $name): ?ClassDefinitionContract;
}
@@ -0,0 +1,32 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use ReflectionAttribute;
class AttributeDefinition
{
public const IS_INSTANCEOF = ReflectionAttribute::IS_INSTANCEOF;
public function __construct(
public string $name,
public array $arguments = [],
) {}
/**
* @param ReflectionAttribute[] $reflectionAttributes
* @return self[]
*/
public static function fromReflectionAttributesArray(array $reflectionAttributes): array
{
return array_map(static::fromReflectionAttribute(...), $reflectionAttributes);
}
public static function fromReflectionAttribute(ReflectionAttribute $reflectionAttribute): self
{
return new self(
name: $reflectionAttribute->getName(),
arguments: $reflectionAttribute->getArguments(),
);
}
}
@@ -0,0 +1,477 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use Dedoc\Scramble\Infer\Analyzer\MethodAnalyzer;
use Dedoc\Scramble\Infer\Contracts\ClassDefinition as ClassDefinitionContract;
use Dedoc\Scramble\Infer\DefinitionBuilders\FunctionLikeAstDefinitionBuilder;
use Dedoc\Scramble\Infer\DefinitionBuilders\FunctionLikeReflectionDefinitionBuilder;
use Dedoc\Scramble\Infer\Reflector\ClassReflector;
use Dedoc\Scramble\Infer\Scope\GlobalScope;
use Dedoc\Scramble\Infer\Scope\Index;
use Dedoc\Scramble\Infer\Scope\NodeTypesResolver;
use Dedoc\Scramble\Infer\Scope\Scope;
use Dedoc\Scramble\Infer\Scope\ScopeContext;
use Dedoc\Scramble\Infer\Services\FileNameResolver;
use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper;
use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\IndexBuilders\IndexBuilder;
use Dedoc\Scramble\Support\PhpDoc;
use Dedoc\Scramble\Support\Type\FunctionType;
use Dedoc\Scramble\Support\Type\Generic;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Support\Type\TemplateType;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\TypeWalker;
use Dedoc\Scramble\Support\Type\UnknownType;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use PhpParser\ErrorHandler\Throwing;
use PhpParser\NameContext;
use PHPStan\PhpDocParser\Ast\PhpDoc\ExtendsTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\MixinTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\UsesTagValueNode;
class ClassDefinition implements ClassDefinitionContract
{
private bool $propagatesTemplates = false;
/** @var array<string, true> */
protected array $loadedMethods = [];
/** @var Collection<string, Collection<string, Type>> */
private Collection $classContexts;
private Index $index;
public function __construct(
// FQ name
public string $name,
/** @var TemplateType[] $templateTypes */
public array $templateTypes = [],
/** @var array<string, ClassPropertyDefinition> $properties */
public array $properties = [],
/** @var array<string, FunctionLikeDefinition> $methods */
public array $methods = [],
public ?string $parentFqn = null,
) {}
public function propagatesTemplates(?bool $propagatesTemplates = null): bool
{
if ($propagatesTemplates === null) {
return $this->propagatesTemplates;
}
return $this->propagatesTemplates = $propagatesTemplates;
}
public function isInstanceOf(string $className)
{
return is_a($this->name, $className, true);
}
public function isChildOf(string $className)
{
return $this->isInstanceOf($className) && $this->name !== $className;
}
public function hasMethodDefinition(string $name): bool
{
return $this->lazilyLoadMethodDefinition($name) || $this->findReflectionMethod($name);
}
public function getMethodDefinitionWithoutAnalysis(string $name): ?FunctionLikeDefinition
{
return $this->lazilyLoadMethodDefinition($name);
}
protected function lazilyLoadMethodDefinition(string $name): ?FunctionLikeDefinition
{
if (array_key_exists($name, $this->loadedMethods)) {
return $this->methods[$name] ?? null;
}
$this->loadedMethods[$name] = true;
/** @var \ReflectionMethod|null $reflectionMethod */
$reflectionMethod = rescue(
fn () => (new \ReflectionClass($this->name))->getMethod($name), // @phpstan-ignore argument.type
report: false,
);
if (! $reflectionMethod) {
return $this->methods[$name] ?? null;
}
if ($this->isMethodDefinedInNonAstAnalyzableTrait($name)) {
return $this->methods[$name] ?? null;
}
if (
$reflectionMethod->class === $this->name
|| Scramble::infer()->config->shouldAnalyzeAst($reflectionMethod->class)
) {
return $this->methods[$reflectionMethod->name] = new FunctionLikeDefinition(
new FunctionType(
$reflectionMethod->name,
arguments: [],
returnType: new UnknownType,
),
definingClassName: $reflectionMethod->class,
isStatic: $reflectionMethod->isStatic(),
);
}
return $this->methods[$name] ?? null;
}
public function getMethodDefiningClassName(string $name, Index $index)
{
$lastLookedUpClassName = $this->name;
while ($lastLookedUpClassDefinition = $index->getClass($lastLookedUpClassName)) {
if ($methodDefinition = $lastLookedUpClassDefinition->getMethodDefinitionWithoutAnalysis($name)) {
return $methodDefinition->definingClassName;
}
if ($lastLookedUpClassDefinition->parentFqn) {
$lastLookedUpClassName = $lastLookedUpClassDefinition->parentFqn;
continue;
}
break;
}
return $lastLookedUpClassName;
}
/**
* @param IndexBuilder<array<string, mixed>>[] $indexBuilders
*/
public function getMethodDefinition(string $name, Scope $scope = new GlobalScope, array $indexBuilders = [], bool $withSideEffects = false): ?FunctionLikeDefinition
{
if (! $methodDefinition = $this->lazilyLoadMethodDefinition($name)) {
return $this->getFunctionLikeDefinitionBuiltFromReflection($name);
}
return $this->getFunctionLikeDefinitionBuiltFromAst($methodDefinition, $name, $scope, $indexBuilders, $withSideEffects);
}
protected function isMethodDefinedInNonAstAnalyzableTrait(string $name): bool
{
if (! $reflectionMethod = $this->findReflectionMethod($name)) {
return false;
}
/** @var \ReflectionClass<object>|null $classReflection */
$classReflection = rescue(fn () => new \ReflectionClass($this->name), report: false); // @phpstan-ignore argument.type
if (! $classReflection) {
return false;
}
if ($this->isDeclaredIn($reflectionMethod, $classReflection)) {
return false;
}
foreach (class_uses_recursive($classReflection->name) as $traitName) {
$traitReflection = rescue(
fn () => new \ReflectionClass($traitName), // @phpstan-ignore argument.type
report: false,
);
if (! $traitReflection) {
continue;
}
if (! $this->isDeclaredIn($reflectionMethod, $traitReflection)) {
return false;
}
return ! Scramble::infer()->config->shouldAnalyzeAst($traitName);
}
return false;
}
/**
* @param \ReflectionClass<object> $class
*/
private function isDeclaredIn(\ReflectionMethod $reflectionMethod, \ReflectionClass $class): bool
{
$reflectionMethodFileName = $reflectionMethod->getFileName();
$reflectionMethodStartLine = $reflectionMethod->getStartLine();
$classFileName = $class->getFileName();
$classStartLine = $class->getStartLine();
$classEndLine = $class->getEndLine();
if (! $reflectionMethodFileName || ! $classFileName) {
return false;
}
if ($reflectionMethodFileName !== $classFileName) {
return false;
}
return $reflectionMethodStartLine > $classStartLine && $reflectionMethodStartLine < $classEndLine;
}
private function findReflectionMethod(string $name): ?\ReflectionMethod
{
/** @var \ReflectionClass<object>|null $classReflection */
$classReflection = rescue(fn () => new \ReflectionClass($this->name), report: false); // @phpstan-ignore argument.type
/** @var \ReflectionMethod|null $methodReflection */
$methodReflection = rescue(fn () => $classReflection?->getMethod($name), report: false);
// The case when method is defined in the class or its parents.
if ($methodReflection && $this->isDeclaredIn($methodReflection, $methodReflection->getDeclaringClass())) {
return $methodReflection;
}
foreach ($this->getClassContexts()->keys() as $class) {
/** @var \ReflectionClass<object>|null $classReflection */
$classReflection = rescue(fn () => new \ReflectionClass($class), report: false); // @phpstan-ignore argument.type
/** @var \ReflectionMethod|null $traitMethodReflection */
$traitMethodReflection = rescue(fn () => $classReflection?->getMethod($name), report: false);
if ($traitMethodReflection) {
return $traitMethodReflection;
}
}
return $methodReflection;
}
protected function getFunctionLikeDefinitionBuiltFromReflection(string $name): ?FunctionLikeDefinition
{
if (array_key_exists($name, $this->methods)) {
return $this->methods[$name];
}
if (! $methodReflection = $this->findReflectionMethod($name)) {
return null;
}
$definition = (new FunctionLikeReflectionDefinitionBuilder(
$name,
$methodReflection,
collect($this->templateTypes)->keyBy->name
->merge($this->getMethodContextTemplates($methodReflection)),
))->build();
return $this->methods[$name] = $definition;
}
/**
* @param IndexBuilder<array<string, mixed>>[] $indexBuilders
*/
protected function getFunctionLikeDefinitionBuiltFromAst(
FunctionLikeDefinition $methodDefinition,
string $name,
Scope $scope = new GlobalScope,
array $indexBuilders = [],
bool $withSideEffects = false,
): ?FunctionLikeDefinition {
if (! $methodDefinition->isFullyAnalyzed()) {
$this->methods[$name] = (new MethodAnalyzer(
$scope->index,
$this,
))->analyze($methodDefinition, $indexBuilders, $withSideEffects);
}
if (! $this->methods[$name]) { // @phpstan-ignore booleanNot.alwaysFalse
return $this->methods[$name];
}
if (! $this->methods[$name]->referencesResolved) {
$methodScope = new Scope(
$scope->index,
new NodeTypesResolver,
new ScopeContext($this, $methodDefinition),
new FileNameResolver(
class_exists($this->name)
? ClassReflector::make($this->name)->getNameContext()
: tap(new NameContext(new Throwing), fn (NameContext $nc) => $nc->startNamespace()),
),
);
FunctionLikeAstDefinitionBuilder::resolveFunctionParameterDefaults($methodScope, $this->methods[$name]);
FunctionLikeAstDefinitionBuilder::resolveFunctionReturnReferences($methodScope, $this->methods[$name]);
FunctionLikeAstDefinitionBuilder::resolveFunctionExceptions($methodScope, $this->methods[$name]);
$this->methods[$name]->referencesResolved = true;
}
return $this->methods[$name];
}
/**
* @return Collection<string, Type>
*/
private function getMethodContextTemplates(\ReflectionMethod $methodReflection): Collection
{
$classContexts = $this->getClassContexts();
return $classContexts->get($methodReflection->class, collect());
}
/**
* @param string[] $ignoreClasses
* @return Collection<string, Collection<string, Type>>
*/
public function getClassContexts(array $ignoreClasses = []): Collection
{
if (isset($this->classContexts)) {
return $this->classContexts;
}
if (in_array($this->name, $ignoreClasses, true)) {
return collect();
}
$reflector = ClassReflector::make($this->name);
$docComment = rescue(fn () => $reflector->getReflection()->getDocComment(), report: false) ?: '';
$classSource = $docComment."\n".rescue($reflector->getSource(...), '', report: false);
/** @var Collection<int, string> $tagsDoc */
$tagsDoc = Str::matchAll('/@(?:use|extends|mixin)\s+[^\r\n*]+/', $classSource);
$contextPhpDoc = $tagsDoc->map(fn ($s) => " * $s")->prepend('/**')->push('*/')->join("\n");
$nameContext = rescue($reflector->getNameContext(...), report: false);
$phpDoc = PhpDoc::parse(
$contextPhpDoc,
$nameContext ? new FileNameResolver($nameContext) : null,
);
/** @var (ExtendsTagValueNode|UsesTagValueNode|MixinTagValueNode)[] $tags */
$tags = [
...array_values($phpDoc->getExtendsTagValues()),
...array_values($phpDoc->getUsesTagValues()),
...array_values($phpDoc->getMixinTagValues()),
];
$classTemplatesByName = collect($this->templateTypes)->keyBy->name;
$types = collect($tags)
->map(fn ($tag) => PhpDocTypeHelper::toType($tag->type))
->filter(fn ($type) => $type instanceof ObjectType);
if ($this->parentFqn && ! $types->firstWhere('name', $this->parentFqn)) {
$types->push(new ObjectType($this->parentFqn));
}
$classContexts = collect();
foreach ($types as $type) {
$classContext = collect();
$type = ! $type instanceof Generic ? new Generic($type->name) : $type;
if ($definition = $this->getIndex()->getClass($type->name)) {
foreach ($definition->templateTypes as $i => $templateType) {
$concreteType = (new TypeWalker)->map(
$type->templateTypes[$i]
?? $this->templateTypes[$i]
?? $templateType->default
?? new UnknownType,
fn ($t) => $t instanceof ObjectType ? $classTemplatesByName->get($t->name, $t) : $t,
);
$classContext->offsetSet($templateType->name, $concreteType);
}
}
$classContexts->offsetSet($type->name, $classContext);
}
foreach ($classContexts as $class => $localContext) {
if (in_array($class, $ignoreClasses, true)) {
continue;
}
if (! $classDef = $this->getIndex()->getClass($class)) {
continue;
}
$classContext = $classDef->getClassContexts([...$ignoreClasses, $this->name]);
$localizedClassContext = $classContext->map->map(function ($type) use ($localContext) {
return (new TypeWalker)->map(
$type,
fn ($t) => $type instanceof TemplateType
? $localContext->get($type->name, new UnknownType)
: $type,
);
});
$classContexts = $localizedClassContext->merge($classContexts);
}
// $this->dumpContext($this, $classContexts);
return $this->classContexts = $classContexts;
}
/**
* @param Collection<string, Collection<string, Type>> $classContexts
*/
private function dumpContext(ClassDefinition $def, Collection $classContexts): void // @phpstan-ignore method.unused
{
$className = $def->name.($def->templateTypes ? '<'.implode(',', array_map($this->dumpTemplateName(...), $def->templateTypes)).'>' : '');
$contextNames = $classContexts->map(function ($contexts, $name) {
$templates = $contexts->map(fn ($t) => $t instanceof TemplateType ? $this->dumpTemplateName($t) : $t->toString());
return $name.($templates->count() ? '<'.$templates->join(',').'>' : '');
})->values()->toArray();
dump([
$className => $contextNames,
]);
}
private function dumpTemplateName(TemplateType $tt): string
{
return $tt->name.'#'.spl_object_id($tt);
}
public function setIndex(Index $index): void
{
$this->index = $index;
}
protected function getIndex(): Index
{
return isset($this->index) ? $this->index : app(Index::class);
}
public function getPropertyDefinition($name)
{
return $this->properties[$name] ?? null;
}
public function hasPropertyDefinition(string $name): bool
{
return array_key_exists($name, $this->properties);
}
public function getMethodCallType(string $name)
{
return $this->getMethodDefinition($name)?->getReturnType()
?: new UnknownType("Cannot get type of calling method [$name] on object [$this->name]");
}
public function getMethod(string $name): ?FunctionLikeDefinition
{
return $this->getMethodDefinition($name);
}
public function getData(): ClassDefinition
{
return $this;
}
}
@@ -0,0 +1,51 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use Dedoc\Scramble\Support\Type\Type;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
class ClassPropertyDefinition
{
private ?PhpDocNode $docNode = null;
public function __construct(
public Type $type,
public ?Type $defaultType = null,
public readonly bool $isStatic = false,
public readonly PropertyVisibility $visibility = PropertyVisibility::Public,
/** @var AttributeDefinition[] */
public readonly array $attributes = [],
private readonly ?PendingDocComment $pendingDocComment = null,
) {}
public function getDocNode(): ?PhpDocNode
{
if (! $this->pendingDocComment) {
return null;
}
return $this->docNode ??= $this->pendingDocComment->resolve();
}
/**
* @return AttributeDefinition[]
*/
public function getAttributes(?string $name = null, int $flags = 0): array
{
if ($name === null) {
return $this->attributes;
}
return array_values(array_filter(
$this->attributes,
function (AttributeDefinition $attribute) use ($name, $flags) {
if ($flags & AttributeDefinition::IS_INSTANCEOF) {
return is_a($attribute->name, $name, true);
}
return $attribute->name === $name;
},
));
}
}
@@ -0,0 +1,129 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use Dedoc\Scramble\Infer\Flow\Nodes;
use Dedoc\Scramble\Infer\FlowBuilder;
use Dedoc\Scramble\Infer\Scope\Scope;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\UnknownType;
use PhpParser\Node\FunctionLike;
use PhpParser\NodeTraverser;
class FunctionLikeAstDefinition extends FunctionLikeDefinition
{
private ?FunctionLikeDefinition $declarationDefinition = null;
private ?Scope $scope = null;
private ?FunctionLike $astNode = null;
private ?Nodes $flowContainer = null;
public function setDeclarationDefinition(?FunctionLikeDefinition $declarationDefinition): self
{
$this->declarationDefinition = $declarationDefinition;
return $this;
}
public function getDeclarationDefinition(): ?FunctionLikeDefinition
{
return $this->declarationDefinition;
}
public function setAstNode(FunctionLike $astNode): self
{
$this->astNode = $astNode;
return $this;
}
public function getAstNode(): FunctionLike
{
if (! $this->astNode) {
/**
* @see \Dedoc\Scramble\Infer\DefinitionBuilders\FunctionLikeAstDefinitionBuilder
*/
throw new \LogicException('AST node must be set before accessing it on FunctionLikeAstDefinition');
}
return $this->astNode;
}
public function setScope(Scope $scope): self
{
$this->scope = $scope;
return $this;
}
public function getScope(): Scope
{
if (! $this->scope) {
/**
* @see \Dedoc\Scramble\Infer\DefinitionBuilders\FunctionLikeAstDefinitionBuilder
*/
throw new \LogicException('Scope must be set before accessing it on FunctionLikeAstDefinition');
}
return $this->scope;
}
public function getFlowContainer(): Nodes
{
if ($this->flowContainer) {
return $this->flowContainer;
}
$traverser = new NodeTraverser;
$traverser->addVisitor($flowBuilder = new FlowBuilder(
$this->type->arguments, // parameters
$this->getScope(),
));
$traverser->traverse([$this->getAstNode()]);
return $this->flowContainer = $flowBuilder->flowNodes;
}
public function getInferredReturnType(): Type
{
return $this->type->getReturnType();
}
public function getReturnType(): Type
{
$inferredReturnType = $this->type->getReturnType();
if ($inferredReturnType->getAttribute('fromScrambleReturn') === true) {
return $inferredReturnType;
}
if (! $returnDeclarationType = $this->getDeclarationDefinition()?->getReturnType()) {
return $inferredReturnType;
}
return $this->prefersInferredReturnType($returnDeclarationType, $inferredReturnType)
? $inferredReturnType
: $returnDeclarationType;
}
private function prefersInferredReturnType(?Type $declarationType, Type $inferredType): bool
{
if (! $declarationType || $declarationType instanceof UnknownType) {
return true;
}
if ($declarationType->accepts($inferredType)) {
return true;
}
if ($inferredType->acceptedBy($declarationType)) {
return true;
}
return false;
}
}
@@ -0,0 +1,61 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use Dedoc\Scramble\Infer\DefinitionBuilders\SelfOutTypeBuilder;
use Dedoc\Scramble\Support\Type\FunctionType;
use Dedoc\Scramble\Support\Type\Generic;
use Dedoc\Scramble\Support\Type\Type;
class FunctionLikeDefinition
{
public bool $isFullyAnalyzed = false;
public bool $referencesResolved = false;
public ?Generic $selfOutType;
/**
* @param array<string, Type> $argumentsDefaults A map where the key is arg name and value is a default type.
*/
public function __construct(
public FunctionType $type,
public array $argumentsDefaults = [],
public ?string $definingClassName = null,
public bool $isStatic = false,
public ?SelfOutTypeBuilder $selfOutTypeBuilder = null,
) {}
public function isFullyAnalyzed(): bool
{
return $this->isFullyAnalyzed;
}
public function addArgumentDefault(string $paramName, Type $type): self
{
$this->argumentsDefaults[$paramName] = $type;
return $this;
}
public function getSelfOutType(): ?Generic
{
return $this->selfOutType ??= $this->selfOutTypeBuilder?->build();
}
public function getReturnType(): Type
{
return $this->type->getReturnType();
}
/**
* When analyzing parent classes, function like definitions are "copied" from parent class. When function
* like is sourced from AST, we don't want to make a deep clone to save some memory (is the difference really makes sense?)
* as the definition will be re-build to the specifics of a given class. However, other types of fn definitions
* may override this method and do a deeper cloning (or cloning at all!).
*/
public function copyFromParent(): self
{
return $this;
}
}
@@ -0,0 +1,38 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use Dedoc\Scramble\Infer\Contracts\ClassDefinition as ClassDefinitionContract;
use Dedoc\Scramble\Infer\Definition\ClassDefinition as ClassDefinitionData;
use Dedoc\Scramble\Infer\DefinitionBuilders\FunctionLikeReflectionDefinitionBuilder;
use ReflectionClass;
use ReflectionException;
class LazyShallowClassDefinition implements ClassDefinitionContract
{
public function __construct(
public ClassDefinitionContract $definition,
) {}
public function getMethod(string $name): ?FunctionLikeDefinition
{
$data = $this->getData();
if (isset($data->methods[$name])) {
return $data->methods[$name];
}
try {
$reflection = (new ReflectionClass($data->name))->getMethod($name);
} catch (ReflectionException) {
return null;
}
return $data->methods[$name] = (new FunctionLikeReflectionDefinitionBuilder($name, $reflection))->build();
}
public function getData(): ClassDefinitionData
{
return $this->definition->getData();
}
}
@@ -0,0 +1,51 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use Dedoc\Scramble\Infer\Services\FileNameResolver;
use Dedoc\Scramble\Support\PhpDoc;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
class PendingDocComment
{
public function __construct(
public string $docComment,
public ?string $fileName = null,
public ?string $declaringClass = null,
) {}
public function resolve(): PhpDocNode
{
$fileName = $this->resolveFileName();
return PhpDoc::parse(
$this->docComment,
$fileName ? FileNameResolver::createForFile($fileName) : null,
);
}
private function resolveFileName(): ?string
{
if ($this->fileName) {
return $this->fileName;
}
if ($this->declaringClass) {
return $this->getDeclaringClassFile($this->declaringClass);
}
return null;
}
private function getDeclaringClassFile(string $declaringClass): ?string
{
try {
$reflectionClass = new \ReflectionClass($declaringClass);
return $reflectionClass->getFileName() ?: null;
} catch (\Throwable) {
}
return null;
}
}
@@ -0,0 +1,21 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use ReflectionProperty;
enum PropertyVisibility
{
case Private;
case Protected;
case Public;
public static function fromReflectionProperty(ReflectionProperty $property): self
{
return match (true) {
$property->isPrivate() => self::Private,
$property->isProtected() => self::Protected,
default => self::Public,
};
}
}
@@ -0,0 +1,23 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use Dedoc\Scramble\Infer\Scope\GlobalScope;
use Dedoc\Scramble\Infer\Scope\Scope;
class ShallowClassDefinition extends ClassDefinition
{
protected function lazilyLoadMethodDefinition(string $name): ?FunctionLikeDefinition
{
return $this->methods[$name] ?? null;
}
public function getMethodDefinition(string $name, Scope $scope = new GlobalScope, array $indexBuilders = [], bool $withSideEffects = false): ?FunctionLikeDefinition
{
if ($this->isMethodDefinedInNonAstAnalyzableTrait($name)) {
return $this->getFunctionLikeDefinitionBuiltFromReflection($name);
}
return $this->getMethodDefinitionWithoutAnalysis($name) ?: $this->getFunctionLikeDefinitionBuiltFromReflection($name);
}
}
@@ -0,0 +1,376 @@
<?php
namespace Dedoc\Scramble\Infer\DefinitionBuilders;
use Dedoc\Scramble\Infer\Context;
use Dedoc\Scramble\Infer\Contracts\FunctionLikeDefinitionBuilder;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Definition\FunctionLikeAstDefinition;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
use Dedoc\Scramble\Infer\Extensions\Event\MethodCallEvent;
use Dedoc\Scramble\Infer\Extensions\Event\SideEffectCallEvent;
use Dedoc\Scramble\Infer\Extensions\ExtensionsBroker;
use Dedoc\Scramble\Infer\Handler\IndexBuildingHandler;
use Dedoc\Scramble\Infer\Scope\Index;
use Dedoc\Scramble\Infer\Scope\LazyShallowReflectionIndex;
use Dedoc\Scramble\Infer\Scope\NodeTypesResolver;
use Dedoc\Scramble\Infer\Scope\Scope;
use Dedoc\Scramble\Infer\Scope\ScopeContext;
use Dedoc\Scramble\Infer\Services\FileNameResolver;
use Dedoc\Scramble\Infer\Services\ReferenceTypeResolver;
use Dedoc\Scramble\Infer\Services\ShallowTypeResolver;
use Dedoc\Scramble\Infer\TypeInferer;
use Dedoc\Scramble\Infer\UnresolvableArgumentTypeBag;
use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper;
use Dedoc\Scramble\Support\IndexBuilders\IndexBuilder;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Support\Type\TemplateType;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\TypeWalker;
use Illuminate\Support\Arr;
use LogicException;
use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Expr\NullsafeMethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\FunctionLike;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\NodeTraverser;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode;
use ReflectionClass;
use ReflectionFunction;
use ReflectionMethod;
class FunctionLikeAstDefinitionBuilder implements FunctionLikeDefinitionBuilder
{
private LazyShallowReflectionIndex $shallowIndex;
/**
* @param IndexBuilder<array<string, mixed>>[] $indexBuilders
*/
public function __construct(
public string $name,
public FunctionLike $functionLike,
public Index $index,
public FileNameResolver $fileNameResolver,
public ?ClassDefinition $classDefinition = null,
public array $indexBuilders = [],
public bool $withSideEffects = false,
) {
$this->shallowIndex = app(LazyShallowReflectionIndex::class);
}
public function build(): FunctionLikeAstDefinition
{
$inferrer = $this->traverseAstNode($this->functionLike);
$scope = $inferrer->getFunctionLikeScope($this->functionLike);
if (! $scope) {
throw new LogicException('Scope must be available when building FunctionLikeAstDefinition');
}
$definition = $scope->context->functionDefinition;
if (! $definition instanceof FunctionLikeAstDefinition) {
throw new LogicException('Definition must be an instance of FunctionLikeAstDefinition');
}
$definition
->setAstNode($this->functionLike)
->setScope($scope);
if ($this->functionLike instanceof ClassMethod) {
$definition->selfOutTypeBuilder = new SelfOutTypeBuilder($scope, $this->functionLike);
}
$definition->setDeclarationDefinition($this->buildDeclarationDefinition());
$this->overrideInferredReturnTypeWithManualAnnotation($definition);
if ($this->withSideEffects) {
$this->analyzeSideEffects($definition, $inferrer);
}
$definition->isFullyAnalyzed = true;
return $definition;
}
private function traverseAstNode(Node $node): TypeInferer
{
$traverser = new NodeTraverser;
$traverser->addVisitor($inferrer = new TypeInferer(
$this->index,
$this->fileNameResolver,
new Scope($this->index, new NodeTypesResolver, new ScopeContext($this->classDefinition), $this->fileNameResolver),
Context::getInstance()->extensionsBroker->extensions,
[new IndexBuildingHandler($this->indexBuilders)],
));
$traverser->traverse(Arr::wrap($node));
return $inferrer;
}
private function analyzeSideEffects(FunctionLikeDefinition $methodDefinition, TypeInferer $inferrer): void
{
$fnScope = $inferrer->getFunctionLikeScope($this->functionLike);
if (! $fnScope) {
return;
}
foreach ($fnScope->getMethodCalls() as $methodCall) {
match (true) {
$methodCall instanceof MethodCall || $methodCall instanceof NullsafeMethodCall => $this->analyzeMethodCall($methodDefinition, $fnScope, $methodCall),
$methodCall instanceof StaticCall => $this->analyzeStaticMethodCall($methodDefinition, $fnScope, $methodCall),
$methodCall instanceof FuncCall => $this->analyzeFuncCall($methodDefinition, $fnScope, $methodCall),
$methodCall instanceof New_ => null,
default => null,
};
}
}
private function analyzeMethodCall(FunctionLikeDefinition $methodDefinition, Scope $fnScope, MethodCall|NullsafeMethodCall $methodCall): void
{
// 1. ensure method call should be handled
/*
* Only explicit method calls are supported. So the following is supported:
* $this->foo()
* But when the expression is in place, we skip analysis:
* $this->{$var}()
*/
$this->applyExceptionsFromMethodCall($methodDefinition, $fnScope, $methodCall);
if (! $methodCall->name instanceof Identifier) {
return;
}
// 2. get called method definition and if not yet analyzed, analyze shallowly (PHPDoc, type hints)
// get shallow method definition (get shallow callee type, get the shallow definition)
$calleeType = (new ShallowTypeResolver($this->shallowIndex))->resolve($fnScope, $fnScope->getType($methodCall->var));
if ($calleeType instanceof TemplateType && $calleeType->is) {
$calleeType = $calleeType->is;
}
if (! $calleeType instanceof ObjectType) {
return;
}
$definition = $this->shallowIndex->getClass($calleeType->name);
if (! $definition) {
return;
}
$shallowMethodDefinition = $definition->getMethod($methodCall->name->name);
if (! $shallowMethodDefinition) {
return;
}
$this->applySideEffectsFromCall(new SideEffectCallEvent(
definition: $methodDefinition,
calledDefinition: $shallowMethodDefinition,
node: $methodCall,
scope: $fnScope,
arguments: new UnresolvableArgumentTypeBag($fnScope->getArgsTypes($methodCall->args)),
));
}
private function applyExceptionsFromMethodCall(FunctionLikeDefinition $methodDefinition, Scope $fnScope, MethodCall|NullsafeMethodCall $methodCall): void
{
if (! $methodCall->name instanceof Identifier) {
return;
}
$calleeType = $fnScope->getType($methodCall->var);
if (! $calleeType instanceof ObjectType) {
return;
}
$event = new MethodCallEvent(
$calleeType,
$methodCall->name->name,
$fnScope,
new UnresolvableArgumentTypeBag($fnScope->getArgsTypes($methodCall->args)),
$calleeType->name
);
$exceptions = app(ExtensionsBroker::class)->getMethodCallExceptions($event);
if (empty($exceptions)) {
return;
}
$methodDefinition->type->exceptions = array_merge(
$methodDefinition->type->exceptions,
$exceptions,
);
}
private function analyzeStaticMethodCall(FunctionLikeDefinition $methodDefinition, Scope $fnScope, StaticCall $methodCall): void
{
if (! $methodCall->name instanceof Identifier) {
return;
}
if (! $methodCall->class instanceof Name) {
return;
}
$class = ReferenceTypeResolver::resolveClassName($fnScope, $methodCall->class->name);
if (! $class) {
return;
}
$definition = $this->shallowIndex->getClass($class);
if (! $definition) {
return;
}
$shallowMethodDefinition = $definition->getMethod($methodCall->name->name);
if (! $shallowMethodDefinition) {
return;
}
$this->applySideEffectsFromCall(new SideEffectCallEvent(
definition: $methodDefinition,
calledDefinition: $shallowMethodDefinition,
node: $methodCall,
scope: $fnScope,
arguments: new UnresolvableArgumentTypeBag($fnScope->getArgsTypes($methodCall->args)),
));
}
private function analyzeFuncCall(FunctionLikeDefinition $methodDefinition, Scope $fnScope, FuncCall $call): void
{
$name = $call->name->getAttribute('namespacedName', $call->name);
if (! $name instanceof Name) {
return;
}
$functionDefinition = $this->shallowIndex->getFunction($name);
if (! $functionDefinition) {
return;
}
$this->applySideEffectsFromCall(new SideEffectCallEvent(
definition: $methodDefinition,
calledDefinition: $functionDefinition,
node: $call,
scope: $fnScope,
arguments: new UnresolvableArgumentTypeBag($fnScope->getArgsTypes($call->args)),
));
}
private function applySideEffectsFromCall(SideEffectCallEvent $event): void
{
foreach ($event->calledDefinition->type->exceptions as $exception) {
$event->definition->type->exceptions[] = $exception;
}
Context::getInstance()->extensionsBroker->afterSideEffectCallAnalyzed($event);
}
private function overrideInferredReturnTypeWithManualAnnotation(FunctionLikeDefinition $definition): void
{
if (! $type = $this->getExplicitScrambleReturnType()) {
return;
}
$definition->type->setReturnType($type);
}
protected function buildDeclarationDefinition(): ?FunctionLikeDefinition
{
$definition = (new FunctionLikeDeclarationAstDefinitionBuilder(
$this->functionLike,
$this->classDefinition,
))->build();
$phpDocNode = $this->functionLike->getAttribute('parsedPhpDoc');
if (! $phpDocNode instanceof PhpDocNode) {
return $definition;
}
return (new FunctionLikeDeclarationPhpDocDefinitionBuilder(
$definition,
$phpDocNode,
$this->classDefinition,
))->build();
}
protected function getReflection(): ReflectionFunction|ReflectionMethod|null
{
try {
if ($this->classDefinition) {
return (new ReflectionClass($this->classDefinition->name))->getMethod($this->name); // @phpstan-ignore argument.type
}
return new ReflectionFunction($this->name);
} catch (\ReflectionException) {
}
return null;
}
private function getExplicitScrambleReturnType(): ?Type
{
$phpDoc = $this->functionLike->getAttribute('parsedPhpDoc');
if (! $phpDoc instanceof PhpDocNode) {
return null;
}
if (! $scrambleReturn = Arr::first($phpDoc->getReturnTagValues('@scramble-return'))) {
return null;
}
/** @var ReturnTagValueNode $scrambleReturn */
$type = PhpDocTypeHelper::toType($scrambleReturn->type);
foreach (($this->classDefinition?->templateTypes ?: []) as $template) {
$type = (new TypeWalker)->map($type, fn ($t) => $t instanceof ObjectType && $t->name === $template->name ? $template : $t);
}
$type->setAttribute('fromScrambleReturn', true);
return $type;
}
public static function resolveFunctionExceptions(Scope $scope, FunctionLikeDefinition $functionLikeDefinition): void
{
$functionType = $functionLikeDefinition->type;
foreach ($functionType->exceptions as $i => $exceptionType) {
$exception = (new ReferenceTypeResolver($scope->index))->resolve($scope, $exceptionType);
if (! $exception instanceof ObjectType) {
continue;
}
$functionType->exceptions[$i] = $exception;
}
}
public static function resolveFunctionReturnReferences(Scope $scope, FunctionLikeDefinition $functionLikeDefinition): void
{
$functionType = $functionLikeDefinition->type;
$returnType = $functionType->getReturnType();
$resolvedReference = ReferenceTypeResolver::getInstance()->resolve($scope, $returnType);
$functionType->setReturnType($resolvedReference);
}
public static function resolveFunctionParameterDefaults(Scope $scope, FunctionLikeDefinition $functionLikeDefinition): void
{
$referenceTypeResolver = ReferenceTypeResolver::getInstance();
foreach ($functionLikeDefinition->argumentsDefaults as $name => $argumentDefault) {
$functionLikeDefinition->argumentsDefaults[$name] = $referenceTypeResolver->resolve($scope, $argumentDefault);
}
}
}
@@ -0,0 +1,100 @@
<?php
namespace Dedoc\Scramble\Infer\DefinitionBuilders;
use Dedoc\Scramble\Infer\Contracts\FunctionLikeDefinitionBuilder;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
use Dedoc\Scramble\Infer\Scope\GlobalScope;
use Dedoc\Scramble\Support\Type\FunctionType;
use Dedoc\Scramble\Support\Type\MixedType;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\TypeHelper;
use Dedoc\Scramble\Support\Type\UnknownType;
use PhpParser\Node;
use PhpParser\Node\FunctionLike;
use PhpParser\Node\Stmt\ClassMethod;
class FunctionLikeDeclarationAstDefinitionBuilder implements FunctionLikeDefinitionBuilder
{
public function __construct(
private FunctionLike $node,
private ?ClassDefinition $classDefinition = null,
) {}
public function build(): FunctionLikeDefinition
{
return new FunctionLikeDefinition(
$this->buildType(),
argumentsDefaults: $this->getArgumentDefaults(),
definingClassName: $this->classDefinition?->name,
isStatic: $this->node instanceof Node\Stmt\ClassMethod ? $this->node->isStatic() : false,
);
}
private function buildType(): FunctionType
{
return new FunctionType(
$this->getName(),
arguments: $this->getArgumentTypes(),
returnType: $this->getReturnType(),
);
}
private function getReturnType(): Type
{
if (! $returnType = $this->node->getReturnType()) {
return new UnknownType;
}
return TypeHelper::createTypeFromTypeNode($returnType);
}
/** @return array<string, Type> */
private function getArgumentTypes(): array
{
return collect($this->node->getParams())
->mapWithKeys(function (Node\Param $param) {
if (! $param->var instanceof Node\Expr\Variable || ! is_string($param->var->name)) {
return [];
}
$type = $param->type
? TypeHelper::createTypeFromTypeNode($param->type)
: new MixedType;
return [$param->var->name => $type];
})
->all();
}
/** @return array<string, Type> */
private function getArgumentDefaults(): array
{
return collect($this->node->getParams())
->mapWithKeys(function (Node\Param $param) {
if (! $param->default) {
return [];
}
if (! $param->var instanceof Node\Expr\Variable || ! is_string($param->var->name)) {
return [];
}
return [
$param->var->name => (new GlobalScope)->getType($param->default),
];
})
->filter()
->all();
}
private function getName(): string
{
if ($this->node instanceof ClassMethod || $this->node instanceof Node\Stmt\Function_) {
return $this->node->name->name;
}
return '{anonymous}';
}
}
@@ -0,0 +1,95 @@
<?php
namespace Dedoc\Scramble\Infer\DefinitionBuilders;
use Dedoc\Scramble\Infer\Contracts\FunctionLikeDefinitionBuilder;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Support\Type\TemplateType;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\TypeWalker;
use Illuminate\Support\Str;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
class FunctionLikeDeclarationPhpDocDefinitionBuilder implements FunctionLikeDefinitionBuilder
{
private PhpDocNode $phpDocNode;
public function __construct(
private FunctionLikeDefinition $definition,
?PhpDocNode $phpDocNode = null,
private ?ClassDefinition $classDefinition = null,
) {
$this->phpDocNode = $phpDocNode ?: new PhpDocNode([]);
}
public function build(): FunctionLikeDefinition
{
$definition = clone $this->definition;
$definition->type = $definition->type->clone();
$phpDoc = $this->phpDocNode;
foreach ($phpDoc->getTemplateTagValues() as $templateTagValue) {
$definition->type->templates[] = new TemplateType(
name: $templateTagValue->name,
);
}
foreach ($phpDoc->getParamTagValues() as $paramTagValue) {
$name = Str::replaceFirst('$ ', '', $paramTagValue->parameterName);
if (! array_key_exists($name, $definition->type->arguments)) {
continue;
}
$definition->type->arguments[$name] = $this->handleStatic(
PhpDocTypeHelper::toType($paramTagValue->type),
$definition->type->templates,
);
}
foreach ($phpDoc->getThrowsTagValues() as $throwsTagValue) {
$definition->type->exceptions[] = $this->handleStatic(
PhpDocTypeHelper::toType($throwsTagValue->type),
$definition->type->templates,
);
}
if ($returnTagValues = array_values($phpDoc->getReturnTagValues())) {
$definition->type->returnType = $this->handleStatic(
PhpDocTypeHelper::toType($returnTagValues[0]->type),
$definition->type->templates,
);
}
return $definition;
}
/**
* @param TemplateType[] $functionTemplates
*/
private function handleStatic(Type $type, array $functionTemplates): Type
{
$classTemplates = collect($this->classDefinition?->templateTypes ?: [])->keyBy->name;
$functionTemplatesByKeys = collect($functionTemplates)->keyBy->name;
return (new TypeWalker)->map($type, function (Type $t) use ($functionTemplatesByKeys, $classTemplates) {
if (! $t instanceof ObjectType) {
return $t;
}
$t->name = ltrim($t->name, '\\');
if ($definedTemplate = $classTemplates->get($t->name)) {
return $definedTemplate;
}
if ($definedFnTemplate = $functionTemplatesByKeys->get($t->name)) {
return $definedFnTemplate;
}
return $t;
});
}
}
@@ -0,0 +1,170 @@
<?php
namespace Dedoc\Scramble\Infer\DefinitionBuilders;
use Dedoc\Scramble\Infer\Contracts\FunctionLikeDefinitionBuilder;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
use Dedoc\Scramble\Infer\Services\FileNameResolver;
use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper;
use Dedoc\Scramble\Support\PhpDoc;
use Dedoc\Scramble\Support\Type\FunctionType;
use Dedoc\Scramble\Support\Type\Generic;
use Dedoc\Scramble\Support\Type\MixedType;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Support\Type\TemplateType;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\TypeHelper;
use Dedoc\Scramble\Support\Type\TypeWalker;
use Dedoc\Scramble\Support\Type\UnknownType;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
use ReflectionFunction;
use ReflectionMethod;
use ReflectionParameter;
class FunctionLikeReflectionDefinitionBuilder implements FunctionLikeDefinitionBuilder
{
private ReflectionFunction|ReflectionMethod $reflection;
/** @var Collection<string, covariant Type> */
private Collection $classTemplates;
/**
* @param Collection<string, covariant Type>|null $classTemplates
*/
public function __construct(
public string $name,
ReflectionFunction|ReflectionMethod|null $reflection = null,
?Collection $classTemplates = null,
) {
$this->reflection = $reflection ?: new ReflectionFunction($this->name);
$this->classTemplates = $classTemplates ?: collect();
}
public function build(): FunctionLikeDefinition
{
$functionDefinition = $this->buildFunctionDefinitionFromTypeHints();
$this->applyPhpDoc(
$functionDefinition,
PhpDoc::parse($this->reflection->getDocComment() ?: '/** */', FileNameResolver::createForFile($this->reflection->getFileName())),
);
$functionDefinition->isFullyAnalyzed = true;
return $functionDefinition;
}
private function buildFunctionDefinitionFromTypeHints(): FunctionLikeDefinition
{
$parameters = collect($this->reflection->getParameters())
->mapWithKeys(fn (ReflectionParameter $p) => [
$p->name => ($paramType = $p->getType())
? TypeHelper::createTypeFromReflectionType($paramType)
: new MixedType,
])
->all();
$argumentDefaults = collect($this->reflection->getParameters())
->mapWithKeys(fn (ReflectionParameter $p) => [
$p->name => rescue(function () use ($p) {
return TypeHelper::createTypeFromValue($p->getDefaultValue());
}, report: false),
])
->filter()
->all();
$returnType = ($retType = $this->reflection->getReturnType())
? TypeHelper::createTypeFromReflectionType($retType)
: new UnknownType;
$type = new FunctionType($this->name, $parameters, $returnType);
return new FunctionLikeDefinition(
$type,
argumentsDefaults: $argumentDefaults,
definingClassName: $this->reflection instanceof ReflectionMethod ? $this->reflection->class : null,
);
}
private function applyPhpDoc(FunctionLikeDefinition $definition, PhpDocNode $phpDoc): void
{
foreach ($phpDoc->getTemplateTagValues() as $templateTagValue) {
$definition->type->templates[] = new TemplateType(
name: $templateTagValue->name,
);
}
foreach ($phpDoc->getParamTagValues() as $paramTagValue) {
$name = Str::replaceFirst('$', '', $paramTagValue->parameterName);
if (! array_key_exists($name, $definition->type->arguments)) {
continue;
}
$definition->type->arguments[$name] = $this->handleStatic(
PhpDocTypeHelper::toType($paramTagValue->type),
$definition->type->templates,
);
}
foreach ($phpDoc->getThrowsTagValues() as $throwsTagValue) {
$definition->type->exceptions[] = $this->handleStatic(
PhpDocTypeHelper::toType($throwsTagValue->type),
$definition->type->templates,
);
}
if ($returnTagValues = array_values($phpDoc->getReturnTagValues())) {
$definition->type->returnType = $this->handleStatic(
PhpDocTypeHelper::toType($returnTagValues[0]->type),
$definition->type->templates,
);
}
if (method_exists($phpDoc, 'getSelfOutTypeTagValues')) { // @phpstan-ignore function.alreadyNarrowedType
foreach ($phpDoc->getSelfOutTypeTagValues() as $selfOutTypeTagValue) {
$selfOutType = $this->handleStatic(
PhpDocTypeHelper::toType($selfOutTypeTagValue->type),
$definition->type->templates,
);
if (! $selfOutType instanceof Generic) {
continue;
}
$definition->selfOutType = $this->handleSelfOutType($selfOutType);
}
}
}
/**
* @param TemplateType[] $functionTemplates
*/
private function handleStatic(Type $type, array $functionTemplates): Type
{
$functionTemplatesByKeys = collect($functionTemplates)->keyBy->name;
return (new TypeWalker)->map($type, function (Type $t) use ($functionTemplatesByKeys) {
if (! $t instanceof ObjectType) {
return $t;
}
$t->name = ltrim($t->name, '\\');
if ($definedTemplate = $this->classTemplates->get($t->name)) {
return $definedTemplate;
}
if ($definedFnTemplate = $functionTemplatesByKeys->get($t->name)) {
return $definedFnTemplate;
}
return $t;
});
}
private function handleSelfOutType(Generic $type): Generic
{
return new Generic('self', $type->templateTypes);
}
}

Some files were not shown because too many files have changed in this diff Show More