From a2d5d468f205b99d3ed43982da5963493c8dfe34 Mon Sep 17 00:00:00 2001 From: tolawho Date: Fri, 5 Jun 2026 22:10:00 +0700 Subject: [PATCH 01/27] feat: add technical analysis and architecture design for swagghp --- .agent/skills/safe_framework/SKILL.md | 61 ++++++ TECHNICAL_ANALYSIS.md | 257 ++++++++++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 .agent/skills/safe_framework/SKILL.md create mode 100644 TECHNICAL_ANALYSIS.md diff --git a/.agent/skills/safe_framework/SKILL.md b/.agent/skills/safe_framework/SKILL.md new file mode 100644 index 0000000..9802c79 --- /dev/null +++ b/.agent/skills/safe_framework/SKILL.md @@ -0,0 +1,61 @@ +--- +name: safe_framework +description: Guide for applying Essential SAFe practices (PI Planning, Iterations, Artifacts). +--- + +# SAFE Framework Skill + +This skill guides you in applying Essential SAFe practices within the `wfxp.api` project. + +## 1. Core Concepts +- **ART (Agile Release Train)**: A virtual organization of 5-12 teams (50-125+ people) that plans, commits, and executes together. +- **PI (Program Increment)**: A timebox (typically 8-12 weeks) during which an ART delivers code. +- **Iteration**: A standard 2-week agile sprint. + +## 2. Planning & Execution + +### 2.1 PI Planning (The Heart of SAFe) +When participating in or simulating PI Planning: +1. **Context**: Understand the *Vision* and *Roadmap* first. +2. **Breakdown**: Decompose **Features** into **User Stories**. +3. **Dependencies**: Identify dependencies with other teams (or hypothetical teams if working alone). +4. **Risks**: ROAM your risks: + - **R**esolved: Addressed now. + - **O**wned: assigned to someone. + - **A**ccepted: Live with it. + - **M**itigated: Plan B. +5. **Output**: A set of committed PI Objectives with business value. + +### 2.2 Iteration Execution +during an iteration (sprint): +- **Planning**: Commit to specific stories from the PI backlog. +- **Daily**: Standup to track progress toward Iteration Goals. +- **Review**: Demonstrate working software (System Demo). +- **Retro**: Inspect and Adapt. + +## 3. Artifact Guidelines + +### 3.1 Features (Program Level) +- **Structure**: Benefit hypothesis + Acceptance Criteria. +- **Estimation**: WSJF (Weighted Shortest Job First). + - `WSJF = Cost of Delay / Job Size` + - *Cost of Delay* = User-Business Value + Time Criticality + RR | OE (Risk Reduction | Opportunity Enablement). + +### 3.2 Stories (Team Level) +- **Format**: "As a [Role], I want [Activity], so that [Benefit]". +- **Acceptance Criteria**: Clear Pass/Fail conditions. +- **Estimation**: Story Points (Fibonacci). + +### 3.3 Enablers +- Work that supports future business functionality (Architecture, Infrastructure, Compliance). +- Treat them like Features/Stories but with technical "Business Value". + +## 4. Hierarchy Check +- **Portfolio**: Epics (Strategic Themes). +- **Program (ART)**: Features (fit in a PI). +- **Team**: Stories (fit in an Iteration). + +## 5. Principles to Remember +- **#1 Take an economic view**: Optimize for shortest sustainable lead time. +- **#6 Visualize and limit WIP**: Stop starting, start finishing. +- **#9 Decentralize decision-making**: Don't wait for approval on tactical decisions. diff --git a/TECHNICAL_ANALYSIS.md b/TECHNICAL_ANALYSIS.md new file mode 100644 index 0000000..2ce41da --- /dev/null +++ b/TECHNICAL_ANALYSIS.md @@ -0,0 +1,257 @@ +# BÁO CÁO PHÂN TÍCH KỸ THUẬT: THƯ VIỆN PHP-SWAG (SWAGGO FOR PHP) + +## 1. Đánh giá tính khả thi (Feasibility Analysis) + +Việc xây dựng một thư viện tạo Swagger/OpenAPI bằng cách phân tích tĩnh (Static Analysis) PHPDocs là **hoàn toàn khả thi** trong hệ sinh thái PHP hiện nay, nhờ vào các công cụ mạnh mẽ sau: + +### 1.1. Phân tích mã nguồn với `nikic/php-parser` +- **Khả thi:** Rất cao. +- **Vai trò:** Chuyển đổi mã nguồn PHP thành Cây cú pháp trừu tượng (AST). Giúp trích xuất cấu trúc Class, Method, Property và các thuộc tính liên quan mà không cần chạy code (Runtime). +- **Ưu điểm:** Hỗ trợ đầy đủ các phiên bản PHP mới nhất, có khả năng đọc được cả Comments và Attributes. + +### 1.2. Phân tích PHPDoc với `phpstan/phpdoc-parser` +- **Khả thi:** Rất cao. +- **Vai trò:** Đây là thư viện tiêu chuẩn để parse các PHPDoc phức tạp. Nó không chỉ đọc text thô mà còn hiểu được cấu trúc của các kiểu dữ liệu nâng cao như Generics (`Collection`), Union types (`User|Admin`), và Intersection types. +- **Ưu điểm:** Độ chính xác cực cao, được tin dùng bởi các công cụ lớn như PHPStan và Rector. + +### 1.3. Khả năng suy luận kiểu (Type Inference) +- **Khả thi:** Trung bình - Cao. +- **Cơ chế:** Kết hợp thông tin từ Type-hint gốc của PHP (ví dụ: `public string $name`) và thông tin bổ sung từ PHPDoc (ví dụ: `@var array`). +- **Thách thức:** Cần bộ giải mã (Resolver) để ánh xạ các Class Name ngắn (ví dụ: `User`) thành Full-Qualified Class Name (FQCN) (ví dụ: `App\Models\User`) dựa trên các câu lệnh `use` trong file. + +## 2. Các khó khăn kỹ thuật trọng tâm (Technical Challenges) + +### 2.1. Phân giải Namespace và Use Statements +Khi phân tích tĩnh, thư viện phải tự mình hiểu được ngữ cảnh của file để biết `User` thực sự là class nào. +- **Giải pháp:** Xây dựng `NameResolver` đi kèm với bộ quét AST để lưu trữ bản đồ các alias `use`. + +### 2.2. Xử lý kiểu dữ liệu Generic và lồng nhau +Cú pháp `ApiResponse>` không tồn tại trong PHP thuần nhưng lại phổ biến trong Swagger. +- **Khó khăn:** OpenAPI 3.0 không hỗ trợ Generics thực thụ. +- **Giải pháp:** Sử dụng cơ chế "Flattening" hoặc tạo các Schema trung gian (ví dụ: `UserListApiResponse`) trong quá trình sinh tài liệu. + +### 2.3. Quét Route toàn cục (Global Route Discovery) +Quét toàn bộ thư mục để tìm `@route` yêu cầu hiệu năng tốt. +- **Khó khăn:** Project lớn có thể có hàng nghìn file. +- **Giải pháp:** Sử dụng `Symfony Finder` hoặc `RecursiveDirectoryIterator` kết hợp với việc lọc nhanh nội dung file (regex sơ bộ) trước khi đưa vào bộ Parse AST chính thức. + +### 2.4. Tham chiếu vòng (Circular References) +Class `User` chứa `Post`, và `Post` lại chứa `User`. +- **Khó khăn:** Gây ra lặp vô tận khi xây dựng Schema. +- **Giải pháp:** Sử dụng `Schema Registry` để lưu trữ các model đã được xử lý và sử dụng `$ref` trong OpenAPI để trỏ đến nhau. + + +## 3. Thiết kế kiến trúc chi tiết (Architectural Design) + +### 3.1. Mô hình Pipeline xử lý + +Thư viện sẽ hoạt động theo quy trình 5 bước: + +1. **Scanner (Bộ quét):** Tìm kiếm tất cả các file `.php` trong thư mục cấu hình. Lọc nhanh các file có chứa từ khóa `@route`. +2. **AST Parser (Phân tích cú pháp):** Sử dụng `nikic/php-parser` để bóc tách cấu trúc class, method và lấy khối PHPDoc tương ứng. +3. **DocBlock Analyzer (Phân tích tài liệu):** Sử dụng `phpstan/phpdoc-parser` để chuyển đổi PHPDoc thô thành các Object định nghĩa kiểu (Nodes). +4. **Type Resolver & Schema Registry (Giải mã kiểu):** + - Giải mã tên class (FQCN). + - Phân tích các thuộc tính (Properties) của Model để tạo ra các OpenAPI Schema. + - Đưa vào Registry để quản lý trùng lặp và tham chiếu. +5. **OpenAPI Generator (Sinh tài liệu):** Chuyển đổi dữ liệu trung gian thành định dạng YAML hoặc JSON tuân thủ chuẩn OpenAPI 3.0/3.1. + +### 3.2. Cấu trúc dữ liệu trung gian (Intermediate Representation - IR) + +Để tránh phụ thuộc quá nhiều vào cấu trúc của OpenAPI ngay từ đầu, dữ liệu sau khi parse sẽ được lưu vào một cấu trúc IR thuần túy: +- `RouteDefinition`: path, method, summary, parameters, response_ref. +- `SchemaDefinition`: name, type, properties (array of PropertyDefinition). +- `PropertyDefinition`: name, type, is_nullable, description. + +## 4. Đặc tả các PHPDoc Tags hỗ trợ + +### 4.1. Endpoint Annotations (Dành cho Controller Method) +- `@route [METHOD] [PATH]` (Bắt buộc): Định nghĩa endpoint. +- `@summary [TEXT]`: Mô tả ngắn gọn. +- `@description [TEXT]`: Mô tả chi tiết. +- `@tag [NAME]`: Nhóm các API. +- `@request [CLASS_NAME]`: Định nghĩa Body request (suy luận từ class). +- `@response [CODE] [CLASS_NAME]`: Định nghĩa response. +- `@query [NAME] [TYPE] [DESCRIPTION]`: Tham số URL. + +### 4.2. Schema Annotations (Dành cho Model/DTO) +- `@property [TYPE] $[NAME] [DESCRIPTION]`: Định nghĩa thuộc tính. +- `@var [TYPE]`: Dùng cho thuộc tính trong class. + + +## 5. Ví dụ minh họa (The "Magic" Experience) + +**Code người dùng viết:** + +```php +namespace App\Controllers; + +use App\Resources\UserResource; + +class UserController { + /** + * @route GET /api/users/{id} + * @summary Lấy thông tin chi tiết người dùng + * @response 200 UserResource + */ + public function show(int $id) { ... } +} + +namespace App\Resources; + +/** + * @property int $id ID người dùng + * @property string $name Tên hiển thị + * @property string|null $email + */ +class UserResource { } +``` + +**Thư viện tự suy luận:** +- Path parameter `id` có kiểu `integer` (từ type-hint của hàm `show`). +- Response 200 sử dụng Schema `UserResource`. +- Schema `UserResource` có 3 trường, trong đó `email` là `nullable`. + +## 6. Kết luận & Đề xuất Lộ trình (Roadmap) + +Dự án này mang tính thực tiễn cao, giúp giảm thiểu sự trùng lặp code (DRY) và giữ tài liệu luôn đi kèm với code. + +**Giai đoạn 1: Core Engine** +- Xây dựng bộ quét AST và giải mã Namespace. +- Hỗ trợ các Tag cơ bản: `@route`, `@summary`, `@property`. + +**Giai đoạn 2: Type System Pro** +- Xử lý Generics (`Collection`) và Union types. +- Tự động tìm kiếm Class định nghĩa trong toàn bộ project. + +**Giai đoạn 3: Integration & CLI** +- Xây dựng CLI tool `php-swag`. +- Xuất file `swagger.yaml` hoặc `swagger.json`. + + +## 7. Phân tích sâu các khó khăn kỹ thuật (Deep Dive into Technical Challenges) + +### 7.1. Dependency Resolution & Autoloading Mapping +Khi gặp một class `UserResource`, bộ phân tích tĩnh cần biết file vật lý của nó ở đâu để đọc PHPDoc. +- **Vấn đề:** PHP không có cấu trúc file cố định cho namespace (dù PSR-4 là phổ biến). +- **Giải pháp:** + - Đọc file `composer.json` để lấy thông tin `autoload` (PSR-4 mapping). + - Xây dựng một `ClassIndex` (bản đồ Class FQCN -> File Path) trước khi bắt đầu parse chi tiết. + +### 7.2. Hiệu năng & Bộ nhớ (Performance) +Việc parse AST là một tiến trình tiêu tốn CPU và RAM. +- **Vấn đề:** Project lớn có thể làm treo quá trình generate. +- **Giải pháp:** + - **Caching:** Lưu trữ kết quả parse của từng file (hash của nội dung file). Chỉ parse lại những file có thay đổi. + - **Lazy Loading:** Chỉ parse các Model Schema khi chúng thực sự được tham chiếu bởi một `@route`. + +### 7.3. Xử lý Thừa kế (Inheritance & Traits) +Một Model có thể kế thừa từ một Base Model hoặc sử dụng Traits chứa các `@property`. +- **Vấn đề:** Nếu chỉ parse class hiện tại, ta sẽ mất các trường dữ liệu từ class cha. +- **Giải pháp:** Cần một cơ chế "Recursive Parsing" để duyệt ngược lên các class cha và gộp (merge) các định nghĩa thuộc tính. + +### 7.4. Mâu thuẫn giữa Type-hint và PHPDoc +```php +public int $status; // PHP Type-hint +/** @var string */ // PHPDoc mâu thuẫn +public $status; +``` +- **Nguyên tắc xử lý:** PHPDoc luôn có độ ưu tiên cao hơn (vì nó cho phép mô tả chi tiết hơn như `string|null`, `regex`, v.v.), nhưng nếu PHPDoc không có, sẽ lấy Type-hint làm fallback. + + +## 8. Thiết kế kiến trúc chi tiết (Architectural Design) + +### 8.1. Sơ đồ thành phần (Component Diagram) + +```text ++----------------+ +-------------------+ +---------------------+ +| CLI / Core |----->| Scanner |----->| Finder | ++----------------+ +-------------------+ +---------------------+ + | | + v v ++----------------+ +-------------------+ +---------------------+ +| Registry |<-----| AST Collector |----->| nikic/php-parser | ++----------------+ +-------------------+ +---------------------+ + | | + v v ++----------------+ +-------------------+ +---------------------+ +| Type Resolver |<-----| DocBlock Parser |----->| phpstan/doc-parser | ++----------------+ +-------------------+ +---------------------+ + | + v ++----------------+ +-------------------+ +| Generator |----->| OpenAPI Spec (YAML)| ++----------------+ +-------------------+ +``` + +### 8.2. Các Interface quan trọng (Internal API Design) + +#### a. `CollectorInterface` +Chịu trách nhiệm duyệt qua AST và tìm kiếm các thông tin liên quan. +```php +interface Collector { + public function collect(Node $node): void; + public function getResults(): array; +} +``` + +#### b. `TypeResolverInterface` +Chịu trách nhiệm chuyển đổi một chuỗi tên type (ví dụ: `User[]`) thành một Object định nghĩa kiểu. +```php +interface TypeResolver { + public function resolve(string $type, Context $context): TypeDefinition; +} +``` + +#### c. `SchemaRegistry` +Nơi lưu trữ tập trung các Model. Đảm bảo mỗi model chỉ được parse một lần. +```php +class SchemaRegistry { + private array $schemas = []; + public function register(string $fqcn): Reference; + public function getDefinitions(): array; +} +``` + +### 8.3. Luồng dữ liệu (Data Flow) + +1. **Giai đoạn Thu thập (Collection Phase):** + - `Scanner` tìm file -> `AST Collector` tìm các class có `@route`. + - `AST Collector` cũng thu thập thông tin về `use` statements để tạo `Context`. +2. **Giai đoạn Phân giải (Resolution Phase):** + - Khi gặp một Class trong `@response`, `TypeResolver` sẽ tra cứu FQCN. + - Nếu Class đó chưa có trong `Registry`, tiến trình Parse Model sẽ được kích hoạt cho file chứa Class đó. +3. **Giai đoạn Sinh mã (Generation Phase):** + - `Generator` duyệt qua danh sách Route đã thu thập. + - Map các `TypeDefinition` sang cấu trúc `components/schemas` của OpenAPI. + - Xuất file kết quả. + + +## 9. Định nghĩa bộ đặc tả PHPDoc (PHPDoc Specification) + +### 9.1. Tags cho Controller (Endpoints) + +| Tag | Tham số | Ví dụ | OpenAPI Mapping | +|:---|:---|:---|:---| +| `@route` | `[METHOD] [PATH]` | `@route POST /users` | `paths -> /users -> post` | +| `@summary` | `[STRING]` | `@summary Tạo user mới` | `summary` | +| `@description`| `[STRING]` | `@description Mô tả chi tiết` | `description` | +| `@tag` | `[STRING]` | `@tag User Management` | `tags` | +| `@request` | `[CLASS]` | `@request CreateUserDto` | `requestBody` | +| `@response` | `[CODE] [CLASS]` | `@response 200 UserDto` | `responses -> 200` | +| `@query` | `[NAME] [TYPE] [DESC]`| `@query page int Số trang` | `parameters (in: query)` | +| `@path` | `[NAME] [TYPE] [DESC]`| `@path id string ID User` | `parameters (in: path)` | + +### 9.2. Tags cho Model (Schemas) + +| Tag | Cú pháp | Ví dụ | +|:---|:---|:---| +| `@property` | `[TYPE] $[NAME] [DESC]`| `@property string $name Tên` | +| `@var` | `[TYPE]` | `@var int` | +| `@template` | `[NAME]` | `@template T` (Dùng cho Generics) | + +### 9.3. Xử lý Kiểu dữ liệu đặc biệt + +- `array` hoặc `User[]` -> `type: array, items: { $ref: '#/components/schemas/User' }` +- `string|null` -> `type: string, nullable: true` (OpenAPI 3.0) hoặc `type: [string, null]` (OpenAPI 3.1) +- `User|Admin` -> `oneOf: [ { $ref: 'User' }, { $ref: 'Admin' } ]` From d7844ebcb303d06bca333e292fa5735ad0d03df4 Mon Sep 17 00:00:00 2001 From: tolawho Date: Fri, 5 Jun 2026 23:28:36 +0700 Subject: [PATCH 02/27] Setup SAFe transformation artifacts including backlog, WSJF, and strategy for PHP Swagger Generator project. (#2) --- SAFE_BACKLOG.md | 143 +++++++++++++++++++++++++++++++++++++++++++++++ SAFE_STRATEGY.md | 25 +++++++++ 2 files changed, 168 insertions(+) create mode 100644 SAFE_BACKLOG.md create mode 100644 SAFE_STRATEGY.md diff --git a/SAFE_BACKLOG.md b/SAFE_BACKLOG.md new file mode 100644 index 0000000..c0ec274 --- /dev/null +++ b/SAFE_BACKLOG.md @@ -0,0 +1,143 @@ +# SAFe Backlog - PHP Swagger Generator Project + +## 1. Portfolio Backlog (Epics) + +### [Epic 1] Xây dựng Core Engine cho PHP Swagger Generator +- **Trạng thái:** To Do +- **Chủ sở hữu:** Fullstack Developer (User) +- **Tóm tắt:** Xây dựng bộ khung cơ bản có khả năng quét mã nguồn PHP và trích xuất các thông tin route cơ bản thông qua AST. +- **Giả thuyết Lợi ích (Benefit Hypothesis):** Cung cấp một công cụ mã nguồn mở giúp lập trình viên PHP tự động hóa việc tạo tài liệu Swagger từ mã nguồn mà không cần cấu hình thủ công phức tạp, từ đó giảm sai sót và tiết kiệm thời gian bảo trì tài liệu. +- **Tiêu chí chấp nhận (Acceptance Criteria):** + - Có khả năng quét thư mục và tìm kiếm file .php. + - Phân tích được cấu trúc AST và giải quyết được Namespace/Use statements. + - Nhận diện và trích xuất được các tag cơ bản: `@route`, `@summary`, `@property`. + - Xuất ra cấu trúc dữ liệu trung gian (IR). + +### [Epic 2] Nâng cấp Hệ thống Type System (Pro) +- **Trạng thái:** To Do +- **Chủ sở hữu:** Fullstack Developer (User) +- **Tóm tắt:** Mở rộng khả năng phân tích kiểu dữ liệu phức tạp bao gồm Generics, Union types và xử lý thừa kế. +- **Giả thuyết Lợi ích (Benefit Hypothesis):** Cho phép thư viện hỗ trợ các dự án PHP hiện đại sử dụng cấu trúc dữ liệu phức tạp (như Collection, DTO kế thừa), tăng tính chính xác và độ phủ của tài liệu API được sinh ra. +- **Tiêu chí chấp nhận (Acceptance Criteria):** + - Hỗ trợ cú pháp Generics trong PHPDoc (ví dụ: `ApiResponse`). + - Xử lý được Union types (`string|null`, `User|Admin`). + - Có cơ chế Recursive Parsing để thu thập thuộc tính từ class cha và Trait. + - Xử lý được tham chiếu vòng (Circular References). + +### [Epic 3] Tích hợp CLI và Tối ưu hóa Hiệu năng +- **Trạng thái:** To Do +- **Chủ sở hữu:** Fullstack Developer (User) +- **Tóm tắt:** Hoàn thiện công cụ dưới dạng CLI, hỗ trợ nhiều định dạng xuất bản và cơ chế bộ nhớ đệm (Caching). +- **Giả thuyết Lợi ích (Benefit Hypothesis):** Biến thư viện thành một công cụ dòng lệnh chuyên nghiệp dễ dàng tích hợp vào quy trình CI/CD, đồng thời đảm bảo tốc độ xử lý nhanh cho các dự án lớn. +- **Tiêu chí chấp nhận (Acceptance Criteria):** + - Cung cấp lệnh `php-swag generate` dễ sử dụng. + - Xuất ra định dạng YAML và JSON chuẩn OpenAPI 3.0/3.1. + - Tích hợp cơ chế Caching dựa trên file hash để tăng tốc độ quét lần sau. + - Có tài liệu hướng dẫn sử dụng (README) hoàn chỉnh cho cộng đồng. + + +## 2. Program Backlog (Features) + +### Features cho [Epic 1] Core Engine +- **[F1.1] File Scanner & Finder:** Tìm kiếm đệ quy tất cả các file .php trong các thư mục được cấu hình. + - *AC:* Trả về danh sách đường dẫn file hợp lệ; bỏ qua các file trong vendor hoặc thư mục bị loại trừ. +- **[F1.2] AST Parser Integration:** Tích hợp `nikic/php-parser` để đọc cấu trúc code. + - *AC:* Chuyển đổi mã nguồn thành cây AST; trích xuất được các Class Node và Method Node. +- **[F1.3] Namespace Resolver:** Xác định chính xác FQCN (Fully Qualified Class Name) dựa trên `namespace` và `use` statements. + - *AC:* Trả về tên class đầy đủ ngay cả khi sử dụng alias. +- **[F1.4] Basic DocBlock Collector:** Thu thập và phân tích các tag đơn giản (@route, @summary, @property). + - *AC:* Chuyển đổi PHPDoc thô thành các Object thuộc tính tương ứng. + +### Features cho [Epic 2] Type System Pro +- **[F2.1] Advanced Type Resolver:** Hỗ trợ các kiểu dữ liệu phức tạp của PHP hiện đại. + - *AC:* Xử lý được union types (A|B), nullable (?A), và các kiểu nguyên thủy. +- **[F2.2] Generics Support:** Phân tích cú pháp template cho các kiểu dữ liệu generic. + - *AC:* Hiểu được `Collection` hoặc `ApiResponse`. +- **[F2.3] Inheritance & Trait Merger:** Gộp các thuộc tính từ các class cha và traits. + - *AC:* Schema của class con phải bao gồm đầy đủ thuộc tính từ cây kế thừa. +- **[F2.4] Schema Registry:** Quản lý tập trung các định nghĩa Model để tránh trùng lặp và xử lý tham chiếu vòng. + - *AC:* Không bị lỗi vòng lặp vô tận khi Class A chứa Class B và ngược lại. + +### Features cho [Epic 3] Integration & CLI +- **[F3.1] CLI Command Interface:** Cung cấp giao diện dòng lệnh cho người dùng. + - *AC:* Chạy được lệnh `php-swag generate --path=src`. +- **[F3.2] OpenAPI Spec Generator:** Chuyển đổi dữ liệu IR thành file chuẩn OpenAPI. + - *AC:* Xuất ra file `swagger.yaml` hoặc `swagger.json` hợp lệ (v3.0/3.1). +- **[F3.3] Performance Caching:** Lưu trữ kết quả phân tích để tăng tốc cho các lần chạy sau. + - *AC:* Tốc độ generate lần 2 phải nhanh hơn ít nhất 50% so với lần đầu. +- **[F3.4] README & Documentation:** Hướng dẫn cộng đồng cách sử dụng và đóng góp. + - *AC:* Có file README.md chi tiết với ví dụ minh họa rõ ràng. + +## 3. Team Backlog (User Stories - Examples for PI-1) + +### Stories cho [F1.3] Namespace Resolver +- **[S1.3.1] Parse Use Statements:** + - *Câu chuyện:* Là một hệ thống phân tích, tôi muốn đọc và lưu trữ các alias trong phần `use` của file PHP, để tôi biết chính xác tên class được tham chiếu trong code. + - *AC:* Xử lý được các trường hợp: `use App\User;`, `use App\Resource as Res;`, `use Group\{ClassA, ClassB};`. +- **[S1.3.2] Contextual Class Resolution:** + - *Câu chuyện:* Là một hệ thống phân tích, tôi muốn tìm được FQCN của một class dựa trên context hiện tại (Namespace + Use statements), để tạo tham chiếu chính xác trong OpenAPI. + - *AC:* Trả về `App\Resources\UserResource` khi gặp code sử dụng `UserResource` trong namespace `App\Controllers` có `use App\Resources\UserResource`. + +### Stories cho [F1.4] Basic DocBlock Collector +- **[S1.4.1] Extract @route tag:** + - *Câu chuyện:* Là một lập trình viên, tôi muốn dùng tag `@route` để định nghĩa endpoint, để tôi không phải viết cấu trúc path phức tạp trong file cấu hình. + - *AC:* Bóc tách được `METHOD` (GET, POST, ...) và `PATH` từ chuỗi `@route GET /users`. +- **[S1.4.2] Extract @property tag:** + - *Câu chuyện:* Là một lập trình viên, tôi muốn dùng tag `@property` trong Model, để mô tả cấu trúc JSON của API. + - *AC:* Trích xuất được kiểu dữ liệu (`string`, `int`), tên biến (`$name`) và mô tả kèm theo. + +### Stories cho [F2.1] Advanced Type Resolver +- **[S2.1.1] Handle Nullable Types:** + - *Câu chuyện:* Là một lập trình viên, tôi muốn hỗ trợ kiểu nullable, để tài liệu API phản ánh đúng tính chất dữ liệu (có thể null). + - *AC:* Nhận diện `?string`, `string|null` và ánh xạ sang `nullable: true` trong OpenAPI. + + +## 4. Ưu tiên hóa bằng WSJF (Weighted Shortest Job First) + +Chúng ta sẽ tính toán cho các Feature chính trong PI-1 (dự kiến tập trung vào Epic 1 và một phần Epic 2). +Thang điểm Fibonacci: 1, 2, 3, 5, 8, 13, 20. + +| Feature | Business Value | Time Criticality | RR \| OE | Cost of Delay (CoD) | Job Size | **WSJF** | +| :--- | :---: | :---: | :---: | :---: | :---: | :---: | +| [F1.2] AST Parser | 13 | 5 | 20 | 38 | 8 | **4.75** | +| [F1.3] Namespace Resolver | 8 | 3 | 13 | 24 | 5 | **4.8** | +| [F1.1] File Scanner | 5 | 2 | 5 | 12 | 3 | **4.0** | +| [F1.4] Basic DocBlock | 13 | 8 | 8 | 29 | 5 | **5.8** | +| [F2.1] Adv. Type Resolver | 8 | 3 | 8 | 19 | 5 | **3.8** | +| [F2.4] Schema Registry | 5 | 2 | 13 | 20 | 3 | **6.67** | + +**Phân tích:** +- **[F2.4] Schema Registry** có WSJF cao nhất vì nó giải quyết rủi ro kỹ thuật lớn (tham chiếu vòng) và có kích thước nhỏ. Cần làm sớm để làm nền móng. +- **[F1.4] Basic DocBlock** có WSJF cao vì nó mang lại giá trị trực tiếp cho người dùng (thấy được kết quả). +- **[F1.2] và [F1.3]** là nền tảng bắt buộc. + + +## 5. Transformation Roadmap & PI Objectives + +### Lộ trình (Roadmap) +- **PI-1 (Foundation):** Thiết lập Core Engine, giải quyết Namespace, và hỗ trợ các Tag cơ bản. Kết thúc PI-1 với một bản MVP có thể quét được các dự án PHP đơn giản. +- **PI-2 (Advanced Logic):** Tập trung vào Type System (Generics, Inheritance). Xử lý các trường hợp phức tạp để thư viện có thể dùng cho các Framework như Laravel/Symfony. +- **PI-3 (Productization):** Hoàn thiện CLI, Caching và đóng gói để phát hành phiên bản 1.0.0 cho cộng đồng. + +### PI-1 Objectives (Mục tiêu PI-1) +1. **Mục tiêu kỹ thuật (Committed):** + - Hoàn thành bộ quét AST có độ chính xác > 95% với các project PSR-4. + - Hỗ trợ đầy đủ các Tag `@route`, `@summary`, `@property`. + - Xuất được file YAML hợp lệ có thể mở bằng Swagger UI. +2. **Mục tiêu phi kỹ thuật (Uncommitted):** + - Thiết lập CI/CD cơ bản (Github Actions) để tự động kiểm tra code. + - Viết bài blog giới thiệu ý tưởng dự án lên cộng đồng PHP Việt Nam. + + +## 6. Lean Governance & Improvement Backlog + +### Lean Governance (Quản trị tinh gọn) +Vì đây là dự án cá nhân, cơ chế quản trị sẽ tập trung vào sự kỷ luật tự thân: +- **Lấy giá trị làm trọng tâm:** Mỗi Story được viết ra phải chứng minh được giá trị cho người dùng cuối (Cộng đồng PHP). +- **Phê duyệt Epic:** User đóng vai trò Epic Owner, tự đánh giá tính khả thi và Business Value trước khi bắt đầu một Epic mới. +- **Minh bạch:** Sử dụng Kanban Board (giả định) để theo dõi luồng công việc từ To Do -> In Progress -> Done. + +### Improvement Backlog (Kế hoạch cải tiến) +- **[IMP-1] Automation:** Tự động hóa việc sinh tài liệu cho chính dự án này (Self-documenting). +- **[IMP-2] Feedback Loop:** Sau PI-1, gửi bản MVP cho 3-5 đồng nghiệp để lấy feedback sớm, thay vì đợi đến khi hoàn thiện 100%. +- **[IMP-3] Quality Gate:** Thiết lập ngưỡng coverage cho unit test tối thiểu 80% trước khi merge Feature vào nhánh chính. diff --git a/SAFE_STRATEGY.md b/SAFE_STRATEGY.md new file mode 100644 index 0000000..cf70581 --- /dev/null +++ b/SAFE_STRATEGY.md @@ -0,0 +1,25 @@ +# SAFe Transformation Strategy - PHP Swagger Generator + +## 1. Xác định Cấp độ (Levels) +Do đặc thù dự án có 1 nhân sự (Fullstack), chúng ta áp dụng mô hình **Essential SAFe** tinh gọn: +- **Team Level:** Bạn đóng vai trò là Agile Team (Scrum/Kanban) thực thi các Stories. +- **Program Level (ART):** Bạn đóng vai trò Product Management/System Architect để điều phối Release Train. +- **Portfolio Level:** Bạn đóng vai trò Epic Owner để định hướng giá trị lâu dài cho thư viện. + +## 2. Ánh xạ Cấu trúc (Mapping) +- **Epics:** 3 Giai đoạn lớn trong TECHNICAL_ANALYSIS.md. +- **Capabilities:** (Không áp dụng vì chưa đạt quy mô Large Solution). +- **Features:** Các module chức năng lớn (Parser, Resolver, CLI). +- **Stories:** Các đơn vị công việc nhỏ có thể hoàn thành trong 1-2 ngày. + +## 3. Đánh giá Vận hành (Assessment) +- **Flow:** Sử dụng Kanban để tối ưu hóa dòng chảy, hạn chế WIP (Work In Progress) để tránh quá tải cho 1 người. +- **Predictability:** Đo lường qua "Velocity" cá nhân sau mỗi Iteration (2 tuần). +- **Quality:** Áp dụng Built-in Quality thông qua Unit Testing và Static Analysis (PHPStan). +- **Dependency:** Hiện tại không có phụ thuộc bên ngoài (External Dependencies), chủ yếu là phụ thuộc kỹ thuật (Technical Debt/Enablers). + +## 4. Các Sự kiện SAFe (Events) +- **PI Planning:** Thực hiện định kỳ mỗi 8-12 tuần để nhìn lại Roadmap. +- **Iteration Planning:** Thực hiện vào đầu mỗi 2 tuần. +- **System Demo:** Tự kiểm thử và chạy thử các ví dụ (Example code) để xác nhận tính năng đã hoàn thiện. +- **Inspect & Adapt (I&A):** Đánh giá lại quy trình sau mỗi PI để cải tiến năng suất. From 0589636d7b1ed96820968b12b48d2c8a036ccdb0 Mon Sep 17 00:00:00 2001 From: tolawho Date: Sat, 6 Jun 2026 00:12:33 +0700 Subject: [PATCH 03/27] feat: implement Core Engine for PHP Swagger Generator (#3) * feat: implement Core Engine for PHP Swagger Generator - Initialize project with composer.json and PSR-4 structure - Implement Scanner to find PHP files - Implement AST Parser and NameResolver for FQCN resolution - Implement DocBlockCollector using phpstan/phpdoc-parser - Implement IR and OpenAPI Generator for YAML output - Add examples and unit tests - Update SAFe Backlog status to reflect progress on PI-1 Foundation. * feat: implement Core Engine for PHP Swagger Generator - Initialize project with composer.json and PSR-4 structure - Implement Scanner to find PHP files - Implement AST Parser and NameResolver for FQCN resolution - Implement DocBlockCollector using phpstan/phpdoc-parser - Implement IR and OpenAPI Generator for YAML output - Add examples and unit tests - Update README with testing instructions - Update SAFe Backlog status to reflect progress on PI-1 Foundation. --------- Co-authored-by: tolawho <12527881+tolawho@users.noreply.github.com> --- .gitignore | 3 + README.md | 40 ++++++ SAFE_BACKLOG.md | 12 +- composer.json | 29 ++++ examples/App/Controllers/UserController.php | 26 ++++ examples/App/Models/User.php | 12 ++ examples/generate.php | 11 ++ phpunit.xml | 11 ++ src/Core.php | 148 ++++++++++++++++++++ src/DocBlockCollector.php | 62 ++++++++ src/DocBlockParser.php | 30 ++++ src/Generator.php | 103 ++++++++++++++ src/IR/PropertyDefinition.php | 13 ++ src/IR/RouteDefinition.php | 16 +++ src/IR/SchemaDefinition.php | 11 ++ src/NameResolver.php | 58 ++++++++ src/Parser.php | 38 +++++ src/Scanner.php | 46 ++++++ tests/DocBlockCollectorTest.php | 26 ++++ tests/GeneratorTest.php | 44 ++++++ tests/NameResolverTest.php | 35 +++++ tests/ScannerTest.php | 51 +++++++ 22 files changed, 819 insertions(+), 6 deletions(-) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 examples/App/Controllers/UserController.php create mode 100644 examples/App/Models/User.php create mode 100644 examples/generate.php create mode 100644 phpunit.xml create mode 100644 src/Core.php create mode 100644 src/DocBlockCollector.php create mode 100644 src/DocBlockParser.php create mode 100644 src/Generator.php create mode 100644 src/IR/PropertyDefinition.php create mode 100644 src/IR/RouteDefinition.php create mode 100644 src/IR/SchemaDefinition.php create mode 100644 src/NameResolver.php create mode 100644 src/Parser.php create mode 100644 src/Scanner.php create mode 100644 tests/DocBlockCollectorTest.php create mode 100644 tests/GeneratorTest.php create mode 100644 tests/NameResolverTest.php create mode 100644 tests/ScannerTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47d1cb8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +vendor/ +.phpunit.result.cache +composer.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..45944e5 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# PHP Swagger Generator + +A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AST) and PHPDoc. + +## Installation + +```bash +composer require php-swag/php-swag +``` + +## Usage + +```php +use PhpSwag\Core; + +$core = new Core(); +$yaml = $core->generate(['./src']); + +file_put_contents('swagger.yaml', $yaml); +``` + +## Support Tags + +- `@route [METHOD] [PATH]` +- `@summary [TEXT]` +- `@response [CODE] [CLASS]` +- `@property [TYPE] $[NAME] [DESCRIPTION]` +- `@var [TYPE]` + +## Testing + +To run the unit tests: +```bash +./vendor/bin/phpunit +``` + +To run the example generator: +```bash +php examples/generate.php +``` diff --git a/SAFE_BACKLOG.md b/SAFE_BACKLOG.md index c0ec274..c1e87c3 100644 --- a/SAFE_BACKLOG.md +++ b/SAFE_BACKLOG.md @@ -3,7 +3,7 @@ ## 1. Portfolio Backlog (Epics) ### [Epic 1] Xây dựng Core Engine cho PHP Swagger Generator -- **Trạng thái:** To Do +- **Trạng thái:** Done - **Chủ sở hữu:** Fullstack Developer (User) - **Tóm tắt:** Xây dựng bộ khung cơ bản có khả năng quét mã nguồn PHP và trích xuất các thông tin route cơ bản thông qua AST. - **Giả thuyết Lợi ích (Benefit Hypothesis):** Cung cấp một công cụ mã nguồn mở giúp lập trình viên PHP tự động hóa việc tạo tài liệu Swagger từ mã nguồn mà không cần cấu hình thủ công phức tạp, từ đó giảm sai sót và tiết kiệm thời gian bảo trì tài liệu. @@ -39,13 +39,13 @@ ## 2. Program Backlog (Features) ### Features cho [Epic 1] Core Engine -- **[F1.1] File Scanner & Finder:** Tìm kiếm đệ quy tất cả các file .php trong các thư mục được cấu hình. +- [x] **[F1.1] File Scanner & Finder:** Tìm kiếm đệ quy tất cả các file .php trong các thư mục được cấu hình. - *AC:* Trả về danh sách đường dẫn file hợp lệ; bỏ qua các file trong vendor hoặc thư mục bị loại trừ. -- **[F1.2] AST Parser Integration:** Tích hợp `nikic/php-parser` để đọc cấu trúc code. +- [x] **[F1.2] AST Parser Integration:** Tích hợp `nikic/php-parser` để đọc cấu trúc code. - *AC:* Chuyển đổi mã nguồn thành cây AST; trích xuất được các Class Node và Method Node. -- **[F1.3] Namespace Resolver:** Xác định chính xác FQCN (Fully Qualified Class Name) dựa trên `namespace` và `use` statements. +- [x] **[F1.3] Namespace Resolver:** Xác định chính xác FQCN (Fully Qualified Class Name) dựa trên `namespace` và `use` statements. - *AC:* Trả về tên class đầy đủ ngay cả khi sử dụng alias. -- **[F1.4] Basic DocBlock Collector:** Thu thập và phân tích các tag đơn giản (@route, @summary, @property). +- [x] **[F1.4] Basic DocBlock Collector:** Thu thập và phân tích các tag đơn giản (@route, @summary, @property). - *AC:* Chuyển đổi PHPDoc thô thành các Object thuộc tính tương ứng. ### Features cho [Epic 2] Type System Pro @@ -109,7 +109,7 @@ Thang điểm Fibonacci: 1, 2, 3, 5, 8, 13, 20. **Phân tích:** - **[F2.4] Schema Registry** có WSJF cao nhất vì nó giải quyết rủi ro kỹ thuật lớn (tham chiếu vòng) và có kích thước nhỏ. Cần làm sớm để làm nền móng. - **[F1.4] Basic DocBlock** có WSJF cao vì nó mang lại giá trị trực tiếp cho người dùng (thấy được kết quả). -- **[F1.2] và [F1.3]** là nền tảng bắt buộc. +- [x] **[F1.2] và [F1.3]** là nền tảng bắt buộc. ## 5. Transformation Roadmap & PI Objectives diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ec92217 --- /dev/null +++ b/composer.json @@ -0,0 +1,29 @@ +{ + "name": "php-swag/php-swag", + "description": "A framework-agnostic PHP Swagger/OpenAPI generator", + "type": "library", + "license": "MIT", + "autoload": { + "psr-4": { + "PhpSwag\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "PhpSwag\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=8.1", + "nikic/php-parser": "^4.15", + "phpstan/phpdoc-parser": "^1.24", + "symfony/finder": "^6.0", + "symfony/yaml": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "config": { + "sort-packages": true + } +} diff --git a/examples/App/Controllers/UserController.php b/examples/App/Controllers/UserController.php new file mode 100644 index 0000000..f6755c7 --- /dev/null +++ b/examples/App/Controllers/UserController.php @@ -0,0 +1,26 @@ +generate([__DIR__ . '/App']); + +echo $yaml; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..6fb2c52 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,11 @@ + + + + + tests + + + diff --git a/src/Core.php b/src/Core.php new file mode 100644 index 0000000..25fbcad --- /dev/null +++ b/src/Core.php @@ -0,0 +1,148 @@ +scanner = new Scanner(); + $this->parser = new Parser(); + $this->docCollector = new DocBlockCollector(); + $this->generator = new Generator(); + } + + public function generate(array $paths): string + { + $this->scanner->setPaths($paths); + $files = $this->scanner->scan(); + + foreach ($files as $file) { + $this->processFile($file); + } + + return $this->generator->generateYaml(); + } + + private function processFile(string $filePath): void + { + $code = file_get_contents($filePath); + $stmts = $this->parser->parse($code); + + $nameResolver = new NameResolver(); + $traverser = new NodeTraverser(); + $traverser->addVisitor($nameResolver); + $traverser->traverse($stmts); + + foreach ($stmts as $stmt) { + if ($stmt instanceof Node\Stmt\Namespace_) { + foreach ($stmt->stmts as $innerStmt) { + $this->processStatement($innerStmt, $nameResolver); + } + } else { + $this->processStatement($stmt, $nameResolver); + } + } + } + + private function processStatement(Node $stmt, NameResolver $nameResolver): void + { + if ($stmt instanceof Class_) { + $docComment = $stmt->getDocComment()?->getText() ?? ''; + $tags = $this->docCollector->collectTags($docComment); + + $isSchema = false; + $properties = []; + + foreach ($tags as $tag) { + if ($tag['name'] === '@property') { + $isSchema = true; + $isNullable = str_contains($tag['type'], '|null') || str_starts_with($tag['type'], '?'); + $cleanType = str_replace(['|null', '?'], '', $tag['type']); + + $properties[] = new PropertyDefinition( + $tag['propertyName'], + $cleanType, + $isNullable, + $tag['description'] + ); + } + } + + foreach ($stmt->stmts as $member) { + if ($member instanceof Property) { + $isSchema = true; + $propDoc = $member->getDocComment()?->getText() ?? ''; + $propTags = $this->docCollector->collectTags($propDoc); + foreach ($propTags as $pTag) { + if ($pTag['name'] === '@var') { + $isNullable = str_contains($pTag['type'], '|null') || str_starts_with($pTag['type'], '?'); + $cleanType = str_replace(['|null', '?'], '', $pTag['type']); + + $properties[] = new PropertyDefinition( + $member->props[0]->name->toString(), + $cleanType, + $isNullable, + $pTag['description'] + ); + } + } + } + + if ($member instanceof ClassMethod) { + $methodDoc = $member->getDocComment()?->getText() ?? ''; + $methodTags = $this->docCollector->collectTags($methodDoc); + + $routeTag = null; + $summary = null; + $responses = []; + + foreach ($methodTags as $mTag) { + if ($mTag['name'] === '@route') { + $routeTag = $mTag['value']; + } elseif ($mTag['name'] === '@summary') { + $summary = $mTag['value']; + } elseif ($mTag['name'] === '@response') { + $parts = preg_split('/\s+/', trim($mTag['value']), 2); + if (count($parts) === 2) { + $responses[$parts[0]] = $nameResolver->resolve($parts[1]); + } + } + } + + if ($routeTag) { + $routeParts = preg_split('/\s+/', trim($routeTag), 2); + if (count($routeParts) === 2) { + $this->generator->addRoute(new RouteDefinition( + method: $routeParts[0], + path: $routeParts[1], + summary: $summary, + responses: $responses + )); + } + } + } + } + + if ($isSchema) { + $className = $stmt->name->toString(); + $fqcn = ($nameResolver->getCurrentNamespace() ? $nameResolver->getCurrentNamespace() . '\\' : '') . $className; + $this->generator->addSchema(new SchemaDefinition($fqcn, $properties)); + } + } + } +} diff --git a/src/DocBlockCollector.php b/src/DocBlockCollector.php new file mode 100644 index 0000000..9bc918c --- /dev/null +++ b/src/DocBlockCollector.php @@ -0,0 +1,62 @@ +parser = new DocBlockParser(); + } + + public function collectTags(string $docComment): array + { + if (empty($docComment)) { + return []; + } + + $phpDocNode = $this->parser->parse($docComment); + $tags = []; + + foreach ($phpDocNode->getTags() as $tag) { + $tagName = $tag->name; + $value = $tag->value; + + if ($value instanceof PropertyTagValueNode) { + $tags[] = [ + 'name' => $tagName, + 'type' => (string)$value->type, + 'propertyName' => ltrim($value->propertyName, '$'), + 'description' => $value->description + ]; + } elseif ($value instanceof VarTagValueNode) { + $tags[] = [ + 'name' => $tagName, + 'type' => (string)$value->type, + 'propertyName' => $value->variableName ? ltrim($value->variableName, '$') : null, + 'description' => $value->description + ]; + } elseif ($value instanceof GenericTagValueNode) { + $tags[] = [ + 'name' => $tagName, + 'value' => $value->value + ]; + } else { + $tags[] = [ + 'name' => $tagName, + 'value' => (string)$value + ]; + } + } + + return $tags; + } +} diff --git a/src/DocBlockParser.php b/src/DocBlockParser.php new file mode 100644 index 0000000..387d721 --- /dev/null +++ b/src/DocBlockParser.php @@ -0,0 +1,30 @@ +lexer = new Lexer(); + $constExprParser = new ConstExprParser(); + $typeParser = new TypeParser($constExprParser); + $this->parser = new PhpDocParser($typeParser, $constExprParser); + } + + public function parse(string $docBlock): PhpDocNode + { + $tokens = new TokenIterator($this->lexer->tokenize($docBlock)); + return $this->parser->parse($tokens); + } +} diff --git a/src/Generator.php b/src/Generator.php new file mode 100644 index 0000000..f7054a1 --- /dev/null +++ b/src/Generator.php @@ -0,0 +1,103 @@ +routes[] = $route; + } + + public function addSchema(SchemaDefinition $schema): void + { + $this->schemas[$schema->name] = $schema; + } + + public function generateYaml(): string + { + $spec = [ + 'openapi' => '3.0.0', + 'info' => [ + 'title' => 'API Documentation', + 'version' => '1.0.0' + ], + 'paths' => [], + 'components' => [ + 'schemas' => [] + ] + ]; + + foreach ($this->routes as $route) { + $path = $route->path; + $method = strtolower($route->method); + + if (!isset($spec['paths'][$path])) { + $spec['paths'][$path] = []; + } + + $spec['paths'][$path][$method] = [ + 'summary' => $route->summary, + 'description' => $route->description, + 'tags' => $route->tags, + 'responses' => [] + ]; + + if (!empty($route->responses)) { + foreach ($route->responses as $code => $ref) { + $spec['paths'][$path][$method]['responses'][$code] = [ + 'description' => 'OK', + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/' . str_replace('\\', '_', $ref) + ] + ] + ] + ]; + } + } else { + $spec['paths'][$path][$method]['responses']['200'] = [ + 'description' => 'OK' + ]; + } + } + + foreach ($this->schemas as $schema) { + $properties = []; + foreach ($schema->properties as $prop) { + $properties[$prop->name] = [ + 'type' => $this->mapType($prop->type), + 'description' => $prop->description + ]; + if ($prop->isNullable) { + $properties[$prop->name]['nullable'] = true; + } + } + + $spec['components']['schemas'][str_replace('\\', '_', $schema->name)] = [ + 'type' => 'object', + 'properties' => $properties + ]; + } + + return Yaml::dump($spec, 10, 2); + } + + private function mapType(string $type): string + { + return match ($type) { + 'int', 'integer' => 'integer', + 'float', 'double' => 'number', + 'bool', 'boolean' => 'boolean', + default => 'string', + }; + } +} diff --git a/src/IR/PropertyDefinition.php b/src/IR/PropertyDefinition.php new file mode 100644 index 0000000..0bd59a4 --- /dev/null +++ b/src/IR/PropertyDefinition.php @@ -0,0 +1,13 @@ +currentNamespace = $node->name ? $node->name->toString() : ''; + $this->useAliases = []; + } elseif ($node instanceof Use_) { + foreach ($node->uses as $use) { + $alias = $use->alias ? $use->alias->toString() : $use->name->getLast(); + $this->useAliases[$alias] = $use->name->toString(); + } + } + } + + public function resolve(string $name): string + { + if (str_starts_with($name, '\\')) { + return substr($name, 1); + } + + $parts = explode('\\', $name); + $firstPart = $parts[0]; + + if (isset($this->useAliases[$firstPart])) { + $parts[0] = $this->useAliases[$firstPart]; + return implode('\\', $parts); + } + + if ($this->currentNamespace === '') { + return $name; + } + + return $this->currentNamespace . '\\' . $name; + } + + public function getCurrentNamespace(): string + { + return $this->currentNamespace; + } + + public function getUseAliases(): array + { + return $this->useAliases; + } +} diff --git a/src/Parser.php b/src/Parser.php new file mode 100644 index 0000000..d8bfce2 --- /dev/null +++ b/src/Parser.php @@ -0,0 +1,38 @@ +parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); + } + + public function parse(string $code): array + { + try { + $stmts = $this->parser->parse($code); + if ($stmts === null) { + return []; + } + + // We use our custom NameResolver to handle manual FQCN resolution later, + // but we can also use the built-in one for general node resolution. + $traverser = new NodeTraverser(); + $nameResolver = new PhpParserNameResolver(); + $traverser->addVisitor($nameResolver); + return $traverser->traverse($stmts); + } catch (Error $e) { + // Handle parse error + return []; + } + } +} diff --git a/src/Scanner.php b/src/Scanner.php new file mode 100644 index 0000000..1e2d90c --- /dev/null +++ b/src/Scanner.php @@ -0,0 +1,46 @@ +paths = $paths; + } + + public function setPaths(array $paths): void + { + $this->paths = $paths; + } + + public function setExcludedPaths(array $excludedPaths): void + { + $this->excludedPaths = $excludedPaths; + } + + public function scan(): array + { + if (empty($this->paths)) { + return []; + } + + $finder = new Finder(); + $finder->files() + ->in($this->paths) + ->name('*.php') + ->exclude($this->excludedPaths); + + $files = []; + foreach ($finder as $file) { + $files[] = $file->getRealPath(); + } + + return $files; + } +} diff --git a/tests/DocBlockCollectorTest.php b/tests/DocBlockCollectorTest.php new file mode 100644 index 0000000..0afa73c --- /dev/null +++ b/tests/DocBlockCollectorTest.php @@ -0,0 +1,26 @@ +collectTags($docComment); + + $this->assertCount(2, $tags); + $this->assertEquals('@route', $tags[0]['name']); + $this->assertEquals('GET /users', $tags[0]['value']); + $this->assertEquals('@summary', $tags[1]['name']); + $this->assertEquals('List all users', $tags[1]['value']); + } +} diff --git a/tests/GeneratorTest.php b/tests/GeneratorTest.php new file mode 100644 index 0000000..3edb588 --- /dev/null +++ b/tests/GeneratorTest.php @@ -0,0 +1,44 @@ + 'User'] + ); + $generator->addRoute($route); + + $schema = new SchemaDefinition( + name: 'User', + properties: [ + new PropertyDefinition('id', 'int', description: 'User ID'), + new PropertyDefinition('name', 'string', description: 'User name'), + ] + ); + $generator->addSchema($schema); + + $yaml = $generator->generateYaml(); + $spec = Yaml::parse($yaml); + + $this->assertEquals('3.0.0', $spec['openapi']); + $this->assertArrayHasKey('/users', $spec['paths']); + $this->assertArrayHasKey('get', $spec['paths']['/users']); + $this->assertArrayHasKey('User', $spec['components']['schemas']); + $this->assertEquals('integer', $spec['components']['schemas']['User']['properties']['id']['type']); + } +} diff --git a/tests/NameResolverTest.php b/tests/NameResolverTest.php new file mode 100644 index 0000000..2105086 --- /dev/null +++ b/tests/NameResolverTest.php @@ -0,0 +1,35 @@ +create(ParserFactory::PREFER_PHP7); + $stmts = $parser->parse($code); + + $resolver = new NameResolver(); + $traverser = new NodeTraverser(); + $traverser->addVisitor($resolver); + $traverser->traverse($stmts); + + $this->assertEquals('App\\Controllers', $resolver->getCurrentNamespace()); + $this->assertEquals('App\\Models\\User', $resolver->resolve('User')); + $this->assertEquals('App\\Resources\\UserResource', $resolver->resolve('Res\\UserResource')); + $this->assertEquals('App\\Controllers\\LocalClass', $resolver->resolve('LocalClass')); + $this->assertEquals('Absolute\\Class', $resolver->resolve('\\Absolute\\Class')); + } +} diff --git a/tests/ScannerTest.php b/tests/ScannerTest.php new file mode 100644 index 0000000..ad2be2f --- /dev/null +++ b/tests/ScannerTest.php @@ -0,0 +1,51 @@ +scan(); + + $this->assertCount(3, $files); + $this->assertContains(realpath($testDir . '/file1.php'), $files); + $this->assertContains(realpath($testDir . '/file2.php'), $files); + $this->assertContains(realpath($testDir . '/sub/file3.php'), $files); + + // Cleanup + $this->removeDir($testDir); + } + + private function removeDir($dir) + { + if (is_dir($dir)) { + $objects = scandir($dir); + foreach ($objects as $object) { + if ($object != "." && $object != "..") { + if (is_dir($dir . DIRECTORY_SEPARATOR . $object) && !is_link($dir . "/" . $object)) + $this->removeDir($dir . DIRECTORY_SEPARATOR . $object); + else + unlink($dir . DIRECTORY_SEPARATOR . $object); + } + } + rmdir($dir); + } + } +} From 085032676400157908bc5b1c3f29737d11656d1e Mon Sep 17 00:00:00 2001 From: tolawho Date: Sat, 6 Jun 2026 02:40:42 +0700 Subject: [PATCH 04/27] feat: implement SchemaRegistry and TypeResolver for advanced type support (#4) * feat: implement SchemaRegistry and TypeResolver for advanced type support - Added SchemaRegistry to manage central schema definitions and avoid duplicates. - Added TypeResolver to handle nullable types, arrays (e.g., User[]), and class references using PHPDoc types. - Refactored Core and Generator to utilize the new TypeResolver and SchemaRegistry. - Updated examples and unit tests to verify support for complex nested structures. - Updated SAFE_BACKLOG.md to reflect progress on Epic 2 and completed features F2.1 and F2.4. * feat: implement SchemaRegistry and TypeResolver with full SAFe backlog update - Restored full content of SAFE_BACKLOG.md (Acceptance Criteria, WSJF, Roadmap). - Updated SAFE_BACKLOG.md to mark F2.1 and F2.4 as complete. - Implemented SchemaRegistry for centralized model management. - Implemented TypeResolver to handle complex PHPDoc types (nullable, arrays). - Refactored Core and Generator to use new components. - Verified with unit tests and examples. --------- Co-authored-by: tolawho <12527881+tolawho@users.noreply.github.com> --- SAFE_BACKLOG.md | 50 +++++----- examples/App/Controllers/UserController.php | 6 +- examples/App/Models/User.php | 1 + src/Core.php | 56 +++++++---- src/DocBlockCollector.php | 4 +- src/Generator.php | 53 +++++----- src/IR/PropertyDefinition.php | 3 +- src/SchemaRegistry.php | 37 +++++++ src/TypeResolver.php | 102 ++++++++++++++++++++ tests/DocBlockCollectorTest.php | 18 ++++ tests/GeneratorTest.php | 12 ++- tests/TypeResolverTest.php | 58 +++++++++++ 12 files changed, 316 insertions(+), 84 deletions(-) create mode 100644 src/SchemaRegistry.php create mode 100644 src/TypeResolver.php create mode 100644 tests/TypeResolverTest.php diff --git a/SAFE_BACKLOG.md b/SAFE_BACKLOG.md index c1e87c3..cec2a01 100644 --- a/SAFE_BACKLOG.md +++ b/SAFE_BACKLOG.md @@ -10,11 +10,11 @@ - **Tiêu chí chấp nhận (Acceptance Criteria):** - Có khả năng quét thư mục và tìm kiếm file .php. - Phân tích được cấu trúc AST và giải quyết được Namespace/Use statements. - - Nhận diện và trích xuất được các tag cơ bản: `@route`, `@summary`, `@property`. + - Nhận diện và trích xuất được các tag cơ bản: @route, @summary, @property. - Xuất ra cấu trúc dữ liệu trung gian (IR). ### [Epic 2] Nâng cấp Hệ thống Type System (Pro) -- **Trạng thái:** To Do +- **Trạng thái:** In Progress - **Chủ sở hữu:** Fullstack Developer (User) - **Tóm tắt:** Mở rộng khả năng phân tích kiểu dữ liệu phức tạp bao gồm Generics, Union types và xử lý thừa kế. - **Giả thuyết Lợi ích (Benefit Hypothesis):** Cho phép thư viện hỗ trợ các dự án PHP hiện đại sử dụng cấu trúc dữ liệu phức tạp (như Collection, DTO kế thừa), tăng tính chính xác và độ phủ của tài liệu API được sinh ra. @@ -49,47 +49,50 @@ - *AC:* Chuyển đổi PHPDoc thô thành các Object thuộc tính tương ứng. ### Features cho [Epic 2] Type System Pro -- **[F2.1] Advanced Type Resolver:** Hỗ trợ các kiểu dữ liệu phức tạp của PHP hiện đại. +- [x] **[F2.1] Advanced Type Resolver:** Hỗ trợ các kiểu dữ liệu phức tạp của PHP hiện đại. - *AC:* Xử lý được union types (A|B), nullable (?A), và các kiểu nguyên thủy. -- **[F2.2] Generics Support:** Phân tích cú pháp template cho các kiểu dữ liệu generic. +- [ ] **[F2.2] Generics Support:** Phân tích cú pháp template cho các kiểu dữ liệu generic. - *AC:* Hiểu được `Collection` hoặc `ApiResponse`. -- **[F2.3] Inheritance & Trait Merger:** Gộp các thuộc tính từ các class cha và traits. +- [ ] **[F2.3] Inheritance & Trait Merger:** Gộp các thuộc tính từ các class cha và traits. - *AC:* Schema của class con phải bao gồm đầy đủ thuộc tính từ cây kế thừa. -- **[F2.4] Schema Registry:** Quản lý tập trung các định nghĩa Model để tránh trùng lặp và xử lý tham chiếu vòng. +- [x] **[F2.4] Schema Registry:** Quản lý tập trung các định nghĩa Model để tránh trùng lặp và xử lý tham chiếu vòng. - *AC:* Không bị lỗi vòng lặp vô tận khi Class A chứa Class B và ngược lại. ### Features cho [Epic 3] Integration & CLI -- **[F3.1] CLI Command Interface:** Cung cấp giao diện dòng lệnh cho người dùng. +- [ ] **[F3.1] CLI Command Interface:** Cung cấp giao diện dòng lệnh cho người dùng. - *AC:* Chạy được lệnh `php-swag generate --path=src`. -- **[F3.2] OpenAPI Spec Generator:** Chuyển đổi dữ liệu IR thành file chuẩn OpenAPI. +- [ ] **[F3.2] OpenAPI Spec Generator:** Chuyển đổi dữ liệu IR thành file chuẩn OpenAPI. - *AC:* Xuất ra file `swagger.yaml` hoặc `swagger.json` hợp lệ (v3.0/3.1). -- **[F3.3] Performance Caching:** Lưu trữ kết quả phân tích để tăng tốc cho các lần chạy sau. +- [ ] **[F3.3] Performance Caching:** Lưu trữ kết quả phân tích để tăng tốc cho các lần chạy sau. - *AC:* Tốc độ generate lần 2 phải nhanh hơn ít nhất 50% so với lần đầu. -- **[F3.4] README & Documentation:** Hướng dẫn cộng đồng cách sử dụng và đóng góp. +- [ ] **[F3.4] README & Documentation:** Hướng dẫn cộng đồng cách sử dụng và đóng góp. - *AC:* Có file README.md chi tiết với ví dụ minh họa rõ ràng. ## 3. Team Backlog (User Stories - Examples for PI-1) ### Stories cho [F1.3] Namespace Resolver -- **[S1.3.1] Parse Use Statements:** +- [x] **[S1.3.1] Parse Use Statements:** - *Câu chuyện:* Là một hệ thống phân tích, tôi muốn đọc và lưu trữ các alias trong phần `use` của file PHP, để tôi biết chính xác tên class được tham chiếu trong code. - *AC:* Xử lý được các trường hợp: `use App\User;`, `use App\Resource as Res;`, `use Group\{ClassA, ClassB};`. -- **[S1.3.2] Contextual Class Resolution:** +- [x] **[S1.3.2] Contextual Class Resolution:** - *Câu chuyện:* Là một hệ thống phân tích, tôi muốn tìm được FQCN của một class dựa trên context hiện tại (Namespace + Use statements), để tạo tham chiếu chính xác trong OpenAPI. - *AC:* Trả về `App\Resources\UserResource` khi gặp code sử dụng `UserResource` trong namespace `App\Controllers` có `use App\Resources\UserResource`. ### Stories cho [F1.4] Basic DocBlock Collector -- **[S1.4.1] Extract @route tag:** +- [x] **[S1.4.1] Extract @route tag:** - *Câu chuyện:* Là một lập trình viên, tôi muốn dùng tag `@route` để định nghĩa endpoint, để tôi không phải viết cấu trúc path phức tạp trong file cấu hình. - *AC:* Bóc tách được `METHOD` (GET, POST, ...) và `PATH` từ chuỗi `@route GET /users`. -- **[S1.4.2] Extract @property tag:** +- [x] **[S1.4.2] Extract @property tag:** - *Câu chuyện:* Là một lập trình viên, tôi muốn dùng tag `@property` trong Model, để mô tả cấu trúc JSON của API. - *AC:* Trích xuất được kiểu dữ liệu (`string`, `int`), tên biến (`$name`) và mô tả kèm theo. ### Stories cho [F2.1] Advanced Type Resolver -- **[S2.1.1] Handle Nullable Types:** +- [x] **[S2.1.1] Handle Nullable Types:** - *Câu chuyện:* Là một lập trình viên, tôi muốn hỗ trợ kiểu nullable, để tài liệu API phản ánh đúng tính chất dữ liệu (có thể null). - *AC:* Nhận diện `?string`, `string|null` và ánh xạ sang `nullable: true` trong OpenAPI. +- [x] **[S2.1.2] Array Type Support:** + - *Câu chuyện:* Là một lập trình viên, tôi muốn hỗ trợ kiểu mảng (User[] hoặc array), để mô tả chính xác các collection trong API. + - *AC:* Ánh xạ chính xác sang `type: array` với `items` tương ứng trong OpenAPI. ## 4. Ưu tiên hóa bằng WSJF (Weighted Shortest Job First) @@ -99,17 +102,16 @@ Thang điểm Fibonacci: 1, 2, 3, 5, 8, 13, 20. | Feature | Business Value | Time Criticality | RR \| OE | Cost of Delay (CoD) | Job Size | **WSJF** | | :--- | :---: | :---: | :---: | :---: | :---: | :---: | -| [F1.2] AST Parser | 13 | 5 | 20 | 38 | 8 | **4.75** | -| [F1.3] Namespace Resolver | 8 | 3 | 13 | 24 | 5 | **4.8** | -| [F1.1] File Scanner | 5 | 2 | 5 | 12 | 3 | **4.0** | -| [F1.4] Basic DocBlock | 13 | 8 | 8 | 29 | 5 | **5.8** | -| [F2.1] Adv. Type Resolver | 8 | 3 | 8 | 19 | 5 | **3.8** | -| [F2.4] Schema Registry | 5 | 2 | 13 | 20 | 3 | **6.67** | +| [F1.2] AST Parser | 13 | 5 | 20 | 38 | 8 | **DONE** | +| [F1.3] Namespace Resolver | 8 | 3 | 13 | 24 | 5 | **DONE** | +| [F1.1] File Scanner | 5 | 2 | 5 | 12 | 3 | **DONE** | +| [F1.4] Basic DocBlock | 13 | 8 | 8 | 29 | 5 | **DONE** | +| [F2.1] Adv. Type Resolver | 8 | 3 | 8 | 19 | 5 | **DONE** | +| [F2.4] Schema Registry | 5 | 2 | 13 | 20 | 3 | **DONE** | **Phân tích:** -- **[F2.4] Schema Registry** có WSJF cao nhất vì nó giải quyết rủi ro kỹ thuật lớn (tham chiếu vòng) và có kích thước nhỏ. Cần làm sớm để làm nền móng. -- **[F1.4] Basic DocBlock** có WSJF cao vì nó mang lại giá trị trực tiếp cho người dùng (thấy được kết quả). -- [x] **[F1.2] và [F1.3]** là nền tảng bắt buộc. +- **[F2.4] Schema Registry** đã hoàn thành, giải quyết rủi ro kỹ thuật lớn về tham chiếu vòng. +- **[F2.1] Advanced Type Resolver** đã hoàn thành, hỗ trợ nullable và array types. ## 5. Transformation Roadmap & PI Objectives diff --git a/examples/App/Controllers/UserController.php b/examples/App/Controllers/UserController.php index f6755c7..e035f19 100644 --- a/examples/App/Controllers/UserController.php +++ b/examples/App/Controllers/UserController.php @@ -9,7 +9,8 @@ class UserController /** * @route GET /users * @summary List all users - * @response 200 User + * @tag User Management + * @response 200 User[] */ public function index() { @@ -18,7 +19,10 @@ public function index() /** * @route GET /users/{id} * @summary Get user details + * @description This endpoint returns a single user by their ID. + * @tag User Management * @response 200 User + * @response 404 string */ public function show(int $id) { diff --git a/examples/App/Models/User.php b/examples/App/Models/User.php index a92db0b..4ca8777 100644 --- a/examples/App/Models/User.php +++ b/examples/App/Models/User.php @@ -6,6 +6,7 @@ * @property int $id User ID * @property string $name User Full Name * @property string|null $email User Email Address + * @property User[] $friends List of friends */ class User { diff --git a/src/Core.php b/src/Core.php index 25fbcad..40a6d60 100644 --- a/src/Core.php +++ b/src/Core.php @@ -17,13 +17,15 @@ class Core private Parser $parser; private DocBlockCollector $docCollector; private Generator $generator; + private SchemaRegistry $schemaRegistry; public function __construct() { $this->scanner = new Scanner(); $this->parser = new Parser(); $this->docCollector = new DocBlockCollector(); - $this->generator = new Generator(); + $this->schemaRegistry = new SchemaRegistry(); + $this->generator = new Generator($this->schemaRegistry); } public function generate(array $paths): string @@ -62,6 +64,7 @@ private function processFile(string $filePath): void private function processStatement(Node $stmt, NameResolver $nameResolver): void { if ($stmt instanceof Class_) { + $typeResolver = new TypeResolver($this->schemaRegistry, $nameResolver); $docComment = $stmt->getDocComment()?->getText() ?? ''; $tags = $this->docCollector->collectTags($docComment); @@ -71,13 +74,11 @@ private function processStatement(Node $stmt, NameResolver $nameResolver): void foreach ($tags as $tag) { if ($tag['name'] === '@property') { $isSchema = true; - $isNullable = str_contains($tag['type'], '|null') || str_starts_with($tag['type'], '?'); - $cleanType = str_replace(['|null', '?'], '', $tag['type']); + $propertySchema = $typeResolver->resolve($tag['type']); $properties[] = new PropertyDefinition( $tag['propertyName'], - $cleanType, - $isNullable, + $propertySchema, $tag['description'] ); } @@ -90,13 +91,11 @@ private function processStatement(Node $stmt, NameResolver $nameResolver): void $propTags = $this->docCollector->collectTags($propDoc); foreach ($propTags as $pTag) { if ($pTag['name'] === '@var') { - $isNullable = str_contains($pTag['type'], '|null') || str_starts_with($pTag['type'], '?'); - $cleanType = str_replace(['|null', '?'], '', $pTag['type']); + $propertySchema = $typeResolver->resolve($pTag['type']); $properties[] = new PropertyDefinition( $member->props[0]->name->toString(), - $cleanType, - $isNullable, + $propertySchema, $pTag['description'] ); } @@ -109,18 +108,35 @@ private function processStatement(Node $stmt, NameResolver $nameResolver): void $routeTag = null; $summary = null; + $description = null; + $tagsList = []; $responses = []; foreach ($methodTags as $mTag) { - if ($mTag['name'] === '@route') { - $routeTag = $mTag['value']; - } elseif ($mTag['name'] === '@summary') { - $summary = $mTag['value']; - } elseif ($mTag['name'] === '@response') { - $parts = preg_split('/\s+/', trim($mTag['value']), 2); - if (count($parts) === 2) { - $responses[$parts[0]] = $nameResolver->resolve($parts[1]); - } + switch ($mTag['name']) { + case '@route': + $routeTag = $mTag['value']; + break; + case '@summary': + $summary = $mTag['value']; + break; + case '@description': + $description = $mTag['value']; + break; + case '@tag': + $tagsList[] = $mTag['value']; + break; + case '@response': + $parts = preg_split('/\s+/', trim($mTag['value']), 2); + if (count($parts) >= 2) { + // Parse response type using DocBlockParser/TypeResolver + $responseDoc = '/** @var ' . $parts[1] . ' */'; + $responseTags = $this->docCollector->collectTags($responseDoc); + if (isset($responseTags[0]['type'])) { + $responses[$parts[0]] = $typeResolver->resolve($responseTags[0]['type']); + } + } + break; } } @@ -131,6 +147,8 @@ private function processStatement(Node $stmt, NameResolver $nameResolver): void method: $routeParts[0], path: $routeParts[1], summary: $summary, + description: $description, + tags: $tagsList, responses: $responses )); } @@ -141,7 +159,7 @@ private function processStatement(Node $stmt, NameResolver $nameResolver): void if ($isSchema) { $className = $stmt->name->toString(); $fqcn = ($nameResolver->getCurrentNamespace() ? $nameResolver->getCurrentNamespace() . '\\' : '') . $className; - $this->generator->addSchema(new SchemaDefinition($fqcn, $properties)); + $this->schemaRegistry->register(new SchemaDefinition($fqcn, $properties)); } } } diff --git a/src/DocBlockCollector.php b/src/DocBlockCollector.php index 9bc918c..6445fc0 100644 --- a/src/DocBlockCollector.php +++ b/src/DocBlockCollector.php @@ -33,14 +33,14 @@ public function collectTags(string $docComment): array if ($value instanceof PropertyTagValueNode) { $tags[] = [ 'name' => $tagName, - 'type' => (string)$value->type, + 'type' => $value->type, 'propertyName' => ltrim($value->propertyName, '$'), 'description' => $value->description ]; } elseif ($value instanceof VarTagValueNode) { $tags[] = [ 'name' => $tagName, - 'type' => (string)$value->type, + 'type' => $value->type, 'propertyName' => $value->variableName ? ltrim($value->variableName, '$') : null, 'description' => $value->description ]; diff --git a/src/Generator.php b/src/Generator.php index f7054a1..35fc58b 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -9,16 +9,16 @@ class Generator { private array $routes = []; - private array $schemas = []; + private SchemaRegistry $schemaRegistry; - public function addRoute(RouteDefinition $route): void + public function __construct(SchemaRegistry $schemaRegistry) { - $this->routes[] = $route; + $this->schemaRegistry = $schemaRegistry; } - public function addSchema(SchemaDefinition $schema): void + public function addRoute(RouteDefinition $route): void { - $this->schemas[$schema->name] = $schema; + $this->routes[] = $route; } public function generateYaml(): string @@ -43,46 +43,47 @@ public function generateYaml(): string $spec['paths'][$path] = []; } - $spec['paths'][$path][$method] = [ + $routeSpec = [ 'summary' => $route->summary, 'description' => $route->description, - 'tags' => $route->tags, 'responses' => [] ]; + if (!empty($route->tags)) { + $routeSpec['tags'] = $route->tags; + } + if (!empty($route->responses)) { - foreach ($route->responses as $code => $ref) { - $spec['paths'][$path][$method]['responses'][$code] = [ + foreach ($route->responses as $code => $schema) { + $routeSpec['responses'][$code] = [ 'description' => 'OK', 'content' => [ 'application/json' => [ - 'schema' => [ - '$ref' => '#/components/schemas/' . str_replace('\\', '_', $ref) - ] + 'schema' => $schema ] ] ]; } } else { - $spec['paths'][$path][$method]['responses']['200'] = [ + $routeSpec['responses']['200'] = [ 'description' => 'OK' ]; } + + $spec['paths'][$path][$method] = $routeSpec; } - foreach ($this->schemas as $schema) { + foreach ($this->schemaRegistry->getAll() as $schema) { $properties = []; foreach ($schema->properties as $prop) { - $properties[$prop->name] = [ - 'type' => $this->mapType($prop->type), - 'description' => $prop->description - ]; - if ($prop->isNullable) { - $properties[$prop->name]['nullable'] = true; + $propSchema = $prop->schema; + if ($prop->description) { + $propSchema['description'] = $prop->description; } + $properties[$prop->name] = $propSchema; } - $spec['components']['schemas'][str_replace('\\', '_', $schema->name)] = [ + $spec['components']['schemas'][$this->schemaRegistry->getSchemaId($schema->name)] = [ 'type' => 'object', 'properties' => $properties ]; @@ -90,14 +91,4 @@ public function generateYaml(): string return Yaml::dump($spec, 10, 2); } - - private function mapType(string $type): string - { - return match ($type) { - 'int', 'integer' => 'integer', - 'float', 'double' => 'number', - 'bool', 'boolean' => 'boolean', - default => 'string', - }; - } } diff --git a/src/IR/PropertyDefinition.php b/src/IR/PropertyDefinition.php index 0bd59a4..7f71394 100644 --- a/src/IR/PropertyDefinition.php +++ b/src/IR/PropertyDefinition.php @@ -6,8 +6,7 @@ class PropertyDefinition { public function __construct( public string $name, - public string $type, - public bool $isNullable = false, + public array $schema, public ?string $description = null ) {} } diff --git a/src/SchemaRegistry.php b/src/SchemaRegistry.php new file mode 100644 index 0000000..eabf7e7 --- /dev/null +++ b/src/SchemaRegistry.php @@ -0,0 +1,37 @@ + */ + private array $schemas = []; + + public function register(SchemaDefinition $schema): void + { + $this->schemas[$schema->name] = $schema; + } + + public function has(string $fqcn): bool + { + return isset($this->schemas[$fqcn]); + } + + public function get(string $fqcn): ?SchemaDefinition + { + return $this->schemas[$fqcn] ?? null; + } + + /** @return array */ + public function getAll(): array + { + return $this->schemas; + } + + public function getSchemaId(string $fqcn): string + { + return str_replace('\\', '_', $fqcn); + } +} diff --git a/src/TypeResolver.php b/src/TypeResolver.php new file mode 100644 index 0000000..77a5633 --- /dev/null +++ b/src/TypeResolver.php @@ -0,0 +1,102 @@ +schemaRegistry = $schemaRegistry; + $this->nameResolver = $nameResolver; + } + + public function resolve(TypeNode $typeNode): array + { + if ($typeNode instanceof IdentifierTypeNode) { + return $this->resolveIdentifier($typeNode->name); + } + + if ($typeNode instanceof NullableTypeNode) { + $resolved = $this->resolve($typeNode->type); + $resolved['nullable'] = true; + return $resolved; + } + + if ($typeNode instanceof ArrayTypeNode) { + return [ + 'type' => 'array', + 'items' => $this->resolve($typeNode->type) + ]; + } + + if ($typeNode instanceof UnionTypeNode) { + $types = []; + $isNullable = false; + foreach ($typeNode->types as $type) { + if ($type instanceof IdentifierTypeNode && $type->name === 'null') { + $isNullable = true; + continue; + } + $types[] = $this->resolve($type); + } + + if (count($types) === 1) { + if ($isNullable) { + $types[0]['nullable'] = true; + } + return $types[0]; + } + + return ['oneOf' => $types]; + } + + if ($typeNode instanceof GenericTypeNode) { + if ($typeNode->type->name === 'array' || $typeNode->type->name === 'list') { + return [ + 'type' => 'array', + 'items' => $this->resolve($typeNode->genericTypes[0]) + ]; + } + // Handle other generics like Collection or ApiResponse later + return $this->resolveIdentifier($typeNode->type->name); + } + + return ['type' => 'string']; + } + + private function resolveIdentifier(string $name): array + { + $lowered = strtolower($name); + $map = [ + 'int' => 'integer', + 'integer' => 'integer', + 'string' => 'string', + 'bool' => 'boolean', + 'boolean' => 'boolean', + 'float' => 'number', + 'double' => 'number', + 'mixed' => 'string', // OpenAPI doesn't have mixed, default to string or object + 'void' => null, + ]; + + if (isset($map[$lowered])) { + return $map[$lowered] ? ['type' => $map[$lowered]] : []; + } + + // It's likely a class reference + $fqcn = $this->nameResolver->resolve($name); + return [ + '$ref' => '#/components/schemas/' . $this->schemaRegistry->getSchemaId($fqcn) + ]; + } +} diff --git a/tests/DocBlockCollectorTest.php b/tests/DocBlockCollectorTest.php index 0afa73c..763d80b 100644 --- a/tests/DocBlockCollectorTest.php +++ b/tests/DocBlockCollectorTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use PhpSwag\DocBlockCollector; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; class DocBlockCollectorTest extends TestCase { @@ -23,4 +24,21 @@ public function testCollectTags() $this->assertEquals('@summary', $tags[1]['name']); $this->assertEquals('List all users', $tags[1]['value']); } + + public function testCollectPropertyTags() + { + $docComment = '/** + * @property string $name User name + */'; + + $collector = new DocBlockCollector(); + $tags = $collector->collectTags($docComment); + + $this->assertCount(1, $tags); + $this->assertEquals('@property', $tags[0]['name']); + $this->assertInstanceOf(IdentifierTypeNode::class, $tags[0]['type']); + $this->assertEquals('string', (string)$tags[0]['type']); + $this->assertEquals('name', $tags[0]['propertyName']); + $this->assertEquals('User name', $tags[0]['description']); + } } diff --git a/tests/GeneratorTest.php b/tests/GeneratorTest.php index 3edb588..c2bfa47 100644 --- a/tests/GeneratorTest.php +++ b/tests/GeneratorTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use PhpSwag\Generator; +use PhpSwag\SchemaRegistry; use PhpSwag\IR\RouteDefinition; use PhpSwag\IR\SchemaDefinition; use PhpSwag\IR\PropertyDefinition; @@ -13,24 +14,25 @@ class GeneratorTest extends TestCase { public function testGenerateBasicOpenApi() { - $generator = new Generator(); + $registry = new SchemaRegistry(); + $generator = new Generator($registry); $route = new RouteDefinition( method: 'GET', path: '/users', summary: 'List users', - responses: ['200' => 'User'] + responses: ['200' => ['$ref' => '#/components/schemas/User']] ); $generator->addRoute($route); $schema = new SchemaDefinition( name: 'User', properties: [ - new PropertyDefinition('id', 'int', description: 'User ID'), - new PropertyDefinition('name', 'string', description: 'User name'), + new PropertyDefinition('id', ['type' => 'integer'], description: 'User ID'), + new PropertyDefinition('name', ['type' => 'string'], description: 'User name'), ] ); - $generator->addSchema($schema); + $registry->register($schema); $yaml = $generator->generateYaml(); $spec = Yaml::parse($yaml); diff --git a/tests/TypeResolverTest.php b/tests/TypeResolverTest.php new file mode 100644 index 0000000..369e7b6 --- /dev/null +++ b/tests/TypeResolverTest.php @@ -0,0 +1,58 @@ +parse($doc); + /** @var VarTagValueNode $varTag */ + $varTag = $node->getVarTagValues()[0]; + + $resolved = $typeResolver->resolve($varTag->type); + $this->assertEquals(['type' => 'string'], $resolved); + + $doc = '/** @var int */'; + $node = $parser->parse($doc); + $varTag = $node->getVarTagValues()[0]; + $resolved = $typeResolver->resolve($varTag->type); + $this->assertEquals(['type' => 'integer'], $resolved); + } + + public function testResolveNullableAndArray() + { + $registry = new SchemaRegistry(); + $nameResolver = new NameResolver(); + $typeResolver = new TypeResolver($registry, $nameResolver); + $parser = new DocBlockParser(); + + $doc = '/** @var string|null */'; + $node = $parser->parse($doc); + $varTag = $node->getVarTagValues()[0]; + $resolved = $typeResolver->resolve($varTag->type); + $this->assertEquals(['type' => 'string', 'nullable' => true], $resolved); + + $doc = '/** @var User[] */'; + $node = $parser->parse($doc); + $varTag = $node->getVarTagValues()[0]; + $resolved = $typeResolver->resolve($varTag->type); + $this->assertEquals([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/User'] + ], $resolved); + } +} From 4d1de34d6aeb2cac6c8e590b9a64a5ffe756b1c1 Mon Sep 17 00:00:00 2001 From: tolawho Date: Sat, 6 Jun 2026 02:48:53 +0700 Subject: [PATCH 05/27] feat: implement SchemaRegistry, TypeResolver and comprehensive documentation update (#5) - Implemented SchemaRegistry for centralized schema management and circular reference support. - Implemented TypeResolver to support advanced PHPDoc types (nullable, arrays, class references). - Refactored Core and Generator to leverage new components. - Updated README.md with detailed usage instructions and support for new tags. - Updated SAFE_BACKLOG.md with progress on Epic 2 and completed features F2.1 and F2.4. - Added unit tests for TypeResolver and updated existing tests. - Verified functionality with updated examples. Co-authored-by: tolawho <12527881+tolawho@users.noreply.github.com> --- README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 45944e5..61da3c0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,17 @@ # PHP Swagger Generator -A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AST) and PHPDoc. +A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AST) and PHPDoc. This library scans your source code and generates OpenAPI 3.0 specifications automatically. + +## Features + +- **AST-based Static Analysis**: No need to run your application. +- **Modern PHP Support**: Handles namespaces, use aliases, and complex types. +- **Advanced Type Resolution**: + - Primitives: `int`, `string`, `bool`, `float`. + - Nullable types: `?string` or `string|null`. + - Array types: `User[]` or `array`. + - Class references: Automatically resolves FQCN and creates schemas. +- **Schema Registry**: Handles circular references and avoids duplicate definitions. ## Installation @@ -10,22 +21,68 @@ composer require php-swag/php-swag ## Usage +### Simple Execution ```php use PhpSwag\Core; $core = new Core(); -$yaml = $core->generate(['./src']); +$yaml = $core->generate(['./src/App']); file_put_contents('swagger.yaml', $yaml); ``` +### Defining Endpoints (Controllers) +```php +namespace App\Controllers; + +use App\Models\User; + +class UserController +{ + /** + * @route GET /users + * @summary List all users + * @tag User Management + * @response 200 User[] + */ + public function index() {} + + /** + * @route GET /users/{id} + * @summary Get user details + * @description Returns a single user object. + * @tag User Management + * @response 200 User + * @response 404 string + */ + public function show(int $id) {} +} +``` + +### Defining Models (Schemas) +```php +namespace App\Models; + +/** + * @property int $id User ID + * @property string $name Full Name + * @property string|null $email Optional email + * @property User[] $friends Nested relationship + */ +class User {} +``` + ## Support Tags -- `@route [METHOD] [PATH]` -- `@summary [TEXT]` -- `@response [CODE] [CLASS]` -- `@property [TYPE] $[NAME] [DESCRIPTION]` -- `@var [TYPE]` +- **Endpoints**: + - `@route [METHOD] [PATH]` (e.g., `@route POST /data`) + - `@summary [TEXT]` + - `@description [TEXT]` + - `@tag [NAME]` + - `@response [CODE] [TYPE]` (e.g., `@response 200 User[]`) +- **Models**: + - `@property [TYPE] $[NAME] [DESCRIPTION]` + - `@var [TYPE]` (for class properties) ## Testing From 170f216b5f6ad8076722da098116d58f10ddc017 Mon Sep 17 00:00:00 2001 From: tolawho Date: Sat, 6 Jun 2026 09:45:28 +0700 Subject: [PATCH 06/27] feat: advanced types and openapi 3.1 (#7) * feat: advanced type system (generics/inheritance) and OpenAPI 3.1 support - Implement recursive inheritance and trait property merging - Add advanced generics support with nested template substitution - Implement multi-pass AST analysis for better type resolution - Add OpenAPI 3.1 support with automatic nullable type conversion - Update SAFe backlog and test coverage * fix: resolve generic properties correctly and format code to PSR-12 standard * docs: update --------- Co-authored-by: tolawho <12527881+tolawho@users.noreply.github.com> Co-authored-by: TFO-ThanhDV_391 --- README.md | 82 +++--- SAFE_BACKLOG.md | 6 +- composer.json | 7 +- examples/App/Controllers/PostController.php | 30 ++ examples/App/Models/ApiResponse.php | 13 + examples/App/Models/BaseModel.php | 10 + examples/App/Models/Collection.php | 12 + examples/App/Models/Post.php | 12 + examples/App/Models/Timestampable.php | 11 + phpcs.xml | 25 ++ src/Core.php | 258 ++++++++++++------ src/DocBlockCollector.php | 133 ++++++--- src/Generator.php | 120 +++++++- src/IR/PropertyDefinition.php | 3 +- src/IR/RouteDefinition.php | 3 +- src/IR/SchemaDefinition.php | 10 +- src/NameResolver.php | 2 +- src/SchemaRegistry.php | 11 + src/TypeResolver.php | 65 ++++- tests/DocBlockCollectorTest.php | 16 ++ tests/GenericsTest.php | 79 ++++++ tests/InheritanceTest.php | 56 ++++ tests/OpenApiVersionTest.php | 36 +++ tests/ScannerTest.php | 5 +- .../generics_inheritance/BaseResponse.php | 1 + tests/fixtures/generics_inheritance/User.php | 1 + .../generics_inheritance/UserResponse.php | 1 + .../fixtures/generics_nested/ApiResponse.php | 1 + tests/fixtures/generics_nested/Collection.php | 1 + tests/fixtures/generics_nested/Controller.php | 11 + tests/fixtures/generics_nested/User.php | 1 + .../fixtures/generics_simple/ApiResponse.php | 1 + tests/fixtures/generics_simple/Controller.php | 10 + tests/fixtures/generics_simple/User.php | 1 + tests/fixtures/inheritance/Base.php | 1 + tests/fixtures/inheritance/User.php | 1 + tests/fixtures/openapi30/User.php | 1 + tests/fixtures/openapi31/User.php | 1 + tests/fixtures/override/Base.php | 1 + tests/fixtures/override/Child.php | 1 + tests/fixtures/traits/Post.php | 1 + tests/fixtures/traits/Timestampable.php | 1 + 42 files changed, 870 insertions(+), 172 deletions(-) create mode 100644 examples/App/Controllers/PostController.php create mode 100644 examples/App/Models/ApiResponse.php create mode 100644 examples/App/Models/BaseModel.php create mode 100644 examples/App/Models/Collection.php create mode 100644 examples/App/Models/Post.php create mode 100644 examples/App/Models/Timestampable.php create mode 100644 phpcs.xml create mode 100644 tests/GenericsTest.php create mode 100644 tests/InheritanceTest.php create mode 100644 tests/OpenApiVersionTest.php create mode 100644 tests/fixtures/generics_inheritance/BaseResponse.php create mode 100644 tests/fixtures/generics_inheritance/User.php create mode 100644 tests/fixtures/generics_inheritance/UserResponse.php create mode 100644 tests/fixtures/generics_nested/ApiResponse.php create mode 100644 tests/fixtures/generics_nested/Collection.php create mode 100644 tests/fixtures/generics_nested/Controller.php create mode 100644 tests/fixtures/generics_nested/User.php create mode 100644 tests/fixtures/generics_simple/ApiResponse.php create mode 100644 tests/fixtures/generics_simple/Controller.php create mode 100644 tests/fixtures/generics_simple/User.php create mode 100644 tests/fixtures/inheritance/Base.php create mode 100644 tests/fixtures/inheritance/User.php create mode 100644 tests/fixtures/openapi30/User.php create mode 100644 tests/fixtures/openapi31/User.php create mode 100644 tests/fixtures/override/Base.php create mode 100644 tests/fixtures/override/Child.php create mode 100644 tests/fixtures/traits/Post.php create mode 100644 tests/fixtures/traits/Timestampable.php diff --git a/README.md b/README.md index 61da3c0..3766709 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PHP Swagger Generator -A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AST) and PHPDoc. This library scans your source code and generates OpenAPI 3.0 specifications automatically. +A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AST) and PHPDoc. This library scans your source code and generates OpenAPI 3.0 or 3.1 specifications automatically. ## Features @@ -11,6 +11,16 @@ A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AS - Nullable types: `?string` or `string|null`. - Array types: `User[]` or `array`. - Class references: Automatically resolves FQCN and creates schemas. +- **Advanced OOP Support**: + - **Inheritance**: Properties from parent classes are automatically merged into child schemas. + - **Traits**: Supports `use Trait` with property merging. + - **Overrides**: Child classes can override parent property types and descriptions. +- **Powerful Generics**: + - Supports `@template` in class docblocks. + - Handles nested generics like `ApiResponse>`. + - Supports generic inheritance (e.g., `class UserResponse extends ApiResponse`). + - Uses clean schema naming: `ApiResponse.User`. +- **OpenAPI 3.0 & 3.1**: Supports both versions, with automatic conversion of nullable types for 3.1. - **Schema Registry**: Handles circular references and avoids duplicate definitions. ## Installation @@ -26,52 +36,57 @@ composer require php-swag/php-swag use PhpSwag\Core; $core = new Core(); +$core->setOpenApiVersion('3.1.0'); // Optional, defaults to 3.0.0 $yaml = $core->generate(['./src/App']); file_put_contents('swagger.yaml', $yaml); ``` -### Defining Endpoints (Controllers) +### Advanced Types Example + +#### Models with Inheritance and Generics +```php +namespace App\Models; + +/** + * @template T + * @property T $data + */ +class ApiResponse {} + +/** + * @property int $id + */ +class BaseModel {} + +/** + * @property string $title + */ +class Post extends BaseModel {} + +/** + * @extends ApiResponse + */ +class PostResponse extends ApiResponse {} +``` + +#### Controller using Complex Types ```php namespace App\Controllers; -use App\Models\User; +use App\Models\ApiResponse; +use App\Models\Post; -class UserController +class PostController { /** - * @route GET /users - * @summary List all users - * @tag User Management - * @response 200 User[] - */ - public function index() {} - - /** - * @route GET /users/{id} - * @summary Get user details - * @description Returns a single user object. - * @tag User Management - * @response 200 User - * @response 404 string + * @route GET /posts/{id} + * @response 200 ApiResponse */ public function show(int $id) {} } ``` -### Defining Models (Schemas) -```php -namespace App\Models; - -/** - * @property int $id User ID - * @property string $name Full Name - * @property string|null $email Optional email - * @property User[] $friends Nested relationship - */ -class User {} -``` - ## Support Tags - **Endpoints**: @@ -79,15 +94,18 @@ class User {} - `@summary [TEXT]` - `@description [TEXT]` - `@tag [NAME]` - - `@response [CODE] [TYPE]` (e.g., `@response 200 User[]`) + - `@response [CODE] [TYPE]` (e.g., `@response 200 ApiResponse`) - **Models**: - `@property [TYPE] $[NAME] [DESCRIPTION]` - `@var [TYPE]` (for class properties) + - `@template [NAME]` (for generics) + - `@extends [TYPE]` or `@use [TYPE]` (for generic arguments) ## Testing To run the unit tests: ```bash +composer install ./vendor/bin/phpunit ``` diff --git a/SAFE_BACKLOG.md b/SAFE_BACKLOG.md index cec2a01..dfc8db3 100644 --- a/SAFE_BACKLOG.md +++ b/SAFE_BACKLOG.md @@ -51,9 +51,9 @@ ### Features cho [Epic 2] Type System Pro - [x] **[F2.1] Advanced Type Resolver:** Hỗ trợ các kiểu dữ liệu phức tạp của PHP hiện đại. - *AC:* Xử lý được union types (A|B), nullable (?A), và các kiểu nguyên thủy. -- [ ] **[F2.2] Generics Support:** Phân tích cú pháp template cho các kiểu dữ liệu generic. +- [x] **[F2.2] Generics Support:** Phân tích cú pháp template cho các kiểu dữ liệu generic. - *AC:* Hiểu được `Collection` hoặc `ApiResponse`. -- [ ] **[F2.3] Inheritance & Trait Merger:** Gộp các thuộc tính từ các class cha và traits. +- [x] **[F2.3] Inheritance & Trait Merger:** Gộp các thuộc tính từ các class cha và traits. - *AC:* Schema của class con phải bao gồm đầy đủ thuộc tính từ cây kế thừa. - [x] **[F2.4] Schema Registry:** Quản lý tập trung các định nghĩa Model để tránh trùng lặp và xử lý tham chiếu vòng. - *AC:* Không bị lỗi vòng lặp vô tận khi Class A chứa Class B và ngược lại. @@ -61,7 +61,7 @@ ### Features cho [Epic 3] Integration & CLI - [ ] **[F3.1] CLI Command Interface:** Cung cấp giao diện dòng lệnh cho người dùng. - *AC:* Chạy được lệnh `php-swag generate --path=src`. -- [ ] **[F3.2] OpenAPI Spec Generator:** Chuyển đổi dữ liệu IR thành file chuẩn OpenAPI. +- [x] **[F3.2] OpenAPI Spec Generator:** Chuyển đổi dữ liệu IR thành file chuẩn OpenAPI. - *AC:* Xuất ra file `swagger.yaml` hoặc `swagger.json` hợp lệ (v3.0/3.1). - [ ] **[F3.3] Performance Caching:** Lưu trữ kết quả phân tích để tăng tốc cho các lần chạy sau. - *AC:* Tốc độ generate lần 2 phải nhanh hơn ít nhất 50% so với lần đầu. diff --git a/composer.json b/composer.json index ec92217..c4ef0ae 100644 --- a/composer.json +++ b/composer.json @@ -21,9 +21,14 @@ "symfony/yaml": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^10.0", + "squizlabs/php_codesniffer": "^4.0" }, "config": { "sort-packages": true + }, + "scripts": { + "lint": "phpcs", + "format": "phpcbf" } } diff --git a/examples/App/Controllers/PostController.php b/examples/App/Controllers/PostController.php new file mode 100644 index 0000000..e54d641 --- /dev/null +++ b/examples/App/Controllers/PostController.php @@ -0,0 +1,30 @@ +> + */ + public function index() + { + } + + /** + * @route GET /posts/{id} + * @summary Get a single post + * @tag Posts + * @response 200 ApiResponse + */ + public function show(int $id) + { + } +} diff --git a/examples/App/Models/ApiResponse.php b/examples/App/Models/ApiResponse.php new file mode 100644 index 0000000..a4ddbcd --- /dev/null +++ b/examples/App/Models/ApiResponse.php @@ -0,0 +1,13 @@ + + + The coding standard for PhpSwag. + + + + + + + tests/* + + + + src + tests + + + tests/fixtures/* + + vendor/* + + + + + diff --git a/src/Core.php b/src/Core.php index 40a6d60..d720124 100644 --- a/src/Core.php +++ b/src/Core.php @@ -6,6 +6,8 @@ use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Property; +use PhpParser\Node\Stmt\Trait_; +use PhpParser\Node\Stmt\TraitUse; use PhpParser\NodeTraverser; use PhpSwag\IR\PropertyDefinition; use PhpSwag\IR\RouteDefinition; @@ -19,6 +21,9 @@ class Core private Generator $generator; private SchemaRegistry $schemaRegistry; + /** @var array */ + private array $discoveredClasses = []; + public function __construct() { $this->scanner = new Scanner(); @@ -28,19 +33,30 @@ public function __construct() $this->generator = new Generator($this->schemaRegistry); } + public function setOpenApiVersion(string $version): void + { + $this->generator->setVersion($version); + } + public function generate(array $paths): string { $this->scanner->setPaths($paths); $files = $this->scanner->scan(); + // Pass 1: Discovery foreach ($files as $file) { - $this->processFile($file); + $this->discoverFile($file); + } + + // Pass 2: Analysis + foreach ($this->discoveredClasses as $fqcn => $data) { + $this->analyzeClass($fqcn, $data['node'], $data['nameResolver']); } return $this->generator->generateYaml(); } - private function processFile(string $filePath): void + private function discoverFile(string $filePath): void { $code = file_get_contents($filePath); $stmts = $this->parser->parse($code); @@ -53,114 +69,190 @@ private function processFile(string $filePath): void foreach ($stmts as $stmt) { if ($stmt instanceof Node\Stmt\Namespace_) { foreach ($stmt->stmts as $innerStmt) { - $this->processStatement($innerStmt, $nameResolver); + $this->discoverStatement($innerStmt, $nameResolver); } } else { - $this->processStatement($stmt, $nameResolver); + $this->discoverStatement($stmt, $nameResolver); } } } - private function processStatement(Node $stmt, NameResolver $nameResolver): void + private function discoverStatement(Node $stmt, NameResolver $nameResolver): void { - if ($stmt instanceof Class_) { - $typeResolver = new TypeResolver($this->schemaRegistry, $nameResolver); + if ($stmt instanceof Class_ || $stmt instanceof Trait_) { + $className = $stmt->name->toString(); + $namespace = $nameResolver->getCurrentNamespace(); + $fqcn = ($namespace ? $namespace . '\\' : '') . $className; + + $this->discoveredClasses[$fqcn] = [ + 'node' => $stmt, + 'nameResolver' => $nameResolver + ]; + $docComment = $stmt->getDocComment()?->getText() ?? ''; $tags = $this->docCollector->collectTags($docComment); - $isSchema = false; - $properties = []; + $templates = []; + $typeArguments = []; + $parent = null; foreach ($tags as $tag) { - if ($tag['name'] === '@property') { - $isSchema = true; - $propertySchema = $typeResolver->resolve($tag['type']); - - $properties[] = new PropertyDefinition( - $tag['propertyName'], - $propertySchema, - $tag['description'] - ); + if ($tag['name'] === '@template') { + $parts = preg_split('/\s+/', trim($tag['value'])); + if (!empty($parts[0])) { + $templates[] = $parts[0]; + } + } + + if ($tag['name'] === '@extends' || $tag['name'] === '@implements') { + $typeNode = $this->docCollector->parseType($tag['value']); + if ($typeNode instanceof \PHPStan\PhpDocParser\Ast\Type\GenericTypeNode) { + $parent = $nameResolver->resolve($typeNode->type->name); + } else { + $parent = $nameResolver->resolve($tag['value']); + } } } + if ($parent === null && $stmt instanceof Class_ && $stmt->extends) { + $parent = $nameResolver->resolve($stmt->extends->toString()); + } + + $traits = []; foreach ($stmt->stmts as $member) { - if ($member instanceof Property) { - $isSchema = true; - $propDoc = $member->getDocComment()?->getText() ?? ''; - $propTags = $this->docCollector->collectTags($propDoc); - foreach ($propTags as $pTag) { - if ($pTag['name'] === '@var') { - $propertySchema = $typeResolver->resolve($pTag['type']); - - $properties[] = new PropertyDefinition( - $member->props[0]->name->toString(), - $propertySchema, - $pTag['description'] - ); - } + if ($member instanceof TraitUse) { + foreach ($member->traits as $trait) { + $traits[] = $nameResolver->resolve($trait->toString()); } } + } + + $this->schemaRegistry->register(new SchemaDefinition( + name: $fqcn, + parent: $parent, + traits: $traits, + templates: $templates, + typeArguments: $typeArguments + )); + } + } + + private function analyzeClass(string $fqcn, Class_|Trait_ $stmt, NameResolver $nameResolver): void + { + $schema = $this->schemaRegistry->get($fqcn); + $typeResolver = new TypeResolver($this->schemaRegistry, $nameResolver, $schema->templates); + $docComment = $stmt->getDocComment()?->getText() ?? ''; + $tags = $this->docCollector->collectTags($docComment); - if ($member instanceof ClassMethod) { - $methodDoc = $member->getDocComment()?->getText() ?? ''; - $methodTags = $this->docCollector->collectTags($methodDoc); - - $routeTag = null; - $summary = null; - $description = null; - $tagsList = []; - $responses = []; - - foreach ($methodTags as $mTag) { - switch ($mTag['name']) { - case '@route': - $routeTag = $mTag['value']; - break; - case '@summary': - $summary = $mTag['value']; - break; - case '@description': - $description = $mTag['value']; - break; - case '@tag': - $tagsList[] = $mTag['value']; - break; - case '@response': - $parts = preg_split('/\s+/', trim($mTag['value']), 2); - if (count($parts) >= 2) { - // Parse response type using DocBlockParser/TypeResolver - $responseDoc = '/** @var ' . $parts[1] . ' */'; - $responseTags = $this->docCollector->collectTags($responseDoc); - if (isset($responseTags[0]['type'])) { - $responses[$parts[0]] = $typeResolver->resolve($responseTags[0]['type']); - } - } - break; + foreach ($tags as $tag) { + if ($tag['name'] === '@extends' || $tag['name'] === '@use') { + $typeNode = $this->docCollector->parseType($tag['value']); + if ($typeNode instanceof \PHPStan\PhpDocParser\Ast\Type\GenericTypeNode) { + $targetFqcn = $nameResolver->resolve($typeNode->type->name); + $targetSchema = $this->schemaRegistry->get($targetFqcn); + if ($targetSchema && !empty($targetSchema->templates)) { + foreach ($typeNode->genericTypes as $i => $argNode) { + $templateName = $targetSchema->templates[$i] ?? "T$i"; + $schema->typeArguments[$templateName] = $typeResolver->resolve($argNode); } } + } + } + } - if ($routeTag) { - $routeParts = preg_split('/\s+/', trim($routeTag), 2); - if (count($routeParts) === 2) { - $this->generator->addRoute(new RouteDefinition( - method: $routeParts[0], - path: $routeParts[1], - summary: $summary, - description: $description, - tags: $tagsList, - responses: $responses - )); - } + $isSchema = false; + $properties = []; + + foreach ($tags as $tag) { + if ($tag['name'] === '@property' && isset($tag['type'])) { + $isSchema = true; + $propertySchema = $typeResolver->resolve($tag['type']); + + $properties[] = new PropertyDefinition( + $tag['propertyName'], + $propertySchema, + $tag['description'] + ); + } + } + + foreach ($stmt->stmts as $member) { + if ($member instanceof Property) { + $isSchema = true; + $propDoc = $member->getDocComment()?->getText() ?? ''; + $propTags = $this->docCollector->collectTags($propDoc); + foreach ($propTags as $pTag) { + if ($pTag['name'] === '@var' && isset($pTag['type'])) { + $propertySchema = $typeResolver->resolve($pTag['type']); + + $properties[] = new PropertyDefinition( + $member->props[0]->name->toString(), + $propertySchema, + $pTag['description'] + ); } } } - if ($isSchema) { - $className = $stmt->name->toString(); - $fqcn = ($nameResolver->getCurrentNamespace() ? $nameResolver->getCurrentNamespace() . '\\' : '') . $className; - $this->schemaRegistry->register(new SchemaDefinition($fqcn, $properties)); + if ($member instanceof ClassMethod) { + $this->analyzeMethod($member, $nameResolver, $typeResolver); } } + + if ($isSchema || !empty($schema->templates) || $stmt instanceof Trait_) { + $schema->properties = $properties; + } + } + + private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, TypeResolver $typeResolver): void + { + $methodDoc = $member->getDocComment()?->getText() ?? ''; + + $routeTag = null; + $summary = null; + $description = null; + $tagsList = []; + $responses = []; + + $lines = explode("\n", $methodDoc); + foreach ($lines as $line) { + $line = trim($line, " \t\n\r\0\x0B*/"); + if (empty($line)) { + continue; + } + + if (preg_match('/^@route\s+(GET|POST|PUT|DELETE|PATCH)\s+(\S+)/i', $line, $matches)) { + $routeTag = strtoupper($matches[1]) . ' ' . $matches[2]; + } elseif (preg_match('/^@summary\s+(.*)$/i', $line, $matches)) { + $summary = trim($matches[1]); + } elseif (preg_match('/^@description\s+(.*)$/i', $line, $matches)) { + $description = trim($matches[1]); + } elseif (preg_match('/^@tag\s+(.*)$/i', $line, $matches)) { + $tagsList[] = trim($matches[1]); + } elseif (preg_match('/^@response\s+(\d+)\s+(.*)$/i', $line, $matches)) { + $code = $matches[1]; + $typeStr = trim($matches[2]); + + $typeParts = preg_split('/\s+/', $typeStr); + $typeToParse = $typeParts[0]; + + $typeNode = $this->docCollector->parseType($typeToParse); + if ($typeNode) { + $responses[$code] = $typeResolver->resolve($typeNode); + } + } + } + + if ($routeTag) { + $routeParts = explode(' ', $routeTag); + $this->generator->addRoute(new RouteDefinition( + method: $routeParts[0], + path: $routeParts[1], + summary: $summary, + description: $description, + tags: $tagsList, + responses: $responses + )); + } } } diff --git a/src/DocBlockCollector.php b/src/DocBlockCollector.php index 6445fc0..2fdad38 100644 --- a/src/DocBlockCollector.php +++ b/src/DocBlockCollector.php @@ -2,11 +2,9 @@ namespace PhpSwag; -use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; -use PHPStan\PhpDocParser\Ast\PhpDoc\GenericTagValueNode; -use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; -use PHPStan\PhpDocParser\Ast\PhpDoc\PropertyTagValueNode; -use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; +use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; class DocBlockCollector { @@ -23,40 +21,107 @@ public function collectTags(string $docComment): array return []; } - $phpDocNode = $this->parser->parse($docComment); $tags = []; + $lines = explode("\n", $docComment); + foreach ($lines as $line) { + $line = trim($line, " \t\n\r\0\x0B*/"); + if (empty($line)) { + continue; + } - foreach ($phpDocNode->getTags() as $tag) { - $tagName = $tag->name; - $value = $tag->value; - - if ($value instanceof PropertyTagValueNode) { - $tags[] = [ - 'name' => $tagName, - 'type' => $value->type, - 'propertyName' => ltrim($value->propertyName, '$'), - 'description' => $value->description - ]; - } elseif ($value instanceof VarTagValueNode) { - $tags[] = [ - 'name' => $tagName, - 'type' => $value->type, - 'propertyName' => $value->variableName ? ltrim($value->variableName, '$') : null, - 'description' => $value->description - ]; - } elseif ($value instanceof GenericTagValueNode) { - $tags[] = [ - 'name' => $tagName, - 'value' => $value->value - ]; - } else { - $tags[] = [ - 'name' => $tagName, - 'value' => (string)$value - ]; + if (preg_match('/^(@[a-zA-Z0-9_]+)(?:\s+(.*))?$/', $line, $matches)) { + $tagName = $matches[1]; + $value = isset($matches[2]) ? trim($matches[2]) : ''; + + if (in_array($tagName, ['@property', '@var', '@param', '@return'])) { + try { + $doc = "/** $line */"; + $node = $this->parser->parse($doc); + foreach ($node->getTags() as $tag) { + $v = $tag->value; + if ($v instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\PropertyTagValueNode) { + $tags[] = [ + 'name' => $tagName, + 'type' => $v->type, + 'propertyName' => ltrim($v->propertyName, '$'), + 'description' => $v->description + ]; + } elseif ($v instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode) { + $tags[] = [ + 'name' => $tagName, + 'type' => $v->type, + 'propertyName' => $v->variableName ? ltrim($v->variableName, '$') : null, + 'description' => $v->description + ]; + } + } + } catch (\Exception $e) { + $tags[] = ['name' => $tagName, 'value' => $value]; + } + } else { + $tags[] = [ + 'name' => $tagName, + 'value' => $value + ]; + } } } return $tags; } + + public function parseType(string $typeString): \PHPStan\PhpDocParser\Ast\Type\TypeNode + { + if (str_ends_with($typeString, '[]')) { + $inner = substr($typeString, 0, -2); + return new ArrayTypeNode($this->parseType($inner)); + } + + if (preg_match('/^([a-zA-Z0-9_\\\\]+)<(.*)>$/', $typeString, $matches)) { + $base = $matches[1]; + $inner = $matches[2]; + + $innerNodes = []; + $parts = $this->splitByComma($inner); + foreach ($parts as $part) { + $node = $this->parseType(trim($part)); + if ($node) { + $innerNodes[] = $node; + } + } + + return new GenericTypeNode( + new IdentifierTypeNode($base), + $innerNodes + ); + } + + return new IdentifierTypeNode($typeString); + } + + private function splitByComma(string $str): array + { + $parts = []; + $current = ''; + $depth = 0; + for ($i = 0; $i < strlen($str); $i++) { + $char = $str[$i]; + if ($char === '<') { + $depth++; + } elseif ($char === '>') { + $depth--; + } + + if ($char === ',' && $depth === 0) { + $parts[] = $current; + $current = ''; + } else { + $current .= $char; + } + } + if ($current !== '') { + $parts[] = $current; + } + return $parts; + } } diff --git a/src/Generator.php b/src/Generator.php index 35fc58b..065a29a 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -5,17 +5,24 @@ use Symfony\Component\Yaml\Yaml; use PhpSwag\IR\RouteDefinition; use PhpSwag\IR\SchemaDefinition; +use PhpSwag\IR\PropertyDefinition; class Generator { private array $routes = []; private SchemaRegistry $schemaRegistry; + private string $openApiVersion = '3.0.0'; public function __construct(SchemaRegistry $schemaRegistry) { $this->schemaRegistry = $schemaRegistry; } + public function setVersion(string $version): void + { + $this->openApiVersion = $version; + } + public function addRoute(RouteDefinition $route): void { $this->routes[] = $route; @@ -24,7 +31,7 @@ public function addRoute(RouteDefinition $route): void public function generateYaml(): string { $spec = [ - 'openapi' => '3.0.0', + 'openapi' => $this->openApiVersion, 'info' => [ 'title' => 'API Documentation', 'version' => '1.0.0' @@ -59,7 +66,7 @@ public function generateYaml(): string 'description' => 'OK', 'content' => [ 'application/json' => [ - 'schema' => $schema + 'schema' => $this->processSchemaOutput($schema) ] ] ]; @@ -74,21 +81,120 @@ public function generateYaml(): string } foreach ($this->schemaRegistry->getAll() as $schema) { - $properties = []; - foreach ($schema->properties as $prop) { - $propSchema = $prop->schema; + if (!empty($schema->templates) && empty($schema->typeArguments)) { + continue; // Don't generate base generic schemas + } + + $properties = $this->resolveAllProperties($schema); + $propSpecs = []; + foreach ($properties as $prop) { + $propSchema = $this->applyTypeArguments($prop->schema, $schema->typeArguments); if ($prop->description) { $propSchema['description'] = $prop->description; } - $properties[$prop->name] = $propSchema; + $propSpecs[$prop->name] = $this->processSchemaOutput($propSchema); } $spec['components']['schemas'][$this->schemaRegistry->getSchemaId($schema->name)] = [ 'type' => 'object', - 'properties' => $properties + 'properties' => $propSpecs ]; } return Yaml::dump($spec, 10, 2); } + + private function processSchemaOutput(array $schema): array + { + if ($this->openApiVersion === '3.1.0') { + if (isset($schema['nullable']) && $schema['nullable'] === true) { + unset($schema['nullable']); + if (isset($schema['type'])) { + if (is_array($schema['type'])) { + if (!in_array('null', $schema['type'])) { + $schema['type'][] = 'null'; + } + } else { + $schema['type'] = [$schema['type'], 'null']; + } + } elseif (isset($schema['oneOf'])) { + $schema['oneOf'][] = ['type' => 'null']; + } + } + } + + if (isset($schema['items'])) { + $schema['items'] = $this->processSchemaOutput($schema['items']); + } + foreach (['oneOf', 'anyOf', 'allOf'] as $key) { + if (isset($schema[$key])) { + foreach ($schema[$key] as $i => $sub) { + $schema[$key][$i] = $this->processSchemaOutput($sub); + } + } + } + + return $schema; + } + + private function resolveAllProperties(SchemaDefinition $schema): array + { + $properties = []; + + $targetSchema = $schema; + if ($schema->base && $baseSchema = $this->schemaRegistry->get($schema->base)) { + $targetSchema = $baseSchema; + } + + if ($targetSchema->parent && $parentSchema = $this->schemaRegistry->get($targetSchema->parent)) { + $parentProps = $this->resolveAllProperties($parentSchema); + foreach ($parentProps as $p) { + $properties[$p->name] = $p; + } + } + + foreach ($targetSchema->traits as $traitFqcn) { + if ($traitSchema = $this->schemaRegistry->get($traitFqcn)) { + $traitProps = $this->resolveAllProperties($traitSchema); + foreach ($traitProps as $p) { + $properties[$p->name] = $p; + } + } + } + + foreach ($targetSchema->properties as $p) { + $properties[$p->name] = $p; + } + + return array_values($properties); + } + + private function applyTypeArguments(array $schema, array $typeArgs): array + { + if (empty($typeArgs)) { + return $schema; + } + + if (isset($schema['type']) && is_string($schema['type']) && isset($typeArgs[$schema['type']])) { + $substituted = $typeArgs[$schema['type']]; + if (isset($schema['nullable']) && $schema['nullable']) { + $substituted['nullable'] = true; + } + return $substituted; + } + + if (isset($schema['type']) && $schema['type'] === 'array' && isset($schema['items'])) { + $schema['items'] = $this->applyTypeArguments($schema['items'], $typeArgs); + } + + foreach (['oneOf', 'anyOf', 'allOf'] as $key) { + if (isset($schema[$key])) { + foreach ($schema[$key] as $i => $subSchema) { + $schema[$key][$i] = $this->applyTypeArguments($subSchema, $typeArgs); + } + } + } + + return $schema; + } } diff --git a/src/IR/PropertyDefinition.php b/src/IR/PropertyDefinition.php index 7f71394..bd4e707 100644 --- a/src/IR/PropertyDefinition.php +++ b/src/IR/PropertyDefinition.php @@ -8,5 +8,6 @@ public function __construct( public string $name, public array $schema, public ?string $description = null - ) {} + ) { + } } diff --git a/src/IR/RouteDefinition.php b/src/IR/RouteDefinition.php index 0419db4..d63e900 100644 --- a/src/IR/RouteDefinition.php +++ b/src/IR/RouteDefinition.php @@ -12,5 +12,6 @@ public function __construct( public array $tags = [], public ?string $responseRef = null, public array $responses = [] - ) {} + ) { + } } diff --git a/src/IR/SchemaDefinition.php b/src/IR/SchemaDefinition.php index 0a13170..3532267 100644 --- a/src/IR/SchemaDefinition.php +++ b/src/IR/SchemaDefinition.php @@ -6,6 +6,12 @@ class SchemaDefinition { public function __construct( public string $name, - public array $properties = [] - ) {} + public array $properties = [], + public ?string $parent = null, + public array $traits = [], + public array $templates = [], // e.g. ['T', 'K'] + public array $typeArguments = [], // e.g. ['T' => ['type' => 'string']] + public ?string $base = null + ) { + } } diff --git a/src/NameResolver.php b/src/NameResolver.php index cda3288..af93702 100644 --- a/src/NameResolver.php +++ b/src/NameResolver.php @@ -28,7 +28,7 @@ public function enterNode(Node $node) public function resolve(string $name): string { if (str_starts_with($name, '\\')) { - return substr($name, 1); + return ltrim($name, '\\'); } $parts = explode('\\', $name); diff --git a/src/SchemaRegistry.php b/src/SchemaRegistry.php index eabf7e7..f9d2802 100644 --- a/src/SchemaRegistry.php +++ b/src/SchemaRegistry.php @@ -9,6 +9,9 @@ class SchemaRegistry /** @var array */ private array $schemas = []; + /** @var array */ + private array $customSchemaIds = []; + public function register(SchemaDefinition $schema): void { $this->schemas[$schema->name] = $schema; @@ -30,8 +33,16 @@ public function getAll(): array return $this->schemas; } + public function setCustomSchemaId(string $fqcn, string $id): void + { + $this->customSchemaIds[$fqcn] = $id; + } + public function getSchemaId(string $fqcn): string { + if (isset($this->customSchemaIds[$fqcn])) { + return $this->customSchemaIds[$fqcn]; + } return str_replace('\\', '_', $fqcn); } } diff --git a/src/TypeResolver.php b/src/TypeResolver.php index 77a5633..8626e75 100644 --- a/src/TypeResolver.php +++ b/src/TypeResolver.php @@ -8,16 +8,19 @@ use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; +use PhpSwag\IR\SchemaDefinition; class TypeResolver { private SchemaRegistry $schemaRegistry; private NameResolver $nameResolver; + private array $templates = []; - public function __construct(SchemaRegistry $schemaRegistry, NameResolver $nameResolver) + public function __construct(SchemaRegistry $schemaRegistry, NameResolver $nameResolver, array $templates = []) { $this->schemaRegistry = $schemaRegistry; $this->nameResolver = $nameResolver; + $this->templates = $templates; } public function resolve(TypeNode $typeNode): array @@ -67,8 +70,8 @@ public function resolve(TypeNode $typeNode): array 'items' => $this->resolve($typeNode->genericTypes[0]) ]; } - // Handle other generics like Collection or ApiResponse later - return $this->resolveIdentifier($typeNode->type->name); + + return $this->resolveGeneric($typeNode); } return ['type' => 'string']; @@ -76,6 +79,10 @@ public function resolve(TypeNode $typeNode): array private function resolveIdentifier(string $name): array { + if (in_array($name, $this->templates)) { + return ['type' => $name]; // Return template name as "type" to be substituted later + } + $lowered = strtolower($name); $map = [ 'int' => 'integer', @@ -85,7 +92,7 @@ private function resolveIdentifier(string $name): array 'boolean' => 'boolean', 'float' => 'number', 'double' => 'number', - 'mixed' => 'string', // OpenAPI doesn't have mixed, default to string or object + 'mixed' => 'string', 'void' => null, ]; @@ -93,10 +100,58 @@ private function resolveIdentifier(string $name): array return $map[$lowered] ? ['type' => $map[$lowered]] : []; } - // It's likely a class reference $fqcn = $this->nameResolver->resolve($name); return [ '$ref' => '#/components/schemas/' . $this->schemaRegistry->getSchemaId($fqcn) ]; } + + private function resolveGeneric(GenericTypeNode $typeNode): array + { + $baseName = $typeNode->type->name; + $fqcn = $this->nameResolver->resolve($baseName); + + $baseSchema = $this->schemaRegistry->get($fqcn); + if (!$baseSchema || empty($baseSchema->templates)) { + return $this->resolveIdentifier($baseName); + } + + $args = []; + $ids = []; + foreach ($typeNode->genericTypes as $i => $argNode) { + $resolvedArg = $this->resolve($argNode); + $templateName = $baseSchema->templates[$i] ?? "T$i"; + $args[$templateName] = $resolvedArg; + + if (isset($resolvedArg['$ref'])) { + $refParts = explode('/', $resolvedArg['$ref']); + $ids[] = end($refParts); + } elseif (isset($resolvedArg['type'])) { + $ids[] = ucfirst($resolvedArg['type']); + } else { + $ids[] = 'Mixed'; + } + } + + $instantiatedFqcn = $fqcn . '<' . implode(',', $ids) . '>'; + + if (!$this->schemaRegistry->has($instantiatedFqcn)) { + $instantiatedSchema = new SchemaDefinition( + name: $instantiatedFqcn, + properties: $baseSchema->properties, + parent: $baseSchema->parent, + traits: $baseSchema->traits, + typeArguments: $args, + base: $fqcn + ); + $this->schemaRegistry->register($instantiatedSchema); + + $baseId = $this->schemaRegistry->getSchemaId($fqcn); + $this->schemaRegistry->setCustomSchemaId($instantiatedFqcn, $baseId . '.' . implode('.', $ids)); + } + + return [ + '$ref' => '#/components/schemas/' . $this->schemaRegistry->getSchemaId($instantiatedFqcn) + ]; + } } diff --git a/tests/DocBlockCollectorTest.php b/tests/DocBlockCollectorTest.php index 763d80b..cafa208 100644 --- a/tests/DocBlockCollectorTest.php +++ b/tests/DocBlockCollectorTest.php @@ -41,4 +41,20 @@ public function testCollectPropertyTags() $this->assertEquals('name', $tags[0]['propertyName']); $this->assertEquals('User name', $tags[0]['description']); } + + public function testParseTypeWithArrays() + { + $collector = new DocBlockCollector(); + + $node = $collector->parseType('User[]'); + $this->assertInstanceOf(\PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode::class, $node); + $this->assertInstanceOf(IdentifierTypeNode::class, $node->type); + $this->assertEquals('User', $node->type->name); + + $nestedNode = $collector->parseType('User[][]'); + $this->assertInstanceOf(\PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode::class, $nestedNode); + $this->assertInstanceOf(\PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode::class, $nestedNode->type); + $this->assertInstanceOf(IdentifierTypeNode::class, $nestedNode->type->type); + $this->assertEquals('User', $nestedNode->type->type->name); + } } diff --git a/tests/GenericsTest.php b/tests/GenericsTest.php new file mode 100644 index 0000000..6575005 --- /dev/null +++ b/tests/GenericsTest.php @@ -0,0 +1,79 @@ + + */ + public function getUser() {} + }'); + + $core = new Core(); + $yaml = $core->generate([$dir]); + + $this->assertStringContainsString("App_ApiResponse.App_User:", $yaml); + $this->assertStringContainsString("\$ref: '#/components/schemas/App_User'", $yaml); + } + + public function testNestedGenerics() + { + $dir = __DIR__ . '/fixtures/generics_nested'; + @mkdir($dir, 0777, true); + + file_put_contents($dir . '/ApiResponse.php', '> + */ + public function getUsers() {} + }'); + + $core = new Core(); + $yaml = $core->generate([$dir]); + + $this->assertStringContainsString("App_ApiResponse.App_Collection.App_User:", $yaml); + $this->assertStringContainsString("App_Collection.App_User:", $yaml); + } + + public function testGenericInheritance() + { + $dir = __DIR__ . '/fixtures/generics_inheritance'; + @mkdir($dir, 0777, true); + + file_put_contents($dir . '/BaseResponse.php', ' */ class UserResponse extends BaseResponse { /** @var string */ public $message; }'); + file_put_contents($dir . '/User.php', 'generate([$dir]); + + $this->assertStringContainsString("App_UserResponse:", $yaml); + $this->assertStringContainsString("data:", $yaml); + $this->assertStringContainsString("message:", $yaml); + $this->assertStringContainsString("App_User", $yaml); + } +} diff --git a/tests/InheritanceTest.php b/tests/InheritanceTest.php new file mode 100644 index 0000000..28d78d6 --- /dev/null +++ b/tests/InheritanceTest.php @@ -0,0 +1,56 @@ +generate([$dir]); + + $this->assertStringContainsString("App_User:", $yaml); + $this->assertStringContainsString("id:", $yaml); + $this->assertStringContainsString("name:", $yaml); + } + + public function testTraitMerging() + { + $dir = __DIR__ . '/fixtures/traits'; + @mkdir($dir, 0777, true); + + file_put_contents($dir . '/Timestampable.php', 'generate([$dir]); + + $this->assertStringContainsString("App_Post:", $yaml); + $this->assertStringContainsString("createdAt:", $yaml); + $this->assertStringContainsString("title:", $yaml); + } + + public function testOverride() + { + $dir = __DIR__ . '/fixtures/override'; + @mkdir($dir, 0777, true); + + file_put_contents($dir . '/Base.php', 'generate([$dir]); + + $this->assertStringContainsString("App_Child:", $yaml); + $this->assertStringContainsString("type:\n type: integer", $yaml); + } +} diff --git a/tests/OpenApiVersionTest.php b/tests/OpenApiVersionTest.php new file mode 100644 index 0000000..7a93e4a --- /dev/null +++ b/tests/OpenApiVersionTest.php @@ -0,0 +1,36 @@ +setOpenApiVersion('3.0.0'); + $yaml = $core->generate([$dir]); + + $this->assertStringContainsString("nullable: true", $yaml); + } + + public function testOpenApi31Nullable() + { + $dir = __DIR__ . '/fixtures/openapi31'; + @mkdir($dir, 0777, true); + file_put_contents($dir . '/User.php', 'setOpenApiVersion('3.1.0'); + $yaml = $core->generate([$dir]); + + $this->assertStringNotContainsString("nullable: true", $yaml); + $this->assertStringContainsString("type:\n - string\n - 'null'", $yaml); + } +} diff --git a/tests/ScannerTest.php b/tests/ScannerTest.php index ad2be2f..38e92df 100644 --- a/tests/ScannerTest.php +++ b/tests/ScannerTest.php @@ -39,10 +39,11 @@ private function removeDir($dir) $objects = scandir($dir); foreach ($objects as $object) { if ($object != "." && $object != "..") { - if (is_dir($dir . DIRECTORY_SEPARATOR . $object) && !is_link($dir . "/" . $object)) + if (is_dir($dir . DIRECTORY_SEPARATOR . $object) && !is_link($dir . "/" . $object)) { $this->removeDir($dir . DIRECTORY_SEPARATOR . $object); - else + } else { unlink($dir . DIRECTORY_SEPARATOR . $object); + } } } rmdir($dir); diff --git a/tests/fixtures/generics_inheritance/BaseResponse.php b/tests/fixtures/generics_inheritance/BaseResponse.php new file mode 100644 index 0000000..d094be4 --- /dev/null +++ b/tests/fixtures/generics_inheritance/BaseResponse.php @@ -0,0 +1 @@ + */ class UserResponse extends BaseResponse { /** @var string */ public $message; } \ No newline at end of file diff --git a/tests/fixtures/generics_nested/ApiResponse.php b/tests/fixtures/generics_nested/ApiResponse.php new file mode 100644 index 0000000..bd26376 --- /dev/null +++ b/tests/fixtures/generics_nested/ApiResponse.php @@ -0,0 +1 @@ +> + */ + public function getUsers() {} + } \ No newline at end of file diff --git a/tests/fixtures/generics_nested/User.php b/tests/fixtures/generics_nested/User.php new file mode 100644 index 0000000..427d30f --- /dev/null +++ b/tests/fixtures/generics_nested/User.php @@ -0,0 +1 @@ + + */ + public function getUser() {} + } \ No newline at end of file diff --git a/tests/fixtures/generics_simple/User.php b/tests/fixtures/generics_simple/User.php new file mode 100644 index 0000000..427d30f --- /dev/null +++ b/tests/fixtures/generics_simple/User.php @@ -0,0 +1 @@ + Date: Sat, 6 Jun 2026 11:01:18 +0700 Subject: [PATCH 07/27] fix: OpenAPI compliance and add parameter discovery & schema filtering - Added Yaml::DUMP_NUMERIC_KEY_AS_STRING flag to Generator's YAML export. - This ensures HTTP status codes (like '200') are treated as strings by Swagger Editor. - Updated getUsedSchemas to exclude classes that are only used for inheritance (flattened). - Enabled setFilterUnusedSchemas(true) by default in generate.php. - This resolves 'Definition was declared but never used' warnings in Swagger Editor. --------- Co-authored-by: tolawho <12527881+tolawho@users.noreply.github.com> --- examples/App/Controllers/PostController.php | 1 + examples/App/Controllers/UserController.php | 1 + examples/generate.php | 2 +- examples/generate_filtered.php | 11 ++ output_filtered.yaml | 142 +++++++++++++++++ output_final.yaml | 142 +++++++++++++++++ output_new.yaml | 163 ++++++++++++++++++++ output_v2.yaml | 163 ++++++++++++++++++++ src/Core.php | 60 +++---- src/DocBlockCollector.php | 7 + src/Generator.php | 134 ++++++++++++++-- src/IR/RouteDefinition.php | 3 +- test_yaml.php | 12 ++ test_yaml_2.php | 14 ++ test_yaml_3.php | 19 +++ test_yaml_4.php | 13 ++ test_yaml_5.php | 11 ++ test_yaml_quotes.php | 22 +++ 18 files changed, 883 insertions(+), 37 deletions(-) create mode 100644 examples/generate_filtered.php create mode 100644 output_filtered.yaml create mode 100644 output_final.yaml create mode 100644 output_new.yaml create mode 100644 output_v2.yaml create mode 100644 test_yaml.php create mode 100644 test_yaml_2.php create mode 100644 test_yaml_3.php create mode 100644 test_yaml_4.php create mode 100644 test_yaml_5.php create mode 100644 test_yaml_quotes.php diff --git a/examples/App/Controllers/PostController.php b/examples/App/Controllers/PostController.php index e54d641..1f26340 100644 --- a/examples/App/Controllers/PostController.php +++ b/examples/App/Controllers/PostController.php @@ -22,6 +22,7 @@ public function index() * @route GET /posts/{id} * @summary Get a single post * @tag Posts + * @param int $id The post ID * @response 200 ApiResponse */ public function show(int $id) diff --git a/examples/App/Controllers/UserController.php b/examples/App/Controllers/UserController.php index e035f19..42a266d 100644 --- a/examples/App/Controllers/UserController.php +++ b/examples/App/Controllers/UserController.php @@ -21,6 +21,7 @@ public function index() * @summary Get user details * @description This endpoint returns a single user by their ID. * @tag User Management + * @param int $id The user ID * @response 200 User * @response 404 string */ diff --git a/examples/generate.php b/examples/generate.php index 4383145..d04df11 100644 --- a/examples/generate.php +++ b/examples/generate.php @@ -5,7 +5,7 @@ use PhpSwag\Core; $core = new Core(); -// Assuming you have App/Controllers and App/Models in examples/ +$core->setFilterUnusedSchemas(true); $yaml = $core->generate([__DIR__ . '/App']); echo $yaml; diff --git a/examples/generate_filtered.php b/examples/generate_filtered.php new file mode 100644 index 0000000..d04df11 --- /dev/null +++ b/examples/generate_filtered.php @@ -0,0 +1,11 @@ +setFilterUnusedSchemas(true); +$yaml = $core->generate([__DIR__ . '/App']); + +echo $yaml; diff --git a/output_filtered.yaml b/output_filtered.yaml new file mode 100644 index 0000000..19e8e13 --- /dev/null +++ b/output_filtered.yaml @@ -0,0 +1,142 @@ +openapi: 3.0.0 +info: + title: 'API Documentation' + version: 1.0.0 +paths: + /users: + get: + summary: 'List all users' + responses: + 200: + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/App_Models_User' + tags: + - 'User Management' + '/users/{id}': + get: + summary: 'Get user details' + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/App_Models_User' + 404: + description: OK + content: + application/json: + schema: + type: string + description: 'This endpoint returns a single user by their ID.' + tags: + - 'User Management' + parameters: + - + name: id + in: path + required: true + schema: + type: integer + description: 'The user ID' + /posts: + get: + summary: 'List all posts with pagination' + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/App_Models_ApiResponse.App_Models_Collection.App_Models_Post' + tags: + - Posts + '/posts/{id}': + get: + summary: 'Get a single post' + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/App_Models_ApiResponse.App_Models_Post' + tags: + - Posts + parameters: + - + name: id + in: path + required: true + schema: + type: integer + description: 'The post ID' +components: + schemas: + App_Models_User: + type: object + properties: + id: + type: integer + description: 'User ID' + name: + type: string + description: 'User Full Name' + email: + type: string + nullable: true + description: 'User Email Address' + friends: + type: array + items: + $ref: '#/components/schemas/App_Models_User' + description: 'List of friends' + App_Models_ApiResponse.App_Models_Collection.App_Models_Post: + type: object + properties: + data: + $ref: '#/components/schemas/App_Models_Collection.App_Models_Post' + status: + type: string + description: 'Status code (success/error)' + message: + type: string + nullable: true + description: 'Optional message' + App_Models_ApiResponse.App_Models_Post: + type: object + properties: + data: + $ref: '#/components/schemas/App_Models_Post' + status: + type: string + description: 'Status code (success/error)' + message: + type: string + nullable: true + description: 'Optional message' + App_Models_Collection.App_Models_Post: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/App_Models_Post' + description: 'List of items' + total: + type: integer + description: 'Total count' + App_Models_Post: + type: object + properties: + title: + type: string + description: 'Post title' + content: + type: string + description: 'Post body content' diff --git a/output_final.yaml b/output_final.yaml new file mode 100644 index 0000000..2a63c6e --- /dev/null +++ b/output_final.yaml @@ -0,0 +1,142 @@ +openapi: 3.0.0 +info: + title: 'API Documentation' + version: 1.0.0 +paths: + /users: + get: + summary: 'List all users' + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/App_Models_User' + tags: + - 'User Management' + '/users/{id}': + get: + summary: 'Get user details' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/App_Models_User' + '404': + description: OK + content: + application/json: + schema: + type: string + description: 'This endpoint returns a single user by their ID.' + tags: + - 'User Management' + parameters: + - + name: id + in: path + required: true + schema: + type: integer + description: 'The user ID' + /posts: + get: + summary: 'List all posts with pagination' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/App_Models_ApiResponse.App_Models_Collection.App_Models_Post' + tags: + - Posts + '/posts/{id}': + get: + summary: 'Get a single post' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/App_Models_ApiResponse.App_Models_Post' + tags: + - Posts + parameters: + - + name: id + in: path + required: true + schema: + type: integer + description: 'The post ID' +components: + schemas: + App_Models_User: + type: object + properties: + id: + type: integer + description: 'User ID' + name: + type: string + description: 'User Full Name' + email: + type: string + nullable: true + description: 'User Email Address' + friends: + type: array + items: + $ref: '#/components/schemas/App_Models_User' + description: 'List of friends' + App_Models_ApiResponse.App_Models_Collection.App_Models_Post: + type: object + properties: + data: + $ref: '#/components/schemas/App_Models_Collection.App_Models_Post' + status: + type: string + description: 'Status code (success/error)' + message: + type: string + nullable: true + description: 'Optional message' + App_Models_ApiResponse.App_Models_Post: + type: object + properties: + data: + $ref: '#/components/schemas/App_Models_Post' + status: + type: string + description: 'Status code (success/error)' + message: + type: string + nullable: true + description: 'Optional message' + App_Models_Collection.App_Models_Post: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/App_Models_Post' + description: 'List of items' + total: + type: integer + description: 'Total count' + App_Models_Post: + type: object + properties: + title: + type: string + description: 'Post title' + content: + type: string + description: 'Post body content' diff --git a/output_new.yaml b/output_new.yaml new file mode 100644 index 0000000..39c6ce4 --- /dev/null +++ b/output_new.yaml @@ -0,0 +1,163 @@ +openapi: 3.0.0 +info: + title: 'API Documentation' + version: 1.0.0 +paths: + /users: + get: + summary: 'List all users' + responses: + 200: + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/App_Models_User' + tags: + - 'User Management' + '/users/{id}': + get: + summary: 'Get user details' + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/App_Models_User' + 404: + description: OK + content: + application/json: + schema: + type: string + description: 'This endpoint returns a single user by their ID.' + tags: + - 'User Management' + parameters: + - + name: id + in: path + required: true + schema: + type: integer + description: 'The user ID' + /posts: + get: + summary: 'List all posts with pagination' + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/App_Models_ApiResponse.App_Models_Collection.App_Models_Post' + tags: + - Posts + '/posts/{id}': + get: + summary: 'Get a single post' + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/App_Models_ApiResponse.App_Models_Post' + tags: + - Posts + parameters: + - + name: id + in: path + required: true + schema: + type: integer + description: 'The post ID' +components: + schemas: + App_Models_User: + type: object + properties: + id: + type: integer + description: 'User ID' + name: + type: string + description: 'User Full Name' + email: + type: string + nullable: true + description: 'User Email Address' + friends: + type: array + items: + $ref: '#/components/schemas/App_Models_User' + description: 'List of friends' + App_Models_Timestampable: + type: object + properties: + createdAt: + type: string + description: 'Creation timestamp' + updatedAt: + type: string + description: 'Last update timestamp' + App_Models_Post: + type: object + properties: + title: + type: string + description: 'Post title' + content: + type: string + description: 'Post body content' + App_Models_BaseModel: + type: object + properties: + id: + type: integer + description: 'Unique identifier' + App_Controllers_UserController: + type: object + properties: { } + App_Controllers_PostController: + type: object + properties: { } + App_Models_Collection.App_Models_Post: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/App_Models_Post' + description: 'List of items' + total: + type: integer + description: 'Total count' + App_Models_ApiResponse.App_Models_Collection.App_Models_Post: + type: object + properties: + data: + $ref: '#/components/schemas/App_Models_Collection.App_Models_Post' + status: + type: string + description: 'Status code (success/error)' + message: + type: string + nullable: true + description: 'Optional message' + App_Models_ApiResponse.App_Models_Post: + type: object + properties: + data: + $ref: '#/components/schemas/App_Models_Post' + status: + type: string + description: 'Status code (success/error)' + message: + type: string + nullable: true + description: 'Optional message' diff --git a/output_v2.yaml b/output_v2.yaml new file mode 100644 index 0000000..4d6c8be --- /dev/null +++ b/output_v2.yaml @@ -0,0 +1,163 @@ +openapi: 3.0.0 +info: + title: 'API Documentation' + version: 1.0.0 +paths: + /users: + get: + summary: 'List all users' + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/App_Models_User' + tags: + - 'User Management' + '/users/{id}': + get: + summary: 'Get user details' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/App_Models_User' + '404': + description: OK + content: + application/json: + schema: + type: string + description: 'This endpoint returns a single user by their ID.' + tags: + - 'User Management' + parameters: + - + name: id + in: path + required: true + schema: + type: integer + description: 'The user ID' + /posts: + get: + summary: 'List all posts with pagination' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/App_Models_ApiResponse.App_Models_Collection.App_Models_Post' + tags: + - Posts + '/posts/{id}': + get: + summary: 'Get a single post' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/App_Models_ApiResponse.App_Models_Post' + tags: + - Posts + parameters: + - + name: id + in: path + required: true + schema: + type: integer + description: 'The post ID' +components: + schemas: + App_Models_User: + type: object + properties: + id: + type: integer + description: 'User ID' + name: + type: string + description: 'User Full Name' + email: + type: string + nullable: true + description: 'User Email Address' + friends: + type: array + items: + $ref: '#/components/schemas/App_Models_User' + description: 'List of friends' + App_Models_Timestampable: + type: object + properties: + createdAt: + type: string + description: 'Creation timestamp' + updatedAt: + type: string + description: 'Last update timestamp' + App_Models_Post: + type: object + properties: + title: + type: string + description: 'Post title' + content: + type: string + description: 'Post body content' + App_Models_BaseModel: + type: object + properties: + id: + type: integer + description: 'Unique identifier' + App_Controllers_UserController: + type: object + properties: { } + App_Controllers_PostController: + type: object + properties: { } + App_Models_Collection.App_Models_Post: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/App_Models_Post' + description: 'List of items' + total: + type: integer + description: 'Total count' + App_Models_ApiResponse.App_Models_Collection.App_Models_Post: + type: object + properties: + data: + $ref: '#/components/schemas/App_Models_Collection.App_Models_Post' + status: + type: string + description: 'Status code (success/error)' + message: + type: string + nullable: true + description: 'Optional message' + App_Models_ApiResponse.App_Models_Post: + type: object + properties: + data: + $ref: '#/components/schemas/App_Models_Post' + status: + type: string + description: 'Status code (success/error)' + message: + type: string + nullable: true + description: 'Optional message' diff --git a/src/Core.php b/src/Core.php index d720124..0b66e33 100644 --- a/src/Core.php +++ b/src/Core.php @@ -38,6 +38,11 @@ public function setOpenApiVersion(string $version): void $this->generator->setVersion($version); } + public function setFilterUnusedSchemas(bool $filter): void + { + $this->generator->setFilterUnusedSchemas($filter); + } + public function generate(array $paths): string { $this->scanner->setPaths($paths); @@ -207,39 +212,41 @@ private function analyzeClass(string $fqcn, Class_|Trait_ $stmt, NameResolver $n private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, TypeResolver $typeResolver): void { $methodDoc = $member->getDocComment()?->getText() ?? ''; + $tags = $this->docCollector->collectTags($methodDoc); $routeTag = null; $summary = null; $description = null; $tagsList = []; $responses = []; + $parameters = []; - $lines = explode("\n", $methodDoc); - foreach ($lines as $line) { - $line = trim($line, " \t\n\r\0\x0B*/"); - if (empty($line)) { - continue; - } - - if (preg_match('/^@route\s+(GET|POST|PUT|DELETE|PATCH)\s+(\S+)/i', $line, $matches)) { - $routeTag = strtoupper($matches[1]) . ' ' . $matches[2]; - } elseif (preg_match('/^@summary\s+(.*)$/i', $line, $matches)) { - $summary = trim($matches[1]); - } elseif (preg_match('/^@description\s+(.*)$/i', $line, $matches)) { - $description = trim($matches[1]); - } elseif (preg_match('/^@tag\s+(.*)$/i', $line, $matches)) { - $tagsList[] = trim($matches[1]); - } elseif (preg_match('/^@response\s+(\d+)\s+(.*)$/i', $line, $matches)) { - $code = $matches[1]; - $typeStr = trim($matches[2]); - - $typeParts = preg_split('/\s+/', $typeStr); - $typeToParse = $typeParts[0]; - - $typeNode = $this->docCollector->parseType($typeToParse); - if ($typeNode) { - $responses[$code] = $typeResolver->resolve($typeNode); + foreach ($tags as $tag) { + if ($tag['name'] === '@route') { + if (preg_match('/^(GET|POST|PUT|DELETE|PATCH)\s+(\S+)/i', $tag['value'], $matches)) { + $routeTag = strtoupper($matches[1]) . ' ' . $matches[2]; + } + } elseif ($tag['name'] === '@summary') { + $summary = $tag['value']; + } elseif ($tag['name'] === '@description') { + $description = $tag['value']; + } elseif ($tag['name'] === '@tag') { + $tagsList[] = $tag['value']; + } elseif ($tag['name'] === '@response') { + if (preg_match('/^(\d+)\s+(.*)$/', $tag['value'], $matches)) { + $code = $matches[1]; + $typeToParse = trim($matches[2]); + $typeNode = $this->docCollector->parseType($typeToParse); + if ($typeNode) { + $responses[$code] = $typeResolver->resolve($typeNode); + } } + } elseif ($tag['name'] === '@param') { + $parameters[] = [ + 'name' => $tag['propertyName'], + 'schema' => $typeResolver->resolve($tag['type']), + 'description' => $tag['description'] ?: null, + ]; } } @@ -251,7 +258,8 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, summary: $summary, description: $description, tags: $tagsList, - responses: $responses + responses: $responses, + parameters: $parameters )); } } diff --git a/src/DocBlockCollector.php b/src/DocBlockCollector.php index 2fdad38..48fb77d 100644 --- a/src/DocBlockCollector.php +++ b/src/DocBlockCollector.php @@ -53,6 +53,13 @@ public function collectTags(string $docComment): array 'propertyName' => $v->variableName ? ltrim($v->variableName, '$') : null, 'description' => $v->description ]; + } elseif ($v instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode) { + $tags[] = [ + 'name' => $tagName, + 'type' => $v->type, + 'propertyName' => ltrim($v->parameterName, '$'), + 'description' => $v->description + ]; } } } catch (\Exception $e) { diff --git a/src/Generator.php b/src/Generator.php index 065a29a..9f67f78 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -12,6 +12,7 @@ class Generator private array $routes = []; private SchemaRegistry $schemaRegistry; private string $openApiVersion = '3.0.0'; + private bool $filterUnusedSchemas = false; public function __construct(SchemaRegistry $schemaRegistry) { @@ -23,6 +24,11 @@ public function setVersion(string $version): void $this->openApiVersion = $version; } + public function setFilterUnusedSchemas(bool $filter): void + { + $this->filterUnusedSchemas = $filter; + } + public function addRoute(RouteDefinition $route): void { $this->routes[] = $route; @@ -52,17 +58,43 @@ public function generateYaml(): string $routeSpec = [ 'summary' => $route->summary, - 'description' => $route->description, 'responses' => [] ]; + if ($route->description !== null && $route->description !== '') { + $routeSpec['description'] = (string)$route->description; + } + if (!empty($route->tags)) { $routeSpec['tags'] = $route->tags; } + if (!empty($route->parameters)) { + $routeSpec['parameters'] = []; + foreach ($route->parameters as $param) { + $in = 'query'; + if (strpos($path, '{' . $param['name'] . '}') !== false) { + $in = 'path'; + } + + $paramSpec = [ + 'name' => $param['name'], + 'in' => $in, + 'required' => $in === 'path', + 'schema' => $this->processSchemaOutput($param['schema']) + ]; + + if (!empty($param['description'])) { + $paramSpec['description'] = (string)$param['description']; + } + + $routeSpec['parameters'][] = $paramSpec; + } + } + if (!empty($route->responses)) { foreach ($route->responses as $code => $schema) { - $routeSpec['responses'][$code] = [ + $routeSpec['responses'][(string)$code] = [ 'description' => 'OK', 'content' => [ 'application/json' => [ @@ -80,7 +112,12 @@ public function generateYaml(): string $spec['paths'][$path][$method] = $routeSpec; } - foreach ($this->schemaRegistry->getAll() as $schema) { + $schemasToGenerate = $this->schemaRegistry->getAll(); + if ($this->filterUnusedSchemas) { + $schemasToGenerate = $this->getUsedSchemas(); + } + + foreach ($schemasToGenerate as $schema) { if (!empty($schema->templates) && empty($schema->typeArguments)) { continue; // Don't generate base generic schemas } @@ -89,10 +126,7 @@ public function generateYaml(): string $propSpecs = []; foreach ($properties as $prop) { $propSchema = $this->applyTypeArguments($prop->schema, $schema->typeArguments); - if ($prop->description) { - $propSchema['description'] = $prop->description; - } - $propSpecs[$prop->name] = $this->processSchemaOutput($propSchema); + $propSpecs[$prop->name] = $this->processSchemaOutput($propSchema, $prop->description); } $spec['components']['schemas'][$this->schemaRegistry->getSchemaId($schema->name)] = [ @@ -101,10 +135,10 @@ public function generateYaml(): string ]; } - return Yaml::dump($spec, 10, 2); + return Yaml::dump($spec, 10, 2, Yaml::DUMP_NUMERIC_KEY_AS_STRING); } - private function processSchemaOutput(array $schema): array + private function processSchemaOutput(array $schema, ?string $description = null): array { if ($this->openApiVersion === '3.1.0') { if (isset($schema['nullable']) && $schema['nullable'] === true) { @@ -123,6 +157,15 @@ private function processSchemaOutput(array $schema): array } } + if (isset($schema['$ref'])) { + // Omit description when $ref is present as per OpenAPI 3.0 rules + return ['$ref' => $schema['$ref']]; + } + + if ($description !== null && $description !== '') { + $schema['description'] = (string)$description; + } + if (isset($schema['items'])) { $schema['items'] = $this->processSchemaOutput($schema['items']); } @@ -197,4 +240,77 @@ private function applyTypeArguments(array $schema, array $typeArgs): array return $schema; } + + private function getUsedSchemas(): array + { + $usedFqcns = []; + + foreach ($this->routes as $route) { + foreach ($route->responses as $schema) { + $this->collectFqcnsFromSchema($schema, $usedFqcns); + } + foreach ($route->parameters as $param) { + $this->collectFqcnsFromSchema($param['schema'], $usedFqcns); + } + } + + $usedSchemas = []; + $processed = []; + + while (!empty($usedFqcns)) { + $fqcn = array_shift($usedFqcns); + if (isset($processed[$fqcn])) { + continue; + } + $processed[$fqcn] = true; + + $schema = $this->schemaRegistry->get($fqcn); + if ($schema) { + $usedSchemas[$fqcn] = $schema; + + // Also collect FQCNs from properties + foreach ($this->resolveAllProperties($schema) as $prop) { + $propSchema = $this->applyTypeArguments($prop->schema, $schema->typeArguments); + $this->collectFqcnsFromSchema($propSchema, $usedFqcns); + } + + // Parent and traits are NOT included in $usedFqcns automatically + // because we flatten them. Only include them if they are explicitly + // referenced via $ref in some property or response. + } + } + + return array_values($usedSchemas); + } + + private function collectFqcnsFromSchema(array $schema, array &$usedFqcns): void + { + if (isset($schema['$ref'])) { + $ref = $schema['$ref']; + $prefix = '#/components/schemas/'; + if (strpos($ref, $prefix) === 0) { + $schemaId = substr($ref, strlen($prefix)); + // We need to find the FQCN by schema ID. + // Let's optimize this by looking at all registered schemas. + foreach ($this->schemaRegistry->getAll() as $s) { + if ($this->schemaRegistry->getSchemaId($s->name) === $schemaId) { + $usedFqcns[] = $s->name; + break; + } + } + } + } + + if (isset($schema['items'])) { + $this->collectFqcnsFromSchema($schema['items'], $usedFqcns); + } + + foreach (['oneOf', 'anyOf', 'allOf'] as $key) { + if (isset($schema[$key])) { + foreach ($schema[$key] as $sub) { + $this->collectFqcnsFromSchema($sub, $usedFqcns); + } + } + } + } } diff --git a/src/IR/RouteDefinition.php b/src/IR/RouteDefinition.php index d63e900..53a4055 100644 --- a/src/IR/RouteDefinition.php +++ b/src/IR/RouteDefinition.php @@ -11,7 +11,8 @@ public function __construct( public ?string $description = null, public array $tags = [], public ?string $responseRef = null, - public array $responses = [] + public array $responses = [], + public array $parameters = [] ) { } } diff --git a/test_yaml.php b/test_yaml.php new file mode 100644 index 0000000..ed2c084 --- /dev/null +++ b/test_yaml.php @@ -0,0 +1,12 @@ + [ + '200' => ['description' => 'OK'], + 'default' => ['description' => 'Error'] + ] +]; + +echo Yaml::dump($data); diff --git a/test_yaml_2.php b/test_yaml_2.php new file mode 100644 index 0000000..8315073 --- /dev/null +++ b/test_yaml_2.php @@ -0,0 +1,14 @@ + ['description' => 'OK'], +]; + +echo "Inline 1:\n"; +echo Yaml::dump($data, 1); +echo "Inline 2:\n"; +echo Yaml::dump($data, 2); +echo "Inline 10:\n"; +echo Yaml::dump($data, 10); diff --git a/test_yaml_3.php b/test_yaml_3.php new file mode 100644 index 0000000..46d57be --- /dev/null +++ b/test_yaml_3.php @@ -0,0 +1,19 @@ + [ + '200' => ['description' => 'OK'], + ] +]; + +// Symfony Yaml 6.0 flags +// DUMP_OBJECT = 1 +// DUMP_EXCEPTION_ON_INVALID_TYPE = 2 +// DUMP_OBJECT_AS_MAP = 4 +// DUMP_MULTI_LINE_LITERAL_BLOCK = 8 +// DUMP_EMPTY_ARRAY_AS_SEQUENCE = 16 +// DUMP_NULL_AS_TILDE = 32 + +echo Yaml::dump($data, 10, 2); diff --git a/test_yaml_4.php b/test_yaml_4.php new file mode 100644 index 0000000..4e16590 --- /dev/null +++ b/test_yaml_4.php @@ -0,0 +1,13 @@ + [ + '200' => ['description' => 'OK'], + ] +]; + +$yaml = Yaml::dump($data, 10, 2); +$yaml = preg_replace('/^(\s*)(\d+):/m', '$1\'$2\':', $yaml); +echo $yaml; diff --git a/test_yaml_5.php b/test_yaml_5.php new file mode 100644 index 0000000..5165197 --- /dev/null +++ b/test_yaml_5.php @@ -0,0 +1,11 @@ + [ + '200' => ['description' => 'OK'], + ] +]; + +echo Yaml::dump($data, 10, 2, Yaml::DUMP_NUMERIC_KEY_AS_STRING); diff --git a/test_yaml_quotes.php b/test_yaml_quotes.php new file mode 100644 index 0000000..cba188d --- /dev/null +++ b/test_yaml_quotes.php @@ -0,0 +1,22 @@ + [ + '200' => ['description' => 'OK'], + ] +]; + +echo "Default:\n"; +echo Yaml::dump($data); + +echo "\nWith DUMP_NUMERIC_KEY_AS_STRING (not a constant in 6.0, it was an option in some versions):\n"; +// Actually it's not a bitmask in all versions. + +echo "\nUsing a bitmask if supported:\n"; +// In Symfony 6.x, Yaml::dump(data, inline, indent, flags) +// Flags: https://github.com/symfony/yaml/blob/6.4/Yaml.php +// There is no specific flag for "quote all numeric keys" easily found. + +// Wait, I can try to use a different approach. From 86fbe1f91eeb7861f4615505129910cc0ee1ed4e Mon Sep 17 00:00:00 2001 From: tolawho Date: Sat, 6 Jun 2026 13:38:39 +0700 Subject: [PATCH 08/27] feat: implement CLI command interface - Created bin/php-swag executable. - Implemented GenerateCommand using symfony/console. - Refactored Generator and Core to support both YAML and JSON outputs. - Added support for multiple scan paths, output redirection, and OpenAPI version selection via CLI. - Updated composer.json to include the binary. - Updated README.md and SAFE_BACKLOG.md. Co-authored-by: tolawho <12527881+tolawho@users.noreply.github.com> --- README.md | 14 +++ SAFE_BACKLOG.md | 4 +- bin/php-swag | 11 +++ composer.json | 2 + examples/generate_filtered.php | 11 --- output_filtered.yaml | 142 ---------------------------- output_new.yaml | 163 --------------------------------- output_v2.yaml | 163 --------------------------------- src/CLI/GenerateCommand.php | 56 +++++++++++ src/Core.php | 26 +++++- src/Generator.php | 12 ++- test_yaml.php | 12 --- test_yaml_2.php | 14 --- test_yaml_3.php | 19 ---- test_yaml_4.php | 13 --- test_yaml_5.php | 11 --- test_yaml_quotes.php | 22 ----- 17 files changed, 121 insertions(+), 574 deletions(-) create mode 100755 bin/php-swag delete mode 100644 examples/generate_filtered.php delete mode 100644 output_filtered.yaml delete mode 100644 output_new.yaml delete mode 100644 output_v2.yaml create mode 100644 src/CLI/GenerateCommand.php delete mode 100644 test_yaml.php delete mode 100644 test_yaml_2.php delete mode 100644 test_yaml_3.php delete mode 100644 test_yaml_4.php delete mode 100644 test_yaml_5.php delete mode 100644 test_yaml_quotes.php diff --git a/README.md b/README.md index 3766709..027d86f 100644 --- a/README.md +++ b/README.md @@ -113,3 +113,17 @@ To run the example generator: ```bash php examples/generate.php ``` + +### CLI Usage +You can use the CLI to generate documentation without writing any PHP code: + +```bash +./vendor/bin/php-swag generate --path src/Controllers --path src/Models --output swagger.yaml +``` + +**Options:** +- `--path`, `-p`: Path(s) to scan (can be used multiple times). +- `--output`, `-o`: Output file path (defaults to stdout). +- `--format`, `-f`: Output format (`yaml` or `json`). Default: `yaml`. +- `--openapi-version`: OpenAPI version (`3.0.0` or `3.1.0`). Default: `3.0.0`. +- `--filter-unused`: Filter out schemas that are not referenced by any route. diff --git a/SAFE_BACKLOG.md b/SAFE_BACKLOG.md index dfc8db3..c808e4f 100644 --- a/SAFE_BACKLOG.md +++ b/SAFE_BACKLOG.md @@ -25,7 +25,7 @@ - Xử lý được tham chiếu vòng (Circular References). ### [Epic 3] Tích hợp CLI và Tối ưu hóa Hiệu năng -- **Trạng thái:** To Do +- **Trạng thái:** In Progress - **Chủ sở hữu:** Fullstack Developer (User) - **Tóm tắt:** Hoàn thiện công cụ dưới dạng CLI, hỗ trợ nhiều định dạng xuất bản và cơ chế bộ nhớ đệm (Caching). - **Giả thuyết Lợi ích (Benefit Hypothesis):** Biến thư viện thành một công cụ dòng lệnh chuyên nghiệp dễ dàng tích hợp vào quy trình CI/CD, đồng thời đảm bảo tốc độ xử lý nhanh cho các dự án lớn. @@ -59,7 +59,7 @@ - *AC:* Không bị lỗi vòng lặp vô tận khi Class A chứa Class B và ngược lại. ### Features cho [Epic 3] Integration & CLI -- [ ] **[F3.1] CLI Command Interface:** Cung cấp giao diện dòng lệnh cho người dùng. +- [x] **[F3.1] CLI Command Interface:** Cung cấp giao diện dòng lệnh cho người dùng. - *AC:* Chạy được lệnh `php-swag generate --path=src`. - [x] **[F3.2] OpenAPI Spec Generator:** Chuyển đổi dữ liệu IR thành file chuẩn OpenAPI. - *AC:* Xuất ra file `swagger.yaml` hoặc `swagger.json` hợp lệ (v3.0/3.1). diff --git a/bin/php-swag b/bin/php-swag new file mode 100755 index 0000000..332bd51 --- /dev/null +++ b/bin/php-swag @@ -0,0 +1,11 @@ +#!/usr/bin/env php +add(new GenerateCommand()); +$application->run(); diff --git a/composer.json b/composer.json index c4ef0ae..55e8712 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "php": ">=8.1", "nikic/php-parser": "^4.15", "phpstan/phpdoc-parser": "^1.24", + "symfony/console": "^6.0", "symfony/finder": "^6.0", "symfony/yaml": "^6.0" }, @@ -27,6 +28,7 @@ "config": { "sort-packages": true }, + "bin": ["bin/php-swag"], "scripts": { "lint": "phpcs", "format": "phpcbf" diff --git a/examples/generate_filtered.php b/examples/generate_filtered.php deleted file mode 100644 index d04df11..0000000 --- a/examples/generate_filtered.php +++ /dev/null @@ -1,11 +0,0 @@ -setFilterUnusedSchemas(true); -$yaml = $core->generate([__DIR__ . '/App']); - -echo $yaml; diff --git a/output_filtered.yaml b/output_filtered.yaml deleted file mode 100644 index 19e8e13..0000000 --- a/output_filtered.yaml +++ /dev/null @@ -1,142 +0,0 @@ -openapi: 3.0.0 -info: - title: 'API Documentation' - version: 1.0.0 -paths: - /users: - get: - summary: 'List all users' - responses: - 200: - description: OK - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/App_Models_User' - tags: - - 'User Management' - '/users/{id}': - get: - summary: 'Get user details' - responses: - 200: - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/App_Models_User' - 404: - description: OK - content: - application/json: - schema: - type: string - description: 'This endpoint returns a single user by their ID.' - tags: - - 'User Management' - parameters: - - - name: id - in: path - required: true - schema: - type: integer - description: 'The user ID' - /posts: - get: - summary: 'List all posts with pagination' - responses: - 200: - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/App_Models_ApiResponse.App_Models_Collection.App_Models_Post' - tags: - - Posts - '/posts/{id}': - get: - summary: 'Get a single post' - responses: - 200: - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/App_Models_ApiResponse.App_Models_Post' - tags: - - Posts - parameters: - - - name: id - in: path - required: true - schema: - type: integer - description: 'The post ID' -components: - schemas: - App_Models_User: - type: object - properties: - id: - type: integer - description: 'User ID' - name: - type: string - description: 'User Full Name' - email: - type: string - nullable: true - description: 'User Email Address' - friends: - type: array - items: - $ref: '#/components/schemas/App_Models_User' - description: 'List of friends' - App_Models_ApiResponse.App_Models_Collection.App_Models_Post: - type: object - properties: - data: - $ref: '#/components/schemas/App_Models_Collection.App_Models_Post' - status: - type: string - description: 'Status code (success/error)' - message: - type: string - nullable: true - description: 'Optional message' - App_Models_ApiResponse.App_Models_Post: - type: object - properties: - data: - $ref: '#/components/schemas/App_Models_Post' - status: - type: string - description: 'Status code (success/error)' - message: - type: string - nullable: true - description: 'Optional message' - App_Models_Collection.App_Models_Post: - type: object - properties: - items: - type: array - items: - $ref: '#/components/schemas/App_Models_Post' - description: 'List of items' - total: - type: integer - description: 'Total count' - App_Models_Post: - type: object - properties: - title: - type: string - description: 'Post title' - content: - type: string - description: 'Post body content' diff --git a/output_new.yaml b/output_new.yaml deleted file mode 100644 index 39c6ce4..0000000 --- a/output_new.yaml +++ /dev/null @@ -1,163 +0,0 @@ -openapi: 3.0.0 -info: - title: 'API Documentation' - version: 1.0.0 -paths: - /users: - get: - summary: 'List all users' - responses: - 200: - description: OK - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/App_Models_User' - tags: - - 'User Management' - '/users/{id}': - get: - summary: 'Get user details' - responses: - 200: - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/App_Models_User' - 404: - description: OK - content: - application/json: - schema: - type: string - description: 'This endpoint returns a single user by their ID.' - tags: - - 'User Management' - parameters: - - - name: id - in: path - required: true - schema: - type: integer - description: 'The user ID' - /posts: - get: - summary: 'List all posts with pagination' - responses: - 200: - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/App_Models_ApiResponse.App_Models_Collection.App_Models_Post' - tags: - - Posts - '/posts/{id}': - get: - summary: 'Get a single post' - responses: - 200: - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/App_Models_ApiResponse.App_Models_Post' - tags: - - Posts - parameters: - - - name: id - in: path - required: true - schema: - type: integer - description: 'The post ID' -components: - schemas: - App_Models_User: - type: object - properties: - id: - type: integer - description: 'User ID' - name: - type: string - description: 'User Full Name' - email: - type: string - nullable: true - description: 'User Email Address' - friends: - type: array - items: - $ref: '#/components/schemas/App_Models_User' - description: 'List of friends' - App_Models_Timestampable: - type: object - properties: - createdAt: - type: string - description: 'Creation timestamp' - updatedAt: - type: string - description: 'Last update timestamp' - App_Models_Post: - type: object - properties: - title: - type: string - description: 'Post title' - content: - type: string - description: 'Post body content' - App_Models_BaseModel: - type: object - properties: - id: - type: integer - description: 'Unique identifier' - App_Controllers_UserController: - type: object - properties: { } - App_Controllers_PostController: - type: object - properties: { } - App_Models_Collection.App_Models_Post: - type: object - properties: - items: - type: array - items: - $ref: '#/components/schemas/App_Models_Post' - description: 'List of items' - total: - type: integer - description: 'Total count' - App_Models_ApiResponse.App_Models_Collection.App_Models_Post: - type: object - properties: - data: - $ref: '#/components/schemas/App_Models_Collection.App_Models_Post' - status: - type: string - description: 'Status code (success/error)' - message: - type: string - nullable: true - description: 'Optional message' - App_Models_ApiResponse.App_Models_Post: - type: object - properties: - data: - $ref: '#/components/schemas/App_Models_Post' - status: - type: string - description: 'Status code (success/error)' - message: - type: string - nullable: true - description: 'Optional message' diff --git a/output_v2.yaml b/output_v2.yaml deleted file mode 100644 index 4d6c8be..0000000 --- a/output_v2.yaml +++ /dev/null @@ -1,163 +0,0 @@ -openapi: 3.0.0 -info: - title: 'API Documentation' - version: 1.0.0 -paths: - /users: - get: - summary: 'List all users' - responses: - '200': - description: OK - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/App_Models_User' - tags: - - 'User Management' - '/users/{id}': - get: - summary: 'Get user details' - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/App_Models_User' - '404': - description: OK - content: - application/json: - schema: - type: string - description: 'This endpoint returns a single user by their ID.' - tags: - - 'User Management' - parameters: - - - name: id - in: path - required: true - schema: - type: integer - description: 'The user ID' - /posts: - get: - summary: 'List all posts with pagination' - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/App_Models_ApiResponse.App_Models_Collection.App_Models_Post' - tags: - - Posts - '/posts/{id}': - get: - summary: 'Get a single post' - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/App_Models_ApiResponse.App_Models_Post' - tags: - - Posts - parameters: - - - name: id - in: path - required: true - schema: - type: integer - description: 'The post ID' -components: - schemas: - App_Models_User: - type: object - properties: - id: - type: integer - description: 'User ID' - name: - type: string - description: 'User Full Name' - email: - type: string - nullable: true - description: 'User Email Address' - friends: - type: array - items: - $ref: '#/components/schemas/App_Models_User' - description: 'List of friends' - App_Models_Timestampable: - type: object - properties: - createdAt: - type: string - description: 'Creation timestamp' - updatedAt: - type: string - description: 'Last update timestamp' - App_Models_Post: - type: object - properties: - title: - type: string - description: 'Post title' - content: - type: string - description: 'Post body content' - App_Models_BaseModel: - type: object - properties: - id: - type: integer - description: 'Unique identifier' - App_Controllers_UserController: - type: object - properties: { } - App_Controllers_PostController: - type: object - properties: { } - App_Models_Collection.App_Models_Post: - type: object - properties: - items: - type: array - items: - $ref: '#/components/schemas/App_Models_Post' - description: 'List of items' - total: - type: integer - description: 'Total count' - App_Models_ApiResponse.App_Models_Collection.App_Models_Post: - type: object - properties: - data: - $ref: '#/components/schemas/App_Models_Collection.App_Models_Post' - status: - type: string - description: 'Status code (success/error)' - message: - type: string - nullable: true - description: 'Optional message' - App_Models_ApiResponse.App_Models_Post: - type: object - properties: - data: - $ref: '#/components/schemas/App_Models_Post' - status: - type: string - description: 'Status code (success/error)' - message: - type: string - nullable: true - description: 'Optional message' diff --git a/src/CLI/GenerateCommand.php b/src/CLI/GenerateCommand.php new file mode 100644 index 0000000..d9ca598 --- /dev/null +++ b/src/CLI/GenerateCommand.php @@ -0,0 +1,56 @@ +setName('generate') + ->setDescription('Generate OpenAPI documentation from PHP source code') + ->addOption('path', 'p', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Path(s) to scan') + ->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Output file path (default: stdout)') + ->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (yaml or json)', 'yaml') + ->addOption('openapi-version', null, InputOption::VALUE_REQUIRED, 'OpenAPI version (3.0.0 or 3.1.0)', '3.0.0') + ->addOption('filter-unused', null, InputOption::VALUE_NONE, 'Filter unused schemas'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $paths = $input->getOption('path'); + if (empty($paths)) { + $output->writeln('At least one --path is required.'); + return Command::FAILURE; + } + + $core = new Core(); + $core->setOpenApiVersion($input->getOption('openapi-version')); + $core->setFilterUnusedSchemas($input->getOption('filter-unused')); + + $format = strtolower($input->getOption('format')); + if ($format === 'json') { + $result = $core->generateJson($paths); + } else { + $result = $core->generateYaml($paths); + } + + $outputPath = $input->getOption('output'); + if ($outputPath) { + file_put_contents($outputPath, $result); + $output->writeln(sprintf('Documentation generated to %s', $outputPath)); + } else { + $output->write($result); + } + + return Command::SUCCESS; + } +} diff --git a/src/Core.php b/src/Core.php index 0b66e33..fe4243a 100644 --- a/src/Core.php +++ b/src/Core.php @@ -24,6 +24,8 @@ class Core /** @var array */ private array $discoveredClasses = []; + private bool $isAnalyzed = false; + public function __construct() { $this->scanner = new Scanner(); @@ -43,8 +45,12 @@ public function setFilterUnusedSchemas(bool $filter): void $this->generator->setFilterUnusedSchemas($filter); } - public function generate(array $paths): string + private function analyze(array $paths): void { + if ($this->isAnalyzed) { + return; + } + $this->scanner->setPaths($paths); $files = $this->scanner->scan(); @@ -58,9 +64,27 @@ public function generate(array $paths): string $this->analyzeClass($fqcn, $data['node'], $data['nameResolver']); } + $this->isAnalyzed = true; + } + + public function generate(array $paths): string + { + $this->analyze($paths); return $this->generator->generateYaml(); } + public function generateYaml(array $paths): string + { + $this->analyze($paths); + return $this->generator->generateYaml(); + } + + public function generateJson(array $paths): string + { + $this->analyze($paths); + return $this->generator->generateJson(); + } + private function discoverFile(string $filePath): void { $code = file_get_contents($filePath); diff --git a/src/Generator.php b/src/Generator.php index 9f67f78..391bbd1 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -35,6 +35,16 @@ public function addRoute(RouteDefinition $route): void } public function generateYaml(): string + { + return Yaml::dump($this->generateSpec(), 10, 2, Yaml::DUMP_NUMERIC_KEY_AS_STRING); + } + + public function generateJson(): string + { + return json_encode($this->generateSpec(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + public function generateSpec(): array { $spec = [ 'openapi' => $this->openApiVersion, @@ -135,7 +145,7 @@ public function generateYaml(): string ]; } - return Yaml::dump($spec, 10, 2, Yaml::DUMP_NUMERIC_KEY_AS_STRING); + return $spec; } private function processSchemaOutput(array $schema, ?string $description = null): array diff --git a/test_yaml.php b/test_yaml.php deleted file mode 100644 index ed2c084..0000000 --- a/test_yaml.php +++ /dev/null @@ -1,12 +0,0 @@ - [ - '200' => ['description' => 'OK'], - 'default' => ['description' => 'Error'] - ] -]; - -echo Yaml::dump($data); diff --git a/test_yaml_2.php b/test_yaml_2.php deleted file mode 100644 index 8315073..0000000 --- a/test_yaml_2.php +++ /dev/null @@ -1,14 +0,0 @@ - ['description' => 'OK'], -]; - -echo "Inline 1:\n"; -echo Yaml::dump($data, 1); -echo "Inline 2:\n"; -echo Yaml::dump($data, 2); -echo "Inline 10:\n"; -echo Yaml::dump($data, 10); diff --git a/test_yaml_3.php b/test_yaml_3.php deleted file mode 100644 index 46d57be..0000000 --- a/test_yaml_3.php +++ /dev/null @@ -1,19 +0,0 @@ - [ - '200' => ['description' => 'OK'], - ] -]; - -// Symfony Yaml 6.0 flags -// DUMP_OBJECT = 1 -// DUMP_EXCEPTION_ON_INVALID_TYPE = 2 -// DUMP_OBJECT_AS_MAP = 4 -// DUMP_MULTI_LINE_LITERAL_BLOCK = 8 -// DUMP_EMPTY_ARRAY_AS_SEQUENCE = 16 -// DUMP_NULL_AS_TILDE = 32 - -echo Yaml::dump($data, 10, 2); diff --git a/test_yaml_4.php b/test_yaml_4.php deleted file mode 100644 index 4e16590..0000000 --- a/test_yaml_4.php +++ /dev/null @@ -1,13 +0,0 @@ - [ - '200' => ['description' => 'OK'], - ] -]; - -$yaml = Yaml::dump($data, 10, 2); -$yaml = preg_replace('/^(\s*)(\d+):/m', '$1\'$2\':', $yaml); -echo $yaml; diff --git a/test_yaml_5.php b/test_yaml_5.php deleted file mode 100644 index 5165197..0000000 --- a/test_yaml_5.php +++ /dev/null @@ -1,11 +0,0 @@ - [ - '200' => ['description' => 'OK'], - ] -]; - -echo Yaml::dump($data, 10, 2, Yaml::DUMP_NUMERIC_KEY_AS_STRING); diff --git a/test_yaml_quotes.php b/test_yaml_quotes.php deleted file mode 100644 index cba188d..0000000 --- a/test_yaml_quotes.php +++ /dev/null @@ -1,22 +0,0 @@ - [ - '200' => ['description' => 'OK'], - ] -]; - -echo "Default:\n"; -echo Yaml::dump($data); - -echo "\nWith DUMP_NUMERIC_KEY_AS_STRING (not a constant in 6.0, it was an option in some versions):\n"; -// Actually it's not a bitmask in all versions. - -echo "\nUsing a bitmask if supported:\n"; -// In Symfony 6.x, Yaml::dump(data, inline, indent, flags) -// Flags: https://github.com/symfony/yaml/blob/6.4/Yaml.php -// There is no specific flag for "quote all numeric keys" easily found. - -// Wait, I can try to use a different approach. From 53fd67ac06a9d22527aa4372bca9bd9bdd6b6b0c Mon Sep 17 00:00:00 2001 From: tolawho Date: Sat, 6 Jun 2026 15:18:47 +0700 Subject: [PATCH 09/27] feat: implement advanced route parameter handling and auto-inference - Implemented explicit route parameter tags: @path, @query, @header, @cookie, @body. - Implemented Auto-inference from method signatures (primitive types to path/query, class types to body). - Removed support for legacy @param and @request tags to avoid ambiguity. - Added support for metadata parsing (enum, default) from PHPDoc descriptions. - Improved NameResolver to handle FQCN resolution idempotently. - Updated README and SAFe backlog. - Added comprehensive unit tests and verified removal of legacy tags. --------- Co-authored-by: tolawho <12527881+tolawho@users.noreply.github.com> --- README.md | 54 +++----- SAFE_BACKLOG.md | 101 ++------------- examples/App/Controllers/PostController.php | 1 - examples/App/Controllers/UserController.php | 1 - src/Core.php | 85 ++++++++++-- src/DocBlockCollector.php | 53 +++++++- src/Generator.php | 50 ++++++-- src/IR/RouteDefinition.php | 4 +- src/NameResolver.php | 6 + tests/DocBlockCollectorTest.php | 2 +- tests/RemovalTest.php | 56 ++++++++ tests/RouteParamsTest.php | 135 ++++++++++++++++++++ 12 files changed, 387 insertions(+), 161 deletions(-) create mode 100644 tests/RemovalTest.php create mode 100644 tests/RouteParamsTest.php diff --git a/README.md b/README.md index 027d86f..1c0f399 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AS - **AST-based Static Analysis**: No need to run your application. - **Modern PHP Support**: Handles namespaces, use aliases, and complex types. +- **Auto-inference**: Automatically resolve route parameters and request bodies from method signatures. - **Advanced Type Resolution**: - Primitives: `int`, `string`, `bool`, `float`. - Nullable types: `?string` or `string|null`. @@ -42,50 +43,22 @@ $yaml = $core->generate(['./src/App']); file_put_contents('swagger.yaml', $yaml); ``` -### Advanced Types Example +### Route Parameters Handling -#### Models with Inheritance and Generics -```php -namespace App\Models; - -/** - * @template T - * @property T $data - */ -class ApiResponse {} - -/** - * @property int $id - */ -class BaseModel {} - -/** - * @property string $title - */ -class Post extends BaseModel {} +The library supports explicit tags and auto-inference (inspired by swaggo). +```php /** - * @extends ApiResponse + * @route GET /users/{id} + * @path int $id User unique ID + * @query string $status Filter by status enum(active,inactive) default(active) */ -class PostResponse extends ApiResponse {} +public function show(int $id, string $status) {} ``` -#### Controller using Complex Types -```php -namespace App\Controllers; - -use App\Models\ApiResponse; -use App\Models\Post; - -class PostController -{ - /** - * @route GET /posts/{id} - * @response 200 ApiResponse - */ - public function show(int $id) {} -} -``` +- **Explicit Tags**: `@path`, `@query`, `@header`, `@cookie`, `@body`. +- **Metadata**: Support `enum(a,b,c)` and `default(value)` in descriptions. +- **Auto-inference**: If no tags are provided, parameters are inferred from the method signature. Primitive types match path/query, and class types match the request body. ## Support Tags @@ -94,6 +67,11 @@ class PostController - `@summary [TEXT]` - `@description [TEXT]` - `@tag [NAME]` + - `@path [TYPE] $[NAME] [DESC]` + - `@query [TYPE] $[NAME] [DESC]` + - `@header [TYPE] $[NAME] [DESC]` + - `@cookie [TYPE] $[NAME] [DESC]` + - `@body [TYPE] [DESC]` - `@response [CODE] [TYPE]` (e.g., `@response 200 ApiResponse`) - **Models**: - `@property [TYPE] $[NAME] [DESCRIPTION]` diff --git a/SAFE_BACKLOG.md b/SAFE_BACKLOG.md index c808e4f..7468915 100644 --- a/SAFE_BACKLOG.md +++ b/SAFE_BACKLOG.md @@ -14,7 +14,7 @@ - Xuất ra cấu trúc dữ liệu trung gian (IR). ### [Epic 2] Nâng cấp Hệ thống Type System (Pro) -- **Trạng thái:** In Progress +- **Trạng thái:** Done - **Chủ sở hữu:** Fullstack Developer (User) - **Tóm tắt:** Mở rộng khả năng phân tích kiểu dữ liệu phức tạp bao gồm Generics, Union types và xử lý thừa kế. - **Giả thuyết Lợi ích (Benefit Hypothesis):** Cho phép thư viện hỗ trợ các dự án PHP hiện đại sử dụng cấu trúc dữ liệu phức tạp (như Collection, DTO kế thừa), tăng tính chính xác và độ phủ của tài liệu API được sinh ra. @@ -40,106 +40,29 @@ ### Features cho [Epic 1] Core Engine - [x] **[F1.1] File Scanner & Finder:** Tìm kiếm đệ quy tất cả các file .php trong các thư mục được cấu hình. - - *AC:* Trả về danh sách đường dẫn file hợp lệ; bỏ qua các file trong vendor hoặc thư mục bị loại trừ. - [x] **[F1.2] AST Parser Integration:** Tích hợp `nikic/php-parser` để đọc cấu trúc code. - - *AC:* Chuyển đổi mã nguồn thành cây AST; trích xuất được các Class Node và Method Node. -- [x] **[F1.3] Namespace Resolver:** Xác định chính xác FQCN (Fully Qualified Class Name) dựa trên `namespace` và `use` statements. - - *AC:* Trả về tên class đầy đủ ngay cả khi sử dụng alias. +- [x] **[F1.3] Namespace Resolver:** Xác định chính xác FQCN dựa trên `namespace` và `use` statements. - [x] **[F1.4] Basic DocBlock Collector:** Thu thập và phân tích các tag đơn giản (@route, @summary, @property). - - *AC:* Chuyển đổi PHPDoc thô thành các Object thuộc tính tương ứng. ### Features cho [Epic 2] Type System Pro -- [x] **[F2.1] Advanced Type Resolver:** Hỗ trợ các kiểu dữ liệu phức tạp của PHP hiện đại. - - *AC:* Xử lý được union types (A|B), nullable (?A), và các kiểu nguyên thủy. +- [x] **[F2.1] Advanced Type Resolver:** Hỗ trợ các kiểu dữ liệu phức tạp (Union, Nullable, Arrays). - [x] **[F2.2] Generics Support:** Phân tích cú pháp template cho các kiểu dữ liệu generic. - - *AC:* Hiểu được `Collection` hoặc `ApiResponse`. - [x] **[F2.3] Inheritance & Trait Merger:** Gộp các thuộc tính từ các class cha và traits. - - *AC:* Schema của class con phải bao gồm đầy đủ thuộc tính từ cây kế thừa. -- [x] **[F2.4] Schema Registry:** Quản lý tập trung các định nghĩa Model để tránh trùng lặp và xử lý tham chiếu vòng. - - *AC:* Không bị lỗi vòng lặp vô tận khi Class A chứa Class B và ngược lại. +- [x] **[F2.4] Schema Registry:** Quản lý tập trung các định nghĩa Model và xử lý tham chiếu vòng. +- [x] **[F2.5] Advanced Route Parameter Handling:** Hỗ trợ tag riêng biệt (@path, @query, @header, @cookie, @body) và tự động suy luận (Auto-inference). ### Features cho [Epic 3] Integration & CLI - [x] **[F3.1] CLI Command Interface:** Cung cấp giao diện dòng lệnh cho người dùng. - - *AC:* Chạy được lệnh `php-swag generate --path=src`. - [x] **[F3.2] OpenAPI Spec Generator:** Chuyển đổi dữ liệu IR thành file chuẩn OpenAPI. - - *AC:* Xuất ra file `swagger.yaml` hoặc `swagger.json` hợp lệ (v3.0/3.1). - [ ] **[F3.3] Performance Caching:** Lưu trữ kết quả phân tích để tăng tốc cho các lần chạy sau. - - *AC:* Tốc độ generate lần 2 phải nhanh hơn ít nhất 50% so với lần đầu. - [ ] **[F3.4] README & Documentation:** Hướng dẫn cộng đồng cách sử dụng và đóng góp. - - *AC:* Có file README.md chi tiết với ví dụ minh họa rõ ràng. - -## 3. Team Backlog (User Stories - Examples for PI-1) - -### Stories cho [F1.3] Namespace Resolver -- [x] **[S1.3.1] Parse Use Statements:** - - *Câu chuyện:* Là một hệ thống phân tích, tôi muốn đọc và lưu trữ các alias trong phần `use` của file PHP, để tôi biết chính xác tên class được tham chiếu trong code. - - *AC:* Xử lý được các trường hợp: `use App\User;`, `use App\Resource as Res;`, `use Group\{ClassA, ClassB};`. -- [x] **[S1.3.2] Contextual Class Resolution:** - - *Câu chuyện:* Là một hệ thống phân tích, tôi muốn tìm được FQCN của một class dựa trên context hiện tại (Namespace + Use statements), để tạo tham chiếu chính xác trong OpenAPI. - - *AC:* Trả về `App\Resources\UserResource` khi gặp code sử dụng `UserResource` trong namespace `App\Controllers` có `use App\Resources\UserResource`. - -### Stories cho [F1.4] Basic DocBlock Collector -- [x] **[S1.4.1] Extract @route tag:** - - *Câu chuyện:* Là một lập trình viên, tôi muốn dùng tag `@route` để định nghĩa endpoint, để tôi không phải viết cấu trúc path phức tạp trong file cấu hình. - - *AC:* Bóc tách được `METHOD` (GET, POST, ...) và `PATH` từ chuỗi `@route GET /users`. -- [x] **[S1.4.2] Extract @property tag:** - - *Câu chuyện:* Là một lập trình viên, tôi muốn dùng tag `@property` trong Model, để mô tả cấu trúc JSON của API. - - *AC:* Trích xuất được kiểu dữ liệu (`string`, `int`), tên biến (`$name`) và mô tả kèm theo. - -### Stories cho [F2.1] Advanced Type Resolver -- [x] **[S2.1.1] Handle Nullable Types:** - - *Câu chuyện:* Là một lập trình viên, tôi muốn hỗ trợ kiểu nullable, để tài liệu API phản ánh đúng tính chất dữ liệu (có thể null). - - *AC:* Nhận diện `?string`, `string|null` và ánh xạ sang `nullable: true` trong OpenAPI. -- [x] **[S2.1.2] Array Type Support:** - - *Câu chuyện:* Là một lập trình viên, tôi muốn hỗ trợ kiểu mảng (User[] hoặc array), để mô tả chính xác các collection trong API. - - *AC:* Ánh xạ chính xác sang `type: array` với `items` tương ứng trong OpenAPI. - - -## 4. Ưu tiên hóa bằng WSJF (Weighted Shortest Job First) - -Chúng ta sẽ tính toán cho các Feature chính trong PI-1 (dự kiến tập trung vào Epic 1 và một phần Epic 2). -Thang điểm Fibonacci: 1, 2, 3, 5, 8, 13, 20. - -| Feature | Business Value | Time Criticality | RR \| OE | Cost of Delay (CoD) | Job Size | **WSJF** | -| :--- | :---: | :---: | :---: | :---: | :---: | :---: | -| [F1.2] AST Parser | 13 | 5 | 20 | 38 | 8 | **DONE** | -| [F1.3] Namespace Resolver | 8 | 3 | 13 | 24 | 5 | **DONE** | -| [F1.1] File Scanner | 5 | 2 | 5 | 12 | 3 | **DONE** | -| [F1.4] Basic DocBlock | 13 | 8 | 8 | 29 | 5 | **DONE** | -| [F2.1] Adv. Type Resolver | 8 | 3 | 8 | 19 | 5 | **DONE** | -| [F2.4] Schema Registry | 5 | 2 | 13 | 20 | 3 | **DONE** | - -**Phân tích:** -- **[F2.4] Schema Registry** đã hoàn thành, giải quyết rủi ro kỹ thuật lớn về tham chiếu vòng. -- **[F2.1] Advanced Type Resolver** đã hoàn thành, hỗ trợ nullable và array types. - - -## 5. Transformation Roadmap & PI Objectives - -### Lộ trình (Roadmap) -- **PI-1 (Foundation):** Thiết lập Core Engine, giải quyết Namespace, và hỗ trợ các Tag cơ bản. Kết thúc PI-1 với một bản MVP có thể quét được các dự án PHP đơn giản. -- **PI-2 (Advanced Logic):** Tập trung vào Type System (Generics, Inheritance). Xử lý các trường hợp phức tạp để thư viện có thể dùng cho các Framework như Laravel/Symfony. -- **PI-3 (Productization):** Hoàn thiện CLI, Caching và đóng gói để phát hành phiên bản 1.0.0 cho cộng đồng. - -### PI-1 Objectives (Mục tiêu PI-1) -1. **Mục tiêu kỹ thuật (Committed):** - - Hoàn thành bộ quét AST có độ chính xác > 95% với các project PSR-4. - - Hỗ trợ đầy đủ các Tag `@route`, `@summary`, `@property`. - - Xuất được file YAML hợp lệ có thể mở bằng Swagger UI. -2. **Mục tiêu phi kỹ thuật (Uncommitted):** - - Thiết lập CI/CD cơ bản (Github Actions) để tự động kiểm tra code. - - Viết bài blog giới thiệu ý tưởng dự án lên cộng đồng PHP Việt Nam. - -## 6. Lean Governance & Improvement Backlog +## 3. Team Backlog (User Stories) -### Lean Governance (Quản trị tinh gọn) -Vì đây là dự án cá nhân, cơ chế quản trị sẽ tập trung vào sự kỷ luật tự thân: -- **Lấy giá trị làm trọng tâm:** Mỗi Story được viết ra phải chứng minh được giá trị cho người dùng cuối (Cộng đồng PHP). -- **Phê duyệt Epic:** User đóng vai trò Epic Owner, tự đánh giá tính khả thi và Business Value trước khi bắt đầu một Epic mới. -- **Minh bạch:** Sử dụng Kanban Board (giả định) để theo dõi luồng công việc từ To Do -> In Progress -> Done. +### Stories cho [F2.5] Advanced Route Parameter Handling +- [x] **[S2.5.1] Explicit Parameter Tags:** Hỗ trợ @path, @query, @header, @cookie với cú pháp chuyên sâu. +- [x] **[S2.5.2] Request Body Tag:** Hỗ trợ tag @body để định nghĩa body request một cách tường minh. +- [x] **[S2.5.3] Auto-inference from Signature:** Tự động nhận diện tham số path/query và body từ type-hint của method. +- [x] **[S2.5.4] Extra Metadata Parsing:** Trích xuất enum() và default() ngay từ chuỗi mô tả trong PHPDoc. -### Improvement Backlog (Kế hoạch cải tiến) -- **[IMP-1] Automation:** Tự động hóa việc sinh tài liệu cho chính dự án này (Self-documenting). -- **[IMP-2] Feedback Loop:** Sau PI-1, gửi bản MVP cho 3-5 đồng nghiệp để lấy feedback sớm, thay vì đợi đến khi hoàn thiện 100%. -- **[IMP-3] Quality Gate:** Thiết lập ngưỡng coverage cho unit test tối thiểu 80% trước khi merge Feature vào nhánh chính. +... (Keep other stories) diff --git a/examples/App/Controllers/PostController.php b/examples/App/Controllers/PostController.php index 1f26340..e54d641 100644 --- a/examples/App/Controllers/PostController.php +++ b/examples/App/Controllers/PostController.php @@ -22,7 +22,6 @@ public function index() * @route GET /posts/{id} * @summary Get a single post * @tag Posts - * @param int $id The post ID * @response 200 ApiResponse */ public function show(int $id) diff --git a/examples/App/Controllers/UserController.php b/examples/App/Controllers/UserController.php index 42a266d..e035f19 100644 --- a/examples/App/Controllers/UserController.php +++ b/examples/App/Controllers/UserController.php @@ -21,7 +21,6 @@ public function index() * @summary Get user details * @description This endpoint returns a single user by their ID. * @tag User Management - * @param int $id The user ID * @response 200 User * @response 404 string */ diff --git a/src/Core.php b/src/Core.php index fe4243a..0488835 100644 --- a/src/Core.php +++ b/src/Core.php @@ -4,10 +4,10 @@ use PhpParser\Node; use PhpParser\Node\Stmt\Class_; -use PhpParser\Node\Stmt\ClassMethod; -use PhpParser\Node\Stmt\Property; use PhpParser\Node\Stmt\Trait_; use PhpParser\Node\Stmt\TraitUse; +use PhpParser\Node\Stmt\Property; +use PhpParser\Node\Stmt\ClassMethod; use PhpParser\NodeTraverser; use PhpSwag\IR\PropertyDefinition; use PhpSwag\IR\RouteDefinition; @@ -197,10 +197,12 @@ private function analyzeClass(string $fqcn, Class_|Trait_ $stmt, NameResolver $n $isSchema = true; $propertySchema = $typeResolver->resolve($tag['type']); + $desc = is_array($tag['description']) ? ($tag['description']['description'] ?? null) : ($tag['description'] ?? null); + $properties[] = new PropertyDefinition( $tag['propertyName'], $propertySchema, - $tag['description'] + $desc ); } } @@ -214,10 +216,12 @@ private function analyzeClass(string $fqcn, Class_|Trait_ $stmt, NameResolver $n if ($pTag['name'] === '@var' && isset($pTag['type'])) { $propertySchema = $typeResolver->resolve($pTag['type']); + $desc = is_array($pTag['description']) ? ($pTag['description']['description'] ?? null) : ($pTag['description'] ?? null); + $properties[] = new PropertyDefinition( $member->props[0]->name->toString(), $propertySchema, - $pTag['description'] + $desc ); } } @@ -244,6 +248,7 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, $tagsList = []; $responses = []; $parameters = []; + $requestBody = null; foreach ($tags as $tag) { if ($tag['name'] === '@route') { @@ -265,25 +270,85 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, $responses[$code] = $typeResolver->resolve($typeNode); } } - } elseif ($tag['name'] === '@param') { - $parameters[] = [ - 'name' => $tag['propertyName'], + } elseif (in_array($tag['name'], ['@path', '@query', '@header', '@cookie'])) { + $in = substr($tag['name'], 1); + $parameters[] = array_merge($tag, [ + 'in' => $in, + 'schema' => $typeResolver->resolve($tag['type']), + 'name' => $tag['propertyName'] + ]); + } elseif ($tag['name'] === '@body') { + $requestBody = [ 'schema' => $typeResolver->resolve($tag['type']), - 'description' => $tag['description'] ?: null, + 'description' => is_array($tag['description']) ? ($tag['description']['description'] ?? null) : ($tag['description'] ?? null) ]; } } if ($routeTag) { $routeParts = explode(' ', $routeTag); + $path = $routeParts[1]; + + // Auto-inference from method parameters + foreach ($member->params as $param) { + $paramName = $param->var->name; + + // Skip if already defined by explicit tags + $exists = false; + foreach ($parameters as $p) { + if ($p['name'] === $paramName) { + $exists = true; + break; + } + } + if ($exists) continue; + + $type = 'mixed'; + if ($param->type instanceof Node\Identifier) { + $type = $param->type->toString(); + } elseif ($param->type instanceof Node\Name) { + $resolved = $param->type->getAttribute('resolvedName'); + if ($resolved) { + $type = '\\' . $resolved->toString(); + } else { + $type = $param->type->toString(); + } + } + + $schema = $typeResolver->resolve($this->docCollector->parseType($type)); + + // If it's a class and not primitive, infer as requestBody if not already set + $isPrimitive = in_array(ltrim($type, '\\'), ['int', 'string', 'bool', 'float', 'array', 'mixed']); + + if (!$isPrimitive && $requestBody === null) { + $requestBody = [ + 'schema' => $schema, + 'description' => 'Auto-inferred from method parameter $' . $paramName + ]; + } else { + $in = 'query'; + if (strpos($path, '{' . $paramName . '}') !== false) { + $in = 'path'; + } + + $parameters[] = [ + 'name' => $paramName, + 'in' => $in, + 'schema' => $schema, + 'description' => 'Auto-inferred from method parameter' + ]; + } + } + $this->generator->addRoute(new RouteDefinition( method: $routeParts[0], - path: $routeParts[1], + path: $path, summary: $summary, description: $description, tags: $tagsList, responses: $responses, - parameters: $parameters + parameters: $parameters, + requestBody: $requestBody )); } } diff --git a/src/DocBlockCollector.php b/src/DocBlockCollector.php index 48fb77d..ec13d08 100644 --- a/src/DocBlockCollector.php +++ b/src/DocBlockCollector.php @@ -33,9 +33,11 @@ public function collectTags(string $docComment): array $tagName = $matches[1]; $value = isset($matches[2]) ? trim($matches[2]) : ''; - if (in_array($tagName, ['@property', '@var', '@param', '@return'])) { + if (in_array($tagName, ['@property', '@var', '@return', '@path', '@query', '@header', '@cookie'])) { try { - $doc = "/** $line */"; + // For @path, @query, etc., we treat them similarly to @param for parsing convenience + $parseTagName = in_array($tagName, ['@path', '@query', '@header', '@cookie']) ? '@param' : $tagName; + $doc = "/** $parseTagName $value */"; $node = $this->parser->parse($doc); foreach ($node->getTags() as $tag) { $v = $tag->value; @@ -44,27 +46,47 @@ public function collectTags(string $docComment): array 'name' => $tagName, 'type' => $v->type, 'propertyName' => ltrim($v->propertyName, '$'), - 'description' => $v->description + 'description' => $this->parseExtraAttributes($v->description) ]; } elseif ($v instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode) { $tags[] = [ 'name' => $tagName, 'type' => $v->type, 'propertyName' => $v->variableName ? ltrim($v->variableName, '$') : null, - 'description' => $v->description + 'description' => $this->parseExtraAttributes($v->description) ]; } elseif ($v instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode) { - $tags[] = [ + $res = [ 'name' => $tagName, 'type' => $v->type, 'propertyName' => ltrim($v->parameterName, '$'), - 'description' => $v->description ]; + $parsedDesc = $this->parseExtraAttributes($v->description); + $res = array_merge($res, $parsedDesc); + $tags[] = $res; } } } catch (\Exception $e) { $tags[] = ['name' => $tagName, 'value' => $value]; } + } elseif ($tagName === '@body') { + // @body [Type] [Description] + if (preg_match('/^([a-zA-Z0-9_\\<>|\[\]]+)(?:\s+(.*))?$/', $value, $m)) { + $typeString = $m[1]; + $desc = isset($m[2]) ? $m[2] : ''; + + try { + $type = $this->parseType($typeString); + } catch (\Exception $e) { + $type = new IdentifierTypeNode($typeString); + } + + $tags[] = [ + 'name' => '@body', + 'type' => $type, + 'description' => $desc + ]; + } } else { $tags[] = [ 'name' => $tagName, @@ -77,6 +99,25 @@ public function collectTags(string $docComment): array return $tags; } + private function parseExtraAttributes(string $description): array + { + $res = ['description' => $description]; + + // Parse enum(a,b,c) + if (preg_match('/enum\(([^)]+)\)/', $description, $matches)) { + $res['enum'] = array_map('trim', explode(',', $matches[1])); + $res['description'] = trim(str_replace($matches[0], '', $res['description'])); + } + + // Parse default(value) + if (preg_match('/default\(([^)]+)\)/', $description, $matches)) { + $res['default'] = trim($matches[1]); + $res['description'] = trim(str_replace($matches[0], '', $res['description'])); + } + + return $res; + } + public function parseType(string $typeString): \PHPStan\PhpDocParser\Ast\Type\TypeNode { if (str_ends_with($typeString, '[]')) { diff --git a/src/Generator.php b/src/Generator.php index 391bbd1..9fc8ca6 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -2,10 +2,9 @@ namespace PhpSwag; -use Symfony\Component\Yaml\Yaml; use PhpSwag\IR\RouteDefinition; use PhpSwag\IR\SchemaDefinition; -use PhpSwag\IR\PropertyDefinition; +use Symfony\Component\Yaml\Yaml; class Generator { @@ -82,16 +81,30 @@ public function generateSpec(): array if (!empty($route->parameters)) { $routeSpec['parameters'] = []; foreach ($route->parameters as $param) { - $in = 'query'; - if (strpos($path, '{' . $param['name'] . '}') !== false) { - $in = 'path'; + if (isset($param['in'])) { + $in = $param['in']; + } else { + $in = 'query'; + if (strpos($path, '{' . $param['name'] . '}') !== false) { + $in = 'path'; + } + } + + $schema = $this->processSchemaOutput($param['schema']); + + // Handle enum and default from extra metadata if present + if (isset($param['enum'])) { + $schema['enum'] = $param['enum']; + } + if (isset($param['default'])) { + $schema['default'] = $param['default']; } $paramSpec = [ 'name' => $param['name'], 'in' => $in, - 'required' => $in === 'path', - 'schema' => $this->processSchemaOutput($param['schema']) + 'required' => ($in === 'path' || (isset($param['required']) && $param['required'])), + 'schema' => $schema ]; if (!empty($param['description'])) { @@ -102,6 +115,20 @@ public function generateSpec(): array } } + if ($route->requestBody) { + $routeSpec['requestBody'] = [ + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => $this->processSchemaOutput($route->requestBody['schema']) + ] + ] + ]; + if (!empty($route->requestBody['description'])) { + $routeSpec['requestBody']['description'] = $route->requestBody['description']; + } + } + if (!empty($route->responses)) { foreach ($route->responses as $code => $schema) { $routeSpec['responses'][(string)$code] = [ @@ -262,6 +289,9 @@ private function getUsedSchemas(): array foreach ($route->parameters as $param) { $this->collectFqcnsFromSchema($param['schema'], $usedFqcns); } + if ($route->requestBody) { + $this->collectFqcnsFromSchema($route->requestBody['schema'], $usedFqcns); + } } $usedSchemas = []; @@ -283,10 +313,6 @@ private function getUsedSchemas(): array $propSchema = $this->applyTypeArguments($prop->schema, $schema->typeArguments); $this->collectFqcnsFromSchema($propSchema, $usedFqcns); } - - // Parent and traits are NOT included in $usedFqcns automatically - // because we flatten them. Only include them if they are explicitly - // referenced via $ref in some property or response. } } @@ -300,8 +326,6 @@ private function collectFqcnsFromSchema(array $schema, array &$usedFqcns): void $prefix = '#/components/schemas/'; if (strpos($ref, $prefix) === 0) { $schemaId = substr($ref, strlen($prefix)); - // We need to find the FQCN by schema ID. - // Let's optimize this by looking at all registered schemas. foreach ($this->schemaRegistry->getAll() as $s) { if ($this->schemaRegistry->getSchemaId($s->name) === $schemaId) { $usedFqcns[] = $s->name; diff --git a/src/IR/RouteDefinition.php b/src/IR/RouteDefinition.php index 53a4055..f77baed 100644 --- a/src/IR/RouteDefinition.php +++ b/src/IR/RouteDefinition.php @@ -10,9 +10,9 @@ public function __construct( public ?string $summary = null, public ?string $description = null, public array $tags = [], - public ?string $responseRef = null, public array $responses = [], - public array $parameters = [] + public array $parameters = [], + public ?array $requestBody = null ) { } } diff --git a/src/NameResolver.php b/src/NameResolver.php index af93702..dc22895 100644 --- a/src/NameResolver.php +++ b/src/NameResolver.php @@ -43,6 +43,12 @@ public function resolve(string $name): string return $name; } + // If the name already starts with the current namespace, treat it as already resolved. + // This is a heuristic for our static analysis tool to avoid double-resolution. + if (str_starts_with($name, $this->currentNamespace . '\\')) { + return $name; + } + return $this->currentNamespace . '\\' . $name; } diff --git a/tests/DocBlockCollectorTest.php b/tests/DocBlockCollectorTest.php index cafa208..260abb1 100644 --- a/tests/DocBlockCollectorTest.php +++ b/tests/DocBlockCollectorTest.php @@ -39,7 +39,7 @@ public function testCollectPropertyTags() $this->assertInstanceOf(IdentifierTypeNode::class, $tags[0]['type']); $this->assertEquals('string', (string)$tags[0]['type']); $this->assertEquals('name', $tags[0]['propertyName']); - $this->assertEquals('User name', $tags[0]['description']); + $this->assertEquals('User name', $tags[0]['description']['description']); } public function testParseTypeWithArrays() diff --git a/tests/RemovalTest.php b/tests/RemovalTest.php new file mode 100644 index 0000000..5c5b16d --- /dev/null +++ b/tests/RemovalTest.php @@ -0,0 +1,56 @@ +generate([dirname($filePath)]); + unlink($filePath); + + $this->assertStringNotContainsString("name: legacy", $yaml); + } + + public function testRequestTagIsIgnored() + { + $code = 'generate([dirname($filePath)]); + unlink($filePath); + + $this->assertStringNotContainsString("requestBody:", $yaml); + } +} diff --git a/tests/RouteParamsTest.php b/tests/RouteParamsTest.php new file mode 100644 index 0000000..1f597d6 --- /dev/null +++ b/tests/RouteParamsTest.php @@ -0,0 +1,135 @@ +generate([dirname($filePath)]); + unlink($filePath); + + $this->assertStringContainsString("name: id", $yaml); + $this->assertStringContainsString("in: path", $yaml); + $this->assertStringContainsString("description: 'The user ID'", $yaml); + + $this->assertStringContainsString("name: status", $yaml); + $this->assertStringContainsString("in: query", $yaml); + $this->assertStringContainsString("enum:", $yaml); + $this->assertStringContainsString("- active", $yaml); + $this->assertStringContainsString("- inactive", $yaml); + $this->assertStringContainsString("default: active", $yaml); + + $this->assertStringContainsString("in: header", $yaml); + + $this->assertStringContainsString("name: session_id", $yaml); + $this->assertStringContainsString("in: cookie", $yaml); + } + + public function testRequestBodyTag() + { + $code = 'generate([dirname($filePath)]); + unlink($filePath); + + $this->assertStringContainsString("requestBody:", $yaml); + $this->assertStringContainsString("description: 'User data to create'", $yaml); + $this->assertStringContainsString("\$ref: '#/components/schemas/App_Controllers_CreateUserRequest'", $yaml); + } + + public function testAutoInference() + { + $code = 'generate([dirname($filePath)]); + unlink($filePath); + + // id should be in path + $this->assertMatchesRegularExpression("/name: id\s+in: path/s", $yaml); + // reason should be in query + $this->assertMatchesRegularExpression("/name: reason\s+in: query/s", $yaml); + // data should be requestBody + $this->assertStringContainsString("requestBody:", $yaml); + $this->assertStringContainsString("\$ref: '#/components/schemas/App_Controllers_UserDTO'", $yaml); + } + + public function testEnumAndDefaultAttributes() + { + $code = 'generate([dirname($filePath)]); + unlink($filePath); + + $this->assertStringContainsString("name: sort", $yaml); + $this->assertStringContainsString("enum:", $yaml); + $this->assertStringContainsString("- asc", $yaml); + $this->assertStringContainsString("- desc", $yaml); + $this->assertStringContainsString("default: asc", $yaml); + } +} From 4045515cc9a9e0ed8e3767ea8caddd821905bad3 Mon Sep 17 00:00:00 2001 From: tolawho Date: Sat, 6 Jun 2026 22:20:32 +0700 Subject: [PATCH 10/27] docs: add Epic 4 for professional API documentation features - Updated SAFE_BACKLOG.md with Epic 4, including Global Info, Security, Validation, MIME types, and Operation metadata features. - Added detailed User Stories for Epic 4. - Updated SAFE_STRATEGY.md with a Proposed Roadmap for PI 1. Co-authored-by: tolawho <12527881+tolawho@users.noreply.github.com> --- SAFE_BACKLOG.md | 41 ++++++++++++++++++++++++++++++++++++++++- SAFE_STRATEGY.md | 17 +++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/SAFE_BACKLOG.md b/SAFE_BACKLOG.md index 7468915..15204d6 100644 --- a/SAFE_BACKLOG.md +++ b/SAFE_BACKLOG.md @@ -35,6 +35,17 @@ - Tích hợp cơ chế Caching dựa trên file hash để tăng tốc độ quét lần sau. - Có tài liệu hướng dẫn sử dụng (README) hoàn chỉnh cho cộng đồng. +### [Epic 4] Professional API Documentation & Advanced Controls +- **Trạng thái:** To Do +- **Chủ sở hữu:** Fullstack Developer (User) +- **Tóm tắt:** Mở rộng các tính năng lấy cảm hứng từ swaggo để hoàn thiện tài liệu API chuyên nghiệp. +- **Giả thuyết Lợi ích (Benefit Hypothesis):** Giúp tạo ra tài liệu OpenAPI đầy đủ thông tin nhất, hỗ trợ bảo mật, validation và các tùy chỉnh nâng cao, giúp frontend và các bên liên quan dễ dàng tích hợp. +- **Tiêu chí chấp nhận (Acceptance Criteria):** + - Trích xuất tự động thông tin Global API (Title, Version, Host, etc.) từ PHPDoc. + - Hỗ trợ định nghĩa Security và áp dụng cho từng endpoint. + - Hỗ trợ đầy đủ các thẻ Validation cho thuộc tính và tham số. + - Cho phép tùy chỉnh MIME Types và OpenAPI Extensions (x-). + - Hỗ trợ alias @success, @failure cho tính rõ ràng. ## 2. Program Backlog (Features) @@ -57,6 +68,13 @@ - [ ] **[F3.3] Performance Caching:** Lưu trữ kết quả phân tích để tăng tốc cho các lần chạy sau. - [ ] **[F3.4] README & Documentation:** Hướng dẫn cộng đồng cách sử dụng và đóng góp. +### Features cho [Epic 4] Professional API Documentation +- [ ] **[F4.1] Global API Metadata Discovery:** Tự động trích xuất @title, @version, @description, @contact.*, @license.*, và @host từ toàn bộ project. +- [ ] **[F4.2] Security & Authentication Support:** Định nghĩa @securityDefinitions (ApiKey/JWT) toàn cục và @security cho endpoint. +- [ ] **[F4.3] Comprehensive Schema Validation:** Hỗ trợ các tag @minimum, @maximum, @minLength, @maxLength, @pattern, @format, @example cho Model properties và Route parameters. +- [ ] **[F4.4] MIME Types & Response Alias:** Hỗ trợ @accept, @produce (mặc định application/json) và alias @success/@failure. +- [ ] **[F4.5] Advanced Operation Metadata:** Hỗ trợ @operationId, @deprecated và OpenAPI Extensions (x-). + ## 3. Team Backlog (User Stories) ### Stories cho [F2.5] Advanced Route Parameter Handling @@ -65,4 +83,25 @@ - [x] **[S2.5.3] Auto-inference from Signature:** Tự động nhận diện tham số path/query và body từ type-hint của method. - [x] **[S2.5.4] Extra Metadata Parsing:** Trích xuất enum() và default() ngay từ chuỗi mô tả trong PHPDoc. -... (Keep other stories) +### Stories cho [F4.1] Global API Metadata +- [ ] **[S4.1.1] Global DocBlock Scanner:** Cơ chế quét và tìm kiếm khối thông tin chung của API trong toàn bộ project. +- [ ] **[S4.1.2] Info Object Mapping:** Ánh xạ các tag @title, @version, @description, @contact, @license vào Info Object của OpenAPI. +- [ ] **[S4.1.3] Host & BasePath Support:** Xử lý tag @host để xác định URL cơ sở. + +### Stories cho [F4.2] Security & Authentication +- [ ] **[S4.2.1] Security Definitions Parser:** Phân tích các định nghĩa bảo mật (API Key, Bearer JWT) từ PHPDoc. +- [ ] **[S4.2.2] Security Requirement Tag:** Áp dụng tag @security cho từng endpoint để chỉ định phương thức bảo mật cần thiết. + +### Stories cho [F4.3] Comprehensive Schema Validation +- [ ] **[S4.3.1] Validation Tag Extraction:** Trích xuất các ràng buộc (minimum, maxLength, pattern, format, etc.) từ mô tả PHPDoc hoặc tag riêng biệt. +- [ ] **[S4.3.2] Validation Mapping to OpenAPI:** Chuyển đổi các ràng buộc kỹ thuật sang Schema Object tương ứng. +- [ ] **[S4.3.3] Example Tag Support:** Hỗ trợ tag @example để hiển thị dữ liệu mẫu trong UI. + +### Stories cho [F4.4] MIME Types & Response Alias +- [ ] **[S4.4.1] MIME Type Tags (@accept, @produce):** Cho phép định nghĩa kiểu nội dung cho request/response. +- [ ] **[S4.4.2] Success/Failure Aliases:** Xử lý @success và @failure như các alias của @response để tăng tính trực quan. + +### Stories cho [F4.5] Advanced Operation Metadata +- [ ] **[S4.5.1] Operation ID Support:** Cho phép đặt tên thủ công cho operation qua tag @operationId. +- [ ] **[S4.5.2] Deprecation Support:** Đánh dấu operation lỗi thời thông qua tag @deprecated. +- [ ] **[S4.5.3] x- Extension Support:** Hỗ trợ trích xuất và xuất các extension OpenAPI tùy chỉnh bắt đầu bằng "x-". diff --git a/SAFE_STRATEGY.md b/SAFE_STRATEGY.md index cf70581..c1804cb 100644 --- a/SAFE_STRATEGY.md +++ b/SAFE_STRATEGY.md @@ -23,3 +23,20 @@ Do đặc thù dự án có 1 nhân sự (Fullstack), chúng ta áp dụng mô h - **Iteration Planning:** Thực hiện vào đầu mỗi 2 tuần. - **System Demo:** Tự kiểm thử và chạy thử các ví dụ (Example code) để xác nhận tính năng đã hoàn thiện. - **Inspect & Adapt (I&A):** Đánh giá lại quy trình sau mỗi PI để cải tiến năng suất. + +## 5. Roadmap Triển khai (Proposed) + +### PI 1: Hoàn thiện Professional API Documentation +- **Iteration 1: Foundation & Global Metadata** + - Thực hiện [F4.1] Global API Metadata Discovery. + - Xây dựng cơ chế quét PHPDoc toàn project cho Info Object. +- **Iteration 2: Security & MIME Types** + - Thực hiện [F4.2] Security & Authentication Support. + - Thực hiện [F4.4] MIME Types & Response Alias. +- **Iteration 3: Validation & Advanced Metadata** + - Thực hiện [F4.3] Comprehensive Schema Validation. + - Thực hiện [F4.5] Advanced Operation Metadata. +- **Iteration 4: Optimization & Polish** + - Thực hiện [F3.3] Performance Caching. + - Cập nhật [F3.4] README & Documentation. + - System Demo & Release v1.0.0. From 747e30c3b1f329a94199f407c69b839321e8d2a5 Mon Sep 17 00:00:00 2001 From: tolawho Date: Sun, 7 Jun 2026 06:32:43 +0700 Subject: [PATCH 11/27] feat: global API metadata discovery - Add support for global PHPDoc tags: @title, @version, @description, @host, @contact.*, @license.* - Implement top-level PHPDoc scanning in Core - Add duplicate global tag detection with exceptions - Add CLI overrides for global metadata in GenerateCommand - Improve Scanner to support individual file paths - Add unit tests for global metadata discovery and overrides - Update README.md with new features and instructions --- README.md | 34 ++++++++++- SAFE_BACKLOG.md | 10 +-- src/CLI/GenerateCommand.php | 19 +++++- src/Core.php | 111 +++++++++++++++++++++++++++++++++ src/DocBlockCollector.php | 2 +- src/Generator.php | 62 +++++++++++++++++-- src/Scanner.php | 30 ++++++--- tests/GlobalMetadataTest.php | 115 +++++++++++++++++++++++++++++++++++ 8 files changed, 362 insertions(+), 21 deletions(-) create mode 100644 tests/GlobalMetadataTest.php diff --git a/README.md b/README.md index 1c0f399..f073391 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AS - **AST-based Static Analysis**: No need to run your application. - **Modern PHP Support**: Handles namespaces, use aliases, and complex types. +- **Global API Metadata Discovery**: Automatically extracts `@title`, `@version`, `@description`, `@contact.*`, `@license.*`, and `@host` from any file. - **Auto-inference**: Automatically resolve route parameters and request bodies from method signatures. - **Advanced Type Resolution**: - Primitives: `int`, `string`, `bool`, `float`. @@ -43,6 +44,23 @@ $yaml = $core->generate(['./src/App']); file_put_contents('swagger.yaml', $yaml); ``` +### Global Metadata Discovery + +You can define your API information in a top-level PHPDoc block in any of your scanned files: + +```php +/** + * @title My Awesome API + * @version 2.1.0 + * @description This is a sample API for testing global metadata. + * @contact.name John Doe + * @contact.email john@example.com + * @license.name MIT + * @license.url https://opensource.org/licenses/MIT + * @host https://api.example.com + */ +``` + ### Route Parameters Handling The library supports explicit tags and auto-inference (inspired by swaggo). @@ -62,6 +80,16 @@ public function show(int $id, string $status) {} ## Support Tags +- **Global Metadata**: + - `@title [TEXT]` + - `@version [TEXT]` + - `@description [TEXT]` + - `@contact.name [TEXT]` + - `@contact.email [TEXT]` + - `@contact.url [TEXT]` + - `@license.name [TEXT]` + - `@license.url [TEXT]` + - `@host [URL]` - **Endpoints**: - `@route [METHOD] [PATH]` (e.g., `@route POST /data`) - `@summary [TEXT]` @@ -100,8 +128,12 @@ You can use the CLI to generate documentation without writing any PHP code: ``` **Options:** -- `--path`, `-p`: Path(s) to scan (can be used multiple times). +- `--path`, `-p`: Path(s) to scan (can be used multiple times). Supports individual files or directories. - `--output`, `-o`: Output file path (defaults to stdout). - `--format`, `-f`: Output format (`yaml` or `json`). Default: `yaml`. - `--openapi-version`: OpenAPI version (`3.0.0` or `3.1.0`). Default: `3.0.0`. - `--filter-unused`: Filter out schemas that are not referenced by any route. +- `--title`: API Title override. +- `--api-version`: API Version override. +- `--description`: API Description override. +- `--host`: API Host/Server URL override. diff --git a/SAFE_BACKLOG.md b/SAFE_BACKLOG.md index 15204d6..18b07a7 100644 --- a/SAFE_BACKLOG.md +++ b/SAFE_BACKLOG.md @@ -66,10 +66,10 @@ - [x] **[F3.1] CLI Command Interface:** Cung cấp giao diện dòng lệnh cho người dùng. - [x] **[F3.2] OpenAPI Spec Generator:** Chuyển đổi dữ liệu IR thành file chuẩn OpenAPI. - [ ] **[F3.3] Performance Caching:** Lưu trữ kết quả phân tích để tăng tốc cho các lần chạy sau. -- [ ] **[F3.4] README & Documentation:** Hướng dẫn cộng đồng cách sử dụng và đóng góp. +- [x] **[F3.4] README & Documentation:** Hướng dẫn cộng đồng cách sử dụng và đóng góp. ### Features cho [Epic 4] Professional API Documentation -- [ ] **[F4.1] Global API Metadata Discovery:** Tự động trích xuất @title, @version, @description, @contact.*, @license.*, và @host từ toàn bộ project. +- [x] **[F4.1] Global API Metadata Discovery:** Tự động trích xuất @title, @version, @description, @contact.*, @license.*, và @host từ toàn bộ project. - [ ] **[F4.2] Security & Authentication Support:** Định nghĩa @securityDefinitions (ApiKey/JWT) toàn cục và @security cho endpoint. - [ ] **[F4.3] Comprehensive Schema Validation:** Hỗ trợ các tag @minimum, @maximum, @minLength, @maxLength, @pattern, @format, @example cho Model properties và Route parameters. - [ ] **[F4.4] MIME Types & Response Alias:** Hỗ trợ @accept, @produce (mặc định application/json) và alias @success/@failure. @@ -84,9 +84,9 @@ - [x] **[S2.5.4] Extra Metadata Parsing:** Trích xuất enum() và default() ngay từ chuỗi mô tả trong PHPDoc. ### Stories cho [F4.1] Global API Metadata -- [ ] **[S4.1.1] Global DocBlock Scanner:** Cơ chế quét và tìm kiếm khối thông tin chung của API trong toàn bộ project. -- [ ] **[S4.1.2] Info Object Mapping:** Ánh xạ các tag @title, @version, @description, @contact, @license vào Info Object của OpenAPI. -- [ ] **[S4.1.3] Host & BasePath Support:** Xử lý tag @host để xác định URL cơ sở. +- [x] **[S4.1.1] Global DocBlock Scanner:** Cơ chế quét và tìm kiếm khối thông tin chung của API trong toàn bộ project. +- [x] **[S4.1.2] Info Object Mapping:** Ánh xạ các tag @title, @version, @description, @contact, @license vào Info Object của OpenAPI. +- [x] **[S4.1.3] Host & BasePath Support:** Xử lý tag @host để xác định URL cơ sở. ### Stories cho [F4.2] Security & Authentication - [ ] **[S4.2.1] Security Definitions Parser:** Phân tích các định nghĩa bảo mật (API Key, Bearer JWT) từ PHPDoc. diff --git a/src/CLI/GenerateCommand.php b/src/CLI/GenerateCommand.php index d9ca598..6aa3615 100644 --- a/src/CLI/GenerateCommand.php +++ b/src/CLI/GenerateCommand.php @@ -21,7 +21,11 @@ protected function configure(): void ->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Output file path (default: stdout)') ->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (yaml or json)', 'yaml') ->addOption('openapi-version', null, InputOption::VALUE_REQUIRED, 'OpenAPI version (3.0.0 or 3.1.0)', '3.0.0') - ->addOption('filter-unused', null, InputOption::VALUE_NONE, 'Filter unused schemas'); + ->addOption('filter-unused', null, InputOption::VALUE_NONE, 'Filter unused schemas') + ->addOption('title', null, InputOption::VALUE_REQUIRED, 'API Title') + ->addOption('api-version', null, InputOption::VALUE_REQUIRED, 'API Version') + ->addOption('description', null, InputOption::VALUE_REQUIRED, 'API Description') + ->addOption('host', null, InputOption::VALUE_REQUIRED, 'API Host/Server URL'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -36,6 +40,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int $core->setOpenApiVersion($input->getOption('openapi-version')); $core->setFilterUnusedSchemas($input->getOption('filter-unused')); + if ($title = $input->getOption('title')) { + $core->setTitle($title); + } + if ($apiVersion = $input->getOption('api-version')) { + $core->setApiVersion($apiVersion); + } + if ($description = $input->getOption('description')) { + $core->setDescription($description); + } + if ($host = $input->getOption('host')) { + $core->setServers([['url' => $host]]); + } + $format = strtolower($input->getOption('format')); if ($format === 'json') { $result = $core->generateJson($paths); diff --git a/src/Core.php b/src/Core.php index 0488835..886b888 100644 --- a/src/Core.php +++ b/src/Core.php @@ -26,6 +26,10 @@ class Core private bool $isAnalyzed = false; + private array $globalMetadata = []; + private array $metadataSources = []; + private array $cliOverrides = []; + public function __construct() { $this->scanner = new Scanner(); @@ -59,6 +63,8 @@ private function analyze(array $paths): void $this->discoverFile($file); } + $this->applyGlobalMetadata(); + // Pass 2: Analysis foreach ($this->discoveredClasses as $fqcn => $data) { $this->analyzeClass($fqcn, $data['node'], $data['nameResolver']); @@ -90,6 +96,9 @@ private function discoverFile(string $filePath): void $code = file_get_contents($filePath); $stmts = $this->parser->parse($code); + // Check for global metadata in comments + $this->discoverGlobalMetadata($code, $filePath); + $nameResolver = new NameResolver(); $traverser = new NodeTraverser(); $traverser->addVisitor($nameResolver); @@ -106,6 +115,74 @@ private function discoverFile(string $filePath): void } } + private function discoverGlobalMetadata(string $code, string $filePath): void + { + $tokens = token_get_all($code); + foreach ($tokens as $token) { + if (is_array($token) && $token[0] === T_DOC_COMMENT) { + $docComment = $token[1]; + $tags = $this->docCollector->collectTags($docComment); + foreach ($tags as $tag) { + $tagName = $tag['name']; + if (in_array($tagName, ['@title', '@version', '@description', '@host']) || + str_starts_with($tagName, '@contact.') || + str_starts_with($tagName, '@license.')) { + + $val = $tag['value'] ?? ''; + if (isset($this->globalMetadata[$tagName]) && $this->globalMetadata[$tagName] !== $val) { + throw new \Exception(sprintf( + "Duplicate global tag '%s' found in %s and %s", + $tagName, + $this->metadataSources[$tagName], + $filePath + )); + } + $this->globalMetadata[$tagName] = $val; + $this->metadataSources[$tagName] = $filePath; + } + } + } + } + } + + private function applyGlobalMetadata(): void + { + $title = $this->cliOverrides['title'] ?? $this->globalMetadata['@title'] ?? null; + if ($title !== null) { + $this->generator->setTitle($title); + } + + $apiVersion = $this->cliOverrides['api-version'] ?? $this->globalMetadata['@version'] ?? null; + if ($apiVersion !== null) { + $this->generator->setApiVersion($apiVersion); + } + + $description = $this->cliOverrides['description'] ?? $this->globalMetadata['@description'] ?? null; + if ($description !== null) { + $this->generator->setDescription($description); + } + + $contact = []; + if (isset($this->globalMetadata['@contact.name'])) $contact['name'] = $this->globalMetadata['@contact.name']; + if (isset($this->globalMetadata['@contact.email'])) $contact['email'] = $this->globalMetadata['@contact.email']; + if (isset($this->globalMetadata['@contact.url'])) $contact['url'] = $this->globalMetadata['@contact.url']; + if (!empty($contact)) { + $this->generator->setContact($contact); + } + + $license = []; + if (isset($this->globalMetadata['@license.name'])) $license['name'] = $this->globalMetadata['@license.name']; + if (isset($this->globalMetadata['@license.url'])) $license['url'] = $this->globalMetadata['@license.url']; + if (!empty($license)) { + $this->generator->setLicense($license); + } + + $host = $this->cliOverrides['host'] ?? $this->globalMetadata['@host'] ?? null; + if ($host !== null) { + $this->generator->setServers([['url' => $host]]); + } + } + private function discoverStatement(Node $stmt, NameResolver $nameResolver): void { if ($stmt instanceof Class_ || $stmt instanceof Trait_) { @@ -352,4 +429,38 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, )); } } + + public function setTitle(string $title): void + { + $this->cliOverrides['title'] = $title; + } + + public function setApiVersion(string $version): void + { + $this->cliOverrides['api-version'] = $version; + } + + public function setDescription(?string $description): void + { + $this->cliOverrides['description'] = $description; + } + + public function setContact(?array $contact): void + { + $this->generator->setContact($contact); + } + + public function setLicense(?array $license): void + { + $this->generator->setLicense($license); + } + + public function setServers(array $servers): void + { + if (isset($servers[0]['url'])) { + $this->cliOverrides['host'] = $servers[0]['url']; + } else { + $this->generator->setServers($servers); + } + } } diff --git a/src/DocBlockCollector.php b/src/DocBlockCollector.php index ec13d08..be6597f 100644 --- a/src/DocBlockCollector.php +++ b/src/DocBlockCollector.php @@ -29,7 +29,7 @@ public function collectTags(string $docComment): array continue; } - if (preg_match('/^(@[a-zA-Z0-9_]+)(?:\s+(.*))?$/', $line, $matches)) { + if (preg_match('/^(@[a-zA-Z0-9_.]+)(?:\s+(.*))?$/', $line, $matches)) { $tagName = $matches[1]; $value = isset($matches[2]) ? trim($matches[2]) : ''; diff --git a/src/Generator.php b/src/Generator.php index 9fc8ca6..7da89b7 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -12,6 +12,12 @@ class Generator private SchemaRegistry $schemaRegistry; private string $openApiVersion = '3.0.0'; private bool $filterUnusedSchemas = false; + private string $title = 'API Documentation'; + private string $apiVersion = '1.0.0'; + private ?string $description = null; + private ?array $contact = null; + private ?array $license = null; + private array $servers = []; public function __construct(SchemaRegistry $schemaRegistry) { @@ -28,6 +34,36 @@ public function setFilterUnusedSchemas(bool $filter): void $this->filterUnusedSchemas = $filter; } + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function setApiVersion(string $version): void + { + $this->apiVersion = $version; + } + + public function setDescription(?string $description): void + { + $this->description = $description; + } + + public function setContact(?array $contact): void + { + $this->contact = $contact; + } + + public function setLicense(?array $license): void + { + $this->license = $license; + } + + public function setServers(array $servers): void + { + $this->servers = $servers; + } + public function addRoute(RouteDefinition $route): void { $this->routes[] = $route; @@ -45,18 +81,36 @@ public function generateJson(): string public function generateSpec(): array { + $info = [ + 'title' => $this->title, + 'version' => $this->apiVersion, + ]; + + if ($this->description !== null) { + $info['description'] = $this->description; + } + + if ($this->contact !== null) { + $info['contact'] = $this->contact; + } + + if ($this->license !== null) { + $info['license'] = $this->license; + } + $spec = [ 'openapi' => $this->openApiVersion, - 'info' => [ - 'title' => 'API Documentation', - 'version' => '1.0.0' - ], + 'info' => $info, 'paths' => [], 'components' => [ 'schemas' => [] ] ]; + if (!empty($this->servers)) { + $spec['servers'] = $this->servers; + } + foreach ($this->routes as $route) { $path = $route->path; $method = strtolower($route->method); diff --git a/src/Scanner.php b/src/Scanner.php index 1e2d90c..dd57672 100644 --- a/src/Scanner.php +++ b/src/Scanner.php @@ -30,17 +30,29 @@ public function scan(): array return []; } - $finder = new Finder(); - $finder->files() - ->in($this->paths) - ->name('*.php') - ->exclude($this->excludedPaths); - $files = []; - foreach ($finder as $file) { - $files[] = $file->getRealPath(); + $dirs = []; + + foreach ($this->paths as $path) { + if (is_file($path)) { + $files[] = realpath($path); + } elseif (is_dir($path)) { + $dirs[] = $path; + } + } + + if (!empty($dirs)) { + $finder = new Finder(); + $finder->files() + ->in($dirs) + ->name('*.php') + ->exclude($this->excludedPaths); + + foreach ($finder as $file) { + $files[] = $file->getRealPath(); + } } - return $files; + return array_unique($files); } } diff --git a/tests/GlobalMetadataTest.php b/tests/GlobalMetadataTest.php new file mode 100644 index 0000000..57e8f22 --- /dev/null +++ b/tests/GlobalMetadataTest.php @@ -0,0 +1,115 @@ +fixtureDir = __DIR__ . '/fixtures/global_metadata'; + if (!is_dir($this->fixtureDir)) { + mkdir($this->fixtureDir, 0777, true); + } + } + + protected function tearDown(): void + { + $this->removeDirectory($this->fixtureDir); + } + + private function removeDirectory($path): void + { + $files = glob($path . '/*'); + foreach ($files as $file) { + is_dir($file) ? $this->removeDirectory($file) : unlink($file); + } + rmdir($path); + } + + public function testGlobalMetadataDiscovery(): void + { + $code = <<<'PHP' +fixtureDir . '/api.php', $code); + + $core = new Core(); + $yaml = $core->generateYaml([$this->fixtureDir]); + + $this->assertStringContainsString('title: My Awesome API', str_replace("'", "", $yaml)); + $this->assertStringContainsString('version: 2.1.0', str_replace("'", "", $yaml)); + $this->assertStringContainsString('description: This is a sample API for testing global metadata.', str_replace("'", "", $yaml)); + $this->assertStringContainsString('name: John Doe', str_replace("'", "", $yaml)); + $this->assertStringContainsString('email: john@example.com', str_replace("'", "", $yaml)); + $this->assertStringContainsString('name: MIT', str_replace("'", "", $yaml)); + $this->assertStringContainsString('url: https://opensource.org/licenses/MIT', str_replace("'", "", $yaml)); + $this->assertStringContainsString('servers:', $yaml); + $this->assertStringContainsString('url: https://api.example.com', str_replace("'", "", $yaml)); + } + + public function testDuplicateGlobalMetadataThrowsException(): void + { + $code1 = <<<'PHP' +fixtureDir . '/file1.php', $code1); + file_put_contents($this->fixtureDir . '/file2.php', $code2); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Duplicate global tag '@title' found"); + + $core = new Core(); + $core->generateYaml([$this->fixtureDir]); + } + + public function testCliOverridePrioritized(): void + { + $code = <<<'PHP' +fixtureDir . '/api.php', $code); + + $core = new Core(); + $core->setTitle('CLI Title'); + $yaml = $core->generateYaml([$this->fixtureDir]); + + $this->assertStringContainsString('title: CLI Title', str_replace("'", "", $yaml)); + $this->assertStringNotContainsString('title: Doc Title', str_replace("'", "", $yaml)); + } +} From 4f81f7af7f88c4982f9c71a6c1d4b357791c51c3 Mon Sep 17 00:00:00 2001 From: tolawho Date: Sun, 7 Jun 2026 07:45:36 +0700 Subject: [PATCH 12/27] feat: implement security & authentication support - Add @securityDefinitions.apikey and @securityDefinitions.jwt support in global PHPDocs. - Add @security support for both global and operation level. - Support OR/AND combinations and scopes in security requirements. - Update Generator to include securitySchemes and security objects in OpenAPI spec. - Add comprehensive test suite for security features. --- README.md | 34 +++++++++ SAFE_BACKLOG.md | 6 +- src/Core.php | 153 ++++++++++++++++++++++++++++--------- src/Generator.php | 61 ++++++++++----- src/IR/RouteDefinition.php | 3 +- tests/SecurityTest.php | 105 +++++++++++++++++++++++++ 6 files changed, 301 insertions(+), 61 deletions(-) create mode 100644 tests/SecurityTest.php diff --git a/README.md b/README.md index f073391..91befdf 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AS - **AST-based Static Analysis**: No need to run your application. - **Modern PHP Support**: Handles namespaces, use aliases, and complex types. - **Global API Metadata Discovery**: Automatically extracts `@title`, `@version`, `@description`, `@contact.*`, `@license.*`, and `@host` from any file. +- **Security & Authentication**: Define global security schemes (ApiKey, JWT) and apply them to endpoints or globally. - **Auto-inference**: Automatically resolve route parameters and request bodies from method signatures. - **Advanced Type Resolution**: - Primitives: `int`, `string`, `bool`, `float`. @@ -61,6 +62,35 @@ You can define your API information in a top-level PHPDoc block in any of your s */ ``` +### Security & Authentication + +Define security schemes and requirements globally or per operation: + +```php +/** + * @securityDefinitions.apikey MyApiKey header X-API-KEY + * @securityDefinitions.jwt MyJwtAuth + * @security MyJwtAuth + */ + +class Controller { + /** + * @route GET /private + * @security MyApiKey + */ + public function secureAction() {} + + /** + * @route GET /scoped + * @security MyJwtAuth[read, write] + */ + public function scopedAction() {} +} +``` + +- **OR logic**: Use multiple `@security` tags on a method. +- **AND logic**: Use a single tag with comma-separated schemes: `@security Key1, Key2`. + ### Route Parameters Handling The library supports explicit tags and auto-inference (inspired by swaggo). @@ -90,6 +120,10 @@ public function show(int $id, string $status) {} - `@license.name [TEXT]` - `@license.url [TEXT]` - `@host [URL]` +- **Security**: + - `@securityDefinitions.apikey [NAME] [IN: header|query|cookie] [KEY_NAME]` + - `@securityDefinitions.jwt [NAME]` + - `@security [NAME]` or `@security [NAME[scopes]]` (supports OR/AND) - **Endpoints**: - `@route [METHOD] [PATH]` (e.g., `@route POST /data`) - `@summary [TEXT]` diff --git a/SAFE_BACKLOG.md b/SAFE_BACKLOG.md index 18b07a7..c94bbe3 100644 --- a/SAFE_BACKLOG.md +++ b/SAFE_BACKLOG.md @@ -70,7 +70,7 @@ ### Features cho [Epic 4] Professional API Documentation - [x] **[F4.1] Global API Metadata Discovery:** Tự động trích xuất @title, @version, @description, @contact.*, @license.*, và @host từ toàn bộ project. -- [ ] **[F4.2] Security & Authentication Support:** Định nghĩa @securityDefinitions (ApiKey/JWT) toàn cục và @security cho endpoint. +- [x] **[F4.2] Security & Authentication Support:** Định nghĩa @securityDefinitions (ApiKey/JWT) toàn cục và @security cho endpoint. - [ ] **[F4.3] Comprehensive Schema Validation:** Hỗ trợ các tag @minimum, @maximum, @minLength, @maxLength, @pattern, @format, @example cho Model properties và Route parameters. - [ ] **[F4.4] MIME Types & Response Alias:** Hỗ trợ @accept, @produce (mặc định application/json) và alias @success/@failure. - [ ] **[F4.5] Advanced Operation Metadata:** Hỗ trợ @operationId, @deprecated và OpenAPI Extensions (x-). @@ -89,8 +89,8 @@ - [x] **[S4.1.3] Host & BasePath Support:** Xử lý tag @host để xác định URL cơ sở. ### Stories cho [F4.2] Security & Authentication -- [ ] **[S4.2.1] Security Definitions Parser:** Phân tích các định nghĩa bảo mật (API Key, Bearer JWT) từ PHPDoc. -- [ ] **[S4.2.2] Security Requirement Tag:** Áp dụng tag @security cho từng endpoint để chỉ định phương thức bảo mật cần thiết. +- [x] **[S4.2.1] Security Definitions Parser:** Phân tích các định nghĩa bảo mật (API Key, Bearer JWT) từ PHPDoc. +- [x] **[S4.2.2] Security Requirement Tag:** Áp dụng tag @security cho từng endpoint để chỉ định phương thức bảo mật cần thiết. ### Stories cho [F4.3] Comprehensive Schema Validation - [ ] **[S4.3.1] Validation Tag Extraction:** Trích xuất các ràng buộc (minimum, maxLength, pattern, format, etc.) từ mô tả PHPDoc hoặc tag riêng biệt. diff --git a/src/Core.php b/src/Core.php index 886b888..fa9f025 100644 --- a/src/Core.php +++ b/src/Core.php @@ -30,6 +30,9 @@ class Core private array $metadataSources = []; private array $cliOverrides = []; + private array $securitySchemes = []; + private array $globalSecurity = []; + public function __construct() { $this->scanner = new Scanner(); @@ -49,6 +52,23 @@ public function setFilterUnusedSchemas(bool $filter): void $this->generator->setFilterUnusedSchemas($filter); } + public function generate(array $paths): string + { + return $this->generateYaml($paths); + } + + public function generateYaml(array $paths): string + { + $this->analyze($paths); + return $this->generator->generateYaml(); + } + + public function generateJson(array $paths): string + { + $this->analyze($paths); + return $this->generator->generateJson(); + } + private function analyze(array $paths): void { if ($this->isAnalyzed) { @@ -58,14 +78,12 @@ private function analyze(array $paths): void $this->scanner->setPaths($paths); $files = $this->scanner->scan(); - // Pass 1: Discovery foreach ($files as $file) { $this->discoverFile($file); } $this->applyGlobalMetadata(); - // Pass 2: Analysis foreach ($this->discoveredClasses as $fqcn => $data) { $this->analyzeClass($fqcn, $data['node'], $data['nameResolver']); } @@ -73,24 +91,6 @@ private function analyze(array $paths): void $this->isAnalyzed = true; } - public function generate(array $paths): string - { - $this->analyze($paths); - return $this->generator->generateYaml(); - } - - public function generateYaml(array $paths): string - { - $this->analyze($paths); - return $this->generator->generateYaml(); - } - - public function generateJson(array $paths): string - { - $this->analyze($paths); - return $this->generator->generateJson(); - } - private function discoverFile(string $filePath): void { $code = file_get_contents($filePath); @@ -122,6 +122,20 @@ private function discoverGlobalMetadata(string $code, string $filePath): void if (is_array($token) && $token[0] === T_DOC_COMMENT) { $docComment = $token[1]; $tags = $this->docCollector->collectTags($docComment); + + $isGlobalBlock = false; + foreach ($tags as $tag) { + if (in_array($tag['name'], ['@title', '@version', '@description', '@host']) || + str_starts_with($tag['name'], '@contact.') || + str_starts_with($tag['name'], '@license.') || + str_starts_with($tag['name'], '@securityDefinitions.')) { + $isGlobalBlock = true; + break; + } + } + + if (!$isGlobalBlock) continue; + foreach ($tags as $tag) { $tagName = $tag['name']; if (in_array($tagName, ['@title', '@version', '@description', '@host']) || @@ -139,26 +153,88 @@ private function discoverGlobalMetadata(string $code, string $filePath): void } $this->globalMetadata[$tagName] = $val; $this->metadataSources[$tagName] = $filePath; + } elseif ($tagName === '@securityDefinitions.apikey') { + if (preg_match('/^(\S+)\s+(header|query|cookie)\s+(\S+)/', $tag['value'], $matches)) { + $this->securitySchemes[$matches[1]] = [ + 'type' => 'apiKey', + 'in' => $matches[2], + 'name' => $matches[3] + ]; + } + } elseif ($tagName === '@securityDefinitions.jwt') { + $this->securitySchemes[$tag['value']] = [ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => 'JWT' + ]; + } elseif ($tagName === '@security') { + $this->globalSecurity = array_merge($this->globalSecurity, $this->parseSecurityTag($tag['value'])); } } } } } + private function parseSecurityTag(string $value): array + { + if (trim($value) === '') { + return [[]]; // Represents an empty security requirement object, which means "no security" + } + + $requirements = []; + $parts = $this->splitCommasOutsideBrackets($value); + $currentGroup = []; + foreach ($parts as $part) { + $part = trim($part); + if (empty($part)) continue; + + if (preg_match('/^([^\[]+)(?:\[(.*)\])?$/', $part, $matches)) { + $name = trim($matches[1]); + $scopes = isset($matches[2]) ? array_map('trim', explode(',', trim($matches[2]))) : []; + $currentGroup[$name] = $scopes; + } + } + if (!empty($currentGroup)) { + $requirements[] = $currentGroup; + } + return $requirements; + } + + private function splitCommasOutsideBrackets(string $str): array + { + $parts = []; + $current = ''; + $depth = 0; + for ($i = 0; $i < strlen($str); $i++) { + $char = $str[$i]; + if ($char === '[') $depth++; + elseif ($char === ']') $depth--; + + if ($char === ',' && $depth === 0) { + $parts[] = $current; + $current = ''; + } else { + $current .= $char; + } + } + $parts[] = $current; + return $parts; + } + private function applyGlobalMetadata(): void { $title = $this->cliOverrides['title'] ?? $this->globalMetadata['@title'] ?? null; - if ($title !== null) { + if ($title) { $this->generator->setTitle($title); } $apiVersion = $this->cliOverrides['api-version'] ?? $this->globalMetadata['@version'] ?? null; - if ($apiVersion !== null) { + if ($apiVersion) { $this->generator->setApiVersion($apiVersion); } $description = $this->cliOverrides['description'] ?? $this->globalMetadata['@description'] ?? null; - if ($description !== null) { + if ($description) { $this->generator->setDescription($description); } @@ -178,36 +254,37 @@ private function applyGlobalMetadata(): void } $host = $this->cliOverrides['host'] ?? $this->globalMetadata['@host'] ?? null; - if ($host !== null) { + if ($host) { $this->generator->setServers([['url' => $host]]); } + + if (!empty($this->securitySchemes)) { + $this->generator->setSecuritySchemes($this->securitySchemes); + } + + if (!empty($this->globalSecurity)) { + $this->generator->setGlobalSecurity($this->globalSecurity); + } } private function discoverStatement(Node $stmt, NameResolver $nameResolver): void { if ($stmt instanceof Class_ || $stmt instanceof Trait_) { - $className = $stmt->name->toString(); - $namespace = $nameResolver->getCurrentNamespace(); - $fqcn = ($namespace ? $namespace . '\\' : '') . $className; - + $fqcn = $nameResolver->resolve($stmt->name->toString()); $this->discoveredClasses[$fqcn] = [ 'node' => $stmt, 'nameResolver' => $nameResolver ]; - $docComment = $stmt->getDocComment()?->getText() ?? ''; - $tags = $this->docCollector->collectTags($docComment); - $templates = []; $typeArguments = []; $parent = null; + $docComment = $stmt->getDocComment()?->getText() ?? ''; + $tags = $this->docCollector->collectTags($docComment); foreach ($tags as $tag) { if ($tag['name'] === '@template') { - $parts = preg_split('/\s+/', trim($tag['value'])); - if (!empty($parts[0])) { - $templates[] = $parts[0]; - } + $templates[] = $tag['value']; } if ($tag['name'] === '@extends' || $tag['name'] === '@implements') { @@ -326,6 +403,7 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, $responses = []; $parameters = []; $requestBody = null; + $security = []; foreach ($tags as $tag) { if ($tag['name'] === '@route') { @@ -359,6 +437,8 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, 'schema' => $typeResolver->resolve($tag['type']), 'description' => is_array($tag['description']) ? ($tag['description']['description'] ?? null) : ($tag['description'] ?? null) ]; + } elseif ($tag['name'] === '@security') { + $security = array_merge($security, $this->parseSecurityTag($tag['value'])); } } @@ -425,7 +505,8 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, tags: $tagsList, responses: $responses, parameters: $parameters, - requestBody: $requestBody + requestBody: $requestBody, + security: $security )); } } diff --git a/src/Generator.php b/src/Generator.php index 7da89b7..3cfc9d5 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -18,6 +18,8 @@ class Generator private ?array $contact = null; private ?array $license = null; private array $servers = []; + private array $securitySchemes = []; + private array $globalSecurity = []; public function __construct(SchemaRegistry $schemaRegistry) { @@ -64,6 +66,16 @@ public function setServers(array $servers): void $this->servers = $servers; } + public function setSecuritySchemes(array $schemes): void + { + $this->securitySchemes = $schemes; + } + + public function setGlobalSecurity(array $security): void + { + $this->globalSecurity = $security; + } + public function addRoute(RouteDefinition $route): void { $this->routes[] = $route; @@ -71,7 +83,7 @@ public function addRoute(RouteDefinition $route): void public function generateYaml(): string { - return Yaml::dump($this->generateSpec(), 10, 2, Yaml::DUMP_NUMERIC_KEY_AS_STRING); + return Yaml::dump($this->generateSpec(), 10, 2); } public function generateJson(): string @@ -79,41 +91,44 @@ public function generateJson(): string return json_encode($this->generateSpec(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } - public function generateSpec(): array + private function generateSpec(): array { - $info = [ - 'title' => $this->title, - 'version' => $this->apiVersion, - ]; - - if ($this->description !== null) { - $info['description'] = $this->description; - } - - if ($this->contact !== null) { - $info['contact'] = $this->contact; - } - - if ($this->license !== null) { - $info['license'] = $this->license; - } - $spec = [ 'openapi' => $this->openApiVersion, - 'info' => $info, + 'info' => [ + 'title' => $this->title, + 'version' => $this->apiVersion, + ], 'paths' => [], 'components' => [ 'schemas' => [] ] ]; + if ($this->description) { + $spec['info']['description'] = $this->description; + } + if ($this->contact) { + $spec['info']['contact'] = $this->contact; + } + if ($this->license) { + $spec['info']['license'] = $this->license; + } if (!empty($this->servers)) { $spec['servers'] = $this->servers; } + if (!empty($this->securitySchemes)) { + $spec['components']['securitySchemes'] = $this->securitySchemes; + } + + if (!empty($this->globalSecurity)) { + $spec['security'] = $this->globalSecurity; + } + foreach ($this->routes as $route) { - $path = $route->path; $method = strtolower($route->method); + $path = $route->path; if (!isset($spec['paths'][$path])) { $spec['paths'][$path] = []; @@ -132,6 +147,10 @@ public function generateSpec(): array $routeSpec['tags'] = $route->tags; } + if (!empty($route->security)) { + $routeSpec['security'] = $route->security; + } + if (!empty($route->parameters)) { $routeSpec['parameters'] = []; foreach ($route->parameters as $param) { diff --git a/src/IR/RouteDefinition.php b/src/IR/RouteDefinition.php index f77baed..daaf0b8 100644 --- a/src/IR/RouteDefinition.php +++ b/src/IR/RouteDefinition.php @@ -12,7 +12,8 @@ public function __construct( public array $tags = [], public array $responses = [], public array $parameters = [], - public ?array $requestBody = null + public ?array $requestBody = null, + public array $security = [] ) { } } diff --git a/tests/SecurityTest.php b/tests/SecurityTest.php new file mode 100644 index 0000000..42e3533 --- /dev/null +++ b/tests/SecurityTest.php @@ -0,0 +1,105 @@ +fixtureDir = __DIR__ . '/fixtures/security'; + if (!is_dir($this->fixtureDir)) { + mkdir($this->fixtureDir, 0777, true); + } + } + + protected function tearDown(): void + { + $this->removeDirectory($this->fixtureDir); + } + + private function removeDirectory($path): void + { + if (!is_dir($path)) return; + $files = glob($path . '/*'); + foreach ($files as $file) { + is_dir($file) ? $this->removeDirectory($file) : unlink($file); + } + rmdir($path); + } + + public function testSecurityDefinitionsAndGlobalSecurity(): void + { + $code = <<<'PHP' +fixtureDir . '/api.php', $code); + + $core = new Core(); + $yaml = $core->generateYaml([$this->fixtureDir]); + + // Check Security Schemes + $this->assertStringContainsString('securitySchemes:', $yaml); + $this->assertStringContainsString('MyApiKey:', $yaml); + $this->assertStringContainsString('type: apiKey', $yaml); + $this->assertStringContainsString('in: header', $yaml); + $this->assertStringContainsString('name: X-API-KEY', $yaml); + + $this->assertStringContainsString('MyJwtAuth:', $yaml); + $this->assertStringContainsString('type: http', $yaml); + $this->assertStringContainsString('scheme: bearer', $yaml); + $this->assertStringContainsString('bearerFormat: JWT', $yaml); + + // Check Global Security + $this->assertStringContainsString('security:', $yaml); + $this->assertStringContainsString('MyJwtAuth: { }', $yaml); + + // Check Scoped Security + $this->assertStringContainsString('- read', $yaml); + $this->assertStringContainsString('- write', $yaml); + } +} From e5c18c781bafd3674bf123c2a2479a8535eb1429 Mon Sep 17 00:00:00 2001 From: tolawho Date: Sun, 7 Jun 2026 09:22:36 +0700 Subject: [PATCH 13/27] feat: implement comprehensive schema validation support - Added support for validation tags: minimum, maximum, minLength, maxLength, pattern, format, and example. - Tags are supported in both Model properties and Route parameters (query, path, etc.). - Implemented automatic type casting for numeric validation values and default/example values. - Updated SAFe backlog to reflect feature completion. - Added comprehensive test suite for validation tags. --- README.md | 25 ++++++++++- SAFE_BACKLOG.md | 8 ++-- src/CLI/GenerateCommand.php | 8 +++- src/Core.php | 81 ++++++++++++++++++++++++--------- src/DocBlockCollector.php | 36 ++++++++++----- src/Generator.php | 65 ++++++++++++++++++++++++--- src/IR/PropertyDefinition.php | 3 +- tests/RemovalTest.php | 8 +++- tests/RouteParamsTest.php | 16 +++++-- tests/SecurityTest.php | 4 +- tests/ValidationTest.php | 85 +++++++++++++++++++++++++++++++++++ 11 files changed, 285 insertions(+), 54 deletions(-) create mode 100644 tests/ValidationTest.php diff --git a/README.md b/README.md index 91befdf..b1837ba 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AS - **Modern PHP Support**: Handles namespaces, use aliases, and complex types. - **Global API Metadata Discovery**: Automatically extracts `@title`, `@version`, `@description`, `@contact.*`, `@license.*`, and `@host` from any file. - **Security & Authentication**: Define global security schemes (ApiKey, JWT) and apply them to endpoints or globally. +- **Comprehensive Schema Validation**: Support for `minimum`, `maximum`, `minLength`, `maxLength`, `pattern`, `format`, and `example` directly in PHPDoc. - **Auto-inference**: Automatically resolve route parameters and request bodies from method signatures. - **Advanced Type Resolution**: - Primitives: `int`, `string`, `bool`, `float`. @@ -91,6 +92,26 @@ class Controller { - **OR logic**: Use multiple `@security` tags on a method. - **AND logic**: Use a single tag with comma-separated schemes: `@security Key1, Key2`. + +### Validation & Schema Metadata + +You can add validation constraints and metadata directly in the description of `@property`, `@var`, `@query`, `@path`, etc., using a simple function-like syntax. + +```php +/** + * @query int $age User age minimum(18) maximum(100) default(20) + * @query string $email User email format(email) example(user@example.com) + * @query string $code Auth code pattern(^[A-Z0-9]{6}$) minLength(6) maxLength(6) + */ +``` + +Supported constraints: +- **Numeric**: `minimum(n)`, `maximum(n)` +- **String**: `minLength(n)`, `maxLength(n)`, `pattern(regex)`, `format(type)` +- **Common**: `enum(a,b,c)`, `default(value)`, `example(value)` + +Values are automatically cast to their appropriate types (integers, floats, or strings) in the final OpenAPI output. + ### Route Parameters Handling The library supports explicit tags and auto-inference (inspired by swaggo). @@ -136,8 +157,8 @@ public function show(int $id, string $status) {} - `@body [TYPE] [DESC]` - `@response [CODE] [TYPE]` (e.g., `@response 200 ApiResponse`) - **Models**: - - `@property [TYPE] $[NAME] [DESCRIPTION]` - - `@var [TYPE]` (for class properties) + - `@property [TYPE] $[NAME] [DESCRIPTION]` (Supports validation tags in description) + - `@var [TYPE]` (Supports validation tags in description) (for class properties) - `@template [NAME]` (for generics) - `@extends [TYPE]` or `@use [TYPE]` (for generic arguments) diff --git a/SAFE_BACKLOG.md b/SAFE_BACKLOG.md index c94bbe3..adf1667 100644 --- a/SAFE_BACKLOG.md +++ b/SAFE_BACKLOG.md @@ -71,7 +71,7 @@ ### Features cho [Epic 4] Professional API Documentation - [x] **[F4.1] Global API Metadata Discovery:** Tự động trích xuất @title, @version, @description, @contact.*, @license.*, và @host từ toàn bộ project. - [x] **[F4.2] Security & Authentication Support:** Định nghĩa @securityDefinitions (ApiKey/JWT) toàn cục và @security cho endpoint. -- [ ] **[F4.3] Comprehensive Schema Validation:** Hỗ trợ các tag @minimum, @maximum, @minLength, @maxLength, @pattern, @format, @example cho Model properties và Route parameters. +- [x] **[F4.3] Comprehensive Schema Validation:** Hỗ trợ các tag @minimum, @maximum, @minLength, @maxLength, @pattern, @format, @example cho Model properties và Route parameters. - [ ] **[F4.4] MIME Types & Response Alias:** Hỗ trợ @accept, @produce (mặc định application/json) và alias @success/@failure. - [ ] **[F4.5] Advanced Operation Metadata:** Hỗ trợ @operationId, @deprecated và OpenAPI Extensions (x-). @@ -93,9 +93,9 @@ - [x] **[S4.2.2] Security Requirement Tag:** Áp dụng tag @security cho từng endpoint để chỉ định phương thức bảo mật cần thiết. ### Stories cho [F4.3] Comprehensive Schema Validation -- [ ] **[S4.3.1] Validation Tag Extraction:** Trích xuất các ràng buộc (minimum, maxLength, pattern, format, etc.) từ mô tả PHPDoc hoặc tag riêng biệt. -- [ ] **[S4.3.2] Validation Mapping to OpenAPI:** Chuyển đổi các ràng buộc kỹ thuật sang Schema Object tương ứng. -- [ ] **[S4.3.3] Example Tag Support:** Hỗ trợ tag @example để hiển thị dữ liệu mẫu trong UI. +- [x] **[S4.3.1] Validation Tag Extraction:** Trích xuất các ràng buộc (minimum, maxLength, pattern, format, etc.) từ mô tả PHPDoc hoặc tag riêng biệt. +- [x] **[S4.3.2] Validation Mapping to OpenAPI:** Chuyển đổi các ràng buộc kỹ thuật sang Schema Object tương ứng. +- [x] **[S4.3.3] Example Tag Support:** Hỗ trợ tag @example để hiển thị dữ liệu mẫu trong UI. ### Stories cho [F4.4] MIME Types & Response Alias - [ ] **[S4.4.1] MIME Type Tags (@accept, @produce):** Cho phép định nghĩa kiểu nội dung cho request/response. diff --git a/src/CLI/GenerateCommand.php b/src/CLI/GenerateCommand.php index 6aa3615..b5ecb99 100644 --- a/src/CLI/GenerateCommand.php +++ b/src/CLI/GenerateCommand.php @@ -20,7 +20,13 @@ protected function configure(): void ->addOption('path', 'p', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Path(s) to scan') ->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Output file path (default: stdout)') ->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (yaml or json)', 'yaml') - ->addOption('openapi-version', null, InputOption::VALUE_REQUIRED, 'OpenAPI version (3.0.0 or 3.1.0)', '3.0.0') + ->addOption( + 'openapi-version', + null, + InputOption::VALUE_REQUIRED, + 'OpenAPI version (3.0.0 or 3.1.0)', + '3.0.0' + ) ->addOption('filter-unused', null, InputOption::VALUE_NONE, 'Filter unused schemas') ->addOption('title', null, InputOption::VALUE_REQUIRED, 'API Title') ->addOption('api-version', null, InputOption::VALUE_REQUIRED, 'API Version') diff --git a/src/Core.php b/src/Core.php index fa9f025..faa830a 100644 --- a/src/Core.php +++ b/src/Core.php @@ -125,23 +125,28 @@ private function discoverGlobalMetadata(string $code, string $filePath): void $isGlobalBlock = false; foreach ($tags as $tag) { - if (in_array($tag['name'], ['@title', '@version', '@description', '@host']) || + if ( + in_array($tag['name'], ['@title', '@version', '@description', '@host']) || str_starts_with($tag['name'], '@contact.') || str_starts_with($tag['name'], '@license.') || - str_starts_with($tag['name'], '@securityDefinitions.')) { + str_starts_with($tag['name'], '@securityDefinitions.') + ) { $isGlobalBlock = true; break; } } - if (!$isGlobalBlock) continue; + if (!$isGlobalBlock) { + continue; + } foreach ($tags as $tag) { $tagName = $tag['name']; - if (in_array($tagName, ['@title', '@version', '@description', '@host']) || + if ( + in_array($tagName, ['@title', '@version', '@description', '@host']) || str_starts_with($tagName, '@contact.') || - str_starts_with($tagName, '@license.')) { - + str_starts_with($tagName, '@license.') + ) { $val = $tag['value'] ?? ''; if (isset($this->globalMetadata[$tagName]) && $this->globalMetadata[$tagName] !== $val) { throw new \Exception(sprintf( @@ -168,7 +173,10 @@ private function discoverGlobalMetadata(string $code, string $filePath): void 'bearerFormat' => 'JWT' ]; } elseif ($tagName === '@security') { - $this->globalSecurity = array_merge($this->globalSecurity, $this->parseSecurityTag($tag['value'])); + $this->globalSecurity = array_merge( + $this->globalSecurity, + $this->parseSecurityTag($tag['value']) + ); } } } @@ -186,7 +194,9 @@ private function parseSecurityTag(string $value): array $currentGroup = []; foreach ($parts as $part) { $part = trim($part); - if (empty($part)) continue; + if (empty($part)) { + continue; + } if (preg_match('/^([^\[]+)(?:\[(.*)\])?$/', $part, $matches)) { $name = trim($matches[1]); @@ -207,8 +217,11 @@ private function splitCommasOutsideBrackets(string $str): array $depth = 0; for ($i = 0; $i < strlen($str); $i++) { $char = $str[$i]; - if ($char === '[') $depth++; - elseif ($char === ']') $depth--; + if ($char === '[') { + $depth++; + } elseif ($char === ']') { + $depth--; + } if ($char === ',' && $depth === 0) { $parts[] = $current; @@ -239,16 +252,26 @@ private function applyGlobalMetadata(): void } $contact = []; - if (isset($this->globalMetadata['@contact.name'])) $contact['name'] = $this->globalMetadata['@contact.name']; - if (isset($this->globalMetadata['@contact.email'])) $contact['email'] = $this->globalMetadata['@contact.email']; - if (isset($this->globalMetadata['@contact.url'])) $contact['url'] = $this->globalMetadata['@contact.url']; + if (isset($this->globalMetadata['@contact.name'])) { + $contact['name'] = $this->globalMetadata['@contact.name']; + } + if (isset($this->globalMetadata['@contact.email'])) { + $contact['email'] = $this->globalMetadata['@contact.email']; + } + if (isset($this->globalMetadata['@contact.url'])) { + $contact['url'] = $this->globalMetadata['@contact.url']; + } if (!empty($contact)) { $this->generator->setContact($contact); } $license = []; - if (isset($this->globalMetadata['@license.name'])) $license['name'] = $this->globalMetadata['@license.name']; - if (isset($this->globalMetadata['@license.url'])) $license['url'] = $this->globalMetadata['@license.url']; + if (isset($this->globalMetadata['@license.name'])) { + $license['name'] = $this->globalMetadata['@license.name']; + } + if (isset($this->globalMetadata['@license.url'])) { + $license['url'] = $this->globalMetadata['@license.url']; + } if (!empty($license)) { $this->generator->setLicense($license); } @@ -351,12 +374,18 @@ private function analyzeClass(string $fqcn, Class_|Trait_ $stmt, NameResolver $n $isSchema = true; $propertySchema = $typeResolver->resolve($tag['type']); - $desc = is_array($tag['description']) ? ($tag['description']['description'] ?? null) : ($tag['description'] ?? null); + $desc = is_array($tag['description']) + ? ($tag['description']['description'] ?? null) + : ($tag['description'] ?? null); + + $extra = is_array($tag['description']) ? $tag['description'] : []; + unset($extra['description']); $properties[] = new PropertyDefinition( $tag['propertyName'], $propertySchema, - $desc + $desc, + $extra ); } } @@ -370,12 +399,18 @@ private function analyzeClass(string $fqcn, Class_|Trait_ $stmt, NameResolver $n if ($pTag['name'] === '@var' && isset($pTag['type'])) { $propertySchema = $typeResolver->resolve($pTag['type']); - $desc = is_array($pTag['description']) ? ($pTag['description']['description'] ?? null) : ($pTag['description'] ?? null); + $desc = is_array($pTag['description']) + ? ($pTag['description']['description'] ?? null) + : ($pTag['description'] ?? null); + + $extra = is_array($pTag['description']) ? $pTag['description'] : []; + unset($extra['description']); $properties[] = new PropertyDefinition( $member->props[0]->name->toString(), $propertySchema, - $desc + $desc, + $extra ); } } @@ -435,7 +470,9 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, } elseif ($tag['name'] === '@body') { $requestBody = [ 'schema' => $typeResolver->resolve($tag['type']), - 'description' => is_array($tag['description']) ? ($tag['description']['description'] ?? null) : ($tag['description'] ?? null) + 'description' => is_array($tag['description']) + ? ($tag['description']['description'] ?? null) + : ($tag['description'] ?? null) ]; } elseif ($tag['name'] === '@security') { $security = array_merge($security, $this->parseSecurityTag($tag['value'])); @@ -458,7 +495,9 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, break; } } - if ($exists) continue; + if ($exists) { + continue; + } $type = 'mixed'; if ($param->type instanceof Node\Identifier) { diff --git a/src/DocBlockCollector.php b/src/DocBlockCollector.php index be6597f..1d0ae81 100644 --- a/src/DocBlockCollector.php +++ b/src/DocBlockCollector.php @@ -36,7 +36,9 @@ public function collectTags(string $docComment): array if (in_array($tagName, ['@property', '@var', '@return', '@path', '@query', '@header', '@cookie'])) { try { // For @path, @query, etc., we treat them similarly to @param for parsing convenience - $parseTagName = in_array($tagName, ['@path', '@query', '@header', '@cookie']) ? '@param' : $tagName; + $parseTagName = in_array($tagName, ['@path', '@query', '@header', '@cookie']) + ? '@param' + : $tagName; $doc = "/** $parseTagName $value */"; $node = $this->parser->parse($doc); foreach ($node->getTags() as $tag) { @@ -103,16 +105,28 @@ private function parseExtraAttributes(string $description): array { $res = ['description' => $description]; - // Parse enum(a,b,c) - if (preg_match('/enum\(([^)]+)\)/', $description, $matches)) { - $res['enum'] = array_map('trim', explode(',', $matches[1])); - $res['description'] = trim(str_replace($matches[0], '', $res['description'])); - } - - // Parse default(value) - if (preg_match('/default\(([^)]+)\)/', $description, $matches)) { - $res['default'] = trim($matches[1]); - $res['description'] = trim(str_replace($matches[0], '', $res['description'])); + $attributes = [ + 'enum' => '/enum\(([^)]+)\)/', + 'default' => '/default\(([^)]+)\)/', + 'minimum' => '/minimum\(([^)]+)\)/', + 'maximum' => '/maximum\(([^)]+)\)/', + 'minLength' => '/minLength\(([^)]+)\)/', + 'maxLength' => '/maxLength\(([^)]+)\)/', + 'pattern' => '/pattern\(([^)]+)\)/', + 'format' => '/format\(([^)]+)\)/', + 'example' => '/example\(([^)]+)\)/', + ]; + + foreach ($attributes as $key => $pattern) { + if (preg_match($pattern, $res['description'] ?? '', $matches)) { + $val = trim($matches[1]); + if ($key === 'enum') { + $res[$key] = array_map('trim', explode(',', $val)); + } else { + $res[$key] = $val; + } + $res['description'] = trim(str_replace($matches[0], '', $res['description'])); + } } return $res; diff --git a/src/Generator.php b/src/Generator.php index 3cfc9d5..c4a5a5e 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -165,12 +165,33 @@ private function generateSpec(): array $schema = $this->processSchemaOutput($param['schema']); - // Handle enum and default from extra metadata if present - if (isset($param['enum'])) { - $schema['enum'] = $param['enum']; - } - if (isset($param['default'])) { - $schema['default'] = $param['default']; + // Handle validation tags + $validationTags = [ + 'enum', + 'default', + 'minimum', + 'maximum', + 'minLength', + 'maxLength', + 'pattern', + 'format', + 'example', + ]; + foreach ($validationTags as $vTag) { + if (isset($param[$vTag])) { + $val = $param[$vTag]; + if ( + in_array( + $vTag, + ['minimum', 'maximum', 'minLength', 'maxLength', 'default', 'example'] + ) + ) { + $val = is_numeric($val) + ? (strpos($val, '.') !== false ? (float)$val : (int)$val) + : $val; + } + $schema[$vTag] = $val; + } } $paramSpec = [ @@ -235,7 +256,37 @@ private function generateSpec(): array $properties = $this->resolveAllProperties($schema); $propSpecs = []; foreach ($properties as $prop) { - $propSchema = $this->applyTypeArguments($prop->schema, $schema->typeArguments); + $propSchema = $this->applyTypeArguments($prop->schema, $schema->typeArguments); + + // Apply extra validation attributes to property schema + $validationTags = [ + 'enum', + 'default', + 'minimum', + 'maximum', + 'minLength', + 'maxLength', + 'pattern', + 'format', + 'example', + ]; + foreach ($validationTags as $vTag) { + if (isset($prop->extra[$vTag])) { + $val = $prop->extra[$vTag]; + if ( + in_array( + $vTag, + ['minimum', 'maximum', 'minLength', 'maxLength', 'default', 'example'] + ) + ) { + $val = is_numeric($val) + ? (strpos($val, '.') !== false ? (float)$val : (int)$val) + : $val; + } + $propSchema[$vTag] = $val; + } + } + $propSpecs[$prop->name] = $this->processSchemaOutput($propSchema, $prop->description); } diff --git a/src/IR/PropertyDefinition.php b/src/IR/PropertyDefinition.php index bd4e707..8cab075 100644 --- a/src/IR/PropertyDefinition.php +++ b/src/IR/PropertyDefinition.php @@ -7,7 +7,8 @@ class PropertyDefinition public function __construct( public string $name, public array $schema, - public ?string $description = null + public ?string $description = null, + public array $extra = [] ) { } } diff --git a/tests/RemovalTest.php b/tests/RemovalTest.php index 5c5b16d..64518a0 100644 --- a/tests/RemovalTest.php +++ b/tests/RemovalTest.php @@ -20,7 +20,9 @@ public function index() {} }'; $filePath = __DIR__ . "/fixtures/LegacyController.php"; - if (!is_dir(dirname($filePath))) mkdir(dirname($filePath), 0777, true); + if (!is_dir(dirname($filePath))) { + mkdir(dirname($filePath), 0777, true); + } file_put_contents($filePath, $code); $core = new Core(); @@ -44,7 +46,9 @@ public function store() {} }'; $filePath = __DIR__ . "/fixtures/LegacyRequestController.php"; - if (!is_dir(dirname($filePath))) mkdir(dirname($filePath), 0777, true); + if (!is_dir(dirname($filePath))) { + mkdir(dirname($filePath), 0777, true); + } file_put_contents($filePath, $code); $core = new Core(); diff --git a/tests/RouteParamsTest.php b/tests/RouteParamsTest.php index 1f597d6..44a413c 100644 --- a/tests/RouteParamsTest.php +++ b/tests/RouteParamsTest.php @@ -23,7 +23,9 @@ public function show($id) {} }'; $filePath = __DIR__ . "/fixtures/RouteParamsController.php"; - if (!is_dir(dirname($filePath))) mkdir(dirname($filePath), 0777, true); + if (!is_dir(dirname($filePath))) { + mkdir(dirname($filePath), 0777, true); + } file_put_contents($filePath, $code); $core = new Core(); @@ -63,7 +65,9 @@ public function store() {} }'; $filePath = __DIR__ . "/fixtures/RequestBodyController.php"; - if (!is_dir(dirname($filePath))) mkdir(dirname($filePath), 0777, true); + if (!is_dir(dirname($filePath))) { + mkdir(dirname($filePath), 0777, true); + } file_put_contents($filePath, $code); $core = new Core(); @@ -90,7 +94,9 @@ public function update(int $id, UserDTO $data, string $reason) {} }'; $filePath = __DIR__ . "/fixtures/AutoInferenceController.php"; - if (!is_dir(dirname($filePath))) mkdir(dirname($filePath), 0777, true); + if (!is_dir(dirname($filePath))) { + mkdir(dirname($filePath), 0777, true); + } file_put_contents($filePath, $code); $core = new Core(); @@ -119,7 +125,9 @@ public function index($sort) {} }'; $filePath = __DIR__ . "/fixtures/EnumDefaultController.php"; - if (!is_dir(dirname($filePath))) mkdir(dirname($filePath), 0777, true); + if (!is_dir(dirname($filePath))) { + mkdir(dirname($filePath), 0777, true); + } file_put_contents($filePath, $code); $core = new Core(); diff --git a/tests/SecurityTest.php b/tests/SecurityTest.php index 42e3533..f9a31cf 100644 --- a/tests/SecurityTest.php +++ b/tests/SecurityTest.php @@ -24,7 +24,9 @@ protected function tearDown(): void private function removeDirectory($path): void { - if (!is_dir($path)) return; + if (!is_dir($path)) { + return; + } $files = glob($path . '/*'); foreach ($files as $file) { is_dir($file) ? $this->removeDirectory($file) : unlink($file); diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php new file mode 100644 index 0000000..f8a862b --- /dev/null +++ b/tests/ValidationTest.php @@ -0,0 +1,85 @@ +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = \Symfony\Component\Yaml\Yaml::parse($yaml); + + // Check parameters + $params = $spec['paths']['/users']['get']['parameters']; + + $pageParam = array_values(array_filter($params, fn($p) => $p['name'] === 'page'))[0]; + $this->assertEquals(1, $pageParam['schema']['minimum']); + $this->assertEquals(1, $pageParam['schema']['default']); + $this->assertEquals(5, $pageParam['schema']['example']); + $this->assertIsInt($pageParam['schema']['minimum']); + $this->assertIsInt($pageParam['schema']['default']); + $this->assertIsInt($pageParam['schema']['example']); + + $searchParam = array_values(array_filter($params, fn($p) => $p['name'] === 'search'))[0]; + $this->assertEquals(3, $searchParam['schema']['minLength']); + $this->assertEquals(20, $searchParam['schema']['maxLength']); + $this->assertEquals('^[a-z]+$', $searchParam['schema']['pattern']); + + // Check Schema + $userSchema = $spec['components']['schemas']['App_Models_User']; + $props = $userSchema['properties']; + + $this->assertEquals(123, $props['id']['example']); + $this->assertIsInt($props['id']['example']); + $this->assertEquals('email', $props['email']['format']); + $this->assertEquals('user@example.com', $props['email']['example']); + + $this->assertEquals(0.0, $props['score']['minimum']); + $this->assertEquals(100.0, $props['score']['maximum']); + $this->assertIsFloat($props['score']['minimum']); + $this->assertIsFloat($props['score']['maximum']); + } +} From 7826f6a331c58b589dc8125b105de21ff25de30e Mon Sep 17 00:00:00 2001 From: tolawho Date: Sun, 7 Jun 2026 09:43:17 +0700 Subject: [PATCH 14/27] fix: PHPStan type errors and line length lint warnings --- composer.json | 9 ++++-- phpstan.neon | 5 +++ src/Core.php | 42 +++++++++++++++++++++++-- src/DocBlockCollector.php | 16 +++++++--- src/Generator.php | 59 +++++++++++++++++++++++++++++++---- src/IR/PropertyDefinition.php | 4 +++ src/IR/RouteDefinition.php | 7 +++++ src/IR/SchemaDefinition.php | 6 ++++ src/NameResolver.php | 8 +++++ src/Parser.php | 7 ++++- src/Scanner.php | 14 +++++++++ src/TypeResolver.php | 18 +++++++++-- 12 files changed, 176 insertions(+), 19 deletions(-) create mode 100644 phpstan.neon diff --git a/composer.json b/composer.json index 55e8712..d65980a 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "symfony/yaml": "^6.0" }, "require-dev": { + "phpstan/phpstan": "^2.2", "phpunit/phpunit": "^10.0", "squizlabs/php_codesniffer": "^4.0" }, @@ -30,7 +31,11 @@ }, "bin": ["bin/php-swag"], "scripts": { - "lint": "phpcs", - "format": "phpcbf" + "lint": [ + "phpcs", + "phpstan analyse --memory-limit=512M" + ], + "format": "phpcbf", + "test": "phpunit" } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..219ffc8 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + paths: + - src + + level: 7 diff --git a/src/Core.php b/src/Core.php index faa830a..e90a874 100644 --- a/src/Core.php +++ b/src/Core.php @@ -26,11 +26,16 @@ class Core private bool $isAnalyzed = false; + /** @var array */ private array $globalMetadata = []; + /** @var array */ private array $metadataSources = []; + /** @var array */ private array $cliOverrides = []; + /** @var array> */ private array $securitySchemes = []; + /** @var array>> */ private array $globalSecurity = []; public function __construct() @@ -52,23 +57,35 @@ public function setFilterUnusedSchemas(bool $filter): void $this->generator->setFilterUnusedSchemas($filter); } + /** + * @param array $paths + */ public function generate(array $paths): string { return $this->generateYaml($paths); } + /** + * @param array $paths + */ public function generateYaml(array $paths): string { $this->analyze($paths); return $this->generator->generateYaml(); } + /** + * @param array $paths + */ public function generateJson(array $paths): string { $this->analyze($paths); return $this->generator->generateJson(); } + /** + * @param array $paths + */ private function analyze(array $paths): void { if ($this->isAnalyzed) { @@ -94,6 +111,9 @@ private function analyze(array $paths): void private function discoverFile(string $filePath): void { $code = file_get_contents($filePath); + if ($code === false) { + return; + } $stmts = $this->parser->parse($code); // Check for global metadata in comments @@ -183,6 +203,9 @@ private function discoverGlobalMetadata(string $code, string $filePath): void } } + /** + * @return array>> + */ private function parseSecurityTag(string $value): array { if (trim($value) === '') { @@ -210,6 +233,9 @@ private function parseSecurityTag(string $value): array return $requirements; } + /** + * @return array + */ private function splitCommasOutsideBrackets(string $str): array { $parts = []; @@ -456,9 +482,7 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, $code = $matches[1]; $typeToParse = trim($matches[2]); $typeNode = $this->docCollector->parseType($typeToParse); - if ($typeNode) { - $responses[$code] = $typeResolver->resolve($typeNode); - } + $responses[$code] = $typeResolver->resolve($typeNode); } } elseif (in_array($tag['name'], ['@path', '@query', '@header', '@cookie'])) { $in = substr($tag['name'], 1); @@ -485,6 +509,9 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, // Auto-inference from method parameters foreach ($member->params as $param) { + if (!$param->var instanceof Node\Expr\Variable || !is_string($param->var->name)) { + continue; + } $paramName = $param->var->name; // Skip if already defined by explicit tags @@ -565,16 +592,25 @@ public function setDescription(?string $description): void $this->cliOverrides['description'] = $description; } + /** + * @param array|null $contact + */ public function setContact(?array $contact): void { $this->generator->setContact($contact); } + /** + * @param array|null $license + */ public function setLicense(?array $license): void { $this->generator->setLicense($license); } + /** + * @param array> $servers + */ public function setServers(array $servers): void { if (isset($servers[0]['url'])) { diff --git a/src/DocBlockCollector.php b/src/DocBlockCollector.php index 1d0ae81..ef1786a 100644 --- a/src/DocBlockCollector.php +++ b/src/DocBlockCollector.php @@ -15,6 +15,9 @@ public function __construct() $this->parser = new DocBlockParser(); } + /** + * @return array> + */ public function collectTags(string $docComment): array { if (empty($docComment)) { @@ -101,6 +104,9 @@ public function collectTags(string $docComment): array return $tags; } + /** + * @return array + */ private function parseExtraAttributes(string $description): array { $res = ['description' => $description]; @@ -118,7 +124,7 @@ private function parseExtraAttributes(string $description): array ]; foreach ($attributes as $key => $pattern) { - if (preg_match($pattern, $res['description'] ?? '', $matches)) { + if (preg_match($pattern, $res['description'], $matches)) { $val = trim($matches[1]); if ($key === 'enum') { $res[$key] = array_map('trim', explode(',', $val)); @@ -146,10 +152,7 @@ public function parseType(string $typeString): \PHPStan\PhpDocParser\Ast\Type\Ty $innerNodes = []; $parts = $this->splitByComma($inner); foreach ($parts as $part) { - $node = $this->parseType(trim($part)); - if ($node) { - $innerNodes[] = $node; - } + $innerNodes[] = $this->parseType(trim($part)); } return new GenericTypeNode( @@ -161,6 +164,9 @@ public function parseType(string $typeString): \PHPStan\PhpDocParser\Ast\Type\Ty return new IdentifierTypeNode($typeString); } + /** + * @return array + */ private function splitByComma(string $str): array { $parts = []; diff --git a/src/Generator.php b/src/Generator.php index c4a5a5e..4061023 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -2,12 +2,14 @@ namespace PhpSwag; +use PhpSwag\IR\PropertyDefinition; use PhpSwag\IR\RouteDefinition; use PhpSwag\IR\SchemaDefinition; use Symfony\Component\Yaml\Yaml; class Generator { + /** @var array */ private array $routes = []; private SchemaRegistry $schemaRegistry; private string $openApiVersion = '3.0.0'; @@ -15,10 +17,15 @@ class Generator private string $title = 'API Documentation'; private string $apiVersion = '1.0.0'; private ?string $description = null; + /** @var array|null */ private ?array $contact = null; + /** @var array|null */ private ?array $license = null; + /** @var array> */ private array $servers = []; + /** @var array> */ private array $securitySchemes = []; + /** @var array>> */ private array $globalSecurity = []; public function __construct(SchemaRegistry $schemaRegistry) @@ -51,26 +58,41 @@ public function setDescription(?string $description): void $this->description = $description; } + /** + * @param array|null $contact + */ public function setContact(?array $contact): void { $this->contact = $contact; } + /** + * @param array|null $license + */ public function setLicense(?array $license): void { $this->license = $license; } + /** + * @param array> $servers + */ public function setServers(array $servers): void { $this->servers = $servers; } + /** + * @param array> $schemes + */ public function setSecuritySchemes(array $schemes): void { $this->securitySchemes = $schemes; } + /** + * @param array>> $security + */ public function setGlobalSecurity(array $security): void { $this->globalSecurity = $security; @@ -88,9 +110,13 @@ public function generateYaml(): string public function generateJson(): string { - return json_encode($this->generateSpec(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $json = json_encode($this->generateSpec(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + return $json !== false ? $json : '{}'; } + /** + * @return array + */ private function generateSpec(): array { $spec = [ @@ -187,7 +213,7 @@ private function generateSpec(): array ) ) { $val = is_numeric($val) - ? (strpos($val, '.') !== false ? (float)$val : (int)$val) + ? (strpos((string)$val, '.') !== false ? (float)$val : (int)$val) : $val; } $schema[$vTag] = $val; @@ -280,7 +306,7 @@ private function generateSpec(): array ) ) { $val = is_numeric($val) - ? (strpos($val, '.') !== false ? (float)$val : (int)$val) + ? (strpos((string)$val, '.') !== false ? (float)$val : (int)$val) : $val; } $propSchema[$vTag] = $val; @@ -299,6 +325,10 @@ private function generateSpec(): array return $spec; } + /** + * @param array $schema + * @return array + */ private function processSchemaOutput(array $schema, ?string $description = null): array { if ($this->openApiVersion === '3.1.0') { @@ -341,6 +371,9 @@ private function processSchemaOutput(array $schema, ?string $description = null) return $schema; } + /** + * @return array + */ private function resolveAllProperties(SchemaDefinition $schema): array { $properties = []; @@ -373,21 +406,28 @@ private function resolveAllProperties(SchemaDefinition $schema): array return array_values($properties); } + /** + * @param array $schema + * @param array> $typeArgs + * @return array + */ private function applyTypeArguments(array $schema, array $typeArgs): array { if (empty($typeArgs)) { return $schema; } - if (isset($schema['type']) && is_string($schema['type']) && isset($typeArgs[$schema['type']])) { - $substituted = $typeArgs[$schema['type']]; + $type = $schema['type'] ?? null; + + if (is_string($type) && isset($typeArgs[$type])) { + $substituted = $typeArgs[$type]; if (isset($schema['nullable']) && $schema['nullable']) { $substituted['nullable'] = true; } return $substituted; } - if (isset($schema['type']) && $schema['type'] === 'array' && isset($schema['items'])) { + if ($type === 'array' && isset($schema['items'])) { $schema['items'] = $this->applyTypeArguments($schema['items'], $typeArgs); } @@ -402,6 +442,9 @@ private function applyTypeArguments(array $schema, array $typeArgs): array return $schema; } + /** + * @return array + */ private function getUsedSchemas(): array { $usedFqcns = []; @@ -443,6 +486,10 @@ private function getUsedSchemas(): array return array_values($usedSchemas); } + /** + * @param array $schema + * @param array $usedFqcns + */ private function collectFqcnsFromSchema(array $schema, array &$usedFqcns): void { if (isset($schema['$ref'])) { diff --git a/src/IR/PropertyDefinition.php b/src/IR/PropertyDefinition.php index 8cab075..3187f2b 100644 --- a/src/IR/PropertyDefinition.php +++ b/src/IR/PropertyDefinition.php @@ -4,6 +4,10 @@ class PropertyDefinition { + /** + * @param array $schema + * @param array $extra + */ public function __construct( public string $name, public array $schema, diff --git a/src/IR/RouteDefinition.php b/src/IR/RouteDefinition.php index daaf0b8..30b8b08 100644 --- a/src/IR/RouteDefinition.php +++ b/src/IR/RouteDefinition.php @@ -4,6 +4,13 @@ class RouteDefinition { + /** + * @param array $tags + * @param array> $responses + * @param array> $parameters + * @param array{schema: array, description?: string|null}|null $requestBody + * @param array>> $security + */ public function __construct( public string $method, public string $path, diff --git a/src/IR/SchemaDefinition.php b/src/IR/SchemaDefinition.php index 3532267..ae85aa1 100644 --- a/src/IR/SchemaDefinition.php +++ b/src/IR/SchemaDefinition.php @@ -4,6 +4,12 @@ class SchemaDefinition { + /** + * @param array $properties + * @param array $traits + * @param array $templates + * @param array> $typeArguments + */ public function __construct( public string $name, public array $properties = [], diff --git a/src/NameResolver.php b/src/NameResolver.php index dc22895..eeaa656 100644 --- a/src/NameResolver.php +++ b/src/NameResolver.php @@ -10,8 +10,12 @@ class NameResolver extends NodeVisitorAbstract { private string $currentNamespace = ''; + /** @var array */ private array $useAliases = []; + /** + * @return int|Node|null + */ public function enterNode(Node $node) { if ($node instanceof Namespace_) { @@ -23,6 +27,7 @@ public function enterNode(Node $node) $this->useAliases[$alias] = $use->name->toString(); } } + return null; } public function resolve(string $name): string @@ -57,6 +62,9 @@ public function getCurrentNamespace(): string return $this->currentNamespace; } + /** + * @return array + */ public function getUseAliases(): array { return $this->useAliases; diff --git a/src/Parser.php b/src/Parser.php index d8bfce2..6b9a4d6 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -16,6 +16,9 @@ public function __construct() $this->parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); } + /** + * @return array + */ public function parse(string $code): array { try { @@ -29,7 +32,9 @@ public function parse(string $code): array $traverser = new NodeTraverser(); $nameResolver = new PhpParserNameResolver(); $traverser->addVisitor($nameResolver); - return $traverser->traverse($stmts); + /** @var array $resolvedStmts */ + $resolvedStmts = $traverser->traverse($stmts); + return $resolvedStmts; } catch (Error $e) { // Handle parse error return []; diff --git a/src/Scanner.php b/src/Scanner.php index dd57672..f272ec8 100644 --- a/src/Scanner.php +++ b/src/Scanner.php @@ -6,24 +6,38 @@ class Scanner { + /** @var array */ private array $paths = []; + /** @var array */ private array $excludedPaths = ['vendor']; + /** + * @param array $paths + */ public function __construct(array $paths = []) { $this->paths = $paths; } + /** + * @param array $paths + */ public function setPaths(array $paths): void { $this->paths = $paths; } + /** + * @param array $excludedPaths + */ public function setExcludedPaths(array $excludedPaths): void { $this->excludedPaths = $excludedPaths; } + /** + * @return array + */ public function scan(): array { if (empty($this->paths)) { diff --git a/src/TypeResolver.php b/src/TypeResolver.php index 8626e75..a49d5ab 100644 --- a/src/TypeResolver.php +++ b/src/TypeResolver.php @@ -14,8 +14,12 @@ class TypeResolver { private SchemaRegistry $schemaRegistry; private NameResolver $nameResolver; + /** @var array */ private array $templates = []; + /** + * @param array $templates + */ public function __construct(SchemaRegistry $schemaRegistry, NameResolver $nameResolver, array $templates = []) { $this->schemaRegistry = $schemaRegistry; @@ -23,6 +27,9 @@ public function __construct(SchemaRegistry $schemaRegistry, NameResolver $nameRe $this->templates = $templates; } + /** + * @return array + */ public function resolve(TypeNode $typeNode): array { if ($typeNode instanceof IdentifierTypeNode) { @@ -77,6 +84,9 @@ public function resolve(TypeNode $typeNode): array return ['type' => 'string']; } + /** + * @return array + */ private function resolveIdentifier(string $name): array { if (in_array($name, $this->templates)) { @@ -96,8 +106,9 @@ private function resolveIdentifier(string $name): array 'void' => null, ]; - if (isset($map[$lowered])) { - return $map[$lowered] ? ['type' => $map[$lowered]] : []; + if (array_key_exists($lowered, $map)) { + $mappedVal = $map[$lowered]; + return $mappedVal !== null ? ['type' => $mappedVal] : []; } $fqcn = $this->nameResolver->resolve($name); @@ -106,6 +117,9 @@ private function resolveIdentifier(string $name): array ]; } + /** + * @return array + */ private function resolveGeneric(GenericTypeNode $typeNode): array { $baseName = $typeNode->type->name; From 7d1977e4b4332c6dda52c62ba429df04f4f01bf9 Mon Sep 17 00:00:00 2001 From: tolawho Date: Sun, 7 Jun 2026 10:02:57 +0700 Subject: [PATCH 15/27] Feat/caching and advanced metadata (#17) * feat: implement performance caching, MIME types, response aliases, and advanced operation metadata --------- Co-authored-by: TFO-ThanhDV_391 --- README.md | 15 +- SAFE_BACKLOG.md | 16 +-- src/CLI/GenerateCommand.php | 8 +- src/Cache/CacheInterface.php | 12 ++ src/Cache/FileCache.php | 70 ++++++++++ src/Core.php | 219 +++++++++++++++++++++++++++++- src/DocBlockCollector.php | 4 +- src/Generator.php | 99 ++++++++++++-- src/IR/RouteDefinition.php | 10 +- src/SchemaRegistry.php | 8 ++ tests/AdvancedMetadataTest.php | 45 ++++++ tests/CachingTest.php | 53 ++++++++ tests/MimeTypesAndAliasesTest.php | 71 ++++++++++ 13 files changed, 600 insertions(+), 30 deletions(-) create mode 100644 src/Cache/CacheInterface.php create mode 100644 src/Cache/FileCache.php create mode 100644 tests/AdvancedMetadataTest.php create mode 100644 tests/CachingTest.php create mode 100644 tests/MimeTypesAndAliasesTest.php diff --git a/README.md b/README.md index b1837ba..f7fe108 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ use PhpSwag\Core; $core = new Core(); $core->setOpenApiVersion('3.1.0'); // Optional, defaults to 3.0.0 + +// Optional: Enable caching to speed up consecutive generations +$core->enableCache('./.php-swag-cache'); + $yaml = $core->generate(['./src/App']); file_put_contents('swagger.yaml', $yaml); @@ -150,12 +154,19 @@ public function show(int $id, string $status) {} - `@summary [TEXT]` - `@description [TEXT]` - `@tag [NAME]` + - `@accept [MIME_TYPE]` or `@consume [MIME_TYPE]` (e.g. `json`, `xml`, or full MIME type) + - `@produce [MIME_TYPE]` (e.g. `json, xml` or full MIME type) - `@path [TYPE] $[NAME] [DESC]` - `@query [TYPE] $[NAME] [DESC]` - `@header [TYPE] $[NAME] [DESC]` - `@cookie [TYPE] $[NAME] [DESC]` - `@body [TYPE] [DESC]` - - `@response [CODE] [TYPE]` (e.g., `@response 200 ApiResponse`) + - `@response [CODE] [TYPE] [DESC]` (e.g., `@response 200 ApiResponse Success response`) + - `@success [CODE] [TYPE] [DESC]` (Alias of `@response`, e.g., `@success 200 User Success`) + - `@failure [CODE] [TYPE] [DESC]` (Alias of `@response`, e.g., `@failure 400 ErrorResponse Bad Request`) + - `@operationId [TEXT]` (Define explicit operationId) + - `@deprecated` (Mark the operation as deprecated) + - `@x-[EXTENSION_NAME] [VALUE]` (Custom OpenAPI extensions, e.g. `@x-code-samples [{"lang": "PHP"}]`) - **Models**: - `@property [TYPE] $[NAME] [DESCRIPTION]` (Supports validation tags in description) - `@var [TYPE]` (Supports validation tags in description) (for class properties) @@ -192,3 +203,5 @@ You can use the CLI to generate documentation without writing any PHP code: - `--api-version`: API Version override. - `--description`: API Description override. - `--host`: API Host/Server URL override. +- `--cache`: Enable performance caching. +- `--cache-file`: Custom cache file path (default: `./.php-swag-cache`). diff --git a/SAFE_BACKLOG.md b/SAFE_BACKLOG.md index adf1667..b49d1a8 100644 --- a/SAFE_BACKLOG.md +++ b/SAFE_BACKLOG.md @@ -65,15 +65,15 @@ ### Features cho [Epic 3] Integration & CLI - [x] **[F3.1] CLI Command Interface:** Cung cấp giao diện dòng lệnh cho người dùng. - [x] **[F3.2] OpenAPI Spec Generator:** Chuyển đổi dữ liệu IR thành file chuẩn OpenAPI. -- [ ] **[F3.3] Performance Caching:** Lưu trữ kết quả phân tích để tăng tốc cho các lần chạy sau. +- [x] **[F3.3] Performance Caching:** Lưu trữ kết quả phân tích để tăng tốc cho các lần chạy sau. - [x] **[F3.4] README & Documentation:** Hướng dẫn cộng đồng cách sử dụng và đóng góp. ### Features cho [Epic 4] Professional API Documentation - [x] **[F4.1] Global API Metadata Discovery:** Tự động trích xuất @title, @version, @description, @contact.*, @license.*, và @host từ toàn bộ project. - [x] **[F4.2] Security & Authentication Support:** Định nghĩa @securityDefinitions (ApiKey/JWT) toàn cục và @security cho endpoint. - [x] **[F4.3] Comprehensive Schema Validation:** Hỗ trợ các tag @minimum, @maximum, @minLength, @maxLength, @pattern, @format, @example cho Model properties và Route parameters. -- [ ] **[F4.4] MIME Types & Response Alias:** Hỗ trợ @accept, @produce (mặc định application/json) và alias @success/@failure. -- [ ] **[F4.5] Advanced Operation Metadata:** Hỗ trợ @operationId, @deprecated và OpenAPI Extensions (x-). +- [x] **[F4.4] MIME Types & Response Alias:** Hỗ trợ @accept, @produce (mặc định application/json) và alias @success/@failure. +- [x] **[F4.5] Advanced Operation Metadata:** Hỗ trợ @operationId, @deprecated và OpenAPI Extensions (x-). ## 3. Team Backlog (User Stories) @@ -98,10 +98,10 @@ - [x] **[S4.3.3] Example Tag Support:** Hỗ trợ tag @example để hiển thị dữ liệu mẫu trong UI. ### Stories cho [F4.4] MIME Types & Response Alias -- [ ] **[S4.4.1] MIME Type Tags (@accept, @produce):** Cho phép định nghĩa kiểu nội dung cho request/response. -- [ ] **[S4.4.2] Success/Failure Aliases:** Xử lý @success và @failure như các alias của @response để tăng tính trực quan. +- [x] **[S4.4.1] MIME Type Tags (@accept, @produce):** Cho phép định nghĩa kiểu nội dung cho request/response. +- [x] **[S4.4.2] Success/Failure Aliases:** Xử lý @success và @failure như các alias của @response để tăng tính trực quan. ### Stories cho [F4.5] Advanced Operation Metadata -- [ ] **[S4.5.1] Operation ID Support:** Cho phép đặt tên thủ công cho operation qua tag @operationId. -- [ ] **[S4.5.2] Deprecation Support:** Đánh dấu operation lỗi thời thông qua tag @deprecated. -- [ ] **[S4.5.3] x- Extension Support:** Hỗ trợ trích xuất và xuất các extension OpenAPI tùy chỉnh bắt đầu bằng "x-". +- [x] **[S4.5.1] Operation ID Support:** Cho phép đặt tên thủ công cho operation qua tag @operationId. +- [x] **[S4.5.2] Deprecation Support:** Đánh dấu operation lỗi thời thông qua tag @deprecated. +- [x] **[S4.5.3] x- Extension Support:** Hỗ trợ trích xuất và xuất các extension OpenAPI tùy chỉnh bắt đầu bằng "x-". diff --git a/src/CLI/GenerateCommand.php b/src/CLI/GenerateCommand.php index b5ecb99..bece925 100644 --- a/src/CLI/GenerateCommand.php +++ b/src/CLI/GenerateCommand.php @@ -31,7 +31,9 @@ protected function configure(): void ->addOption('title', null, InputOption::VALUE_REQUIRED, 'API Title') ->addOption('api-version', null, InputOption::VALUE_REQUIRED, 'API Version') ->addOption('description', null, InputOption::VALUE_REQUIRED, 'API Description') - ->addOption('host', null, InputOption::VALUE_REQUIRED, 'API Host/Server URL'); + ->addOption('host', null, InputOption::VALUE_REQUIRED, 'API Host/Server URL') + ->addOption('cache', null, InputOption::VALUE_NONE, 'Enable caching to speed up generation') + ->addOption('cache-file', null, InputOption::VALUE_REQUIRED, 'Cache file path', './.php-swag-cache'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -46,6 +48,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $core->setOpenApiVersion($input->getOption('openapi-version')); $core->setFilterUnusedSchemas($input->getOption('filter-unused')); + if ($input->getOption('cache')) { + $core->enableCache($input->getOption('cache-file') ?: './.php-swag-cache'); + } + if ($title = $input->getOption('title')) { $core->setTitle($title); } diff --git a/src/Cache/CacheInterface.php b/src/Cache/CacheInterface.php new file mode 100644 index 0000000..0f573a2 --- /dev/null +++ b/src/Cache/CacheInterface.php @@ -0,0 +1,12 @@ + */ + private array $data = []; + private bool $loaded = false; + + public function __construct(string $filePath) + { + $this->filePath = $filePath; + } + + private function load(): void + { + if ($this->loaded) { + return; + } + + if (file_exists($this->filePath)) { + $content = file_get_contents($this->filePath); + if ($content !== false) { + try { + $decoded = unserialize($content); + if (is_array($decoded)) { + $this->data = $decoded; + } + } catch (\Throwable $e) { + $this->data = []; + } + } + } + + $this->loaded = true; + } + + private function save(): void + { + $dir = dirname($this->filePath); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + file_put_contents($this->filePath, serialize($this->data), LOCK_EX); + } + + public function get(string $key): mixed + { + $this->load(); + return $this->data[$key] ?? null; + } + + public function set(string $key, mixed $value): void + { + $this->load(); + $this->data[$key] = $value; + $this->save(); + } + + public function clear(): void + { + $this->data = []; + if (file_exists($this->filePath)) { + unlink($this->filePath); + } + $this->loaded = true; + } +} diff --git a/src/Core.php b/src/Core.php index e90a874..fed833a 100644 --- a/src/Core.php +++ b/src/Core.php @@ -20,11 +20,13 @@ class Core private DocBlockCollector $docCollector; private Generator $generator; private SchemaRegistry $schemaRegistry; + private ?Cache\CacheInterface $cache = null; - /** @var array */ + /** @var array */ private array $discoveredClasses = []; private bool $isAnalyzed = false; + private ?string $currentlyAnalyzingFile = null; /** @var array */ private array $globalMetadata = []; @@ -95,14 +97,126 @@ private function analyze(array $paths): void $this->scanner->setPaths($paths); $files = $this->scanner->scan(); + $cachedFilesData = []; + $filesToDiscover = []; + foreach ($files as $file) { + $hash = md5_file($file); + $isCached = $this->cache !== null + && ($cached = $this->cache->get($file)) !== null + && isset($cached['hash']) + && $cached['hash'] === $hash; + + if ($isCached) { + $cachedFilesData[$file] = $cached; + } else { + $filesToDiscover[] = $file; + } + } + + // 1. Process cached files first + foreach ($cachedFilesData as $file => $cached) { + // Restore global metadata + $this->globalMetadata = array_merge($this->globalMetadata, $cached['globalMetadata']); + $this->securitySchemes = array_merge($this->securitySchemes, $cached['securitySchemes']); + $this->globalSecurity = array_merge($this->globalSecurity, $cached['globalSecurity']); + $this->metadataSources = array_merge($this->metadataSources, $cached['metadataSources']); + + // Restore schemas + foreach ($cached['schemas'] as $schema) { + $this->schemaRegistry->register($schema); + } + + // Restore custom schema IDs + foreach ($cached['customSchemaIds'] as $fqcn => $schemaId) { + $this->schemaRegistry->setCustomSchemaId($fqcn, $schemaId); + } + + // Restore routes + foreach ($cached['routes'] as $route) { + $this->generator->addRoute($route); + } + } + + // 2. Discover non-cached files + $fileDiscoverResults = []; + foreach ($filesToDiscover as $file) { + $this->currentlyAnalyzingFile = $file; + + $schemasBefore = $this->schemaRegistry->getAll(); + $metadataBefore = $this->globalMetadata; + $securitySchemesBefore = $this->securitySchemes; + $globalSecurityBefore = $this->globalSecurity; + $metadataSourcesBefore = $this->metadataSources; + $this->discoverFile($file); + + $schemasAfter = $this->schemaRegistry->getAll(); + $newSchemas = array_diff_key($schemasAfter, $schemasBefore); + + $newMetadata = array_diff_key($this->globalMetadata, $metadataBefore); + $newSecuritySchemes = array_diff_key($this->securitySchemes, $securitySchemesBefore); + $newGlobalSecurity = array_slice($this->globalSecurity, count($globalSecurityBefore)); + $newMetadataSources = array_diff_key($this->metadataSources, $metadataSourcesBefore); + + $fileDiscoverResults[$file] = [ + 'hash' => md5_file($file), + 'schemas' => $newSchemas, + 'globalMetadata' => $newMetadata, + 'securitySchemes' => $newSecuritySchemes, + 'globalSecurity' => $newGlobalSecurity, + 'metadataSources' => $newMetadataSources, + 'customSchemaIds' => [], + 'routes' => [], + ]; + + $this->currentlyAnalyzingFile = null; } $this->applyGlobalMetadata(); + // 3. Analyze classes from non-cached files foreach ($this->discoveredClasses as $fqcn => $data) { + $file = $data['filePath']; + $this->currentlyAnalyzingFile = $file; + + $schemasBefore = $this->schemaRegistry->getAll(); + $customIdsBefore = $this->schemaRegistry->getCustomSchemaIds(); + $routesCountBefore = count($this->generator->getRoutes()); + $this->analyzeClass($fqcn, $data['node'], $data['nameResolver']); + + $schemasAfter = $this->schemaRegistry->getAll(); + $customIdsAfter = $this->schemaRegistry->getCustomSchemaIds(); + $routesAfter = $this->generator->getRoutes(); + + $newSchemas = array_diff_key($schemasAfter, $schemasBefore); + $newCustomIds = array_diff_key($customIdsAfter, $customIdsBefore); + $newRoutes = array_slice($routesAfter, $routesCountBefore); + + if (isset($fileDiscoverResults[$file])) { + $fileDiscoverResults[$file]['schemas'] = array_merge( + $fileDiscoverResults[$file]['schemas'], + $newSchemas + ); + $fileDiscoverResults[$file]['customSchemaIds'] = array_merge( + $fileDiscoverResults[$file]['customSchemaIds'], + $newCustomIds + ); + $fileDiscoverResults[$file]['routes'] = array_merge( + $fileDiscoverResults[$file]['routes'], + $newRoutes + ); + } + + $this->currentlyAnalyzingFile = null; + } + + // 4. Save cache + if ($this->cache !== null) { + foreach ($fileDiscoverResults as $file => $result) { + $this->cache->set($file, $result); + } } $this->isAnalyzed = true; @@ -322,7 +436,8 @@ private function discoverStatement(Node $stmt, NameResolver $nameResolver): void $fqcn = $nameResolver->resolve($stmt->name->toString()); $this->discoveredClasses[$fqcn] = [ 'node' => $stmt, - 'nameResolver' => $nameResolver + 'nameResolver' => $nameResolver, + 'filePath' => $this->currentlyAnalyzingFile ]; $templates = []; @@ -462,9 +577,15 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, $description = null; $tagsList = []; $responses = []; + $responseDescriptions = []; $parameters = []; $requestBody = null; $security = []; + $accept = null; + $produce = null; + $operationId = null; + $deprecated = false; + $extensions = []; foreach ($tags as $tag) { if ($tag['name'] === '@route') { @@ -477,12 +598,43 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, $description = $tag['value']; } elseif ($tag['name'] === '@tag') { $tagsList[] = $tag['value']; - } elseif ($tag['name'] === '@response') { + } elseif ($tag['name'] === '@accept' || $tag['name'] === '@consume') { + $accept = $tag['value']; + } elseif ($tag['name'] === '@produce') { + $produce = $tag['value']; + } elseif ($tag['name'] === '@operationId' || $tag['name'] === '@operationid') { + $operationId = $tag['value']; + } elseif ($tag['name'] === '@deprecated') { + $deprecated = true; + } elseif (str_starts_with($tag['name'], '@x-')) { + $extName = substr($tag['name'], 1); + $val = $tag['value']; + if (str_starts_with($val, '{') || str_starts_with($val, '[')) { + $decoded = json_decode($val, true); + if (json_last_error() === JSON_ERROR_NONE) { + $val = $decoded; + } + } + $extensions[$extName] = $val; + } elseif (in_array($tag['name'], ['@response', '@success', '@failure'])) { if (preg_match('/^(\d+)\s+(.*)$/', $tag['value'], $matches)) { $code = $matches[1]; - $typeToParse = trim($matches[2]); + $typeAndDesc = trim($matches[2]); + [$typeToParse, $respDesc] = $this->splitTypeAndDescription($typeAndDesc); + + if ($respDesc === '') { + if ($tag['name'] === '@success') { + $respDesc = 'Success'; + } elseif ($tag['name'] === '@failure') { + $respDesc = 'Failure'; + } else { + $respDesc = 'OK'; + } + } + $typeNode = $this->docCollector->parseType($typeToParse); $responses[$code] = $typeResolver->resolve($typeNode); + $responseDescriptions[$code] = $respDesc; } } elseif (in_array($tag['name'], ['@path', '@query', '@header', '@cookie'])) { $in = substr($tag['name'], 1); @@ -572,7 +724,13 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, responses: $responses, parameters: $parameters, requestBody: $requestBody, - security: $security + security: $security, + responseDescriptions: $responseDescriptions, + accept: $accept, + produce: $produce, + operationId: $operationId, + deprecated: $deprecated, + extensions: $extensions )); } } @@ -619,4 +777,55 @@ public function setServers(array $servers): void $this->generator->setServers($servers); } } + + public function setCache(Cache\CacheInterface $cache): void + { + $this->cache = $cache; + } + + public function enableCache(string $cacheFilePath): void + { + $this->cache = new Cache\FileCache($cacheFilePath); + } + + /** + * @return array{0: string, 1: string} + */ + private function splitTypeAndDescription(string $str): array + { + $str = trim($str); + if (preg_match('/^([a-zA-Z0-9_\\\\]+)') { + $depth--; + } + if ($started && $depth === 0) { + $typeLen = $i + 1; + break; + } + } + if ($typeLen > 0) { + $type = substr($str, 0, $typeLen); + $desc = trim(substr($str, $typeLen)); + if (str_starts_with($desc, '[]')) { + $type .= '[]'; + $desc = trim(substr($desc, 2)); + } + return [$type, $desc]; + } + } + + $parts = preg_split('/\s+/', $str, 2); + $type = $parts[0] ?? ''; + $desc = $parts[1] ?? ''; + return [$type, $desc]; + } } diff --git a/src/DocBlockCollector.php b/src/DocBlockCollector.php index ef1786a..7cbd50c 100644 --- a/src/DocBlockCollector.php +++ b/src/DocBlockCollector.php @@ -32,7 +32,7 @@ public function collectTags(string $docComment): array continue; } - if (preg_match('/^(@[a-zA-Z0-9_.]+)(?:\s+(.*))?$/', $line, $matches)) { + if (preg_match('/^(@[a-zA-Z0-9_.-]+)(?:\s+(.*))?$/', $line, $matches)) { $tagName = $matches[1]; $value = isset($matches[2]) ? trim($matches[2]) : ''; @@ -76,7 +76,7 @@ public function collectTags(string $docComment): array } } elseif ($tagName === '@body') { // @body [Type] [Description] - if (preg_match('/^([a-zA-Z0-9_\\<>|\[\]]+)(?:\s+(.*))?$/', $value, $m)) { + if (preg_match('/^([a-zA-Z0-9_\\\\<>|\[\]]+)(?:\s+(.*))?$/', $value, $m)) { $typeString = $m[1]; $desc = isset($m[2]) ? $m[2] : ''; diff --git a/src/Generator.php b/src/Generator.php index 4061023..8efd57a 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -103,6 +103,14 @@ public function addRoute(RouteDefinition $route): void $this->routes[] = $route; } + /** + * @return array + */ + public function getRoutes(): array + { + return $this->routes; + } + public function generateYaml(): string { return Yaml::dump($this->generateSpec(), 10, 2); @@ -177,6 +185,20 @@ private function generateSpec(): array $routeSpec['security'] = $route->security; } + if ($route->operationId !== null && $route->operationId !== '') { + $routeSpec['operationId'] = $route->operationId; + } + + if ($route->deprecated) { + $routeSpec['deprecated'] = true; + } + + if (!empty($route->extensions)) { + foreach ($route->extensions as $extName => $extVal) { + $routeSpec[$extName] = $extVal; + } + } + if (!empty($route->parameters)) { $routeSpec['parameters'] = []; foreach ($route->parameters as $param) { @@ -191,7 +213,7 @@ private function generateSpec(): array $schema = $this->processSchemaOutput($param['schema']); - // Handle validation tags + // Handle validation tags $validationTags = [ 'enum', 'default', @@ -236,13 +258,21 @@ private function generateSpec(): array } if ($route->requestBody) { + $contentTypes = ['application/json']; + if ($route->accept !== null && $route->accept !== '') { + $contentTypes = $this->resolveMimeTypes($route->accept); + } + + $contentSpec = []; + foreach ($contentTypes as $contentType) { + $contentSpec[$contentType] = [ + 'schema' => $this->processSchemaOutput($route->requestBody['schema']) + ]; + } + $routeSpec['requestBody'] = [ 'required' => true, - 'content' => [ - 'application/json' => [ - 'schema' => $this->processSchemaOutput($route->requestBody['schema']) - ] - ] + 'content' => $contentSpec ]; if (!empty($route->requestBody['description'])) { $routeSpec['requestBody']['description'] = $route->requestBody['description']; @@ -251,13 +281,28 @@ private function generateSpec(): array if (!empty($route->responses)) { foreach ($route->responses as $code => $schema) { + $contentTypes = ['application/json']; + if ($route->produce !== null && $route->produce !== '') { + $contentTypes = $this->resolveMimeTypes($route->produce); + } + + $contentSpec = []; + foreach ($contentTypes as $contentType) { + $contentSpec[$contentType] = [ + 'schema' => $this->processSchemaOutput($schema) + ]; + } + + $description = $route->responseDescriptions[(string)$code] + ?? $route->responseDescriptions[$code] + ?? 'OK'; + if ($description === '') { + $description = 'OK'; + } + $routeSpec['responses'][(string)$code] = [ - 'description' => 'OK', - 'content' => [ - 'application/json' => [ - 'schema' => $this->processSchemaOutput($schema) - ] - ] + 'description' => $description, + 'content' => $contentSpec ]; } } else { @@ -518,4 +563,34 @@ private function collectFqcnsFromSchema(array $schema, array &$usedFqcns): void } } } + + /** + * @return array + */ + private function resolveMimeTypes(string $mimeTypesString): array + { + $parts = preg_split('/[\s,]+/', trim($mimeTypesString)); + if ($parts === false) { + return ['application/json']; + } + $resolved = []; + $map = [ + 'json' => 'application/json', + 'xml' => 'application/xml', + 'plain' => 'text/plain', + 'html' => 'text/html', + 'mpfd' => 'multipart/form-data', + 'x-www-form-urlencoded' => 'application/x-www-form-urlencoded', + ]; + + foreach ($parts as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + $resolved[] = $map[strtolower($part)] ?? $part; + } + + return !empty($resolved) ? $resolved : ['application/json']; + } } diff --git a/src/IR/RouteDefinition.php b/src/IR/RouteDefinition.php index 30b8b08..3740885 100644 --- a/src/IR/RouteDefinition.php +++ b/src/IR/RouteDefinition.php @@ -10,6 +10,8 @@ class RouteDefinition * @param array> $parameters * @param array{schema: array, description?: string|null}|null $requestBody * @param array>> $security + * @param array $responseDescriptions + * @param array $extensions */ public function __construct( public string $method, @@ -20,7 +22,13 @@ public function __construct( public array $responses = [], public array $parameters = [], public ?array $requestBody = null, - public array $security = [] + public array $security = [], + public array $responseDescriptions = [], + public ?string $accept = null, + public ?string $produce = null, + public ?string $operationId = null, + public bool $deprecated = false, + public array $extensions = [] ) { } } diff --git a/src/SchemaRegistry.php b/src/SchemaRegistry.php index f9d2802..0627651 100644 --- a/src/SchemaRegistry.php +++ b/src/SchemaRegistry.php @@ -38,6 +38,14 @@ public function setCustomSchemaId(string $fqcn, string $id): void $this->customSchemaIds[$fqcn] = $id; } + /** + * @return array + */ + public function getCustomSchemaIds(): array + { + return $this->customSchemaIds; + } + public function getSchemaId(string $fqcn): string { if (isset($this->customSchemaIds[$fqcn])) { diff --git a/tests/AdvancedMetadataTest.php b/tests/AdvancedMetadataTest.php new file mode 100644 index 0000000..10cc811 --- /dev/null +++ b/tests/AdvancedMetadataTest.php @@ -0,0 +1,45 @@ +getUsers();"}] + * @response 200 string + */ + public function list() {} +} +PHP; + + $tempFile = tempnam(sys_get_temp_dir(), 'php-swag-metadata-test'); + file_put_contents($tempFile, $code); + + $core = new Core(); + $yaml = $core->generateYaml([$tempFile]); + unlink($tempFile); + + $spec = \Symfony\Component\Yaml\Yaml::parse($yaml); + $routeSpec = $spec['paths']['/users']['get']; + + $this->assertEquals('fetchUsersList', $routeSpec['operationId']); + $this->assertTrue($routeSpec['deprecated']); + $this->assertArrayHasKey('x-code-samples', $routeSpec); + $this->assertIsArray($routeSpec['x-code-samples']); + $this->assertEquals('PHP', $routeSpec['x-code-samples'][0]['lang']); + $this->assertEquals('$api->getUsers();', $routeSpec['x-code-samples'][0]['source']); + } +} diff --git a/tests/CachingTest.php b/tests/CachingTest.php new file mode 100644 index 0000000..2504fc9 --- /dev/null +++ b/tests/CachingTest.php @@ -0,0 +1,53 @@ +enableCache($cacheFile); + $yaml1 = $core1->generateYaml([$tempFile]); + + $this->assertFileExists($cacheFile); + + // Run 2: Hot start (file is loaded from cache) + $core2 = new Core(); + $core2->enableCache($cacheFile); + $yaml2 = $core2->generateYaml([$tempFile]); + + $this->assertEquals($yaml1, $yaml2); + + // Clean up + unlink($tempFile); + if (file_exists($cacheFile)) { + unlink($cacheFile); + } + } +} diff --git a/tests/MimeTypesAndAliasesTest.php b/tests/MimeTypesAndAliasesTest.php new file mode 100644 index 0000000..179aeb0 --- /dev/null +++ b/tests/MimeTypesAndAliasesTest.php @@ -0,0 +1,71 @@ +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = \Symfony\Component\Yaml\Yaml::parse($yaml); + + // Check accept / requestBody MIME types + $requestBody = $spec['paths']['/users']['post']['requestBody']; + $this->assertArrayHasKey('application/json', $requestBody['content']); + + // Check produce / response MIME types + $responses = $spec['paths']['/users']['post']['responses']; + $this->assertArrayHasKey('201', $responses); + $this->assertEquals('Created successfully', $responses['201']['description']); + $this->assertArrayHasKey('application/json', $responses['201']['content']); + $this->assertArrayHasKey('application/xml', $responses['201']['content']); + + $this->assertArrayHasKey('400', $responses); + $this->assertEquals('Client side validation failure', $responses['400']['description']); + $this->assertArrayHasKey('application/json', $responses['400']['content']); + $this->assertArrayHasKey('application/xml', $responses['400']['content']); + } +} From c4a983b6f2b1b8b21a23a6e572548eb6a8894fe2 Mon Sep 17 00:00:00 2001 From: tolawho Date: Sun, 7 Jun 2026 11:32:45 +0700 Subject: [PATCH 16/27] feat: implement Petstore example (OAS 3.0 v3) and improve core library --- examples/App/Controllers/PetController.php | 156 +++++++++++++++++++ examples/App/Controllers/PostController.php | 30 ---- examples/App/Controllers/StoreController.php | 73 +++++++++ examples/App/Controllers/UserController.php | 120 ++++++++++++-- examples/App/Models/ApiResponse.php | 7 +- examples/App/Models/BaseModel.php | 10 -- examples/App/Models/Category.php | 11 ++ examples/App/Models/Collection.php | 12 -- examples/App/Models/Order.php | 15 ++ examples/App/Models/Pet.php | 15 ++ examples/App/Models/Post.php | 12 -- examples/App/Models/Tag.php | 11 ++ examples/App/Models/Timestampable.php | 11 -- examples/App/Models/User.php | 12 +- examples/App/OpenApi.php | 10 ++ output_final.yaml | 142 ----------------- src/Core.php | 57 +++++-- src/DocBlockCollector.php | 2 +- src/TypeResolver.php | 10 ++ 19 files changed, 463 insertions(+), 253 deletions(-) create mode 100644 examples/App/Controllers/PetController.php delete mode 100644 examples/App/Controllers/PostController.php create mode 100644 examples/App/Controllers/StoreController.php delete mode 100644 examples/App/Models/BaseModel.php create mode 100644 examples/App/Models/Category.php delete mode 100644 examples/App/Models/Collection.php create mode 100644 examples/App/Models/Order.php create mode 100644 examples/App/Models/Pet.php delete mode 100644 examples/App/Models/Post.php create mode 100644 examples/App/Models/Tag.php delete mode 100644 examples/App/Models/Timestampable.php create mode 100644 examples/App/OpenApi.php delete mode 100644 output_final.yaml diff --git a/examples/App/Controllers/PetController.php b/examples/App/Controllers/PetController.php new file mode 100644 index 0000000..c1d8344 --- /dev/null +++ b/examples/App/Controllers/PetController.php @@ -0,0 +1,156 @@ +> - */ - public function index() - { - } - - /** - * @route GET /posts/{id} - * @summary Get a single post - * @tag Posts - * @response 200 ApiResponse - */ - public function show(int $id) - { - } -} diff --git a/examples/App/Controllers/StoreController.php b/examples/App/Controllers/StoreController.php new file mode 100644 index 0000000..d17c8a8 --- /dev/null +++ b/examples/App/Controllers/StoreController.php @@ -0,0 +1,73 @@ + successful operation + * @response default void Unexpected error + * @security api_key + */ + public function getInventory() + { + } + + /** + * @route POST /store/order + * @summary Place an order for a pet. + * @description Place a new order in the store. + * @operationId placeOrder + * @tag store + * @accept json, xml, x-www-form-urlencoded + * @produce json, xml + * @body \App\Models\Order order placed for purchasing the pet + * @response 200 \App\Models\Order successful operation + * @response 400 void Invalid input + * @response 422 void Validation exception + * @response default void Unexpected error + */ + public function placeOrder() + { + } + + /** + * @route GET /store/order/{orderId} + * @summary Find purchase order by ID. + * @description For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions. + * @operationId getOrderById + * @tag store + * @produce json, xml + * @path int $orderId ID of order that needs to be fetched format(int64) + * @response 200 \App\Models\Order successful operation + * @response 400 void Invalid ID supplied + * @response 404 void Order not found + * @response default void Unexpected error + */ + public function getOrderById(int $orderId) + { + } + + /** + * @route DELETE /store/order/{orderId} + * @summary Delete purchase order by identifier. + * @description For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors. + * @operationId deleteOrder + * @tag store + * @produce json, xml + * @path int $orderId ID of the order that needs to be deleted format(int64) + * @response 200 void order deleted + * @response 400 void Invalid ID supplied + * @response 404 void Order not found + * @response default void Unexpected error + */ + public function deleteOrder(int $orderId) + { + } +} diff --git a/examples/App/Controllers/UserController.php b/examples/App/Controllers/UserController.php index e035f19..746bbb4 100644 --- a/examples/App/Controllers/UserController.php +++ b/examples/App/Controllers/UserController.php @@ -2,29 +2,121 @@ namespace App\Controllers; -use App\Models\User; - class UserController { /** - * @route GET /users - * @summary List all users - * @tag User Management - * @response 200 User[] + * @route POST /user + * @summary Create user. + * @description This can only be done by the logged in user. + * @operationId createUser + * @tag user + * @accept json, xml, x-www-form-urlencoded + * @produce json, xml + * @body \App\Models\User Created user object + * @response 200 \App\Models\User successful operation + * @response default void Unexpected error + */ + public function createUser() + { + } + + /** + * @route POST /user/createWithList + * @summary Creates list of users with given input array. + * @description Creates list of users with given input array. + * @operationId createUsersWithListInput + * @tag user + * @accept json + * @produce json, xml + * @body \App\Models\User[] List of user object + * @response 200 \App\Models\User Successful operation + * @response default void Unexpected error + */ + public function createUsersWithListInput() + { + } + + /** + * @route GET /user/login + * @summary Logs user into the system. + * @description Log into the system. + * @operationId loginUser + * @tag user + * @produce json, xml + * @query string $username The user name for login + * @query string $password The password for login in clear text + * @response 200 string successful operation + * @response 400 void Invalid username/password supplied + * @response default void Unexpected error + */ + public function loginUser() + { + } + + /** + * @route GET /user/logout + * @summary Logs out current logged in user session. + * @description Log user out of the system. + * @operationId logoutUser + * @tag user + * @produce json, xml + * @response 200 void successful operation + * @response default void Unexpected error + */ + public function logoutUser() + { + } + + /** + * @route GET /user/{username} + * @summary Get user by user name. + * @description Get user detail based on username. + * @operationId getUserByName + * @tag user + * @produce json, xml + * @path string $username The name that needs to be fetched. Use user1 for testing + * @response 200 \App\Models\User successful operation + * @response 400 void Invalid username supplied + * @response 404 void User not found + * @response default void Unexpected error + */ + public function getUserByName(string $username) + { + } + + /** + * @route PUT /user/{username} + * @summary Update user resource. + * @description This can only be done by the logged in user. + * @operationId updateUser + * @tag user + * @accept json, xml, x-www-form-urlencoded + * @produce json, xml + * @path string $username name that need to be deleted + * @body \App\Models\User Update an existent user in the store + * @response 200 void successful operation + * @response 400 void bad request + * @response 404 void user not found + * @response default void Unexpected error */ - public function index() + public function updateUser(string $username) { } /** - * @route GET /users/{id} - * @summary Get user details - * @description This endpoint returns a single user by their ID. - * @tag User Management - * @response 200 User - * @response 404 string + * @route DELETE /user/{username} + * @summary Delete user resource. + * @description This can only be done by the logged in user. + * @operationId deleteUser + * @tag user + * @produce json, xml + * @path string $username The name that needs to be deleted + * @response 200 void User deleted + * @response 400 void Invalid username supplied + * @response 404 void User not found + * @response default void Unexpected error */ - public function show(int $id) + public function deleteUser(string $username) { } } diff --git a/examples/App/Models/ApiResponse.php b/examples/App/Models/ApiResponse.php index a4ddbcd..a4512a5 100644 --- a/examples/App/Models/ApiResponse.php +++ b/examples/App/Models/ApiResponse.php @@ -3,10 +3,9 @@ namespace App\Models; /** - * @template T - * @property T $data Response payload - * @property string $status Status code (success/error) - * @property string|null $message Optional message + * @property int $code format(int32) + * @property string $type + * @property string $message */ class ApiResponse { diff --git a/examples/App/Models/BaseModel.php b/examples/App/Models/BaseModel.php deleted file mode 100644 index c3d5b91..0000000 --- a/examples/App/Models/BaseModel.php +++ /dev/null @@ -1,10 +0,0 @@ -docCollector->collectTags($docComment); $isGlobalBlock = false; + $hasRouteOrProperty = false; foreach ($tags as $tag) { - if ( - in_array($tag['name'], ['@title', '@version', '@description', '@host']) || - str_starts_with($tag['name'], '@contact.') || - str_starts_with($tag['name'], '@license.') || - str_starts_with($tag['name'], '@securityDefinitions.') - ) { - $isGlobalBlock = true; + if (in_array($tag['name'], ['@route', '@property', '@var'])) { + $hasRouteOrProperty = true; break; } } + if (!$hasRouteOrProperty) { + foreach ($tags as $tag) { + if ( + in_array($tag['name'], ['@title', '@version', '@description', '@host']) || + str_starts_with($tag['name'], '@contact.') || + str_starts_with($tag['name'], '@license.') || + str_starts_with($tag['name'], '@securityDefinitions.') + ) { + $isGlobalBlock = true; + break; + } + } + } if (!$isGlobalBlock) { continue; @@ -617,8 +626,8 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, } $extensions[$extName] = $val; } elseif (in_array($tag['name'], ['@response', '@success', '@failure'])) { - if (preg_match('/^(\d+)\s+(.*)$/', $tag['value'], $matches)) { - $code = $matches[1]; + if (preg_match('/^(\d+|default)\s+(.*)$/i', $tag['value'], $matches)) { + $code = strtolower($matches[1]); $typeAndDesc = trim($matches[2]); [$typeToParse, $respDesc] = $this->splitTypeAndDescription($typeAndDesc); @@ -644,11 +653,33 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, 'name' => $tag['propertyName'] ]); } elseif ($tag['name'] === '@body') { + $schema = $typeResolver->resolve($tag['type']); + $extra = is_array($tag['description']) ? $tag['description'] : []; + $desc = $extra['description'] ?? null; + unset($extra['description']); + $validationTags = [ + 'enum', 'default', 'minimum', 'maximum', 'minLength', + 'maxLength', 'pattern', 'format', 'example' + ]; + foreach ($validationTags as $vTag) { + if (isset($extra[$vTag])) { + $val = $extra[$vTag]; + if ( + in_array( + $vTag, + ['minimum', 'maximum', 'minLength', 'maxLength', 'default', 'example'] + ) + ) { + $val = is_numeric($val) + ? (strpos((string)$val, '.') !== false ? (float)$val : (int)$val) + : $val; + } + $schema[$vTag] = $val; + } + } $requestBody = [ - 'schema' => $typeResolver->resolve($tag['type']), - 'description' => is_array($tag['description']) - ? ($tag['description']['description'] ?? null) - : ($tag['description'] ?? null) + 'schema' => $schema, + 'description' => $desc ]; } elseif ($tag['name'] === '@security') { $security = array_merge($security, $this->parseSecurityTag($tag['value'])); diff --git a/src/DocBlockCollector.php b/src/DocBlockCollector.php index 7cbd50c..fa1685a 100644 --- a/src/DocBlockCollector.php +++ b/src/DocBlockCollector.php @@ -89,7 +89,7 @@ public function collectTags(string $docComment): array $tags[] = [ 'name' => '@body', 'type' => $type, - 'description' => $desc + 'description' => $this->parseExtraAttributes($desc) ]; } } else { diff --git a/src/TypeResolver.php b/src/TypeResolver.php index a49d5ab..ef10df3 100644 --- a/src/TypeResolver.php +++ b/src/TypeResolver.php @@ -72,6 +72,16 @@ public function resolve(TypeNode $typeNode): array if ($typeNode instanceof GenericTypeNode) { if ($typeNode->type->name === 'array' || $typeNode->type->name === 'list') { + if ( + count($typeNode->genericTypes) === 2 && + $typeNode->genericTypes[0] instanceof IdentifierTypeNode && + $typeNode->genericTypes[0]->name === 'string' + ) { + return [ + 'type' => 'object', + 'additionalProperties' => $this->resolve($typeNode->genericTypes[1]) + ]; + } return [ 'type' => 'array', 'items' => $this->resolve($typeNode->genericTypes[0]) From 47050a7a3d7039a07741ec4555a9a6d96f9894ef Mon Sep 17 00:00:00 2001 From: tolawho Date: Sun, 7 Jun 2026 12:47:44 +0700 Subject: [PATCH 17/27] fix: serialize http status code - document map types, body constraints, and default response codes - serialize HTTP status code keys as strings and empty security scopes as arrays in YAML --- README.md | 9 ++++++--- src/Generator.php | 7 ++++++- tests/SecurityTest.php | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f7fe108..0219928 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AS - Primitives: `int`, `string`, `bool`, `float`. - Nullable types: `?string` or `string|null`. - Array types: `User[]` or `array`. + - Map/Dictionary types: `array` (resolves to an object with `additionalProperties` mapping to `User`). - Class references: Automatically resolves FQCN and creates schemas. - **Advanced OOP Support**: - **Inheritance**: Properties from parent classes are automatically merged into child schemas. @@ -116,6 +117,8 @@ Supported constraints: Values are automatically cast to their appropriate types (integers, floats, or strings) in the final OpenAPI output. +Validation constraints and formats are also fully supported on the `@body` tag description (e.g., `@body string file to upload format(binary)`). + ### Route Parameters Handling The library supports explicit tags and auto-inference (inspired by swaggo). @@ -161,9 +164,9 @@ public function show(int $id, string $status) {} - `@header [TYPE] $[NAME] [DESC]` - `@cookie [TYPE] $[NAME] [DESC]` - `@body [TYPE] [DESC]` - - `@response [CODE] [TYPE] [DESC]` (e.g., `@response 200 ApiResponse Success response`) - - `@success [CODE] [TYPE] [DESC]` (Alias of `@response`, e.g., `@success 200 User Success`) - - `@failure [CODE] [TYPE] [DESC]` (Alias of `@response`, e.g., `@failure 400 ErrorResponse Bad Request`) + - `@response [CODE] [TYPE] [DESC]` (e.g., `@response 200 ApiResponse Success response`, supports `default` code) + - `@success [CODE] [TYPE] [DESC]` (Alias of `@response`, e.g., `@success 200 User Success`, supports `default` code) + - `@failure [CODE] [TYPE] [DESC]` (Alias of `@response`, e.g., `@failure 400 ErrorResponse Bad Request`, supports `default` code) - `@operationId [TEXT]` (Define explicit operationId) - `@deprecated` (Mark the operation as deprecated) - `@x-[EXTENSION_NAME] [VALUE]` (Custom OpenAPI extensions, e.g. `@x-code-samples [{"lang": "PHP"}]`) diff --git a/src/Generator.php b/src/Generator.php index 8efd57a..e9dd069 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -113,7 +113,12 @@ public function getRoutes(): array public function generateYaml(): string { - return Yaml::dump($this->generateSpec(), 10, 2); + $yaml = Yaml::dump($this->generateSpec(), 10, 2, Yaml::DUMP_NUMERIC_KEY_AS_STRING); + return preg_replace( + '/(?<=\n)(\s+)(?!schema\b)([a-zA-Z0-9_-]+):\s*\{\s*\}\s*(?=\n)/', + '$1$2: [ ]', + $yaml + ); } public function generateJson(): string diff --git a/tests/SecurityTest.php b/tests/SecurityTest.php index f9a31cf..775024c 100644 --- a/tests/SecurityTest.php +++ b/tests/SecurityTest.php @@ -98,7 +98,7 @@ public function scoped() {} // Check Global Security $this->assertStringContainsString('security:', $yaml); - $this->assertStringContainsString('MyJwtAuth: { }', $yaml); + $this->assertStringContainsString('MyJwtAuth: [ ]', $yaml); // Check Scoped Security $this->assertStringContainsString('- read', $yaml); From d014ec51c0280537c8228a2ac9d028ccbda47b38 Mon Sep 17 00:00:00 2001 From: tolawho Date: Sun, 7 Jun 2026 16:22:12 +0700 Subject: [PATCH 18/27] docs: corresponding features Added Epic 5, corresponding features, and user stories based on the discussion to enhance the generator's capabilities and developer experience. --- SAFE_BACKLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/SAFE_BACKLOG.md b/SAFE_BACKLOG.md index b49d1a8..8642a86 100644 --- a/SAFE_BACKLOG.md +++ b/SAFE_BACKLOG.md @@ -105,3 +105,49 @@ - [x] **[S4.5.1] Operation ID Support:** Cho phép đặt tên thủ công cho operation qua tag @operationId. - [x] **[S4.5.2] Deprecation Support:** Đánh dấu operation lỗi thời thông qua tag @deprecated. - [x] **[S4.5.3] x- Extension Support:** Hỗ trợ trích xuất và xuất các extension OpenAPI tùy chỉnh bắt đầu bằng "x-". + +### [Epic 5] Developer Experience & Modern PHP Support +- **Trạng thái:** To Do +- **Chủ sở hữu:** Fullstack Developer (User) +- **Tóm tắt:** Tập trung vào việc tối ưu hóa quy trình viết code, tận dụng các tính năng hiện đại của PHP (Enums, Attributes-like inference) và cung cấp thông báo lỗi minh bạch. +- **Giả thuyết Lợi ích (Benefit Hypothesis):** Giảm thiểu mã lặp lại, tận dụng tối đa sức mạnh của ngôn ngữ PHP hiện đại và giúp lập trình viên phát hiện lỗi cấu hình API ngay lập tức, từ đó tăng tốc độ phát triển. +- **Tiêu chí chấp nhận (Acceptance Criteria):** + - Hỗ trợ khai báo metadata ở cấp Controller (Class). + - Tự động suy luận các thuộc tính bắt buộc (required) mà không cần khai báo thủ công. + - Tích hợp sâu với Native Enums (PHP 8.1+). + - Cung cấp cơ chế mapping thông minh cho các kiểu dữ liệu phổ biến (DateTime, UUID). + - Thông báo lỗi chi tiết kèm vị trí file/dòng code. + +## 2. Program Backlog (Features) (Tiếp theo) + +### Features cho [Epic 5] Developer Experience +- [ ] **[F5.1] Controller-level Metadata Support:** Hỗ trợ @tag, @security, @accept, @produce ở cấp Class. +- [ ] **[F5.2] Enhanced Diagnostics & Error Reporting:** Cải thiện thông báo lỗi với đầy đủ thông tin ngữ cảnh (file, line). +- [ ] **[F5.3] Intelligent Schema Inference:** Tự động xác định `required` fields dựa trên type-hint và giá trị mặc định. +- [ ] **[F5.4] Native PHP Enum Support:** Tự động trích xuất các case từ PHP 8.1+ Enums. +- [ ] **[F5.5] Smart Type Mapping Registry:** Map các class phổ biến (DateTime, Uuid, UploadedFile) sang kiểu dữ liệu OpenAPI tương ứng. + +## 3. Team Backlog (User Stories) (Tiếp theo) + +### Stories cho [F5.1] Controller-level Metadata +- [ ] **[S5.1.1] Class-level Tag Collection:** Thu thập @tag từ class docblock và gộp với tags ở method. +- [ ] **[S5.1.2] Class-level Security & Content-Type:** Áp dụng @security, @accept, @produce từ class làm mặc định cho tất cả method bên trong, cho phép method ghi đè. + +### Stories cho [F5.2] Enhanced Diagnostics +- [ ] **[S5.2.1] Source Location Tracking:** Lưu trữ thông tin file và dòng code trong quá trình parse. +- [ ] **[S5.2.2] User-Friendly Error Messages:** Hiển thị lỗi chi tiết khi không phân giải được class hoặc tag sai cú pháp. + +### Stories cho [F5.3] Intelligent Schema Inference +- [ ] **[S5.3.1] Nullable-based Required Detection:** Tự động đánh dấu `required: true` nếu type-hint không nullable. +- [ ] **[S5.3.2] Default Value Inference:** Thuộc tính có giá trị mặc định được coi là optional. +- [ ] **[S5.3.3] Explicit @required Tag:** Hỗ trợ tag @required để ghi đè logic suy luận. + +### Stories cho [F5.4] Native PHP Enum Support +- [ ] **[S5.4.1] Enum Detection logic:** Nhận diện class là Enum thông qua Reflection. +- [ ] **[S5.4.2] BackedEnum Value Extraction:** Tự động lấy `value` cho BackedEnum (string/int). +- [ ] **[S5.4.3] UnitEnum Name Extraction:** Tự động lấy `name` cho UnitEnum. + +### Stories cho [F5.5] Smart Type Mapping +- [ ] **[S5.5.1] Built-in Date/Time Mapping:** Map `DateTimeInterface` sang `string/date-time`. +- [ ] **[S5.5.2] External Library Support (Optional):** Hỗ trợ mapping cho Uuid (Ramsey/Symfony) nếu class tồn tại. +- [ ] **[S5.5.3] Binary/File Mapping:** Map các class UploadedFile phổ biến sang `string/binary`. From 950ceb29255455573e7a3b82b5d2d18cf526453b Mon Sep 17 00:00:00 2001 From: tolawho Date: Sun, 7 Jun 2026 18:10:18 +0700 Subject: [PATCH 19/27] feat: implement developer experience - controller-level metadata and global tag ordering - enhanced diagnostics & error reporting - intelligent Schema Inference and update docs - native PHP Enum support and downgrade PHP requirement to >=8.0 - smart type Mapping Registry for common classes and custom mappings --- README.md | 189 ++++++++++ SAFE_BACKLOG.md | 36 +- composer.json | 2 +- examples/App/OpenApi.php | 4 + src/Core.php | 407 ++++++++++++++++++++-- src/DocBlockCollector.php | 49 ++- src/Exception/DiagnosticException.php | 7 + src/Generator.php | 68 +++- src/IR/PropertyDefinition.php | 5 +- src/IR/RouteDefinition.php | 4 +- src/IR/SchemaDefinition.php | 7 +- src/TypeMappingRegistry.php | 86 +++++ src/TypeResolver.php | 50 ++- tests/ControllerLevelMetadataTest.php | 280 +++++++++++++++ tests/DiagnosticsTest.php | 196 +++++++++++ tests/IntelligentSchemaInferenceTest.php | 253 ++++++++++++++ tests/NativeEnumSupportTest.php | 119 +++++++ tests/SmartTypeMappingTest.php | 152 ++++++++ tests/TypeResolverTest.php | 1 + tests/fixtures/enums/BackedIntEnum.php | 9 + tests/fixtures/enums/BackedStringEnum.php | 9 + tests/fixtures/enums/PureUnitEnum.php | 10 + 22 files changed, 1865 insertions(+), 78 deletions(-) create mode 100644 src/Exception/DiagnosticException.php create mode 100644 src/TypeMappingRegistry.php create mode 100644 tests/ControllerLevelMetadataTest.php create mode 100644 tests/DiagnosticsTest.php create mode 100644 tests/IntelligentSchemaInferenceTest.php create mode 100644 tests/NativeEnumSupportTest.php create mode 100644 tests/SmartTypeMappingTest.php create mode 100644 tests/fixtures/enums/BackedIntEnum.php create mode 100644 tests/fixtures/enums/BackedStringEnum.php create mode 100644 tests/fixtures/enums/PureUnitEnum.php diff --git a/README.md b/README.md index 0219928..a86e31e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AS - **Security & Authentication**: Define global security schemes (ApiKey, JWT) and apply them to endpoints or globally. - **Comprehensive Schema Validation**: Support for `minimum`, `maximum`, `minLength`, `maxLength`, `pattern`, `format`, and `example` directly in PHPDoc. - **Auto-inference**: Automatically resolve route parameters and request bodies from method signatures. +- **Intelligent Schema Inference**: Automatically determines `required` fields for Model schemas based on PHP native type-hint nullability, PHPDoc types, and default values. Override with explicit `@required` tag. - **Advanced Type Resolution**: - Primitives: `int`, `string`, `bool`, `float`. - Nullable types: `?string` or `string|null`. @@ -27,6 +28,7 @@ A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AS - Uses clean schema naming: `ApiResponse.User`. - **OpenAPI 3.0 & 3.1**: Supports both versions, with automatic conversion of nullable types for 3.1. - **Schema Registry**: Handles circular references and avoids duplicate definitions. +- **Native PHP Enum Support (PHP 8.1+)**: Automatically extracts enum cases and types for both `BackedEnum` (string/int) and `UnitEnum`. ## Installation @@ -65,9 +67,43 @@ You can define your API information in a top-level PHPDoc block in any of your s * @license.name MIT * @license.url https://opensource.org/licenses/MIT * @host https://api.example.com + * + * @tag.name Auth Authentication endpoints + * @tag.name Users User management endpoints */ ``` +- **Global Tag Ordering**: Explicitly define tags using `@tag.name [name] [description]` at the global level. The generated OpenAPI spec will preserve the order and descriptions of these tags. Any other tags found on endpoints that are not declared at the global level will be sorted alphabetically and appended to the end of the list. + +### Controller-level Metadata & Inheritance + +You can declare `@tag`, `@security`, `@accept` (or `@consume`), and `@produce` at the Controller (class) level. These act as defaults for all methods in the class: + +```php +/** + * @tag Users + * @security BearerAuth + * @accept json + * @produce json + */ +class UserController { + /** + * @route POST /users + * @body UserCreateRequest + */ + public function create() {} // Inherits @tag Users, @security BearerAuth, @accept json, @produce json + + /** + * @route GET /users/public + * @security + */ + public function listPublic() {} // Overrides security to "no security" (empty array) +} +``` + +- **Multiple/Comma-separated Tags**: You can specify multiple tags on a single line separated by commas, e.g., `@tag Auth, Users` (supported at both class and method level). Class-level and method-level tags are automatically merged. +- **Overrides**: Method-level `@security`, `@accept`/`@consume`, and `@produce` completely override the class-level defaults. An empty `@security` tag on a method overrides class security to disable authentication for that endpoint. + ### Security & Authentication Define security schemes and requirements globally or per operation: @@ -119,6 +155,158 @@ Values are automatically cast to their appropriate types (integers, floats, or s Validation constraints and formats are also fully supported on the `@body` tag description (e.g., `@body string file to upload format(binary)`). +### Intelligent Schema Inference + +The library automatically infers `required` fields for your component schemas based on properties' PHP type-hints, default values, and PHPDocs. + +```php +class User { + /** @var string $id */ + public string $id; // Required (non-nullable native type, no default) + + /** @var string $name */ + public string $name = 'Anonymous'; // Optional (has default value) + + /** @var string $email */ + public ?string $email; // Optional (nullable native type) + + /** @var string $bio */ + public string|null $bio; // Optional (nullable union type) + + /** @var mixed $extra */ + public mixed $extra; // Optional (mixed type can be null) + + /** @var string $status */ + public $status; // Required (no native type, but @var type is non-nullable string) + + /** @var string|null $avatar */ + public $avatar; // Optional (no native type, but @var type is nullable string) +} +``` + +#### Explicit `@required` Tag +You can override the automatic inference using the `@required` tag: +- **For member properties (class properties):** + Use `@required` as a standalone tag or inline in the description: + ```php + class User { + /** + * @var string $email + * @required + */ + public ?string $email; // Required because of explicit @required tag + + /** @var string $name Name @required */ + public ?string $name; // Required because of inline @required + + /** + * @var string $status + * @required false + */ + public string $status; // Optional because of explicit @required false + } + ``` +- **For class-level `@property` definitions:** + Use `@required $propertyName` in the class docblock or inline `@required`: + ```php + /** + * @property string $name @required + * @property string $email + * @property string $status + * + * @required $email + * @required $status false + */ + class User {} + ``` + +### Native PHP Enum Support (PHP 8.1+) + +The library fully supports PHP 8.1+ native Enums (both `BackedEnum` and `UnitEnum`). When an enum is referenced as a type in `@response`, `@property`, `@var`, etc., it is automatically detected and registered in the OpenAPI schemas. + +- **BackedEnum (string/int)**: Automatically resolves the schema `type` to `string` or `integer` based on the backing type, and populates the `enum` array with the backing values of all cases. +- **UnitEnum**: Resolves the schema `type` to `string` and populates the `enum` array with the names of all cases. + +#### Example + +```php +namespace App\Enums; + +// Backed Enum (string) +enum UserStatus: string { + case Pending = 'pending'; + case Active = 'active'; + case Suspended = 'suspended'; +} + +// Backed Enum (int) +enum UserRole: int { + case Admin = 1; + case Editor = 2; + case User = 3; +} + +// Pure Unit Enum +enum TicketPriority { + case Low; + case Medium; + case High; +} +``` + +When you use these enums as property types or response types, they are generated in the OpenAPI specification as: + +```yaml +components: + schemas: + App_Enums_UserStatus: + type: string + enum: + - pending + - active + - suspended + App_Enums_UserRole: + type: integer + enum: + - 1 + - 2 + - 3 + App_Enums_TicketPriority: + type: string + enum: + - Low + - Medium + - High +``` + +### Smart Type Mapping & Custom Registry + +The generator includes a built-in `TypeMappingRegistry` that automatically maps common PHP and library classes to their standard OpenAPI representations without requiring you to document them or triggering unresolved class errors: + +- **DateTime / Date**: + - `DateTime`, `DateTimeImmutable`, `DateTimeInterface` map to `string` with `format: date-time`. +- **File Uploads**: + - `Symfony\Component\HttpFoundation\File\UploadedFile`, `Psr\Http\Message\UploadedFileInterface`, `Illuminate\Http\UploadedFile` map to `string` with `format: binary`. +- **UUIDs**: + - `Ramsey\Uuid\Uuid`, `Ramsey\Uuid\UuidInterface`, `Symfony\Component\Uid\Uuid` map to `string` with `format: uuid` (only if the classes/interfaces exist in your runtime environment). + +#### Custom Type Mapping + +You can register your own custom class-to-schema mappings programmatically using `getTypeMappingRegistry()`: + +```php +use PhpSwag\Core; + +$core = new Core(); +$core->getTypeMappingRegistry()->register( + 'App\ValueObjects\Money', + [ + 'type' => 'number', + 'format' => 'money' + ] +); +``` + ### Route Parameters Handling The library supports explicit tags and auto-inference (inspired by swaggo). @@ -173,6 +361,7 @@ public function show(int $id, string $status) {} - **Models**: - `@property [TYPE] $[NAME] [DESCRIPTION]` (Supports validation tags in description) - `@var [TYPE]` (Supports validation tags in description) (for class properties) + - `@required` (for member properties) or `@required [PROPERTY_NAME] [true|false]` (for class-level properties or overrides) - `@template [NAME]` (for generics) - `@extends [TYPE]` or `@use [TYPE]` (for generic arguments) diff --git a/SAFE_BACKLOG.md b/SAFE_BACKLOG.md index 8642a86..3ffd8fb 100644 --- a/SAFE_BACKLOG.md +++ b/SAFE_BACKLOG.md @@ -121,33 +121,33 @@ ## 2. Program Backlog (Features) (Tiếp theo) ### Features cho [Epic 5] Developer Experience -- [ ] **[F5.1] Controller-level Metadata Support:** Hỗ trợ @tag, @security, @accept, @produce ở cấp Class. -- [ ] **[F5.2] Enhanced Diagnostics & Error Reporting:** Cải thiện thông báo lỗi với đầy đủ thông tin ngữ cảnh (file, line). -- [ ] **[F5.3] Intelligent Schema Inference:** Tự động xác định `required` fields dựa trên type-hint và giá trị mặc định. -- [ ] **[F5.4] Native PHP Enum Support:** Tự động trích xuất các case từ PHP 8.1+ Enums. -- [ ] **[F5.5] Smart Type Mapping Registry:** Map các class phổ biến (DateTime, Uuid, UploadedFile) sang kiểu dữ liệu OpenAPI tương ứng. +- [x] **[F5.1] Controller-level Metadata Support:** Hỗ trợ @tag, @security, @accept, @produce ở cấp Class. +- [x] **[F5.2] Enhanced Diagnostics & Error Reporting:** Cải thiện thông báo lỗi với đầy đủ thông tin ngữ cảnh (file, line). +- [x] **[F5.3] Intelligent Schema Inference:** Tự động xác định `required` fields dựa trên type-hint và giá trị mặc định. +- [x] **[F5.4] Native PHP Enum Support:** Tự động trích xuất các case từ PHP 8.1+ Enums. +- [x] **[F5.5] Smart Type Mapping Registry:** Map các class phổ biến (DateTime, Uuid, UploadedFile) sang kiểu dữ liệu OpenAPI tương ứng. ## 3. Team Backlog (User Stories) (Tiếp theo) ### Stories cho [F5.1] Controller-level Metadata -- [ ] **[S5.1.1] Class-level Tag Collection:** Thu thập @tag từ class docblock và gộp với tags ở method. -- [ ] **[S5.1.2] Class-level Security & Content-Type:** Áp dụng @security, @accept, @produce từ class làm mặc định cho tất cả method bên trong, cho phép method ghi đè. +- [x] **[S5.1.1] Class-level Tag Collection:** Thu thập @tag từ class docblock và gộp với tags ở method. +- [x] **[S5.1.2] Class-level Security & Content-Type:** Áp dụng @security, @accept, @produce từ class làm mặc định cho tất cả method bên trong, cho phép method ghi đè. ### Stories cho [F5.2] Enhanced Diagnostics -- [ ] **[S5.2.1] Source Location Tracking:** Lưu trữ thông tin file và dòng code trong quá trình parse. -- [ ] **[S5.2.2] User-Friendly Error Messages:** Hiển thị lỗi chi tiết khi không phân giải được class hoặc tag sai cú pháp. +- [x] **[S5.2.1] Source Location Tracking:** Lưu trữ thông tin file và dòng code trong quá trình parse. +- [x] **[S5.2.2] User-Friendly Error Messages:** Hiển thị lỗi chi tiết khi không phân giải được class hoặc tag sai cú pháp. ### Stories cho [F5.3] Intelligent Schema Inference -- [ ] **[S5.3.1] Nullable-based Required Detection:** Tự động đánh dấu `required: true` nếu type-hint không nullable. -- [ ] **[S5.3.2] Default Value Inference:** Thuộc tính có giá trị mặc định được coi là optional. -- [ ] **[S5.3.3] Explicit @required Tag:** Hỗ trợ tag @required để ghi đè logic suy luận. +- [x] **[S5.3.1] Nullable-based Required Detection:** Tự động đánh dấu `required: true` nếu type-hint không nullable. +- [x] **[S5.3.2] Default Value Inference:** Thuộc tính có giá trị mặc định được coi là optional. +- [x] **[S5.3.3] Explicit @required Tag:** Hỗ trợ tag @required để ghi đè logic suy luận. ### Stories cho [F5.4] Native PHP Enum Support -- [ ] **[S5.4.1] Enum Detection logic:** Nhận diện class là Enum thông qua Reflection. -- [ ] **[S5.4.2] BackedEnum Value Extraction:** Tự động lấy `value` cho BackedEnum (string/int). -- [ ] **[S5.4.3] UnitEnum Name Extraction:** Tự động lấy `name` cho UnitEnum. +- [x] **[S5.4.1] Enum Detection logic:** Nhận diện class là Enum thông qua Reflection. +- [x] **[S5.4.2] BackedEnum Value Extraction:** Tự động lấy `value` cho BackedEnum (string/int). +- [x] **[S5.4.3] UnitEnum Name Extraction:** Tự động lấy `name` cho UnitEnum. ### Stories cho [F5.5] Smart Type Mapping -- [ ] **[S5.5.1] Built-in Date/Time Mapping:** Map `DateTimeInterface` sang `string/date-time`. -- [ ] **[S5.5.2] External Library Support (Optional):** Hỗ trợ mapping cho Uuid (Ramsey/Symfony) nếu class tồn tại. -- [ ] **[S5.5.3] Binary/File Mapping:** Map các class UploadedFile phổ biến sang `string/binary`. +- [x] **[S5.5.1] Built-in Date/Time Mapping:** Map `DateTimeInterface` sang `string/date-time`. +- [x] **[S5.5.2] External Library Support (Optional):** Hỗ trợ mapping cho Uuid (Ramsey/Symfony) nếu class tồn tại. +- [x] **[S5.5.3] Binary/File Mapping:** Map các class UploadedFile phổ biến sang `string/binary`. diff --git a/composer.json b/composer.json index d65980a..9426523 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ } }, "require": { - "php": ">=8.1", + "php": ">=8.0", "nikic/php-parser": "^4.15", "phpstan/phpdoc-parser": "^1.24", "symfony/console": "^6.0", diff --git a/examples/App/OpenApi.php b/examples/App/OpenApi.php index 0e17be3..676f7a1 100644 --- a/examples/App/OpenApi.php +++ b/examples/App/OpenApi.php @@ -7,4 +7,8 @@ * @host /api/v3 * @securityDefinitions.apikey api_key header api_key * @securityDefinitions.jwt petstore_auth + * + * @tag.name store store management endpoints + * @tag.name user user management endpoints + * @tag.name pet pet management endpoints */ diff --git a/src/Core.php b/src/Core.php index 1c958c1..61f4e07 100644 --- a/src/Core.php +++ b/src/Core.php @@ -5,6 +5,7 @@ use PhpParser\Node; use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\Trait_; +use PhpParser\Node\Stmt\Enum_; use PhpParser\Node\Stmt\TraitUse; use PhpParser\Node\Stmt\Property; use PhpParser\Node\Stmt\ClassMethod; @@ -20,9 +21,10 @@ class Core private DocBlockCollector $docCollector; private Generator $generator; private SchemaRegistry $schemaRegistry; + private TypeMappingRegistry $typeMappingRegistry; private ?Cache\CacheInterface $cache = null; - /** @var array */ + /** @var array */ private array $discoveredClasses = []; private bool $isAnalyzed = false; @@ -39,6 +41,8 @@ class Core private array $securitySchemes = []; /** @var array>> */ private array $globalSecurity = []; + /** @var array */ + private array $globalTags = []; public function __construct() { @@ -46,9 +50,15 @@ public function __construct() $this->parser = new Parser(); $this->docCollector = new DocBlockCollector(); $this->schemaRegistry = new SchemaRegistry(); + $this->typeMappingRegistry = new TypeMappingRegistry(); $this->generator = new Generator($this->schemaRegistry); } + public function getTypeMappingRegistry(): TypeMappingRegistry + { + return $this->typeMappingRegistry; + } + public function setOpenApiVersion(string $version): void { $this->generator->setVersion($version); @@ -121,6 +131,9 @@ private function analyze(array $paths): void $this->securitySchemes = array_merge($this->securitySchemes, $cached['securitySchemes']); $this->globalSecurity = array_merge($this->globalSecurity, $cached['globalSecurity']); $this->metadataSources = array_merge($this->metadataSources, $cached['metadataSources']); + if (isset($cached['globalTags'])) { + $this->globalTags = array_merge($this->globalTags, $cached['globalTags']); + } // Restore schemas foreach ($cached['schemas'] as $schema) { @@ -148,6 +161,7 @@ private function analyze(array $paths): void $securitySchemesBefore = $this->securitySchemes; $globalSecurityBefore = $this->globalSecurity; $metadataSourcesBefore = $this->metadataSources; + $globalTagsBefore = $this->globalTags; $this->discoverFile($file); @@ -158,6 +172,7 @@ private function analyze(array $paths): void $newSecuritySchemes = array_diff_key($this->securitySchemes, $securitySchemesBefore); $newGlobalSecurity = array_slice($this->globalSecurity, count($globalSecurityBefore)); $newMetadataSources = array_diff_key($this->metadataSources, $metadataSourcesBefore); + $newGlobalTags = array_diff_key($this->globalTags, $globalTagsBefore); $fileDiscoverResults[$file] = [ 'hash' => md5_file($file), @@ -166,6 +181,7 @@ private function analyze(array $paths): void 'securitySchemes' => $newSecuritySchemes, 'globalSecurity' => $newGlobalSecurity, 'metadataSources' => $newMetadataSources, + 'globalTags' => $newGlobalTags, 'customSchemaIds' => [], 'routes' => [], ]; @@ -255,7 +271,8 @@ private function discoverGlobalMetadata(string $code, string $filePath): void foreach ($tokens as $token) { if (is_array($token) && $token[0] === T_DOC_COMMENT) { $docComment = $token[1]; - $tags = $this->docCollector->collectTags($docComment); + $startLine = $token[2]; + $tags = $this->docCollector->collectTags($docComment, $startLine, $filePath); $isGlobalBlock = false; $hasRouteOrProperty = false; @@ -271,7 +288,8 @@ private function discoverGlobalMetadata(string $code, string $filePath): void in_array($tag['name'], ['@title', '@version', '@description', '@host']) || str_starts_with($tag['name'], '@contact.') || str_starts_with($tag['name'], '@license.') || - str_starts_with($tag['name'], '@securityDefinitions.') + str_starts_with($tag['name'], '@securityDefinitions.') || + str_starts_with($tag['name'], '@tag.') ) { $isGlobalBlock = true; break; @@ -283,6 +301,7 @@ private function discoverGlobalMetadata(string $code, string $filePath): void continue; } + $currentTagName = null; foreach ($tags as $tag) { $tagName = $tag['name']; if ( @@ -308,18 +327,55 @@ private function discoverGlobalMetadata(string $code, string $filePath): void 'in' => $matches[2], 'name' => $matches[3] ]; + } else { + throw new \PhpSwag\Exception\DiagnosticException(sprintf( + "Invalid syntax for tag '@securityDefinitions.apikey' in %s%s: " + . "expected format is '@securityDefinitions.apikey NAME IN KEY', got '%s'", + $tag['file'] ?? $filePath, + isset($tag['line']) ? " on line " . $tag['line'] : "", + $tag['value'] + )); } } elseif ($tagName === '@securityDefinitions.jwt') { - $this->securitySchemes[$tag['value']] = [ - 'type' => 'http', - 'scheme' => 'bearer', - 'bearerFormat' => 'JWT' - ]; + if (trim($tag['value']) !== '') { + $this->securitySchemes[$tag['value']] = [ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => 'JWT' + ]; + } else { + throw new \PhpSwag\Exception\DiagnosticException(sprintf( + "Invalid syntax for tag '@securityDefinitions.jwt' in %s%s: " + . "expected format is '@securityDefinitions.jwt NAME', got empty value", + $tag['file'] ?? $filePath, + isset($tag['line']) ? " on line " . $tag['line'] : "" + )); + } } elseif ($tagName === '@security') { $this->globalSecurity = array_merge( $this->globalSecurity, $this->parseSecurityTag($tag['value']) ); + } elseif ($tagName === '@tag.name') { + $parts = preg_split('/\s+/', $tag['value'], 2); + if (is_array($parts) && isset($parts[0]) && trim($parts[0]) !== '') { + $name = $parts[0]; + $desc = isset($parts[1]) ? trim($parts[1]) : null; + + $tagData = ['name' => $name]; + if ($desc !== null && $desc !== '') { + $tagData['description'] = $desc; + } + $this->globalTags[$name] = $tagData; + } else { + throw new \PhpSwag\Exception\DiagnosticException(sprintf( + "Invalid syntax for tag '@tag.name' in %s%s: " + . "expected format is '@tag.name NAME [description]', got '%s'", + $tag['file'] ?? $filePath, + isset($tag['line']) ? " on line " . $tag['line'] : "", + $tag['value'] + )); + } } } } @@ -437,11 +493,15 @@ private function applyGlobalMetadata(): void if (!empty($this->globalSecurity)) { $this->generator->setGlobalSecurity($this->globalSecurity); } + + if (!empty($this->globalTags)) { + $this->generator->setGlobalTags($this->globalTags); + } } private function discoverStatement(Node $stmt, NameResolver $nameResolver): void { - if ($stmt instanceof Class_ || $stmt instanceof Trait_) { + if ($stmt instanceof Class_ || $stmt instanceof Trait_ || $stmt instanceof Enum_) { $fqcn = $nameResolver->resolve($stmt->name->toString()); $this->discoveredClasses[$fqcn] = [ 'node' => $stmt, @@ -454,7 +514,8 @@ private function discoverStatement(Node $stmt, NameResolver $nameResolver): void $parent = null; $docComment = $stmt->getDocComment()?->getText() ?? ''; - $tags = $this->docCollector->collectTags($docComment); + $docStartLine = $stmt->getDocComment()?->getStartLine(); + $tags = $this->docCollector->collectTags($docComment, $docStartLine, $this->currentlyAnalyzingFile); foreach ($tags as $tag) { if ($tag['name'] === '@template') { $templates[] = $tag['value']; @@ -488,17 +549,59 @@ private function discoverStatement(Node $stmt, NameResolver $nameResolver): void parent: $parent, traits: $traits, templates: $templates, - typeArguments: $typeArguments + typeArguments: $typeArguments, + file: $this->currentlyAnalyzingFile, + line: $stmt->getStartLine() )); } } - private function analyzeClass(string $fqcn, Class_|Trait_ $stmt, NameResolver $nameResolver): void + private function analyzeClass(string $fqcn, Class_|Trait_|Enum_ $stmt, NameResolver $nameResolver): void { $schema = $this->schemaRegistry->get($fqcn); - $typeResolver = new TypeResolver($this->schemaRegistry, $nameResolver, $schema->templates); + if (function_exists('enum_exists') && enum_exists($fqcn)) { + $reflection = new \ReflectionEnum($fqcn); + $isBacked = $reflection->isBacked(); + $cases = $reflection->getCases(); + $enumType = 'string'; + $enumValues = []; + + if ($isBacked) { + $backingType = $reflection->getBackingType(); + $backingTypeName = $backingType->getName(); + $enumType = $backingTypeName === 'int' ? 'integer' : 'string'; + foreach ($cases as $case) { + if ($case instanceof \ReflectionEnumBackedCase) { + $enumValues[] = $case->getBackingValue(); + } + } + } else { + $enumType = 'string'; + foreach ($cases as $case) { + $enumValues[] = $case->getName(); + } + } + + if ($schema !== null) { + $schema->enum = $enumValues; + $schema->enumType = $enumType; + } + return; + } + $typeResolver = new TypeResolver( + $this->schemaRegistry, + $nameResolver, + $schema->templates, + $this->typeMappingRegistry + ); $docComment = $stmt->getDocComment()?->getText() ?? ''; - $tags = $this->docCollector->collectTags($docComment); + $docStartLine = $stmt->getDocComment()?->getStartLine(); + $tags = $this->docCollector->collectTags($docComment, $docStartLine, $this->currentlyAnalyzingFile); + + $classTags = []; + $classSecurity = []; + $classAccept = null; + $classProduce = null; foreach ($tags as $tag) { if ($tag['name'] === '@extends' || $tag['name'] === '@use') { @@ -509,33 +612,89 @@ private function analyzeClass(string $fqcn, Class_|Trait_ $stmt, NameResolver $n if ($targetSchema && !empty($targetSchema->templates)) { foreach ($typeNode->genericTypes as $i => $argNode) { $templateName = $targetSchema->templates[$i] ?? "T$i"; - $schema->typeArguments[$templateName] = $typeResolver->resolve($argNode); + $schema->typeArguments[$templateName] = $typeResolver->resolve( + $argNode, + $tag['line'] ?? $docStartLine, + $this->currentlyAnalyzingFile + ); } } } + } elseif ($tag['name'] === '@tag') { + $splitTags = array_filter(array_map('trim', explode(',', $tag['value'])), fn($t) => $t !== ''); + $classTags = array_merge($classTags, $splitTags); + } elseif ($tag['name'] === '@security') { + $classSecurity = array_merge($classSecurity, $this->parseSecurityTag($tag['value'])); + } elseif ($tag['name'] === '@accept' || $tag['name'] === '@consume') { + $classAccept = $tag['value']; + } elseif ($tag['name'] === '@produce') { + $classProduce = $tag['value']; } } $isSchema = false; $properties = []; + // Parse any class-level explicit @required tags targeting properties, e.g. @required $name or @required name + $classExplicitRequired = []; + foreach ($tags as $t) { + if ($t['name'] === '@required') { + $val = trim($t['value'] ?? ''); + if ($val !== '') { + if (preg_match('/^([^\s]+)(?:\s+(.*))?$/', $val, $matches)) { + $propName = ltrim($matches[1], '$'); + $optVal = isset($matches[2]) ? trim($matches[2]) : ''; + if (strtolower($optVal) === 'false') { + $classExplicitRequired[$propName] = false; + } else { + $classExplicitRequired[$propName] = true; + } + } + } + } + } + foreach ($tags as $tag) { if ($tag['name'] === '@property' && isset($tag['type'])) { $isSchema = true; - $propertySchema = $typeResolver->resolve($tag['type']); + $propertySchema = $typeResolver->resolve( + $tag['type'], + $tag['line'] ?? $docStartLine, + $this->currentlyAnalyzingFile + ); $desc = is_array($tag['description']) ? ($tag['description']['description'] ?? null) : ($tag['description'] ?? null); - $extra = is_array($tag['description']) ? $tag['description'] : []; + $extra = is_array($tag['description']) ? $tag['description'] : []; unset($extra['description']); + $explicitRequired = $classExplicitRequired[$tag['propertyName']] ?? null; + if ($desc !== null && stripos($desc, '@required') !== false) { + $explicitRequired = true; + $desc = preg_replace('/@required\s*/i', '', $desc); + $desc = trim($desc); + } + + $hasDefault = isset($extra['default']); + $isNullable = $this->isDocTypeNullable($tag['type']); + $required = $this->determineRequired( + $tag['propertyName'], + $isNullable, + $explicitRequired, + $hasDefault, + null + ); + $properties[] = new PropertyDefinition( $tag['propertyName'], $propertySchema, $desc, - $extra + $extra, + $this->currentlyAnalyzingFile, + $tag['line'] ?? $docStartLine, + $required ); } } @@ -544,30 +703,75 @@ private function analyzeClass(string $fqcn, Class_|Trait_ $stmt, NameResolver $n if ($member instanceof Property) { $isSchema = true; $propDoc = $member->getDocComment()?->getText() ?? ''; - $propTags = $this->docCollector->collectTags($propDoc); + $propStartLine = $member->getDocComment()?->getStartLine(); + $propTags = $this->docCollector->collectTags($propDoc, $propStartLine, $this->currentlyAnalyzingFile); foreach ($propTags as $pTag) { if ($pTag['name'] === '@var' && isset($pTag['type'])) { - $propertySchema = $typeResolver->resolve($pTag['type']); + $propertySchema = $typeResolver->resolve( + $pTag['type'], + $pTag['line'] ?? $propStartLine, + $this->currentlyAnalyzingFile + ); $desc = is_array($pTag['description']) ? ($pTag['description']['description'] ?? null) : ($pTag['description'] ?? null); - $extra = is_array($pTag['description']) ? $pTag['description'] : []; + $extra = is_array($pTag['description']) ? $pTag['description'] : []; unset($extra['description']); + // Explicit required tag in property docblock + $explicitRequired = null; + foreach ($propTags as $t) { + if ($t['name'] === '@required') { + $val = trim($t['value'] ?? ''); + if (strtolower($val) === 'false') { + $explicitRequired = false; + } else { + $explicitRequired = true; + } + } + } + + // Also check if @required is inline in the @var tag's description + if ($desc !== null && stripos($desc, '@required') !== false) { + $explicitRequired = true; + $desc = preg_replace('/@required\s*/i', '', $desc); + $desc = trim($desc); + } + + $hasDefault = ($member->props[0]->default !== null) || isset($extra['default']); + $isNullable = $this->isDocTypeNullable($pTag['type']); + $required = $this->determineRequired( + $member->props[0]->name->toString(), + $isNullable, + $explicitRequired, + $hasDefault, + $member->type + ); + $properties[] = new PropertyDefinition( $member->props[0]->name->toString(), $propertySchema, $desc, - $extra + $extra, + $this->currentlyAnalyzingFile, + $pTag['line'] ?? $propStartLine, + $required ); } } } if ($member instanceof ClassMethod) { - $this->analyzeMethod($member, $nameResolver, $typeResolver); + $this->analyzeMethod( + $member, + $typeResolver, + $classTags, + $classSecurity, + $classAccept, + $classProduce + ); } } @@ -576,22 +780,36 @@ private function analyzeClass(string $fqcn, Class_|Trait_ $stmt, NameResolver $n } } - private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, TypeResolver $typeResolver): void - { + /** + * @param array $classTags + * @param array>> $classSecurity + */ + private function analyzeMethod( + ClassMethod $member, + TypeResolver $typeResolver, + array $classTags = [], + array $classSecurity = [], + ?string $classAccept = null, + ?string $classProduce = null + ): void { $methodDoc = $member->getDocComment()?->getText() ?? ''; - $tags = $this->docCollector->collectTags($methodDoc); + $methodStartLine = $member->getDocComment()?->getStartLine(); + $tags = $this->docCollector->collectTags($methodDoc, $methodStartLine, $this->currentlyAnalyzingFile); $routeTag = null; $summary = null; $description = null; - $tagsList = []; + $tagsList = $classTags; $responses = []; $responseDescriptions = []; $parameters = []; $requestBody = null; $security = []; + $hasMethodSecurity = false; $accept = null; + $hasMethodAccept = false; $produce = null; + $hasMethodProduce = false; $operationId = null; $deprecated = false; $extensions = []; @@ -600,17 +818,27 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, if ($tag['name'] === '@route') { if (preg_match('/^(GET|POST|PUT|DELETE|PATCH)\s+(\S+)/i', $tag['value'], $matches)) { $routeTag = strtoupper($matches[1]) . ' ' . $matches[2]; + } else { + throw new \PhpSwag\Exception\DiagnosticException(sprintf( + "Invalid syntax for tag '@route' in %s%s: expected format is '@route METHOD PATH', got '%s'", + $tag['file'] ?? $this->currentlyAnalyzingFile ?? 'unknown', + isset($tag['line']) ? " on line " . $tag['line'] : "", + $tag['value'] + )); } } elseif ($tag['name'] === '@summary') { $summary = $tag['value']; } elseif ($tag['name'] === '@description') { $description = $tag['value']; } elseif ($tag['name'] === '@tag') { - $tagsList[] = $tag['value']; + $splitTags = array_filter(array_map('trim', explode(',', $tag['value'])), fn($t) => $t !== ''); + $tagsList = array_merge($tagsList, $splitTags); } elseif ($tag['name'] === '@accept' || $tag['name'] === '@consume') { $accept = $tag['value']; + $hasMethodAccept = true; } elseif ($tag['name'] === '@produce') { $produce = $tag['value']; + $hasMethodProduce = true; } elseif ($tag['name'] === '@operationId' || $tag['name'] === '@operationid') { $operationId = $tag['value']; } elseif ($tag['name'] === '@deprecated') { @@ -642,18 +870,40 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, } $typeNode = $this->docCollector->parseType($typeToParse); - $responses[$code] = $typeResolver->resolve($typeNode); + $responses[$code] = $typeResolver->resolve( + $typeNode, + $tag['line'] ?? $methodStartLine, + $this->currentlyAnalyzingFile + ); $responseDescriptions[$code] = $respDesc; + } else { + throw new \PhpSwag\Exception\DiagnosticException(sprintf( + "Invalid syntax for tag '%s' in %s%s: " + . "expected format is '%s CODE TYPE [description]', got '%s'", + $tag['name'], + $tag['file'] ?? $this->currentlyAnalyzingFile ?? 'unknown', + isset($tag['line']) ? " on line " . $tag['line'] : "", + $tag['name'], + $tag['value'] + )); } } elseif (in_array($tag['name'], ['@path', '@query', '@header', '@cookie'])) { $in = substr($tag['name'], 1); $parameters[] = array_merge($tag, [ 'in' => $in, - 'schema' => $typeResolver->resolve($tag['type']), + 'schema' => $typeResolver->resolve( + $tag['type'], + $tag['line'] ?? $methodStartLine, + $this->currentlyAnalyzingFile + ), 'name' => $tag['propertyName'] ]); } elseif ($tag['name'] === '@body') { - $schema = $typeResolver->resolve($tag['type']); + $schema = $typeResolver->resolve( + $tag['type'], + $tag['line'] ?? $methodStartLine, + $this->currentlyAnalyzingFile + ); $extra = is_array($tag['description']) ? $tag['description'] : []; $desc = $extra['description'] ?? null; unset($extra['description']); @@ -682,10 +932,22 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, 'description' => $desc ]; } elseif ($tag['name'] === '@security') { + $hasMethodSecurity = true; $security = array_merge($security, $this->parseSecurityTag($tag['value'])); } } + if (!$hasMethodSecurity) { + $security = $classSecurity; + } + if (!$hasMethodAccept) { + $accept = $classAccept; + } + if (!$hasMethodProduce) { + $produce = $classProduce; + } + $tagsList = array_values(array_unique($tagsList)); + if ($routeTag) { $routeParts = explode(' ', $routeTag); $path = $routeParts[1]; @@ -721,7 +983,11 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, } } - $schema = $typeResolver->resolve($this->docCollector->parseType($type)); + $schema = $typeResolver->resolve( + $this->docCollector->parseType($type), + $param->getStartLine(), + $this->currentlyAnalyzingFile + ); // If it's a class and not primitive, infer as requestBody if not already set $isPrimitive = in_array(ltrim($type, '\\'), ['int', 'string', 'bool', 'float', 'array', 'mixed']); @@ -761,7 +1027,9 @@ private function analyzeMethod(ClassMethod $member, NameResolver $nameResolver, produce: $produce, operationId: $operationId, deprecated: $deprecated, - extensions: $extensions + extensions: $extensions, + file: $this->currentlyAnalyzingFile, + line: $member->getStartLine() )); } } @@ -859,4 +1127,77 @@ private function splitTypeAndDescription(string $str): array $desc = $parts[1] ?? ''; return [$type, $desc]; } + + private function isNativeTypeNullable(?Node $type): bool + { + if ($type === null) { + return true; + } + if ($type instanceof Node\NullableType) { + return true; + } + if ($type instanceof Node\Identifier && strtolower($type->name) === 'mixed') { + return true; + } + if ($type instanceof Node\UnionType) { + foreach ($type->types as $subType) { + if ( + $subType instanceof Node\Identifier && + in_array(strtolower($subType->name), ['null', 'mixed']) + ) { + return true; + } + } + } + return false; + } + + private function isDocTypeNullable(\PHPStan\PhpDocParser\Ast\Type\TypeNode $typeNode): bool + { + if ($typeNode instanceof \PHPStan\PhpDocParser\Ast\Type\NullableTypeNode) { + return true; + } + if ( + $typeNode instanceof \PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode && + strtolower($typeNode->name) === 'mixed' + ) { + return true; + } + if ($typeNode instanceof \PHPStan\PhpDocParser\Ast\Type\UnionTypeNode) { + foreach ($typeNode->types as $type) { + if ( + $type instanceof \PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode && + in_array(strtolower($type->name), ['null', 'mixed']) + ) { + return true; + } + } + } + return false; + } + + private function determineRequired( + string $propertyName, + bool $isNullable, + ?bool $explicitRequired, + bool $hasDefault, + ?Node $typeHint + ): bool { + if ($explicitRequired !== null) { + return $explicitRequired; + } + + // If it has a default value, it is optional (not required) + if ($hasDefault) { + return false; + } + + // If there is a native type hint, use its nullability + if ($typeHint !== null) { + return !$this->isNativeTypeNullable($typeHint); + } + + // Otherwise, use the PHPDoc nullability + return !$isNullable; + } } diff --git a/src/DocBlockCollector.php b/src/DocBlockCollector.php index fa1685a..e041ed9 100644 --- a/src/DocBlockCollector.php +++ b/src/DocBlockCollector.php @@ -18,7 +18,7 @@ public function __construct() /** * @return array> */ - public function collectTags(string $docComment): array + public function collectTags(string $docComment, ?int $startLine = null, ?string $filePath = null): array { if (empty($docComment)) { return []; @@ -26,7 +26,8 @@ public function collectTags(string $docComment): array $tags = []; $lines = explode("\n", $docComment); - foreach ($lines as $line) { + foreach ($lines as $index => $line) { + $currentLineNum = $startLine !== null ? ($startLine + $index) : null; $line = trim($line, " \t\n\r\0\x0B*/"); if (empty($line)) { continue; @@ -44,6 +45,7 @@ public function collectTags(string $docComment): array : $tagName; $doc = "/** $parseTagName $value */"; $node = $this->parser->parse($doc); + $found = false; foreach ($node->getTags() as $tag) { $v = $tag->value; if ($v instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\PropertyTagValueNode) { @@ -51,28 +53,49 @@ public function collectTags(string $docComment): array 'name' => $tagName, 'type' => $v->type, 'propertyName' => ltrim($v->propertyName, '$'), - 'description' => $this->parseExtraAttributes($v->description) + 'description' => $this->parseExtraAttributes($v->description), + 'line' => $currentLineNum, + 'file' => $filePath ]; + $found = true; } elseif ($v instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode) { $tags[] = [ 'name' => $tagName, 'type' => $v->type, 'propertyName' => $v->variableName ? ltrim($v->variableName, '$') : null, - 'description' => $this->parseExtraAttributes($v->description) + 'description' => $this->parseExtraAttributes($v->description), + 'line' => $currentLineNum, + 'file' => $filePath ]; + $found = true; } elseif ($v instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode) { $res = [ 'name' => $tagName, 'type' => $v->type, 'propertyName' => ltrim($v->parameterName, '$'), + 'line' => $currentLineNum, + 'file' => $filePath ]; $parsedDesc = $this->parseExtraAttributes($v->description); $res = array_merge($res, $parsedDesc); $tags[] = $res; + $found = true; } } + if (!$found) { + throw new \Exception("Could not parse tag value"); + } } catch (\Exception $e) { - $tags[] = ['name' => $tagName, 'value' => $value]; + throw new \PhpSwag\Exception\DiagnosticException(sprintf( + "Invalid syntax for tag '%s' in %s%s: expected format is '%s TYPE %s\$name%s', got '%s'", + $tagName, + $filePath ?? 'unknown', + $currentLineNum !== null ? " on line $currentLineNum" : "", + $tagName, + $tagName === '@var' ? '[' : '', + $tagName === '@var' ? ']' : '', + $value + ), 0, $e); } } elseif ($tagName === '@body') { // @body [Type] [Description] @@ -89,13 +112,25 @@ public function collectTags(string $docComment): array $tags[] = [ 'name' => '@body', 'type' => $type, - 'description' => $this->parseExtraAttributes($desc) + 'description' => $this->parseExtraAttributes($desc), + 'line' => $currentLineNum, + 'file' => $filePath ]; + } else { + throw new \PhpSwag\Exception\DiagnosticException(sprintf( + "Invalid syntax for tag '@body' in %s%s: " + . "expected format is '@body TYPE [description]', got '%s'", + $filePath ?? 'unknown', + $currentLineNum !== null ? " on line $currentLineNum" : "", + $value + )); } } else { $tags[] = [ 'name' => $tagName, - 'value' => $value + 'value' => $value, + 'line' => $currentLineNum, + 'file' => $filePath ]; } } diff --git a/src/Exception/DiagnosticException.php b/src/Exception/DiagnosticException.php new file mode 100644 index 0000000..878907c --- /dev/null +++ b/src/Exception/DiagnosticException.php @@ -0,0 +1,7 @@ +>> */ private array $globalSecurity = []; + /** @var array */ + private array $globalTags = []; public function __construct(SchemaRegistry $schemaRegistry) { @@ -98,6 +100,14 @@ public function setGlobalSecurity(array $security): void $this->globalSecurity = $security; } + /** + * @param array $globalTags + */ + public function setGlobalTags(array $globalTags): void + { + $this->globalTags = $globalTags; + } + public function addRoute(RouteDefinition $route): void { $this->routes[] = $route; @@ -165,6 +175,42 @@ private function generateSpec(): array $spec['security'] = $this->globalSecurity; } + $routeTags = []; + foreach ($this->routes as $route) { + foreach ($route->tags as $tag) { + if (!in_array($tag, $routeTags)) { + $routeTags[] = $tag; + } + } + } + + $orderedTags = []; + $addedTagNames = []; + + // 1. Add explicitly defined global tags first + foreach ($this->globalTags as $tagName => $tagObj) { + $orderedTags[] = $tagObj; + $addedTagNames[] = $tagName; + } + + // 2. Add used tags that are not in global tags, sorted alphabetically + $remainingTags = []; + foreach ($routeTags as $tag) { + if (!in_array($tag, $addedTagNames)) { + $remainingTags[] = $tag; + } + } + if (!empty($remainingTags)) { + sort($remainingTags, SORT_STRING); + foreach ($remainingTags as $tag) { + $orderedTags[] = ['name' => $tag]; + } + } + + if (!empty($orderedTags)) { + $spec['tags'] = $orderedTags; + } + foreach ($this->routes as $route) { $method = strtolower($route->method); $path = $route->path; @@ -329,10 +375,20 @@ private function generateSpec(): array continue; // Don't generate base generic schemas } + if ($schema->enum !== null) { + $schemaSpec = [ + 'type' => $schema->enumType ?? 'string', + 'enum' => $schema->enum + ]; + $spec['components']['schemas'][$this->schemaRegistry->getSchemaId($schema->name)] = $schemaSpec; + continue; + } + $properties = $this->resolveAllProperties($schema); $propSpecs = []; + $requiredProps = []; foreach ($properties as $prop) { - $propSchema = $this->applyTypeArguments($prop->schema, $schema->typeArguments); + $propSchema = $this->applyTypeArguments($prop->schema, $schema->typeArguments); // Apply extra validation attributes to property schema $validationTags = [ @@ -364,12 +420,20 @@ private function generateSpec(): array } $propSpecs[$prop->name] = $this->processSchemaOutput($propSchema, $prop->description); + if ($prop->required) { + $requiredProps[] = $prop->name; + } } - $spec['components']['schemas'][$this->schemaRegistry->getSchemaId($schema->name)] = [ + $schemaSpec = [ 'type' => 'object', 'properties' => $propSpecs ]; + if (!empty($requiredProps)) { + $schemaSpec['required'] = $requiredProps; + } + + $spec['components']['schemas'][$this->schemaRegistry->getSchemaId($schema->name)] = $schemaSpec; } return $spec; diff --git a/src/IR/PropertyDefinition.php b/src/IR/PropertyDefinition.php index 3187f2b..693f9b3 100644 --- a/src/IR/PropertyDefinition.php +++ b/src/IR/PropertyDefinition.php @@ -12,7 +12,10 @@ public function __construct( public string $name, public array $schema, public ?string $description = null, - public array $extra = [] + public array $extra = [], + public ?string $file = null, + public ?int $line = null, + public ?bool $required = null ) { } } diff --git a/src/IR/RouteDefinition.php b/src/IR/RouteDefinition.php index 3740885..5527639 100644 --- a/src/IR/RouteDefinition.php +++ b/src/IR/RouteDefinition.php @@ -28,7 +28,9 @@ public function __construct( public ?string $produce = null, public ?string $operationId = null, public bool $deprecated = false, - public array $extensions = [] + public array $extensions = [], + public ?string $file = null, + public ?int $line = null ) { } } diff --git a/src/IR/SchemaDefinition.php b/src/IR/SchemaDefinition.php index ae85aa1..5276cbe 100644 --- a/src/IR/SchemaDefinition.php +++ b/src/IR/SchemaDefinition.php @@ -9,6 +9,7 @@ class SchemaDefinition * @param array $traits * @param array $templates * @param array> $typeArguments + * @param array|null $enum */ public function __construct( public string $name, @@ -17,7 +18,11 @@ public function __construct( public array $traits = [], public array $templates = [], // e.g. ['T', 'K'] public array $typeArguments = [], // e.g. ['T' => ['type' => 'string']] - public ?string $base = null + public ?string $base = null, + public ?string $file = null, + public ?int $line = null, + public ?array $enum = null, + public ?string $enumType = null ) { } } diff --git a/src/TypeMappingRegistry.php b/src/TypeMappingRegistry.php new file mode 100644 index 0000000..b7c75ee --- /dev/null +++ b/src/TypeMappingRegistry.php @@ -0,0 +1,86 @@ +> */ + private array $mappings = []; + + public function __construct() + { + $this->registerBuiltInMappings(); + } + + /** + * @param array $schema + */ + public function register(string $class, array $schema): void + { + $this->mappings[$class] = $schema; + } + + public function has(string $class): bool + { + if (isset($this->mappings[$class])) { + return true; + } + + // Special handling for Uuid classes (only if class exists) + $uuidClasses = [ + 'Ramsey\Uuid\Uuid', + 'Ramsey\Uuid\UuidInterface', + 'Symfony\Component\Uid\Uuid', + ]; + if (in_array($class, $uuidClasses)) { + // @phpstan-ignore-next-line + return class_exists($class) || interface_exists($class); + } + + return false; + } + + /** + * @return array|null + */ + public function get(string $class): ?array + { + if (isset($this->mappings[$class])) { + return $this->mappings[$class]; + } + + $uuidClasses = [ + 'Ramsey\Uuid\Uuid', + 'Ramsey\Uuid\UuidInterface', + 'Symfony\Component\Uid\Uuid', + ]; + // @phpstan-ignore-next-line + if (in_array($class, $uuidClasses) && (class_exists($class) || interface_exists($class))) { + return ['type' => 'string', 'format' => 'uuid']; + } + + return null; + } + + private function registerBuiltInMappings(): void + { + // DateTime mappings + $this->mappings[\DateTimeInterface::class] = ['type' => 'string', 'format' => 'date-time']; + $this->mappings[\DateTime::class] = ['type' => 'string', 'format' => 'date-time']; + $this->mappings[\DateTimeImmutable::class] = ['type' => 'string', 'format' => 'date-time']; + + // UploadedFile mappings + $this->mappings['Symfony\Component\HttpFoundation\File\UploadedFile'] = [ + 'type' => 'string', + 'format' => 'binary', + ]; + $this->mappings['Psr\Http\Message\UploadedFileInterface'] = [ + 'type' => 'string', + 'format' => 'binary', + ]; + $this->mappings['Illuminate\Http\UploadedFile'] = [ + 'type' => 'string', + 'format' => 'binary', + ]; + } +} diff --git a/src/TypeResolver.php b/src/TypeResolver.php index ef10df3..95355cb 100644 --- a/src/TypeResolver.php +++ b/src/TypeResolver.php @@ -14,30 +14,39 @@ class TypeResolver { private SchemaRegistry $schemaRegistry; private NameResolver $nameResolver; + private TypeMappingRegistry $typeMappingRegistry; /** @var array */ private array $templates = []; /** * @param array $templates */ - public function __construct(SchemaRegistry $schemaRegistry, NameResolver $nameResolver, array $templates = []) - { + public function __construct( + SchemaRegistry $schemaRegistry, + NameResolver $nameResolver, + array $templates = [], + ?TypeMappingRegistry $typeMappingRegistry = null + ) { $this->schemaRegistry = $schemaRegistry; $this->nameResolver = $nameResolver; $this->templates = $templates; + $this->typeMappingRegistry = $typeMappingRegistry ?? new TypeMappingRegistry(); } /** * @return array */ - public function resolve(TypeNode $typeNode): array + /** + * @return array + */ + public function resolve(TypeNode $typeNode, ?int $line = null, ?string $file = null): array { if ($typeNode instanceof IdentifierTypeNode) { - return $this->resolveIdentifier($typeNode->name); + return $this->resolveIdentifier($typeNode->name, $line, $file); } if ($typeNode instanceof NullableTypeNode) { - $resolved = $this->resolve($typeNode->type); + $resolved = $this->resolve($typeNode->type, $line, $file); $resolved['nullable'] = true; return $resolved; } @@ -45,7 +54,7 @@ public function resolve(TypeNode $typeNode): array if ($typeNode instanceof ArrayTypeNode) { return [ 'type' => 'array', - 'items' => $this->resolve($typeNode->type) + 'items' => $this->resolve($typeNode->type, $line, $file) ]; } @@ -57,7 +66,7 @@ public function resolve(TypeNode $typeNode): array $isNullable = true; continue; } - $types[] = $this->resolve($type); + $types[] = $this->resolve($type, $line, $file); } if (count($types) === 1) { @@ -79,16 +88,16 @@ public function resolve(TypeNode $typeNode): array ) { return [ 'type' => 'object', - 'additionalProperties' => $this->resolve($typeNode->genericTypes[1]) + 'additionalProperties' => $this->resolve($typeNode->genericTypes[1], $line, $file) ]; } return [ 'type' => 'array', - 'items' => $this->resolve($typeNode->genericTypes[0]) + 'items' => $this->resolve($typeNode->genericTypes[0], $line, $file) ]; } - return $this->resolveGeneric($typeNode); + return $this->resolveGeneric($typeNode, $line, $file); } return ['type' => 'string']; @@ -97,7 +106,7 @@ public function resolve(TypeNode $typeNode): array /** * @return array */ - private function resolveIdentifier(string $name): array + private function resolveIdentifier(string $name, ?int $line = null, ?string $file = null): array { if (in_array($name, $this->templates)) { return ['type' => $name]; // Return template name as "type" to be substituted later @@ -122,6 +131,19 @@ private function resolveIdentifier(string $name): array } $fqcn = $this->nameResolver->resolve($name); + if ($this->typeMappingRegistry->has($fqcn)) { + return $this->typeMappingRegistry->get($fqcn) ?? []; + } + + if (!$this->schemaRegistry->has($fqcn)) { + throw new \PhpSwag\Exception\DiagnosticException(sprintf( + "Unresolved class '%s'%s%s", + $fqcn, + $file !== null ? " in $file" : "", + $line !== null ? " on line $line" : "" + )); + } + return [ '$ref' => '#/components/schemas/' . $this->schemaRegistry->getSchemaId($fqcn) ]; @@ -130,20 +152,20 @@ private function resolveIdentifier(string $name): array /** * @return array */ - private function resolveGeneric(GenericTypeNode $typeNode): array + private function resolveGeneric(GenericTypeNode $typeNode, ?int $line = null, ?string $file = null): array { $baseName = $typeNode->type->name; $fqcn = $this->nameResolver->resolve($baseName); $baseSchema = $this->schemaRegistry->get($fqcn); if (!$baseSchema || empty($baseSchema->templates)) { - return $this->resolveIdentifier($baseName); + return $this->resolveIdentifier($baseName, $line, $file); } $args = []; $ids = []; foreach ($typeNode->genericTypes as $i => $argNode) { - $resolvedArg = $this->resolve($argNode); + $resolvedArg = $this->resolve($argNode, $line, $file); $templateName = $baseSchema->templates[$i] ?? "T$i"; $args[$templateName] = $resolvedArg; diff --git a/tests/ControllerLevelMetadataTest.php b/tests/ControllerLevelMetadataTest.php new file mode 100644 index 0000000..0013e12 --- /dev/null +++ b/tests/ControllerLevelMetadataTest.php @@ -0,0 +1,280 @@ +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + + // endpoint1 should have both ClassTags, SharedTag and MethodTag1 (uniquely) + $route1 = $spec['paths']['/endpoint1']['get']; + $this->assertEquals(['ClassTag1', 'ClassTag2', 'SharedTag', 'MethodTag1'], $route1['tags']); + + // endpoint2 should have only ClassTags and SharedTag + $route2 = $spec['paths']['/endpoint2']['get']; + $this->assertEquals(['ClassTag1', 'ClassTag2', 'SharedTag'], $route2['tags']); + + // Global tags should be collected and sorted alphabetically + $this->assertArrayHasKey('tags', $spec); + $expectedGlobalTags = [ + ['name' => 'ClassTag1'], + ['name' => 'ClassTag2'], + ['name' => 'MethodTag1'], + ['name' => 'SharedTag'] + ]; + $this->assertEquals($expectedGlobalTags, $spec['tags']); + } + + public function testCommaSeparatedTags(): void + { + $code = <<<'PHP' +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + + // endpoint1 should have both ClassTags, SharedTag and MethodTag1 (uniquely) + $route1 = $spec['paths']['/endpoint1']['get']; + $this->assertEquals(['ClassTag1', 'ClassTag2', 'SharedTag', 'MethodTag1'], $route1['tags']); + + // endpoint2 should have only ClassTags and SharedTag + $route2 = $spec['paths']['/endpoint2']['get']; + $this->assertEquals(['ClassTag1', 'ClassTag2', 'SharedTag'], $route2['tags']); + } + + public function testControllerLevelSecurity(): void + { + $code = <<<'PHP' +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + + // secure-default should inherit MyApiKey + $route1 = $spec['paths']['/secure-default']['get']; + $this->assertEquals([['MyApiKey' => []]], $route1['security']); + + // secure-override should override to MyJwtAuth + $route2 = $spec['paths']['/secure-override']['get']; + $this->assertEquals([['MyJwtAuth' => []]], $route2['security']); + + // no-security-override should override to no security (empty array or [] in yaml) + $route3 = $spec['paths']['/no-security-override']['get']; + $this->assertEquals([[]], $route3['security']); + } + + public function testControllerLevelAcceptProduce(): void + { + $code = <<<'PHP' +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + + // default-mimes + $route1 = $spec['paths']['/default-mimes']['post']; + $this->assertArrayHasKey('application/json', $route1['requestBody']['content']); + $this->assertArrayNotHasKey('application/xml', $route1['requestBody']['content']); + $this->assertArrayHasKey('application/xml', $route1['responses']['200']['content']); + $this->assertArrayNotHasKey('application/json', $route1['responses']['200']['content']); + + // override-mimes + $route2 = $spec['paths']['/override-mimes']['post']; + $this->assertArrayHasKey('application/xml', $route2['requestBody']['content']); + $this->assertArrayNotHasKey('application/json', $route2['requestBody']['content']); + $this->assertArrayHasKey('application/json', $route2['responses']['200']['content']); + $this->assertArrayNotHasKey('application/xml', $route2['responses']['200']['content']); + } + + public function testGlobalTagOrdering(): void + { + $code = <<<'PHP' +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + + $this->assertArrayHasKey('tags', $spec); + $expectedGlobalTags = [ + ['name' => 'Users', 'description' => 'User management endpoints'], + ['name' => 'Auth', 'description' => 'Authentication endpoints'], + ['name' => 'Apple'], + ['name' => 'Zebra'] + ]; + $this->assertEquals($expectedGlobalTags, $spec['tags']); + } +} diff --git a/tests/DiagnosticsTest.php b/tests/DiagnosticsTest.php new file mode 100644 index 0000000..accb51c --- /dev/null +++ b/tests/DiagnosticsTest.php @@ -0,0 +1,196 @@ +generateYaml([$tempFile]); + + // Use Reflection to inspect private components + $reflection = new \ReflectionClass($core); + + $registryProp = $reflection->getProperty('schemaRegistry'); + $registryProp->setAccessible(true); + $registry = $registryProp->getValue($core); + + $generatorProp = $reflection->getProperty('generator'); + $generatorProp->setAccessible(true); + $generator = $generatorProp->getValue($core); + + // Verify Schema Location + $schema = $registry->get('App\Models\TestModel'); + $this->assertNotNull($schema); + $this->assertEquals($tempFile, $schema->file); + $this->assertEquals(18, $schema->line); + + // Verify Property Location + $this->assertCount(1, $schema->properties); + $prop = $schema->properties[0]; + $this->assertEquals('name', $prop->name); + $this->assertEquals($tempFile, $prop->file); + // Let's count line: /** @var string $name Name of test */ is on line 19 + $this->assertEquals(19, $prop->line); + + // Verify Route Location + $routes = $generator->getRoutes(); + $this->assertCount(1, $routes); + $route = $routes[0]; + $this->assertEquals($tempFile, $route->file); + $this->assertEquals(13, $route->line); // line of public function getTest() {} + + unlink($tempFile); + } + + public function testUnresolvedClassThrowsException() + { + $core = new Core(); + $code = <<<'PHP' +expectException(DiagnosticException::class); + $this->expectExceptionMessage("Unresolved class 'App\Models\NonExistentModel'"); + $this->expectExceptionMessage("on line 7"); + + try { + $core->generateYaml([$tempFile]); + } finally { + unlink($tempFile); + } + } + + public function testInvalidRouteSyntaxThrowsException() + { + $core = new Core(); + $code = <<<'PHP' +expectException(DiagnosticException::class); + $this->expectExceptionMessage("Invalid syntax for tag '@route'"); + $this->expectExceptionMessage("/invalid-route-no-method"); + + try { + $core->generateYaml([$tempFile]); + } finally { + unlink($tempFile); + } + } + + public function testInvalidResponseSyntaxThrowsException() + { + $core = new Core(); + $code = <<<'PHP' +expectException(DiagnosticException::class); + $this->expectExceptionMessage("Invalid syntax for tag '@response'"); + $this->expectExceptionMessage("on line 7"); + + try { + $core->generateYaml([$tempFile]); + } finally { + unlink($tempFile); + } + } + + public function testInvalidTagSyntaxThrowsException() + { + $core = new Core(); + $code = <<<'PHP' +expectException(DiagnosticException::class); + $this->expectExceptionMessage("Invalid syntax for tag '@query'"); + $this->expectExceptionMessage("expected format is '@query TYPE \$name'"); + + try { + $core->generateYaml([$tempFile]); + } finally { + unlink($tempFile); + } + } +} diff --git a/tests/IntelligentSchemaInferenceTest.php b/tests/IntelligentSchemaInferenceTest.php new file mode 100644 index 0000000..9d99338 --- /dev/null +++ b/tests/IntelligentSchemaInferenceTest.php @@ -0,0 +1,253 @@ +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + $userSchema = $spec['components']['schemas']['App_Models_UserModel']; + + $this->assertArrayHasKey('required', $userSchema); + $required = $userSchema['required']; + + // Should contain requiredProp and docRequiredProp + $this->assertContains('requiredProp', $required); + $this->assertContains('docRequiredProp', $required); + + // Should NOT contain optionalProp, optionalUnionProp, optionalMixedProp, docOptionalProp + $this->assertNotContains('optionalProp', $required); + $this->assertNotContains('optionalUnionProp', $required); + $this->assertNotContains('optionalMixedProp', $required); + $this->assertNotContains('docOptionalProp', $required); + } + + public function testDefaultValueInference() + { + $core = new Core(); + $code = <<<'PHP' +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + $schema = $spec['components']['schemas']['App_Models_DefaultModel']; + + $this->assertArrayHasKey('required', $schema); + $required = $schema['required']; + + // Only propWithNoDefault should be required + $this->assertContains('propWithNoDefault', $required); + $this->assertNotContains('propWithNativeDefault', $required); + $this->assertNotContains('propWithDocDefault', $required); + } + + public function testExplicitRequiredTagOnProperties() + { + $core = new Core(); + $code = <<<'PHP' +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + $schema = $spec['components']['schemas']['App_Models_ExplicitModel']; + + $this->assertArrayHasKey('required', $schema); + $required = $schema['required']; + + // propWithStandaloneRequired and propWithInlineRequired should be required + $this->assertContains('propWithStandaloneRequired', $required); + $this->assertContains('propWithInlineRequired', $required); + + // propWithStandaloneRequiredFalse and normalProp should NOT be required + $this->assertNotContains('propWithStandaloneRequiredFalse', $required); + $this->assertNotContains('normalProp', $required); + + // Cleaned description check: "@required" should be stripped from description + $props = $schema['properties']; + $this->assertEquals('This is inline', $props['propWithInlineRequired']['description']); + } + + public function testClassLevelPropertyRequiredTags() + { + $core = new Core(); + $code = <<<'PHP' +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + $schema = $spec['components']['schemas']['App_Models_ClassLevelModel']; + + $this->assertArrayHasKey('required', $schema); + $required = $schema['required']; + + // propInlineRequired, propStandaloneRequired and propNormal should be required + // (propNormal is required because its type-hint 'string' is not nullable and it has no default value) + $this->assertContains('propInlineRequired', $required); + $this->assertContains('propStandaloneRequired', $required); + $this->assertContains('propNormal', $required); + + // propStandaloneRequiredFalse and propNullable should NOT be required + $this->assertNotContains('propStandaloneRequiredFalse', $required); + $this->assertNotContains('propNullable', $required); + + // Cleaned description check: "@required" should be stripped from description + $props = $schema['properties']; + $this->assertEquals('Inline property description', $props['propInlineRequired']['description']); + } +} diff --git a/tests/NativeEnumSupportTest.php b/tests/NativeEnumSupportTest.php new file mode 100644 index 0000000..b882073 --- /dev/null +++ b/tests/NativeEnumSupportTest.php @@ -0,0 +1,119 @@ +generateYaml([ + $tempFile, + __DIR__ . '/fixtures/enums/BackedStringEnum.php' + ]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + $schema = $spec['components']['schemas']['PhpSwag_Tests_Fixtures_Enums_BackedStringEnum']; + + $this->assertEquals('string', $schema['type']); + $this->assertEquals(['H', 'S'], $schema['enum']); + } + + public function testBackedIntEnumSupport() + { + $core = new Core(); + $code = <<<'PHP' +generateYaml([ + $tempFile, + __DIR__ . '/fixtures/enums/BackedIntEnum.php' + ]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + $schema = $spec['components']['schemas']['PhpSwag_Tests_Fixtures_Enums_BackedIntEnum']; + + $this->assertEquals('integer', $schema['type']); + $this->assertEquals([1, 0], $schema['enum']); + } + + public function testPureUnitEnumSupport() + { + $core = new Core(); + $code = <<<'PHP' +generateYaml([ + $tempFile, + __DIR__ . '/fixtures/enums/PureUnitEnum.php' + ]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + $schema = $spec['components']['schemas']['PhpSwag_Tests_Fixtures_Enums_PureUnitEnum']; + + $this->assertEquals('string', $schema['type']); + $this->assertEquals(['Pending', 'Approved', 'Rejected'], $schema['enum']); + } +} diff --git a/tests/SmartTypeMappingTest.php b/tests/SmartTypeMappingTest.php new file mode 100644 index 0000000..e334e6c --- /dev/null +++ b/tests/SmartTypeMappingTest.php @@ -0,0 +1,152 @@ +resolve($node); + $this->assertEquals(['type' => 'string', 'format' => 'date-time'], $resolved); + + $node = new IdentifierTypeNode('DateTimeImmutable'); + $resolved = $typeResolver->resolve($node); + $this->assertEquals(['type' => 'string', 'format' => 'date-time'], $resolved); + + $node = new IdentifierTypeNode('DateTimeInterface'); + $resolved = $typeResolver->resolve($node); + $this->assertEquals(['type' => 'string', 'format' => 'date-time'], $resolved); + } + + public function testUploadedFileMapping() + { + $registry = new SchemaRegistry(); + $nameResolver = new NameResolver(); + $typeResolver = new TypeResolver($registry, $nameResolver); + + $node = new IdentifierTypeNode('Symfony\Component\HttpFoundation\File\UploadedFile'); + $resolved = $typeResolver->resolve($node); + $this->assertEquals(['type' => 'string', 'format' => 'binary'], $resolved); + + $node = new IdentifierTypeNode('Psr\Http\Message\UploadedFileInterface'); + $resolved = $typeResolver->resolve($node); + $this->assertEquals(['type' => 'string', 'format' => 'binary'], $resolved); + + $node = new IdentifierTypeNode('Illuminate\Http\UploadedFile'); + $resolved = $typeResolver->resolve($node); + $this->assertEquals(['type' => 'string', 'format' => 'binary'], $resolved); + } + + public function testUuidMapping() + { + $registry = new SchemaRegistry(); + $nameResolver = new NameResolver(); + $typeResolver = new TypeResolver($registry, $nameResolver); + + $node = new IdentifierTypeNode('Ramsey\Uuid\Uuid'); + $resolved = $typeResolver->resolve($node); + $this->assertEquals(['type' => 'string', 'format' => 'uuid'], $resolved); + + $node = new IdentifierTypeNode('Ramsey\Uuid\UuidInterface'); + $resolved = $typeResolver->resolve($node); + $this->assertEquals(['type' => 'string', 'format' => 'uuid'], $resolved); + + $node = new IdentifierTypeNode('Symfony\Component\Uid\Uuid'); + $resolved = $typeResolver->resolve($node); + $this->assertEquals(['type' => 'string', 'format' => 'uuid'], $resolved); + } + + public function testCustomMapping() + { + $registry = new SchemaRegistry(); + $nameResolver = new NameResolver(); + $mappingRegistry = new TypeMappingRegistry(); + $mappingRegistry->register('App\ValueObjects\Money', ['type' => 'number', 'format' => 'money']); + + $typeResolver = new TypeResolver($registry, $nameResolver, [], $mappingRegistry); + + $node = new IdentifierTypeNode('App\ValueObjects\Money'); + $resolved = $typeResolver->resolve($node); + $this->assertEquals(['type' => 'number', 'format' => 'money'], $resolved); + } + + public function testIntegrationWithGenerator() + { + $core = new Core(); + + // Register custom mapping in core's registry + $core->getTypeMappingRegistry()->register('App\Models\CustomToken', ['type' => 'string', 'format' => 'jwt']); + + $code = <<<'PHP' +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + $schema = $spec['components']['schemas']['App_Models_ModelWithMappedTypes']; + + $this->assertEquals(['type' => 'string', 'format' => 'date-time'], $schema['properties']['createdAt']); + $this->assertEquals(['type' => 'string', 'format' => 'uuid'], $schema['properties']['id']); + $this->assertEquals(['type' => 'string', 'format' => 'binary'], $schema['properties']['avatar']); + $this->assertEquals(['type' => 'string', 'format' => 'jwt'], $schema['properties']['token']); + } +} diff --git a/tests/TypeResolverTest.php b/tests/TypeResolverTest.php index 369e7b6..538c039 100644 --- a/tests/TypeResolverTest.php +++ b/tests/TypeResolverTest.php @@ -47,6 +47,7 @@ public function testResolveNullableAndArray() $this->assertEquals(['type' => 'string', 'nullable' => true], $resolved); $doc = '/** @var User[] */'; + $registry->register(new \PhpSwag\IR\SchemaDefinition('User')); $node = $parser->parse($doc); $varTag = $node->getVarTagValues()[0]; $resolved = $typeResolver->resolve($varTag->type); diff --git a/tests/fixtures/enums/BackedIntEnum.php b/tests/fixtures/enums/BackedIntEnum.php new file mode 100644 index 0000000..6ff1525 --- /dev/null +++ b/tests/fixtures/enums/BackedIntEnum.php @@ -0,0 +1,9 @@ + Date: Sun, 7 Jun 2026 22:33:29 +0700 Subject: [PATCH 20/27] refactor: cli command phpswag and serialize empty object/mapping fields in both YAML and JSON format - refactor cli command - serialize empty object/mapping fields in both YAML and JSON format --- README.md | 8 ++-- TECHNICAL_ANALYSIS.md | 4 +- bin/{php-swag => phpswag} | 0 composer.json | 4 +- src/CLI/GenerateCommand.php | 21 +++++++-- src/Generator.php | 45 +++++++++++++++++- tests/AdvancedMetadataTest.php | 2 +- tests/CachingTest.php | 4 +- tests/GenerateCommandTest.php | 76 +++++++++++++++++++++++++++++++ tests/GeneratorTest.php | 48 +++++++++++++++++++ tests/MimeTypesAndAliasesTest.php | 2 +- tests/ValidationTest.php | 2 +- 12 files changed, 197 insertions(+), 19 deletions(-) rename bin/{php-swag => phpswag} (100%) create mode 100644 tests/GenerateCommandTest.php diff --git a/README.md b/README.md index a86e31e..fe064d5 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AS ## Installation ```bash -composer require php-swag/php-swag +composer require phpswag/phpswag ``` ## Usage @@ -46,7 +46,7 @@ $core = new Core(); $core->setOpenApiVersion('3.1.0'); // Optional, defaults to 3.0.0 // Optional: Enable caching to speed up consecutive generations -$core->enableCache('./.php-swag-cache'); +$core->enableCache('./.phpswag-cache'); $yaml = $core->generate(['./src/App']); @@ -382,7 +382,7 @@ php examples/generate.php You can use the CLI to generate documentation without writing any PHP code: ```bash -./vendor/bin/php-swag generate --path src/Controllers --path src/Models --output swagger.yaml +./vendor/bin/phpswag generate --path src/Controllers --path src/Models --output swagger.yaml ``` **Options:** @@ -396,4 +396,4 @@ You can use the CLI to generate documentation without writing any PHP code: - `--description`: API Description override. - `--host`: API Host/Server URL override. - `--cache`: Enable performance caching. -- `--cache-file`: Custom cache file path (default: `./.php-swag-cache`). +- `--cache-file`: Custom cache file path (default: `./.phpswag-cache`). diff --git a/TECHNICAL_ANALYSIS.md b/TECHNICAL_ANALYSIS.md index 2ce41da..8ec16c9 100644 --- a/TECHNICAL_ANALYSIS.md +++ b/TECHNICAL_ANALYSIS.md @@ -1,4 +1,4 @@ -# BÁO CÁO PHÂN TÍCH KỸ THUẬT: THƯ VIỆN PHP-SWAG (SWAGGO FOR PHP) +# BÁO CÁO PHÂN TÍCH KỸ THUẬT: THƯ VIỆN PHPSWAG (SWAGGO FOR PHP) ## 1. Đánh giá tính khả thi (Feasibility Analysis) @@ -125,7 +125,7 @@ Dự án này mang tính thực tiễn cao, giúp giảm thiểu sự trùng l - Tự động tìm kiếm Class định nghĩa trong toàn bộ project. **Giai đoạn 3: Integration & CLI** -- Xây dựng CLI tool `php-swag`. +- Xây dựng CLI tool `phpswag`. - Xuất file `swagger.yaml` hoặc `swagger.json`. diff --git a/bin/php-swag b/bin/phpswag similarity index 100% rename from bin/php-swag rename to bin/phpswag diff --git a/composer.json b/composer.json index 9426523..8685356 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "php-swag/php-swag", + "name": "phpswag/phpswag", "description": "A framework-agnostic PHP Swagger/OpenAPI generator", "type": "library", "license": "MIT", @@ -29,7 +29,7 @@ "config": { "sort-packages": true }, - "bin": ["bin/php-swag"], + "bin": ["bin/phpswag"], "scripts": { "lint": [ "phpcs", diff --git a/src/CLI/GenerateCommand.php b/src/CLI/GenerateCommand.php index bece925..e1b6025 100644 --- a/src/CLI/GenerateCommand.php +++ b/src/CLI/GenerateCommand.php @@ -27,13 +27,19 @@ protected function configure(): void 'OpenAPI version (3.0.0 or 3.1.0)', '3.0.0' ) - ->addOption('filter-unused', null, InputOption::VALUE_NONE, 'Filter unused schemas') + ->addOption( + 'filter-unused', + null, + InputOption::VALUE_OPTIONAL, + 'Filter unused schemas (true or false)', + 'true' + ) ->addOption('title', null, InputOption::VALUE_REQUIRED, 'API Title') ->addOption('api-version', null, InputOption::VALUE_REQUIRED, 'API Version') ->addOption('description', null, InputOption::VALUE_REQUIRED, 'API Description') ->addOption('host', null, InputOption::VALUE_REQUIRED, 'API Host/Server URL') ->addOption('cache', null, InputOption::VALUE_NONE, 'Enable caching to speed up generation') - ->addOption('cache-file', null, InputOption::VALUE_REQUIRED, 'Cache file path', './.php-swag-cache'); + ->addOption('cache-file', null, InputOption::VALUE_REQUIRED, 'Cache file path', './.phpswag-cache'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -46,10 +52,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int $core = new Core(); $core->setOpenApiVersion($input->getOption('openapi-version')); - $core->setFilterUnusedSchemas($input->getOption('filter-unused')); + + $filterUnused = $input->getOption('filter-unused'); + if ($filterUnused === null) { + $filterUnused = true; + } else { + $filterUnused = filter_var($filterUnused, FILTER_VALIDATE_BOOLEAN); + } + $core->setFilterUnusedSchemas($filterUnused); if ($input->getOption('cache')) { - $core->enableCache($input->getOption('cache-file') ?: './.php-swag-cache'); + $core->enableCache($input->getOption('cache-file') ?: './.phpswag-cache'); } if ($title = $input->getOption('title')) { diff --git a/src/Generator.php b/src/Generator.php index 0a25254..a182695 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -125,7 +125,7 @@ public function generateYaml(): string { $yaml = Yaml::dump($this->generateSpec(), 10, 2, Yaml::DUMP_NUMERIC_KEY_AS_STRING); return preg_replace( - '/(?<=\n)(\s+)(?!schema\b)([a-zA-Z0-9_-]+):\s*\{\s*\}\s*(?=\n)/', + '/(?<=\n)(\s+)(?!(?:schema|properties|paths|schemas|responses|headers|examples|requestBodies|securitySchemes|additionalProperties|items|components|info|contact|license|externalDocs|xml)\b)([a-zA-Z0-9_-]+):\s*\{\s*\}\s*(?=\n)/', '$1$2: [ ]', $yaml ); @@ -133,10 +133,51 @@ public function generateYaml(): string public function generateJson(): string { - $json = json_encode($this->generateSpec(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $spec = $this->forceObjectsForJson($this->generateSpec()); + $json = json_encode($spec, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); return $json !== false ? $json : '{}'; } + /** + * Recursively forces specified keys to be objects instead of empty arrays in JSON output. + */ + private function forceObjectsForJson(mixed $data): mixed + { + if (!is_array($data)) { + return $data; + } + + $objectKeys = [ + 'properties', + 'schemas', + 'paths', + 'responses', + 'schema', + 'additionalProperties', + 'headers', + 'examples', + 'requestBodies', + 'securitySchemes', + 'items', + 'components', + 'info', + 'contact', + 'license', + 'externalDocs', + 'xml', + ]; + + $res = []; + foreach ($data as $key => $value) { + if (in_array($key, $objectKeys, true) && is_array($value) && empty($value)) { + $res[$key] = (object)[]; + } else { + $res[$key] = $this->forceObjectsForJson($value); + } + } + return $res; + } + /** * @return array */ diff --git a/tests/AdvancedMetadataTest.php b/tests/AdvancedMetadataTest.php index 10cc811..ee2c997 100644 --- a/tests/AdvancedMetadataTest.php +++ b/tests/AdvancedMetadataTest.php @@ -25,7 +25,7 @@ public function list() {} } PHP; - $tempFile = tempnam(sys_get_temp_dir(), 'php-swag-metadata-test'); + $tempFile = tempnam(sys_get_temp_dir(), 'phpswag-metadata-test'); file_put_contents($tempFile, $code); $core = new Core(); diff --git a/tests/CachingTest.php b/tests/CachingTest.php index 2504fc9..92fe1bb 100644 --- a/tests/CachingTest.php +++ b/tests/CachingTest.php @@ -22,10 +22,10 @@ public function list() {} } PHP; - $tempFile = tempnam(sys_get_temp_dir(), 'php-swag-cache-test'); + $tempFile = tempnam(sys_get_temp_dir(), 'phpswag-cache-test'); file_put_contents($tempFile, $code); - $cacheFile = sys_get_temp_dir() . '/php-swag-cache-test-store.dat'; + $cacheFile = sys_get_temp_dir() . '/phpswag-cache-test-store.dat'; if (file_exists($cacheFile)) { unlink($cacheFile); } diff --git a/tests/GenerateCommandTest.php b/tests/GenerateCommandTest.php new file mode 100644 index 0000000..c45c990 --- /dev/null +++ b/tests/GenerateCommandTest.php @@ -0,0 +1,76 @@ +add(new GenerateCommand()); + $command = $application->find('generate'); + $this->commandTester = new CommandTester($command); + } + + public function testExecuteWithoutPathFails() + { + $this->commandTester->execute([]); + $this->assertEquals(1, $this->commandTester->getStatusCode()); + $this->assertStringContainsString('At least one --path is required', $this->commandTester->getDisplay()); + } + + public function testExecuteWithFilterUnusedTrue() + { + // Test passing "true" explicitly + $this->commandTester->execute([ + '--path' => ['examples/App'], + '--filter-unused' => 'true', + '--output' => 'test-swagger-true.yaml', + ]); + $this->assertEquals(0, $this->commandTester->getStatusCode()); + $this->assertStringContainsString('Documentation generated to test-swagger-true.yaml', $this->commandTester->getDisplay()); + + if (file_exists('test-swagger-true.yaml')) { + unlink('test-swagger-true.yaml'); + } + } + + public function testExecuteWithFilterUnusedFalse() + { + // Test passing "false" explicitly + $this->commandTester->execute([ + '--path' => ['examples/App'], + '--filter-unused' => 'false', + '--output' => 'test-swagger-false.yaml', + ]); + $this->assertEquals(0, $this->commandTester->getStatusCode()); + $this->assertStringContainsString('Documentation generated to test-swagger-false.yaml', $this->commandTester->getDisplay()); + + if (file_exists('test-swagger-false.yaml')) { + unlink('test-swagger-false.yaml'); + } + } + + public function testExecuteWithFilterUnusedAsFlag() + { + // Test passing option as a flag (no value) + $this->commandTester->execute([ + '--path' => ['examples/App'], + '--filter-unused' => null, + '--output' => 'test-swagger-flag.yaml', + ]); + $this->assertEquals(0, $this->commandTester->getStatusCode()); + $this->assertStringContainsString('Documentation generated to test-swagger-flag.yaml', $this->commandTester->getDisplay()); + + if (file_exists('test-swagger-flag.yaml')) { + unlink('test-swagger-flag.yaml'); + } + } +} diff --git a/tests/GeneratorTest.php b/tests/GeneratorTest.php index c2bfa47..31085d4 100644 --- a/tests/GeneratorTest.php +++ b/tests/GeneratorTest.php @@ -43,4 +43,52 @@ public function testGenerateBasicOpenApi() $this->assertArrayHasKey('User', $spec['components']['schemas']); $this->assertEquals('integer', $spec['components']['schemas']['User']['properties']['id']['type']); } + + public function testEmptyPropertiesAndSecuritySerialization() + { + $registry = new SchemaRegistry(); + $generator = new Generator($registry); + + // Add a route with an empty security scope (api_key) and a response with void type + $route = new RouteDefinition( + method: 'GET', + path: '/users', + summary: 'List users', + responses: ['200' => []], + security: [['api_key' => []]] + ); + $generator->addRoute($route); + + // Add a schema with no properties (like a controller) + $schema = new SchemaDefinition( + name: 'EmptyController', + properties: [] + ); + $registry->register($schema); + + // 1. Verify YAML output + $yaml = $generator->generateYaml(); + + // Assert yaml contains properties: { } or properties: {} (should be object mapping) + $this->assertStringContainsString('properties: { }', $yaml); + // Assert yaml contains api_key: [ ] (should be list/sequence) + $this->assertStringContainsString('api_key: [ ]', $yaml); + + // 2. Verify JSON output + $json = $generator->generateJson(); + $decodedJson = json_decode($json, true); + + // Properties must be an object + $this->assertArrayHasKey('EmptyController', $decodedJson['components']['schemas']); + $this->assertEquals([], $decodedJson['components']['schemas']['EmptyController']['properties']); + $this->assertIsArray($decodedJson['components']['schemas']['EmptyController']['properties']); + // But in raw JSON it should be {} (PHP Decodes empty object as empty array unless cast/flag, so let's check raw JSON string) + $this->assertStringContainsString('"properties": {}', $json); + + // Schema must be an object + $this->assertStringContainsString('"schema": {}', $json); + + // Security scope must be an array + $this->assertStringContainsString('"api_key": []', $json); + } } diff --git a/tests/MimeTypesAndAliasesTest.php b/tests/MimeTypesAndAliasesTest.php index 179aeb0..72255f6 100644 --- a/tests/MimeTypesAndAliasesTest.php +++ b/tests/MimeTypesAndAliasesTest.php @@ -43,7 +43,7 @@ class ErrorResponse { } PHP; - $tempFile = tempnam(sys_get_temp_dir(), 'php-swag-mimes-test'); + $tempFile = tempnam(sys_get_temp_dir(), 'phpswag-mimes-test'); file_put_contents($tempFile, $code); $core = new Core(); diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index f8a862b..7c7807d 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -44,7 +44,7 @@ class User { } PHP; - $tempFile = tempnam(sys_get_temp_dir(), 'php-swag-test'); + $tempFile = tempnam(sys_get_temp_dir(), 'phpswag-test'); file_put_contents($tempFile, $code); $yaml = $core->generateYaml([$tempFile]); From 53bc683ed02c2c88c0cd600e4e317230cf77aea0 Mon Sep 17 00:00:00 2001 From: tolawho Date: Sun, 7 Jun 2026 22:54:21 +0700 Subject: [PATCH 21/27] feat: add support for securityDefinitions.basic (HTTP Basic Auth) --- README.md | 3 +++ src/CLI/GenerateCommand.php | 2 +- src/Core.php | 14 ++++++++++++++ src/Generator.php | 4 +++- tests/DiagnosticsTest.php | 25 +++++++++++++++++++++++++ tests/GenerateCommandTest.php | 2 +- tests/GeneratorTest.php | 2 +- tests/SecurityTest.php | 5 +++++ 8 files changed, 53 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fe064d5..b7e5f1f 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,10 @@ Define security schemes and requirements globally or per operation: ```php /** + * @title Security API * @securityDefinitions.apikey MyApiKey header X-API-KEY * @securityDefinitions.jwt MyJwtAuth + * @securityDefinitions.basic MyBasicAuth * @security MyJwtAuth */ @@ -339,6 +341,7 @@ public function show(int $id, string $status) {} - **Security**: - `@securityDefinitions.apikey [NAME] [IN: header|query|cookie] [KEY_NAME]` - `@securityDefinitions.jwt [NAME]` + - `@securityDefinitions.basic [NAME]` - `@security [NAME]` or `@security [NAME[scopes]]` (supports OR/AND) - **Endpoints**: - `@route [METHOD] [PATH]` (e.g., `@route POST /data`) diff --git a/src/CLI/GenerateCommand.php b/src/CLI/GenerateCommand.php index e1b6025..482932e 100644 --- a/src/CLI/GenerateCommand.php +++ b/src/CLI/GenerateCommand.php @@ -52,7 +52,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $core = new Core(); $core->setOpenApiVersion($input->getOption('openapi-version')); - + $filterUnused = $input->getOption('filter-unused'); if ($filterUnused === null) { $filterUnused = true; diff --git a/src/Core.php b/src/Core.php index 61f4e07..6801215 100644 --- a/src/Core.php +++ b/src/Core.php @@ -351,6 +351,20 @@ private function discoverGlobalMetadata(string $code, string $filePath): void isset($tag['line']) ? " on line " . $tag['line'] : "" )); } + } elseif ($tagName === '@securityDefinitions.basic') { + if (trim($tag['value']) !== '') { + $this->securitySchemes[$tag['value']] = [ + 'type' => 'http', + 'scheme' => 'basic' + ]; + } else { + throw new \PhpSwag\Exception\DiagnosticException(sprintf( + "Invalid syntax for tag '@securityDefinitions.basic' in %s%s: " + . "expected format is '@securityDefinitions.basic NAME', got empty value", + $tag['file'] ?? $filePath, + isset($tag['line']) ? " on line " . $tag['line'] : "" + )); + } } elseif ($tagName === '@security') { $this->globalSecurity = array_merge( $this->globalSecurity, diff --git a/src/Generator.php b/src/Generator.php index a182695..b99f6ce 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -125,7 +125,9 @@ public function generateYaml(): string { $yaml = Yaml::dump($this->generateSpec(), 10, 2, Yaml::DUMP_NUMERIC_KEY_AS_STRING); return preg_replace( - '/(?<=\n)(\s+)(?!(?:schema|properties|paths|schemas|responses|headers|examples|requestBodies|securitySchemes|additionalProperties|items|components|info|contact|license|externalDocs|xml)\b)([a-zA-Z0-9_-]+):\s*\{\s*\}\s*(?=\n)/', + '/(?<=\n)(\s+)(?!(?:schema|properties|paths|schemas|responses|headers|examples|' . + 'requestBodies|securitySchemes|additionalProperties|items|components|info|contact|' . + 'license|externalDocs|xml)\b)([a-zA-Z0-9_-]+):\s*\{\s*\}\s*(?=\n)/', '$1$2: [ ]', $yaml ); diff --git a/tests/DiagnosticsTest.php b/tests/DiagnosticsTest.php index accb51c..ec71fc7 100644 --- a/tests/DiagnosticsTest.php +++ b/tests/DiagnosticsTest.php @@ -193,4 +193,29 @@ public function getTest($name) {} unlink($tempFile); } } + + public function testInvalidSecurityBasicThrowsException() + { + $core = new Core(); + $code = <<<'PHP' +expectException(DiagnosticException::class); + $this->expectExceptionMessage("Invalid syntax for tag '@securityDefinitions.basic'"); + $this->expectExceptionMessage("expected format is '@securityDefinitions.basic NAME', got empty value"); + + try { + $core->generateYaml([$tempFile]); + } finally { + unlink($tempFile); + } + } } diff --git a/tests/GenerateCommandTest.php b/tests/GenerateCommandTest.php index c45c990..fb2faf2 100644 --- a/tests/GenerateCommandTest.php +++ b/tests/GenerateCommandTest.php @@ -36,7 +36,7 @@ public function testExecuteWithFilterUnusedTrue() ]); $this->assertEquals(0, $this->commandTester->getStatusCode()); $this->assertStringContainsString('Documentation generated to test-swagger-true.yaml', $this->commandTester->getDisplay()); - + if (file_exists('test-swagger-true.yaml')) { unlink('test-swagger-true.yaml'); } diff --git a/tests/GeneratorTest.php b/tests/GeneratorTest.php index 31085d4..417b9cf 100644 --- a/tests/GeneratorTest.php +++ b/tests/GeneratorTest.php @@ -68,7 +68,7 @@ public function testEmptyPropertiesAndSecuritySerialization() // 1. Verify YAML output $yaml = $generator->generateYaml(); - + // Assert yaml contains properties: { } or properties: {} (should be object mapping) $this->assertStringContainsString('properties: { }', $yaml); // Assert yaml contains api_key: [ ] (should be list/sequence) diff --git a/tests/SecurityTest.php b/tests/SecurityTest.php index 775024c..9cd71fa 100644 --- a/tests/SecurityTest.php +++ b/tests/SecurityTest.php @@ -42,6 +42,7 @@ public function testSecurityDefinitionsAndGlobalSecurity(): void * @title Security API * @securityDefinitions.apikey MyApiKey header X-API-KEY * @securityDefinitions.jwt MyJwtAuth + * @securityDefinitions.basic MyBasicAuth * @security MyJwtAuth */ @@ -96,6 +97,10 @@ public function scoped() {} $this->assertStringContainsString('scheme: bearer', $yaml); $this->assertStringContainsString('bearerFormat: JWT', $yaml); + $this->assertStringContainsString('MyBasicAuth:', $yaml); + $this->assertStringContainsString('type: http', $yaml); + $this->assertStringContainsString('scheme: basic', $yaml); + // Check Global Security $this->assertStringContainsString('security:', $yaml); $this->assertStringContainsString('MyJwtAuth: [ ]', $yaml); From ab40916d58f16d832de8108dcac2fbc7166540a7 Mon Sep 17 00:00:00 2001 From: tolawho Date: Mon, 8 Jun 2026 11:29:01 +0700 Subject: [PATCH 22/27] refactor: decouple Core class and modularize tag parsing & metadata discovery This commit performs a complete refactoring of PhpSwag's Core component across 6 distinct phases to improve maintainability, clean code, and SOLID compliance. Changelog: 1. Dependency Injection: - Modified Core's constructor to accept Scanner, Parser, DocBlockCollector, SchemaRegistry, TypeMappingRegistry, Generator, TypeAnalyzer, and GlobalMetadataDiscoverer. - Provided Core::createDefault() factory to preserve backward compatibility. - Updated external CLI commands and tests to leverage the new factory. 2. Type Analysis Extraction: - Created PhpSwag\TypeAnalyzer to encapsulate primitive/DocBlock type checks (nullable, required). 3. Tag Parsing Registry: - Designed TagParserInterface and SchemaTagParserInterface for registering custom tag parsers. - Created RouteContext and SchemaContext DTOs to encapsulate parsing state. 4. Method Tag Parsers: - Created RouteTagParser (@route), ResponseTagParser (@response), ParamTagParser (@path, @query, @header, @cookie), BodyTagParser (@body), SecurityTagParser (@security), and BasicMethodTagParser (@summary, @description, etc.). 5. Class & Property Tag Parsers: - Created ExtendsTagParser (@extends, @use), ClassMetadataTagParser (@tag, @security, @accept, @consume, @produce, @required), and PropertyTagParser (@property, @var). 6. Global Metadata Discovery: - Isolated token-level comment scanning (token_get_all) into GlobalMetadataDiscoverer. 7. Test Enhancements: - Added CoreDependencyInjectionTest, TypeAnalyzerTest, and GlobalMetadataDiscovererTest. - Verified clean PSR-12 / PHPStan static analysis and 100% test coverage. --- src/CLI/GenerateCommand.php | 2 +- src/Core.php | 768 +++++---------------- src/Metadata/GlobalMetadataDiscoverer.php | 183 +++++ src/TagParser/BasicMethodTagParser.php | 58 ++ src/TagParser/BodyTagParser.php | 49 ++ src/TagParser/ClassMetadataTagParser.php | 53 ++ src/TagParser/ExtendsTagParser.php | 48 ++ src/TagParser/ParamTagParser.php | 27 + src/TagParser/PropertyTagParser.php | 78 +++ src/TagParser/ResponseTagParser.php | 63 ++ src/TagParser/RouteContext.php | 50 ++ src/TagParser/RouteTagParser.php | 29 + src/TagParser/SchemaContext.php | 33 + src/TagParser/SchemaTagParserInterface.php | 22 + src/TagParser/SecurityTagParser.php | 76 ++ src/TagParser/TagParserInterface.php | 22 + src/TypeAnalyzer.php | 126 ++++ tests/CoreDependencyInjectionTest.php | 98 +++ tests/GlobalMetadataDiscovererTest.php | 210 ++++++ tests/TypeAnalyzerTest.php | 116 ++++ 20 files changed, 1496 insertions(+), 615 deletions(-) create mode 100644 src/Metadata/GlobalMetadataDiscoverer.php create mode 100644 src/TagParser/BasicMethodTagParser.php create mode 100644 src/TagParser/BodyTagParser.php create mode 100644 src/TagParser/ClassMetadataTagParser.php create mode 100644 src/TagParser/ExtendsTagParser.php create mode 100644 src/TagParser/ParamTagParser.php create mode 100644 src/TagParser/PropertyTagParser.php create mode 100644 src/TagParser/ResponseTagParser.php create mode 100644 src/TagParser/RouteContext.php create mode 100644 src/TagParser/RouteTagParser.php create mode 100644 src/TagParser/SchemaContext.php create mode 100644 src/TagParser/SchemaTagParserInterface.php create mode 100644 src/TagParser/SecurityTagParser.php create mode 100644 src/TagParser/TagParserInterface.php create mode 100644 src/TypeAnalyzer.php create mode 100644 tests/CoreDependencyInjectionTest.php create mode 100644 tests/GlobalMetadataDiscovererTest.php create mode 100644 tests/TypeAnalyzerTest.php diff --git a/src/CLI/GenerateCommand.php b/src/CLI/GenerateCommand.php index 482932e..9d358ef 100644 --- a/src/CLI/GenerateCommand.php +++ b/src/CLI/GenerateCommand.php @@ -50,7 +50,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - $core = new Core(); + $core = Core::createDefault(); $core->setOpenApiVersion($input->getOption('openapi-version')); $filterUnused = $input->getOption('filter-unused'); diff --git a/src/Core.php b/src/Core.php index 6801215..e4293a3 100644 --- a/src/Core.php +++ b/src/Core.php @@ -10,7 +10,6 @@ use PhpParser\Node\Stmt\Property; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\NodeTraverser; -use PhpSwag\IR\PropertyDefinition; use PhpSwag\IR\RouteDefinition; use PhpSwag\IR\SchemaDefinition; @@ -23,6 +22,14 @@ class Core private SchemaRegistry $schemaRegistry; private TypeMappingRegistry $typeMappingRegistry; private ?Cache\CacheInterface $cache = null; + private TypeAnalyzer $typeAnalyzer; + private Metadata\GlobalMetadataDiscoverer $metadataDiscoverer; + + /** @var array */ + private array $tagParsers = []; + + /** @var array */ + private array $schemaTagParsers = []; /** @var array */ private array $discoveredClasses = []; @@ -44,14 +51,70 @@ class Core /** @var array */ private array $globalTags = []; - public function __construct() + public function __construct( + ?Scanner $scanner = null, + ?Parser $parser = null, + ?DocBlockCollector $docCollector = null, + ?SchemaRegistry $schemaRegistry = null, + ?TypeMappingRegistry $typeMappingRegistry = null, + ?Generator $generator = null, + ?TypeAnalyzer $typeAnalyzer = null, + ?Metadata\GlobalMetadataDiscoverer $metadataDiscoverer = null + ) { + $this->scanner = $scanner ?? new Scanner(); + $this->parser = $parser ?? new Parser(); + $this->docCollector = $docCollector ?? new DocBlockCollector(); + $this->schemaRegistry = $schemaRegistry ?? new SchemaRegistry(); + $this->typeMappingRegistry = $typeMappingRegistry ?? new TypeMappingRegistry(); + $this->generator = $generator ?? new Generator($this->schemaRegistry); + $this->typeAnalyzer = $typeAnalyzer ?? new TypeAnalyzer(); + $this->metadataDiscoverer = $metadataDiscoverer ?? new Metadata\GlobalMetadataDiscoverer($this->docCollector); + + $this->registerTagParser(new TagParser\RouteTagParser()); + $this->registerTagParser(new TagParser\ResponseTagParser($this->typeAnalyzer, $this->docCollector)); + $this->registerTagParser(new TagParser\ParamTagParser()); + $this->registerTagParser(new TagParser\BodyTagParser()); + $this->registerTagParser(new TagParser\SecurityTagParser()); + $this->registerTagParser(new TagParser\BasicMethodTagParser()); + + $this->registerSchemaTagParser(new TagParser\ExtendsTagParser($this->docCollector, $this->schemaRegistry)); + $this->registerSchemaTagParser(new TagParser\ClassMetadataTagParser()); + $this->registerSchemaTagParser(new TagParser\PropertyTagParser($this->typeAnalyzer)); + } + + public static function createDefault(): self + { + return new self(); + } + + public function registerTagParser(TagParser\TagParserInterface $parser): void { - $this->scanner = new Scanner(); - $this->parser = new Parser(); - $this->docCollector = new DocBlockCollector(); - $this->schemaRegistry = new SchemaRegistry(); - $this->typeMappingRegistry = new TypeMappingRegistry(); - $this->generator = new Generator($this->schemaRegistry); + foreach ($parser->getSupportedTags() as $tag) { + $this->tagParsers[$tag] = $parser; + } + } + + /** + * @return array + */ + public function getTagParsers(): array + { + return $this->tagParsers; + } + + public function registerSchemaTagParser(TagParser\SchemaTagParserInterface $parser): void + { + foreach ($parser->getSupportedTags() as $tag) { + $this->schemaTagParsers[$tag] = $parser; + } + } + + /** + * @return array + */ + public function getSchemaTagParsers(): array + { + return $this->schemaTagParsers; } public function getTypeMappingRegistry(): TypeMappingRegistry @@ -247,7 +310,18 @@ private function discoverFile(string $filePath): void $stmts = $this->parser->parse($code); // Check for global metadata in comments - $this->discoverGlobalMetadata($code, $filePath); + $discovered = $this->metadataDiscoverer->discover( + $code, + $filePath, + $this->globalMetadata, + $this->metadataSources + ); + + $this->globalMetadata = array_merge($this->globalMetadata, $discovered['globalMetadata']); + $this->metadataSources = array_merge($this->metadataSources, $discovered['metadataSources']); + $this->securitySchemes = array_merge($this->securitySchemes, $discovered['securitySchemes']); + $this->globalSecurity = array_merge($this->globalSecurity, $discovered['globalSecurity']); + $this->globalTags = array_merge($this->globalTags, $discovered['globalTags']); $nameResolver = new NameResolver(); $traverser = new NodeTraverser(); @@ -265,193 +339,6 @@ private function discoverFile(string $filePath): void } } - private function discoverGlobalMetadata(string $code, string $filePath): void - { - $tokens = token_get_all($code); - foreach ($tokens as $token) { - if (is_array($token) && $token[0] === T_DOC_COMMENT) { - $docComment = $token[1]; - $startLine = $token[2]; - $tags = $this->docCollector->collectTags($docComment, $startLine, $filePath); - - $isGlobalBlock = false; - $hasRouteOrProperty = false; - foreach ($tags as $tag) { - if (in_array($tag['name'], ['@route', '@property', '@var'])) { - $hasRouteOrProperty = true; - break; - } - } - if (!$hasRouteOrProperty) { - foreach ($tags as $tag) { - if ( - in_array($tag['name'], ['@title', '@version', '@description', '@host']) || - str_starts_with($tag['name'], '@contact.') || - str_starts_with($tag['name'], '@license.') || - str_starts_with($tag['name'], '@securityDefinitions.') || - str_starts_with($tag['name'], '@tag.') - ) { - $isGlobalBlock = true; - break; - } - } - } - - if (!$isGlobalBlock) { - continue; - } - - $currentTagName = null; - foreach ($tags as $tag) { - $tagName = $tag['name']; - if ( - in_array($tagName, ['@title', '@version', '@description', '@host']) || - str_starts_with($tagName, '@contact.') || - str_starts_with($tagName, '@license.') - ) { - $val = $tag['value'] ?? ''; - if (isset($this->globalMetadata[$tagName]) && $this->globalMetadata[$tagName] !== $val) { - throw new \Exception(sprintf( - "Duplicate global tag '%s' found in %s and %s", - $tagName, - $this->metadataSources[$tagName], - $filePath - )); - } - $this->globalMetadata[$tagName] = $val; - $this->metadataSources[$tagName] = $filePath; - } elseif ($tagName === '@securityDefinitions.apikey') { - if (preg_match('/^(\S+)\s+(header|query|cookie)\s+(\S+)/', $tag['value'], $matches)) { - $this->securitySchemes[$matches[1]] = [ - 'type' => 'apiKey', - 'in' => $matches[2], - 'name' => $matches[3] - ]; - } else { - throw new \PhpSwag\Exception\DiagnosticException(sprintf( - "Invalid syntax for tag '@securityDefinitions.apikey' in %s%s: " - . "expected format is '@securityDefinitions.apikey NAME IN KEY', got '%s'", - $tag['file'] ?? $filePath, - isset($tag['line']) ? " on line " . $tag['line'] : "", - $tag['value'] - )); - } - } elseif ($tagName === '@securityDefinitions.jwt') { - if (trim($tag['value']) !== '') { - $this->securitySchemes[$tag['value']] = [ - 'type' => 'http', - 'scheme' => 'bearer', - 'bearerFormat' => 'JWT' - ]; - } else { - throw new \PhpSwag\Exception\DiagnosticException(sprintf( - "Invalid syntax for tag '@securityDefinitions.jwt' in %s%s: " - . "expected format is '@securityDefinitions.jwt NAME', got empty value", - $tag['file'] ?? $filePath, - isset($tag['line']) ? " on line " . $tag['line'] : "" - )); - } - } elseif ($tagName === '@securityDefinitions.basic') { - if (trim($tag['value']) !== '') { - $this->securitySchemes[$tag['value']] = [ - 'type' => 'http', - 'scheme' => 'basic' - ]; - } else { - throw new \PhpSwag\Exception\DiagnosticException(sprintf( - "Invalid syntax for tag '@securityDefinitions.basic' in %s%s: " - . "expected format is '@securityDefinitions.basic NAME', got empty value", - $tag['file'] ?? $filePath, - isset($tag['line']) ? " on line " . $tag['line'] : "" - )); - } - } elseif ($tagName === '@security') { - $this->globalSecurity = array_merge( - $this->globalSecurity, - $this->parseSecurityTag($tag['value']) - ); - } elseif ($tagName === '@tag.name') { - $parts = preg_split('/\s+/', $tag['value'], 2); - if (is_array($parts) && isset($parts[0]) && trim($parts[0]) !== '') { - $name = $parts[0]; - $desc = isset($parts[1]) ? trim($parts[1]) : null; - - $tagData = ['name' => $name]; - if ($desc !== null && $desc !== '') { - $tagData['description'] = $desc; - } - $this->globalTags[$name] = $tagData; - } else { - throw new \PhpSwag\Exception\DiagnosticException(sprintf( - "Invalid syntax for tag '@tag.name' in %s%s: " - . "expected format is '@tag.name NAME [description]', got '%s'", - $tag['file'] ?? $filePath, - isset($tag['line']) ? " on line " . $tag['line'] : "", - $tag['value'] - )); - } - } - } - } - } - } - - /** - * @return array>> - */ - private function parseSecurityTag(string $value): array - { - if (trim($value) === '') { - return [[]]; // Represents an empty security requirement object, which means "no security" - } - - $requirements = []; - $parts = $this->splitCommasOutsideBrackets($value); - $currentGroup = []; - foreach ($parts as $part) { - $part = trim($part); - if (empty($part)) { - continue; - } - - if (preg_match('/^([^\[]+)(?:\[(.*)\])?$/', $part, $matches)) { - $name = trim($matches[1]); - $scopes = isset($matches[2]) ? array_map('trim', explode(',', trim($matches[2]))) : []; - $currentGroup[$name] = $scopes; - } - } - if (!empty($currentGroup)) { - $requirements[] = $currentGroup; - } - return $requirements; - } - - /** - * @return array - */ - private function splitCommasOutsideBrackets(string $str): array - { - $parts = []; - $current = ''; - $depth = 0; - for ($i = 0; $i < strlen($str); $i++) { - $char = $str[$i]; - if ($char === '[') { - $depth++; - } elseif ($char === ']') { - $depth--; - } - - if ($char === ',' && $depth === 0) { - $parts[] = $current; - $current = ''; - } else { - $current .= $char; - } - } - $parts[] = $current; - return $parts; - } private function applyGlobalMetadata(): void { @@ -602,177 +489,62 @@ private function analyzeClass(string $fqcn, Class_|Trait_|Enum_ $stmt, NameResol } return; } + $typeResolver = new TypeResolver( $this->schemaRegistry, $nameResolver, $schema->templates, $this->typeMappingRegistry ); + $docComment = $stmt->getDocComment()?->getText() ?? ''; $docStartLine = $stmt->getDocComment()?->getStartLine(); $tags = $this->docCollector->collectTags($docComment, $docStartLine, $this->currentlyAnalyzingFile); - $classTags = []; - $classSecurity = []; - $classAccept = null; - $classProduce = null; + $context = new TagParser\SchemaContext($schema, $nameResolver); + // First pass: class metadata and inheritance foreach ($tags as $tag) { - if ($tag['name'] === '@extends' || $tag['name'] === '@use') { - $typeNode = $this->docCollector->parseType($tag['value']); - if ($typeNode instanceof \PHPStan\PhpDocParser\Ast\Type\GenericTypeNode) { - $targetFqcn = $nameResolver->resolve($typeNode->type->name); - $targetSchema = $this->schemaRegistry->get($targetFqcn); - if ($targetSchema && !empty($targetSchema->templates)) { - foreach ($typeNode->genericTypes as $i => $argNode) { - $templateName = $targetSchema->templates[$i] ?? "T$i"; - $schema->typeArguments[$templateName] = $typeResolver->resolve( - $argNode, - $tag['line'] ?? $docStartLine, - $this->currentlyAnalyzingFile - ); - } - } - } - } elseif ($tag['name'] === '@tag') { - $splitTags = array_filter(array_map('trim', explode(',', $tag['value'])), fn($t) => $t !== ''); - $classTags = array_merge($classTags, $splitTags); - } elseif ($tag['name'] === '@security') { - $classSecurity = array_merge($classSecurity, $this->parseSecurityTag($tag['value'])); - } elseif ($tag['name'] === '@accept' || $tag['name'] === '@consume') { - $classAccept = $tag['value']; - } elseif ($tag['name'] === '@produce') { - $classProduce = $tag['value']; - } - } - - $isSchema = false; - $properties = []; - - // Parse any class-level explicit @required tags targeting properties, e.g. @required $name or @required name - $classExplicitRequired = []; - foreach ($tags as $t) { - if ($t['name'] === '@required') { - $val = trim($t['value'] ?? ''); - if ($val !== '') { - if (preg_match('/^([^\s]+)(?:\s+(.*))?$/', $val, $matches)) { - $propName = ltrim($matches[1], '$'); - $optVal = isset($matches[2]) ? trim($matches[2]) : ''; - if (strtolower($optVal) === 'false') { - $classExplicitRequired[$propName] = false; - } else { - $classExplicitRequired[$propName] = true; - } - } - } + $tagName = $tag['name']; + if (isset($this->schemaTagParsers[$tagName])) { + $this->schemaTagParsers[$tagName]->parse($tag, $context, $typeResolver); } } + // Second pass: property definitions from class docblock foreach ($tags as $tag) { - if ($tag['name'] === '@property' && isset($tag['type'])) { - $isSchema = true; - $propertySchema = $typeResolver->resolve( - $tag['type'], - $tag['line'] ?? $docStartLine, - $this->currentlyAnalyzingFile - ); - - $desc = is_array($tag['description']) - ? ($tag['description']['description'] ?? null) - : ($tag['description'] ?? null); - - $extra = is_array($tag['description']) ? $tag['description'] : []; - unset($extra['description']); - - $explicitRequired = $classExplicitRequired[$tag['propertyName']] ?? null; - if ($desc !== null && stripos($desc, '@required') !== false) { - $explicitRequired = true; - $desc = preg_replace('/@required\s*/i', '', $desc); - $desc = trim($desc); + if ($tag['name'] === '@property') { + if (isset($this->schemaTagParsers['@property'])) { + $this->schemaTagParsers['@property']->parse($tag, $context, $typeResolver); } - - $hasDefault = isset($extra['default']); - $isNullable = $this->isDocTypeNullable($tag['type']); - $required = $this->determineRequired( - $tag['propertyName'], - $isNullable, - $explicitRequired, - $hasDefault, - null - ); - - $properties[] = new PropertyDefinition( - $tag['propertyName'], - $propertySchema, - $desc, - $extra, - $this->currentlyAnalyzingFile, - $tag['line'] ?? $docStartLine, - $required - ); } } + // Third pass: property definitions from class member variables foreach ($stmt->stmts as $member) { if ($member instanceof Property) { - $isSchema = true; $propDoc = $member->getDocComment()?->getText() ?? ''; $propStartLine = $member->getDocComment()?->getStartLine(); $propTags = $this->docCollector->collectTags($propDoc, $propStartLine, $this->currentlyAnalyzingFile); - foreach ($propTags as $pTag) { - if ($pTag['name'] === '@var' && isset($pTag['type'])) { - $propertySchema = $typeResolver->resolve( - $pTag['type'], - $pTag['line'] ?? $propStartLine, - $this->currentlyAnalyzingFile - ); - - $desc = is_array($pTag['description']) - ? ($pTag['description']['description'] ?? null) - : ($pTag['description'] ?? null); - - $extra = is_array($pTag['description']) ? $pTag['description'] : []; - unset($extra['description']); - - // Explicit required tag in property docblock - $explicitRequired = null; - foreach ($propTags as $t) { - if ($t['name'] === '@required') { - $val = trim($t['value'] ?? ''); - if (strtolower($val) === 'false') { - $explicitRequired = false; - } else { - $explicitRequired = true; - } - } - } - // Also check if @required is inline in the @var tag's description - if ($desc !== null && stripos($desc, '@required') !== false) { - $explicitRequired = true; - $desc = preg_replace('/@required\s*/i', '', $desc); - $desc = trim($desc); - } + $explicitRequired = null; + foreach ($propTags as $t) { + if ($t['name'] === '@required') { + $val = trim($t['value'] ?? ''); + $explicitRequired = (strtolower($val) === 'false') ? false : true; + } + } - $hasDefault = ($member->props[0]->default !== null) || isset($extra['default']); - $isNullable = $this->isDocTypeNullable($pTag['type']); - $required = $this->determineRequired( - $member->props[0]->name->toString(), - $isNullable, - $explicitRequired, - $hasDefault, - $member->type - ); - - $properties[] = new PropertyDefinition( - $member->props[0]->name->toString(), - $propertySchema, - $desc, - $extra, - $this->currentlyAnalyzingFile, - $pTag['line'] ?? $propStartLine, - $required - ); + foreach ($propTags as $pTag) { + if ($pTag['name'] === '@var') { + $pTag['explicitRequired'] = $explicitRequired; + $pTag['hasDefault'] = ($member->props[0]->default !== null); + $pTag['typeHint'] = $member->type; + $pTag['propertyName'] = $member->props[0]->name->toString(); + + if (isset($this->schemaTagParsers['@var'])) { + $this->schemaTagParsers['@var']->parse($pTag, $context, $typeResolver); + } } } } @@ -781,16 +553,16 @@ private function analyzeClass(string $fqcn, Class_|Trait_|Enum_ $stmt, NameResol $this->analyzeMethod( $member, $typeResolver, - $classTags, - $classSecurity, - $classAccept, - $classProduce + $context->classTags, + $context->classSecurity, + $context->classAccept, + $context->classProduce ); } } - if ($isSchema || !empty($schema->templates) || $stmt instanceof Trait_) { - $schema->properties = $properties; + if ($context->isSchema || !empty($schema->templates) || $stmt instanceof Trait_) { + $schema->properties = $context->properties; } } @@ -810,55 +582,13 @@ private function analyzeMethod( $methodStartLine = $member->getDocComment()?->getStartLine(); $tags = $this->docCollector->collectTags($methodDoc, $methodStartLine, $this->currentlyAnalyzingFile); - $routeTag = null; - $summary = null; - $description = null; - $tagsList = $classTags; - $responses = []; - $responseDescriptions = []; - $parameters = []; - $requestBody = null; - $security = []; - $hasMethodSecurity = false; - $accept = null; - $hasMethodAccept = false; - $produce = null; - $hasMethodProduce = false; - $operationId = null; - $deprecated = false; - $extensions = []; + $context = new TagParser\RouteContext($classTags); foreach ($tags as $tag) { - if ($tag['name'] === '@route') { - if (preg_match('/^(GET|POST|PUT|DELETE|PATCH)\s+(\S+)/i', $tag['value'], $matches)) { - $routeTag = strtoupper($matches[1]) . ' ' . $matches[2]; - } else { - throw new \PhpSwag\Exception\DiagnosticException(sprintf( - "Invalid syntax for tag '@route' in %s%s: expected format is '@route METHOD PATH', got '%s'", - $tag['file'] ?? $this->currentlyAnalyzingFile ?? 'unknown', - isset($tag['line']) ? " on line " . $tag['line'] : "", - $tag['value'] - )); - } - } elseif ($tag['name'] === '@summary') { - $summary = $tag['value']; - } elseif ($tag['name'] === '@description') { - $description = $tag['value']; - } elseif ($tag['name'] === '@tag') { - $splitTags = array_filter(array_map('trim', explode(',', $tag['value'])), fn($t) => $t !== ''); - $tagsList = array_merge($tagsList, $splitTags); - } elseif ($tag['name'] === '@accept' || $tag['name'] === '@consume') { - $accept = $tag['value']; - $hasMethodAccept = true; - } elseif ($tag['name'] === '@produce') { - $produce = $tag['value']; - $hasMethodProduce = true; - } elseif ($tag['name'] === '@operationId' || $tag['name'] === '@operationid') { - $operationId = $tag['value']; - } elseif ($tag['name'] === '@deprecated') { - $deprecated = true; - } elseif (str_starts_with($tag['name'], '@x-')) { - $extName = substr($tag['name'], 1); + $tagName = $tag['name']; + + if (str_starts_with($tagName, '@x-')) { + $extName = substr($tagName, 1); $val = $tag['value']; if (str_starts_with($val, '{') || str_starts_with($val, '[')) { $decoded = json_decode($val, true); @@ -866,104 +596,28 @@ private function analyzeMethod( $val = $decoded; } } - $extensions[$extName] = $val; - } elseif (in_array($tag['name'], ['@response', '@success', '@failure'])) { - if (preg_match('/^(\d+|default)\s+(.*)$/i', $tag['value'], $matches)) { - $code = strtolower($matches[1]); - $typeAndDesc = trim($matches[2]); - [$typeToParse, $respDesc] = $this->splitTypeAndDescription($typeAndDesc); - - if ($respDesc === '') { - if ($tag['name'] === '@success') { - $respDesc = 'Success'; - } elseif ($tag['name'] === '@failure') { - $respDesc = 'Failure'; - } else { - $respDesc = 'OK'; - } - } + $context->extensions[$extName] = $val; + continue; + } - $typeNode = $this->docCollector->parseType($typeToParse); - $responses[$code] = $typeResolver->resolve( - $typeNode, - $tag['line'] ?? $methodStartLine, - $this->currentlyAnalyzingFile - ); - $responseDescriptions[$code] = $respDesc; - } else { - throw new \PhpSwag\Exception\DiagnosticException(sprintf( - "Invalid syntax for tag '%s' in %s%s: " - . "expected format is '%s CODE TYPE [description]', got '%s'", - $tag['name'], - $tag['file'] ?? $this->currentlyAnalyzingFile ?? 'unknown', - isset($tag['line']) ? " on line " . $tag['line'] : "", - $tag['name'], - $tag['value'] - )); - } - } elseif (in_array($tag['name'], ['@path', '@query', '@header', '@cookie'])) { - $in = substr($tag['name'], 1); - $parameters[] = array_merge($tag, [ - 'in' => $in, - 'schema' => $typeResolver->resolve( - $tag['type'], - $tag['line'] ?? $methodStartLine, - $this->currentlyAnalyzingFile - ), - 'name' => $tag['propertyName'] - ]); - } elseif ($tag['name'] === '@body') { - $schema = $typeResolver->resolve( - $tag['type'], - $tag['line'] ?? $methodStartLine, - $this->currentlyAnalyzingFile - ); - $extra = is_array($tag['description']) ? $tag['description'] : []; - $desc = $extra['description'] ?? null; - unset($extra['description']); - $validationTags = [ - 'enum', 'default', 'minimum', 'maximum', 'minLength', - 'maxLength', 'pattern', 'format', 'example' - ]; - foreach ($validationTags as $vTag) { - if (isset($extra[$vTag])) { - $val = $extra[$vTag]; - if ( - in_array( - $vTag, - ['minimum', 'maximum', 'minLength', 'maxLength', 'default', 'example'] - ) - ) { - $val = is_numeric($val) - ? (strpos((string)$val, '.') !== false ? (float)$val : (int)$val) - : $val; - } - $schema[$vTag] = $val; - } - } - $requestBody = [ - 'schema' => $schema, - 'description' => $desc - ]; - } elseif ($tag['name'] === '@security') { - $hasMethodSecurity = true; - $security = array_merge($security, $this->parseSecurityTag($tag['value'])); + if (isset($this->tagParsers[$tagName])) { + $this->tagParsers[$tagName]->parse($tag, $context, $typeResolver); } } - if (!$hasMethodSecurity) { - $security = $classSecurity; + if (!$context->hasMethodSecurity) { + $context->security = $classSecurity; } - if (!$hasMethodAccept) { - $accept = $classAccept; + if (!$context->hasMethodAccept) { + $context->accept = $classAccept; } - if (!$hasMethodProduce) { - $produce = $classProduce; + if (!$context->hasMethodProduce) { + $context->produce = $classProduce; } - $tagsList = array_values(array_unique($tagsList)); + $context->tags = array_values(array_unique($context->tags)); - if ($routeTag) { - $routeParts = explode(' ', $routeTag); + if ($context->routeTag) { + $routeParts = explode(' ', $context->routeTag); $path = $routeParts[1]; // Auto-inference from method parameters @@ -975,7 +629,7 @@ private function analyzeMethod( // Skip if already defined by explicit tags $exists = false; - foreach ($parameters as $p) { + foreach ($context->parameters as $p) { if ($p['name'] === $paramName) { $exists = true; break; @@ -1006,8 +660,8 @@ private function analyzeMethod( // If it's a class and not primitive, infer as requestBody if not already set $isPrimitive = in_array(ltrim($type, '\\'), ['int', 'string', 'bool', 'float', 'array', 'mixed']); - if (!$isPrimitive && $requestBody === null) { - $requestBody = [ + if (!$isPrimitive && $context->requestBody === null) { + $context->requestBody = [ 'schema' => $schema, 'description' => 'Auto-inferred from method parameter $' . $paramName ]; @@ -1017,7 +671,7 @@ private function analyzeMethod( $in = 'path'; } - $parameters[] = [ + $context->parameters[] = [ 'name' => $paramName, 'in' => $in, 'schema' => $schema, @@ -1029,19 +683,19 @@ private function analyzeMethod( $this->generator->addRoute(new RouteDefinition( method: $routeParts[0], path: $path, - summary: $summary, - description: $description, - tags: $tagsList, - responses: $responses, - parameters: $parameters, - requestBody: $requestBody, - security: $security, - responseDescriptions: $responseDescriptions, - accept: $accept, - produce: $produce, - operationId: $operationId, - deprecated: $deprecated, - extensions: $extensions, + summary: $context->summary, + description: $context->description, + tags: $context->tags, + responses: $context->responses, + parameters: $context->parameters, + requestBody: $context->requestBody, + security: $context->security, + responseDescriptions: $context->responseDescriptions, + accept: $context->accept, + produce: $context->produce, + operationId: $context->operationId, + deprecated: $context->deprecated, + extensions: $context->extensions, file: $this->currentlyAnalyzingFile, line: $member->getStartLine() )); @@ -1100,118 +754,4 @@ public function enableCache(string $cacheFilePath): void { $this->cache = new Cache\FileCache($cacheFilePath); } - - /** - * @return array{0: string, 1: string} - */ - private function splitTypeAndDescription(string $str): array - { - $str = trim($str); - if (preg_match('/^([a-zA-Z0-9_\\\\]+)') { - $depth--; - } - if ($started && $depth === 0) { - $typeLen = $i + 1; - break; - } - } - if ($typeLen > 0) { - $type = substr($str, 0, $typeLen); - $desc = trim(substr($str, $typeLen)); - if (str_starts_with($desc, '[]')) { - $type .= '[]'; - $desc = trim(substr($desc, 2)); - } - return [$type, $desc]; - } - } - - $parts = preg_split('/\s+/', $str, 2); - $type = $parts[0] ?? ''; - $desc = $parts[1] ?? ''; - return [$type, $desc]; - } - - private function isNativeTypeNullable(?Node $type): bool - { - if ($type === null) { - return true; - } - if ($type instanceof Node\NullableType) { - return true; - } - if ($type instanceof Node\Identifier && strtolower($type->name) === 'mixed') { - return true; - } - if ($type instanceof Node\UnionType) { - foreach ($type->types as $subType) { - if ( - $subType instanceof Node\Identifier && - in_array(strtolower($subType->name), ['null', 'mixed']) - ) { - return true; - } - } - } - return false; - } - - private function isDocTypeNullable(\PHPStan\PhpDocParser\Ast\Type\TypeNode $typeNode): bool - { - if ($typeNode instanceof \PHPStan\PhpDocParser\Ast\Type\NullableTypeNode) { - return true; - } - if ( - $typeNode instanceof \PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode && - strtolower($typeNode->name) === 'mixed' - ) { - return true; - } - if ($typeNode instanceof \PHPStan\PhpDocParser\Ast\Type\UnionTypeNode) { - foreach ($typeNode->types as $type) { - if ( - $type instanceof \PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode && - in_array(strtolower($type->name), ['null', 'mixed']) - ) { - return true; - } - } - } - return false; - } - - private function determineRequired( - string $propertyName, - bool $isNullable, - ?bool $explicitRequired, - bool $hasDefault, - ?Node $typeHint - ): bool { - if ($explicitRequired !== null) { - return $explicitRequired; - } - - // If it has a default value, it is optional (not required) - if ($hasDefault) { - return false; - } - - // If there is a native type hint, use its nullability - if ($typeHint !== null) { - return !$this->isNativeTypeNullable($typeHint); - } - - // Otherwise, use the PHPDoc nullability - return !$isNullable; - } } diff --git a/src/Metadata/GlobalMetadataDiscoverer.php b/src/Metadata/GlobalMetadataDiscoverer.php new file mode 100644 index 0000000..0ff2ae8 --- /dev/null +++ b/src/Metadata/GlobalMetadataDiscoverer.php @@ -0,0 +1,183 @@ +docCollector = $docCollector; + } + + /** + * Scans the file content for global metadata comments and returns the extracted structures. + * @param array $existingMetadata + * @param array $existingSources + * + * @return array{ + * globalMetadata: array, + * metadataSources: array, + * securitySchemes: array>, + * globalSecurity: array>>, + * globalTags: array + * } + */ + public function discover( + string $code, + string $filePath, + array $existingMetadata = [], + array $existingSources = [] + ): array { + $globalMetadata = []; + $metadataSources = []; + $securitySchemes = []; + $globalSecurity = []; + $globalTags = []; + + $tokens = token_get_all($code); + foreach ($tokens as $token) { + if (is_array($token) && $token[0] === T_DOC_COMMENT) { + $docComment = $token[1]; + $startLine = $token[2]; + $tags = $this->docCollector->collectTags($docComment, $startLine, $filePath); + + $isGlobalBlock = false; + $hasRouteOrProperty = false; + foreach ($tags as $tag) { + if (in_array($tag['name'], ['@route', '@property', '@var'])) { + $hasRouteOrProperty = true; + break; + } + } + if (!$hasRouteOrProperty) { + foreach ($tags as $tag) { + if ( + in_array($tag['name'], ['@title', '@version', '@description', '@host']) || + str_starts_with($tag['name'], '@contact.') || + str_starts_with($tag['name'], '@license.') || + str_starts_with($tag['name'], '@securityDefinitions.') || + str_starts_with($tag['name'], '@tag.') + ) { + $isGlobalBlock = true; + break; + } + } + } + + if (!$isGlobalBlock) { + continue; + } + + foreach ($tags as $tag) { + $tagName = $tag['name']; + if ( + in_array($tagName, ['@title', '@version', '@description', '@host']) || + str_starts_with($tagName, '@contact.') || + str_starts_with($tagName, '@license.') + ) { + $val = $tag['value'] ?? ''; + + // Check duplicates against existing and newly found metadata + $currentVal = $globalMetadata[$tagName] ?? $existingMetadata[$tagName] ?? null; + $currentSource = $metadataSources[$tagName] ?? $existingSources[$tagName] ?? null; + + if ($currentVal !== null && $currentVal !== $val) { + throw new \Exception(sprintf( + "Duplicate global tag '%s' found in %s and %s", + $tagName, + $currentSource, + $filePath + )); + } + $globalMetadata[$tagName] = $val; + $metadataSources[$tagName] = $filePath; + } elseif ($tagName === '@securityDefinitions.apikey') { + if (preg_match('/^(\S+)\s+(header|query|cookie)\s+(\S+)/', $tag['value'], $matches)) { + $securitySchemes[$matches[1]] = [ + 'type' => 'apiKey', + 'in' => $matches[2], + 'name' => $matches[3] + ]; + } else { + throw new DiagnosticException(sprintf( + "Invalid syntax for tag '@securityDefinitions.apikey' in %s%s: " + . "expected format is '@securityDefinitions.apikey NAME IN KEY', got '%s'", + $tag['file'] ?? $filePath, + isset($tag['line']) ? " on line " . $tag['line'] : "", + $tag['value'] + )); + } + } elseif ($tagName === '@securityDefinitions.jwt') { + if (trim($tag['value']) !== '') { + $securitySchemes[$tag['value']] = [ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => 'JWT' + ]; + } else { + throw new DiagnosticException(sprintf( + "Invalid syntax for tag '@securityDefinitions.jwt' in %s%s: " + . "expected format is '@securityDefinitions.jwt NAME', got empty value", + $tag['file'] ?? $filePath, + isset($tag['line']) ? " on line " . $tag['line'] : "" + )); + } + } elseif ($tagName === '@securityDefinitions.basic') { + if (trim($tag['value']) !== '') { + $securitySchemes[$tag['value']] = [ + 'type' => 'http', + 'scheme' => 'basic' + ]; + } else { + throw new DiagnosticException(sprintf( + "Invalid syntax for tag '@securityDefinitions.basic' in %s%s: " + . "expected format is '@securityDefinitions.basic NAME', got empty value", + $tag['file'] ?? $filePath, + isset($tag['line']) ? " on line " . $tag['line'] : "" + )); + } + } elseif ($tagName === '@security') { + $globalSecurity = array_merge( + $globalSecurity, + SecurityTagParser::parseSecurityTag($tag['value']) + ); + } elseif ($tagName === '@tag.name') { + $parts = preg_split('/\s+/', $tag['value'], 2); + if (is_array($parts) && isset($parts[0]) && trim($parts[0]) !== '') { + $name = $parts[0]; + $desc = isset($parts[1]) ? trim($parts[1]) : null; + + $tagData = ['name' => $name]; + if ($desc !== null && $desc !== '') { + $tagData['description'] = $desc; + } + $globalTags[$name] = $tagData; + } else { + throw new DiagnosticException(sprintf( + "Invalid syntax for tag '@tag.name' in %s%s: " + . "expected format is '@tag.name NAME [description]', got '%s'", + $tag['file'] ?? $filePath, + isset($tag['line']) ? " on line " . $tag['line'] : "", + $tag['value'] + )); + } + } + } + } + } + + return [ + 'globalMetadata' => $globalMetadata, + 'metadataSources' => $metadataSources, + 'securitySchemes' => $securitySchemes, + 'globalSecurity' => $globalSecurity, + 'globalTags' => $globalTags, + ]; + } +} diff --git a/src/TagParser/BasicMethodTagParser.php b/src/TagParser/BasicMethodTagParser.php new file mode 100644 index 0000000..4f6221a --- /dev/null +++ b/src/TagParser/BasicMethodTagParser.php @@ -0,0 +1,58 @@ +summary = $value; + break; + case '@description': + $context->description = $value; + break; + case '@tag': + $splitTags = array_filter(array_map('trim', explode(',', $value)), fn($t) => $t !== ''); + $context->tags = array_merge($context->tags, $splitTags); + break; + case '@accept': + case '@consume': + $context->accept = $value; + $context->hasMethodAccept = true; + break; + case '@produce': + $context->produce = $value; + $context->hasMethodProduce = true; + break; + case '@operationId': + case '@operationid': + $context->operationId = $value; + break; + case '@deprecated': + $context->deprecated = true; + break; + } + } +} diff --git a/src/TagParser/BodyTagParser.php b/src/TagParser/BodyTagParser.php new file mode 100644 index 0000000..b46e590 --- /dev/null +++ b/src/TagParser/BodyTagParser.php @@ -0,0 +1,49 @@ +resolve( + $tagData['type'], + $tagData['line'] ?? null, + $tagData['file'] ?? null + ); + $extra = is_array($tagData['description']) ? $tagData['description'] : []; + $desc = $extra['description'] ?? null; + unset($extra['description']); + $validationTags = [ + 'enum', 'default', 'minimum', 'maximum', 'minLength', + 'maxLength', 'pattern', 'format', 'example' + ]; + foreach ($validationTags as $vTag) { + if (isset($extra[$vTag])) { + $val = $extra[$vTag]; + if ( + in_array( + $vTag, + ['minimum', 'maximum', 'minLength', 'maxLength', 'default', 'example'] + ) + ) { + $val = is_numeric($val) + ? (strpos((string)$val, '.') !== false ? (float)$val : (int)$val) + : $val; + } + $schema[$vTag] = $val; + } + } + $context->requestBody = [ + 'schema' => $schema, + 'description' => $desc + ]; + } +} diff --git a/src/TagParser/ClassMetadataTagParser.php b/src/TagParser/ClassMetadataTagParser.php new file mode 100644 index 0000000..ef97bd0 --- /dev/null +++ b/src/TagParser/ClassMetadataTagParser.php @@ -0,0 +1,53 @@ + $t !== ''); + $context->classTags = array_merge($context->classTags, $splitTags); + break; + case '@security': + $context->classSecurity = array_merge( + $context->classSecurity, + SecurityTagParser::parseSecurityTag($value) + ); + break; + case '@accept': + case '@consume': + $context->classAccept = $value; + break; + case '@produce': + $context->classProduce = $value; + break; + case '@required': + $val = trim($value); + if ($val !== '') { + if (preg_match('/^([^\s]+)(?:\s+(.*))?$/', $val, $matches)) { + $propName = ltrim($matches[1], '$'); + $optVal = isset($matches[2]) ? trim($matches[2]) : ''; + if (strtolower($optVal) === 'false') { + $context->classExplicitRequired[$propName] = false; + } else { + $context->classExplicitRequired[$propName] = true; + } + } + } + break; + } + } +} diff --git a/src/TagParser/ExtendsTagParser.php b/src/TagParser/ExtendsTagParser.php new file mode 100644 index 0000000..4e61047 --- /dev/null +++ b/src/TagParser/ExtendsTagParser.php @@ -0,0 +1,48 @@ +docCollector = $docCollector; + $this->schemaRegistry = $schemaRegistry; + } + + public function getSupportedTags(): array + { + return ['@extends', '@use']; + } + + public function parse(array $tagData, SchemaContext $context, TypeResolver $typeResolver): void + { + $value = $tagData['value'] ?? ''; + $typeNode = $this->docCollector->parseType($value); + if ($typeNode instanceof GenericTypeNode) { + $targetFqcn = $context->nameResolver->resolve($typeNode->type->name); + $targetSchema = $this->schemaRegistry->get($targetFqcn); + if ($targetSchema && !empty($targetSchema->templates)) { + foreach ($typeNode->genericTypes as $i => $argNode) { + $templateName = $targetSchema->templates[$i] ?? "T$i"; + $context->schema->typeArguments[$templateName] = $typeResolver->resolve( + $argNode, + $tagData['line'] ?? null, + $tagData['file'] ?? null + ); + } + } + } + } +} diff --git a/src/TagParser/ParamTagParser.php b/src/TagParser/ParamTagParser.php new file mode 100644 index 0000000..76e078c --- /dev/null +++ b/src/TagParser/ParamTagParser.php @@ -0,0 +1,27 @@ +parameters[] = array_merge($tagData, [ + 'in' => $in, + 'schema' => $typeResolver->resolve( + $tagData['type'], + $tagData['line'] ?? null, + $tagData['file'] ?? null + ), + 'name' => $tagData['propertyName'] + ]); + } +} diff --git a/src/TagParser/PropertyTagParser.php b/src/TagParser/PropertyTagParser.php new file mode 100644 index 0000000..eba0f4b --- /dev/null +++ b/src/TagParser/PropertyTagParser.php @@ -0,0 +1,78 @@ +typeAnalyzer = $typeAnalyzer; + } + + public function getSupportedTags(): array + { + return ['@property', '@var']; + } + + public function parse(array $tagData, SchemaContext $context, TypeResolver $typeResolver): void + { + if (!isset($tagData['type'])) { + return; + } + + $context->isSchema = true; + + $propertySchema = $typeResolver->resolve( + $tagData['type'], + $tagData['line'] ?? null, + $tagData['file'] ?? null + ); + + $desc = is_array($tagData['description']) + ? ($tagData['description']['description'] ?? null) + : ($tagData['description'] ?? null); + + $extra = is_array($tagData['description']) ? $tagData['description'] : []; + unset($extra['description']); + + $propertyName = $tagData['propertyName']; + + // Sibling or class-level explicit required overrides + $explicitRequired = $tagData['explicitRequired'] ?? $context->classExplicitRequired[$propertyName] ?? null; + + // Also check if @required is inline in the tag's description + if ($desc !== null && stripos($desc, '@required') !== false) { + $explicitRequired = true; + $desc = preg_replace('/@required\s*/i', '', $desc); + $desc = trim($desc); + } + + $hasDefault = ($tagData['hasDefault'] ?? false) || isset($extra['default']); + $isNullable = $this->typeAnalyzer->isDocTypeNullable($tagData['type']); + $typeHint = $tagData['typeHint'] ?? null; + + $required = $this->typeAnalyzer->determineRequired( + $propertyName, + $isNullable, + $explicitRequired, + $hasDefault, + $typeHint + ); + + $context->properties[] = new PropertyDefinition( + $propertyName, + $propertySchema, + $desc, + $extra, + $tagData['file'] ?? null, + $tagData['line'] ?? null, + $required + ); + } +} diff --git a/src/TagParser/ResponseTagParser.php b/src/TagParser/ResponseTagParser.php new file mode 100644 index 0000000..8bdae4b --- /dev/null +++ b/src/TagParser/ResponseTagParser.php @@ -0,0 +1,63 @@ +typeAnalyzer = $typeAnalyzer; + $this->docCollector = $docCollector; + } + + public function getSupportedTags(): array + { + return ['@response', '@success', '@failure']; + } + + public function parse(array $tagData, RouteContext $context, TypeResolver $typeResolver): void + { + $tagName = $tagData['name']; + $value = $tagData['value'] ?? ''; + if (preg_match('/^(\d+|default)\s+(.*)$/i', $value, $matches)) { + $code = strtolower($matches[1]); + $typeAndDesc = trim($matches[2]); + [$typeToParse, $respDesc] = $this->typeAnalyzer->splitTypeAndDescription($typeAndDesc); + + if ($respDesc === '') { + if ($tagName === '@success') { + $respDesc = 'Success'; + } elseif ($tagName === '@failure') { + $respDesc = 'Failure'; + } else { + $respDesc = 'OK'; + } + } + + $typeNode = $this->docCollector->parseType($typeToParse); + $context->responses[$code] = $typeResolver->resolve( + $typeNode, + $tagData['line'] ?? null, + $tagData['file'] ?? null + ); + $context->responseDescriptions[$code] = $respDesc; + } else { + throw new DiagnosticException(sprintf( + "Invalid syntax for tag '%s' in %s%s: expected format is '%s CODE TYPE [description]', got '%s'", + $tagName, + $tagData['file'] ?? 'unknown', + isset($tagData['line']) ? " on line " . $tagData['line'] : "", + $tagName, + $value + )); + } + } +} diff --git a/src/TagParser/RouteContext.php b/src/TagParser/RouteContext.php new file mode 100644 index 0000000..74a0e73 --- /dev/null +++ b/src/TagParser/RouteContext.php @@ -0,0 +1,50 @@ + */ + public array $tags = []; + + /** @var array> */ + public array $responses = []; + + /** @var array */ + public array $responseDescriptions = []; + + /** @var array> */ + public array $parameters = []; + + /** @var array{schema: array, description?: string|null}|null */ + public ?array $requestBody = null; + + /** @var array>> */ + public array $security = []; + + public ?string $accept = null; + public ?string $produce = null; + public ?string $operationId = null; + public bool $deprecated = false; + + /** @var array */ + public array $extensions = []; + + // Tracking flags for method-level overrides + public bool $hasMethodSecurity = false; + public bool $hasMethodAccept = false; + public bool $hasMethodProduce = false; + + /** + * @param array $classTags + */ + public function __construct( + array $classTags = [] + ) { + $this->tags = $classTags; + } +} diff --git a/src/TagParser/RouteTagParser.php b/src/TagParser/RouteTagParser.php new file mode 100644 index 0000000..e59089f --- /dev/null +++ b/src/TagParser/RouteTagParser.php @@ -0,0 +1,29 @@ +routeTag = strtoupper($matches[1]) . ' ' . $matches[2]; + } else { + throw new DiagnosticException(sprintf( + "Invalid syntax for tag '@route' in %s%s: expected format is '@route METHOD PATH', got '%s'", + $tagData['file'] ?? 'unknown', + isset($tagData['line']) ? " on line " . $tagData['line'] : "", + $value + )); + } + } +} diff --git a/src/TagParser/SchemaContext.php b/src/TagParser/SchemaContext.php new file mode 100644 index 0000000..da95648 --- /dev/null +++ b/src/TagParser/SchemaContext.php @@ -0,0 +1,33 @@ + */ + public array $classTags = []; + + /** @var array>> */ + public array $classSecurity = []; + + public ?string $classAccept = null; + public ?string $classProduce = null; + + /** @var array */ + public array $classExplicitRequired = []; + + public bool $isSchema = false; + + /** @var array */ + public array $properties = []; + + public function __construct( + public SchemaDefinition $schema, + public NameResolver $nameResolver + ) { + } +} diff --git a/src/TagParser/SchemaTagParserInterface.php b/src/TagParser/SchemaTagParserInterface.php new file mode 100644 index 0000000..893ed29 --- /dev/null +++ b/src/TagParser/SchemaTagParserInterface.php @@ -0,0 +1,22 @@ + + */ + public function getSupportedTags(): array; + + /** + * Parse the given tag and update the SchemaContext. + * + * @param array $tagData + */ + public function parse(array $tagData, SchemaContext $context, TypeResolver $typeResolver): void; +} diff --git a/src/TagParser/SecurityTagParser.php b/src/TagParser/SecurityTagParser.php new file mode 100644 index 0000000..2cf52c0 --- /dev/null +++ b/src/TagParser/SecurityTagParser.php @@ -0,0 +1,76 @@ +hasMethodSecurity = true; + $context->security = array_merge($context->security, self::parseSecurityTag($tagData['value'] ?? '')); + } + + /** + * @return array>> + */ + public static function parseSecurityTag(string $value): array + { + if (trim($value) === '') { + return [[]]; // Represents an empty security requirement object, which means "no security" + } + + $requirements = []; + $parts = self::splitCommasOutsideBrackets($value); + $currentGroup = []; + foreach ($parts as $part) { + $part = trim($part); + if (empty($part)) { + continue; + } + + if (preg_match('/^([^\[]+)(?:\[(.*)\])?$/', $part, $matches)) { + $name = trim($matches[1]); + $scopes = isset($matches[2]) ? array_map('trim', explode(',', trim($matches[2]))) : []; + $currentGroup[$name] = $scopes; + } + } + if (!empty($currentGroup)) { + $requirements[] = $currentGroup; + } + return $requirements; + } + + /** + * @return array + */ + private static function splitCommasOutsideBrackets(string $str): array + { + $parts = []; + $current = ''; + $depth = 0; + for ($i = 0; $i < strlen($str); $i++) { + $char = $str[$i]; + if ($char === '[') { + $depth++; + } elseif ($char === ']') { + $depth--; + } + + if ($char === ',' && $depth === 0) { + $parts[] = $current; + $current = ''; + } else { + $current .= $char; + } + } + $parts[] = $current; + return $parts; + } +} diff --git a/src/TagParser/TagParserInterface.php b/src/TagParser/TagParserInterface.php new file mode 100644 index 0000000..e03c4fd --- /dev/null +++ b/src/TagParser/TagParserInterface.php @@ -0,0 +1,22 @@ + + */ + public function getSupportedTags(): array; + + /** + * Parse the given tag and update the RouteContext. + * + * @param array $tagData + */ + public function parse(array $tagData, RouteContext $context, TypeResolver $typeResolver): void; +} diff --git a/src/TypeAnalyzer.php b/src/TypeAnalyzer.php new file mode 100644 index 0000000..bb14336 --- /dev/null +++ b/src/TypeAnalyzer.php @@ -0,0 +1,126 @@ +name) === 'mixed') { + return true; + } + if ($type instanceof Node\UnionType) { + foreach ($type->types as $subType) { + if ( + $subType instanceof Node\Identifier && + in_array(strtolower($subType->name), ['null', 'mixed']) + ) { + return true; + } + } + } + return false; + } + + public function isDocTypeNullable(TypeNode $typeNode): bool + { + if ($typeNode instanceof NullableTypeNode) { + return true; + } + if ( + $typeNode instanceof IdentifierTypeNode && + strtolower($typeNode->name) === 'mixed' + ) { + return true; + } + if ($typeNode instanceof UnionTypeNode) { + foreach ($typeNode->types as $type) { + if ( + $type instanceof IdentifierTypeNode && + in_array(strtolower($type->name), ['null', 'mixed']) + ) { + return true; + } + } + } + return false; + } + + public function determineRequired( + string $propertyName, + bool $isNullable, + ?bool $explicitRequired, + bool $hasDefault, + ?Node $typeHint + ): bool { + if ($explicitRequired !== null) { + return $explicitRequired; + } + + // If it has a default value, it is optional (not required) + if ($hasDefault) { + return false; + } + + // If there is a native type hint, use its nullability + if ($typeHint !== null) { + return !$this->isNativeTypeNullable($typeHint); + } + + // Otherwise, use the PHPDoc nullability + return !$isNullable; + } + + /** + * @return array{0: string, 1: string} + */ + public function splitTypeAndDescription(string $str): array + { + $str = trim($str); + if (preg_match('/^([a-zA-Z0-9_\\\\]+)') { + $depth--; + } + if ($started && $depth === 0) { + $typeLen = $i + 1; + break; + } + } + if ($typeLen > 0) { + $type = substr($str, 0, $typeLen); + $desc = trim(substr($str, $typeLen)); + if (str_starts_with($desc, '[]')) { + $type .= '[]'; + $desc = trim(substr($desc, 2)); + } + return [$type, $desc]; + } + } + + $parts = preg_split('/\s+/', $str, 2); + $type = $parts[0] ?? ''; + $desc = $parts[1] ?? ''; + return [$type, $desc]; + } +} diff --git a/tests/CoreDependencyInjectionTest.php b/tests/CoreDependencyInjectionTest.php new file mode 100644 index 0000000..d39e66b --- /dev/null +++ b/tests/CoreDependencyInjectionTest.php @@ -0,0 +1,98 @@ +createMock(Scanner::class); + $parser = $this->createMock(Parser::class); + $docCollector = $this->createMock(DocBlockCollector::class); + $schemaRegistry = $this->createMock(SchemaRegistry::class); + $typeMappingRegistry = $this->createMock(TypeMappingRegistry::class); + $generator = $this->createMock(Generator::class); + + $core = new Core( + $scanner, + $parser, + $docCollector, + $schemaRegistry, + $typeMappingRegistry, + $generator + ); + + $reflection = new \ReflectionClass($core); + + $scannerProp = $reflection->getProperty('scanner'); + $scannerProp->setAccessible(true); + $this->assertSame($scanner, $scannerProp->getValue($core)); + + $parserProp = $reflection->getProperty('parser'); + $parserProp->setAccessible(true); + $this->assertSame($parser, $parserProp->getValue($core)); + + $docCollectorProp = $reflection->getProperty('docCollector'); + $docCollectorProp->setAccessible(true); + $this->assertSame($docCollector, $docCollectorProp->getValue($core)); + + $schemaRegistryProp = $reflection->getProperty('schemaRegistry'); + $schemaRegistryProp->setAccessible(true); + $this->assertSame($schemaRegistry, $schemaRegistryProp->getValue($core)); + + $typeMappingRegistryProp = $reflection->getProperty('typeMappingRegistry'); + $typeMappingRegistryProp->setAccessible(true); + $this->assertSame($typeMappingRegistry, $typeMappingRegistryProp->getValue($core)); + + $generatorProp = $reflection->getProperty('generator'); + $generatorProp->setAccessible(true); + $this->assertSame($generator, $generatorProp->getValue($core)); + } + + public function testCreateDefaultReturnsCoreWithDefaults() + { + $core = Core::createDefault(); + $this->assertInstanceOf(Core::class, $core); + + $reflection = new \ReflectionClass($core); + + $scannerProp = $reflection->getProperty('scanner'); + $scannerProp->setAccessible(true); + $this->assertInstanceOf(Scanner::class, $scannerProp->getValue($core)); + } + + public function testRegisterTagParser() + { + $core = Core::createDefault(); + $parser = $this->createMock(\PhpSwag\TagParser\TagParserInterface::class); + $parser->method('getSupportedTags')->willReturn(['@customTag']); + + $core->registerTagParser($parser); + $parsers = $core->getTagParsers(); + + $this->assertArrayHasKey('@customTag', $parsers); + $this->assertSame($parser, $parsers['@customTag']); + } + + public function testRegisterSchemaTagParser() + { + $core = Core::createDefault(); + $parser = $this->createMock(\PhpSwag\TagParser\SchemaTagParserInterface::class); + $parser->method('getSupportedTags')->willReturn(['@customSchemaTag']); + + $core->registerSchemaTagParser($parser); + $parsers = $core->getSchemaTagParsers(); + + $this->assertArrayHasKey('@customSchemaTag', $parsers); + $this->assertSame($parser, $parsers['@customSchemaTag']); + } +} diff --git a/tests/GlobalMetadataDiscovererTest.php b/tests/GlobalMetadataDiscovererTest.php new file mode 100644 index 0000000..0a49527 --- /dev/null +++ b/tests/GlobalMetadataDiscovererTest.php @@ -0,0 +1,210 @@ +docCollector = new DocBlockCollector(); + $this->discoverer = new GlobalMetadataDiscoverer($this->docCollector); + } + + public function testDiscoverGlobalFields() + { + $code = <<<'PHP' +discoverer->discover($code, 'file.php'); + + $this->assertEquals('Test Title', $res['globalMetadata']['@title']); + $this->assertEquals('1.0.0', $res['globalMetadata']['@version']); + $this->assertEquals('API description', $res['globalMetadata']['@description']); + $this->assertEquals('test.host.com', $res['globalMetadata']['@host']); + $this->assertEquals('Admin', $res['globalMetadata']['@contact.name']); + $this->assertEquals('MIT', $res['globalMetadata']['@license.name']); + $this->assertEquals('file.php', $res['metadataSources']['@title']); + } + + public function testDiscoverSecurityDefinitionsApiKey() + { + $code = <<<'PHP' +discoverer->discover($code, 'file.php'); + + $this->assertArrayHasKey('ApiKeyAuth', $res['securitySchemes']); + $this->assertEquals([ + 'type' => 'apiKey', + 'in' => 'header', + 'name' => 'X-API-KEY', + ], $res['securitySchemes']['ApiKeyAuth']); + } + + public function testDiscoverInvalidApiKeySyntaxThrows() + { + $code = <<<'PHP' +expectException(DiagnosticException::class); + $this->expectExceptionMessage("Invalid syntax for tag '@securityDefinitions.apikey'"); + + $this->discoverer->discover($code, 'file.php'); + } + + public function testDiscoverSecurityDefinitionsJwt() + { + $code = <<<'PHP' +discoverer->discover($code, 'file.php'); + + $this->assertArrayHasKey('BearerAuth', $res['securitySchemes']); + $this->assertEquals([ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => 'JWT', + ], $res['securitySchemes']['BearerAuth']); + } + + public function testDiscoverInvalidJwtSyntaxThrows() + { + $code = <<<'PHP' +expectException(DiagnosticException::class); + $this->expectExceptionMessage("Invalid syntax for tag '@securityDefinitions.jwt'"); + + $this->discoverer->discover($code, 'file.php'); + } + + public function testDiscoverSecurityDefinitionsBasic() + { + $code = <<<'PHP' +discoverer->discover($code, 'file.php'); + + $this->assertArrayHasKey('BasicAuth', $res['securitySchemes']); + $this->assertEquals([ + 'type' => 'http', + 'scheme' => 'basic', + ], $res['securitySchemes']['BasicAuth']); + } + + public function testDiscoverInvalidBasicSyntaxThrows() + { + $code = <<<'PHP' +expectException(DiagnosticException::class); + $this->expectExceptionMessage("Invalid syntax for tag '@securityDefinitions.basic'"); + + $this->discoverer->discover($code, 'file.php'); + } + + public function testDiscoverSecurity() + { + $code = <<<'PHP' +discoverer->discover($code, 'file.php'); + + $this->assertCount(1, $res['globalSecurity']); + $this->assertEquals(['ApiKeyAuth' => ['read', 'write']], $res['globalSecurity'][0]); + } + + public function testDiscoverTagName() + { + $code = <<<'PHP' +discoverer->discover($code, 'file.php'); + + $this->assertArrayHasKey('users', $res['globalTags']); + $this->assertEquals([ + 'name' => 'users', + 'description' => 'Manage users endpoint', + ], $res['globalTags']['users']); + } + + public function testDiscoverInvalidTagNameSyntaxThrows() + { + $code = <<<'PHP' +expectException(DiagnosticException::class); + $this->expectExceptionMessage("Invalid syntax for tag '@tag.name'"); + + $this->discoverer->discover($code, 'file.php'); + } + + public function testDiscoverDuplicateThrows() + { + $code = <<<'PHP' +expectException(\Exception::class); + $this->expectExceptionMessage("Duplicate global tag '@title' found"); + + $this->discoverer->discover($code, 'file.php', ['@title' => 'Existing Title'], ['@title' => 'existing.php']); + } +} diff --git a/tests/TypeAnalyzerTest.php b/tests/TypeAnalyzerTest.php new file mode 100644 index 0000000..2a2e826 --- /dev/null +++ b/tests/TypeAnalyzerTest.php @@ -0,0 +1,116 @@ +analyzer = new TypeAnalyzer(); + } + + public function testIsNativeTypeNullableWithNull() + { + $this->assertTrue($this->analyzer->isNativeTypeNullable(null)); + } + + public function testIsNativeTypeNullableWithNullableType() + { + $nullableType = new Node\NullableType(new Node\Identifier('string')); + $this->assertTrue($this->analyzer->isNativeTypeNullable($nullableType)); + } + + public function testIsNativeTypeNullableWithMixed() + { + $mixedType = new Node\Identifier('mixed'); + $this->assertTrue($this->analyzer->isNativeTypeNullable($mixedType)); + } + + public function testIsNativeTypeNullableWithUnionTypeContainingNull() + { + $unionType = new Node\UnionType([ + new Node\Identifier('string'), + new Node\Identifier('null') + ]); + $this->assertTrue($this->analyzer->isNativeTypeNullable($unionType)); + } + + public function testIsNativeTypeNullableWithUnionTypeNotContainingNull() + { + $unionType = new Node\UnionType([ + new Node\Identifier('string'), + new Node\Identifier('int') + ]); + $this->assertFalse($this->analyzer->isNativeTypeNullable($unionType)); + } + + public function testIsDocTypeNullableWithNullableTypeNode() + { + $nullableTypeNode = new NullableTypeNode(new IdentifierTypeNode('string')); + $this->assertTrue($this->analyzer->isDocTypeNullable($nullableTypeNode)); + } + + public function testIsDocTypeNullableWithMixed() + { + $mixedTypeNode = new IdentifierTypeNode('mixed'); + $this->assertTrue($this->analyzer->isDocTypeNullable($mixedTypeNode)); + } + + public function testIsDocTypeNullableWithUnionTypeContainingNull() + { + $unionTypeNode = new UnionTypeNode([ + new IdentifierTypeNode('string'), + new IdentifierTypeNode('null') + ]); + $this->assertTrue($this->analyzer->isDocTypeNullable($unionTypeNode)); + } + + public function testIsDocTypeNullableWithUnionTypeNotContainingNull() + { + $unionTypeNode = new UnionTypeNode([ + new IdentifierTypeNode('string'), + new IdentifierTypeNode('int') + ]); + $this->assertFalse($this->analyzer->isDocTypeNullable($unionTypeNode)); + } + + public function testDetermineRequiredExplicitOverrides() + { + $this->assertTrue($this->analyzer->determineRequired('prop', false, true, false, null)); + $this->assertFalse($this->analyzer->determineRequired('prop', false, false, false, null)); + } + + public function testDetermineRequiredHasDefaultIsFalse() + { + $this->assertFalse($this->analyzer->determineRequired('prop', false, null, true, null)); + } + + public function testDetermineRequiredUsesNativeTypeHintNullability() + { + // Nullable native type hint -> optional (not required) + $nullableType = new Node\NullableType(new Node\Identifier('string')); + $this->assertFalse($this->analyzer->determineRequired('prop', false, null, false, $nullableType)); + + // Non-nullable native type hint -> required + $nonNullableType = new Node\Identifier('string'); + $this->assertTrue($this->analyzer->determineRequired('prop', true, null, false, $nonNullableType)); + } + + public function testDetermineRequiredUsesDocNullabilityAsFallback() + { + // Nullable doc type -> optional (not required) + $this->assertFalse($this->analyzer->determineRequired('prop', true, null, false, null)); + + // Non-nullable doc type -> required + $this->assertTrue($this->analyzer->determineRequired('prop', false, null, false, null)); + } +} From 94977e3dd8174ea8946db182ba42162420d31edb Mon Sep 17 00:00:00 2001 From: tolawho Date: Mon, 8 Jun 2026 18:25:38 +0700 Subject: [PATCH 23/27] feat: improve dx - improve DX with interactive init wizard, structured error diagnostics, and interface scanning - implement phpswag watch with live preview and configuration integration --- .gitignore | 2 + README.md | 58 ++++- bin/phpswag | 4 + phpswag.yaml | 10 + src/CLI/GenerateCommand.php | 153 +++++++++--- src/CLI/InitCommand.php | 135 +++++++++++ src/CLI/WatchCommand.php | 282 ++++++++++++++++++++++ src/CLI/router.php | 105 ++++++++ src/Core.php | 12 +- src/DocBlockCollector.php | 14 +- src/Exception/DiagnosticException.php | 46 ++++ src/Metadata/GlobalMetadataDiscoverer.php | 52 ++-- src/TagParser/ResponseTagParser.php | 20 +- src/TagParser/RouteTagParser.php | 13 +- src/TypeResolver.php | 13 +- tests/DiagnosticsTest.php | 30 +++ tests/GenerateCommandTest.php | 40 +++ tests/InitCommandTest.php | 75 ++++++ tests/WatchCommandTest.php | 85 +++++++ 19 files changed, 1055 insertions(+), 94 deletions(-) create mode 100644 phpswag.yaml create mode 100644 src/CLI/InitCommand.php create mode 100644 src/CLI/WatchCommand.php create mode 100644 src/CLI/router.php create mode 100644 tests/InitCommandTest.php create mode 100644 tests/WatchCommandTest.php diff --git a/.gitignore b/.gitignore index 47d1cb8..93f5275 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ vendor/ .phpunit.result.cache +.phpswag-cache composer.lock +swagger.yaml diff --git a/README.md b/README.md index b7e5f1f..27ae83e 100644 --- a/README.md +++ b/README.md @@ -382,7 +382,28 @@ php examples/generate.php ``` ### CLI Usage -You can use the CLI to generate documentation without writing any PHP code: + +You can use the CLI to generate documentation without writing any PHP code. + +#### 1. Configuration Initialization (Wizard) + +To easily set up a configuration file for your project, run: + +```bash +./vendor/bin/phpswag init +``` + +This starts an interactive wizard that asks for your project options and generates a `phpswag.yaml` file in your root folder. + +#### 2. Generating Documentation + +If you have a `phpswag.yaml` file in your root directory, you can simply run: + +```bash +./vendor/bin/phpswag generate +``` + +Or, specify options on the command line (which will override values in the configuration file): ```bash ./vendor/bin/phpswag generate --path src/Controllers --path src/Models --output swagger.yaml @@ -398,5 +419,36 @@ You can use the CLI to generate documentation without writing any PHP code: - `--api-version`: API Version override. - `--description`: API Description override. - `--host`: API Host/Server URL override. -- `--cache`: Enable performance caching. -- `--cache-file`: Custom cache file path (default: `./.phpswag-cache`). +- `--cache`: Enable caching to speed up generation. +- `--cache-file`: Cache file path. Default: `./.phpswag-cache`. + +#### 3. Live Preview & Hot Reload (Watch Mode) + +You can launch a built-in preview server that hosts Swagger UI and hot-reloads instantly when you modify your PHP code: + +```bash +./vendor/bin/phpswag watch +``` + +**Options:** +- `--path`, `-p`: Path(s) to scan. +- `--output`, `-o`: Output destination file path (default: `swagger.yaml`). +- `--format`, `-f`: Output format (`yaml` or `json`). +- `--host`: Server host (default: `localhost`). +- `--port`: Server port (default: `8080`). + +#### 4. Configuration File (`phpswag.yaml`) + +An example `phpswag.yaml` file: + +```yaml +paths: + - src/Controllers + - src/Models +openapi_version: 3.1.0 +format: yaml +output: public/swagger.yaml +filter_unused: true +cache: true +cache_file: ./.phpswag-cache +``` diff --git a/bin/phpswag b/bin/phpswag index 332bd51..2188fca 100755 --- a/bin/phpswag +++ b/bin/phpswag @@ -5,7 +5,11 @@ require_once __DIR__ . '/../vendor/autoload.php'; use Symfony\Component\Console\Application; use PhpSwag\CLI\GenerateCommand; +use PhpSwag\CLI\InitCommand; +use PhpSwag\CLI\WatchCommand; $application = new Application('PHP Swagger Generator', '1.0.0'); $application->add(new GenerateCommand()); +$application->add(new InitCommand()); +$application->add(new WatchCommand()); $application->run(); diff --git a/phpswag.yaml b/phpswag.yaml new file mode 100644 index 0000000..45ec4f1 --- /dev/null +++ b/phpswag.yaml @@ -0,0 +1,10 @@ +paths: + - examples +openapi_version: 3.0.0 +format: yaml +output: swagger.yaml +filter_unused: true +cache: true +watch_host: localhost +watch_port: 8888 +cache_file: ./.phpswag-cache diff --git a/src/CLI/GenerateCommand.php b/src/CLI/GenerateCommand.php index 9d358ef..32b4b96 100644 --- a/src/CLI/GenerateCommand.php +++ b/src/CLI/GenerateCommand.php @@ -45,54 +45,129 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $paths = $input->getOption('path'); + $configFile = 'phpswag.yaml'; + $config = []; + if (file_exists($configFile)) { + try { + $config = \Symfony\Component\Yaml\Yaml::parseFile($configFile); + if (!is_array($config)) { + $config = []; + } + } catch (\Exception $e) { + $output->writeln(sprintf( + 'Error parsing configuration file "%s": %s', + $configFile, + $e->getMessage() + )); + return Command::FAILURE; + } + } + + if (empty($paths)) { + $paths = $config['paths'] ?? []; + } + if (empty($paths)) { - $output->writeln('At least one --path is required.'); + $output->writeln('At least one --path is required or defined in phpswag.yaml.'); return Command::FAILURE; } - $core = Core::createDefault(); - $core->setOpenApiVersion($input->getOption('openapi-version')); + try { + $core = Core::createDefault(); - $filterUnused = $input->getOption('filter-unused'); - if ($filterUnused === null) { - $filterUnused = true; - } else { - $filterUnused = filter_var($filterUnused, FILTER_VALIDATE_BOOLEAN); - } - $core->setFilterUnusedSchemas($filterUnused); + // OpenAPI Version + $openapiVersion = $input->getOption('openapi-version'); + if ($openapiVersion === '3.0.0' && isset($config['openapi_version'])) { + $openapiVersion = $config['openapi_version']; + } + $core->setOpenApiVersion($openapiVersion); - if ($input->getOption('cache')) { - $core->enableCache($input->getOption('cache-file') ?: './.phpswag-cache'); - } + // Filter unused schemas + $filterUnusedOption = $input->getOption('filter-unused'); + if ($filterUnusedOption === 'true' && isset($config['filter_unused'])) { + $filterUnused = (bool)$config['filter_unused']; + } else { + $filterUnused = filter_var($filterUnusedOption, FILTER_VALIDATE_BOOLEAN); + } + $core->setFilterUnusedSchemas($filterUnused); - if ($title = $input->getOption('title')) { - $core->setTitle($title); - } - if ($apiVersion = $input->getOption('api-version')) { - $core->setApiVersion($apiVersion); - } - if ($description = $input->getOption('description')) { - $core->setDescription($description); - } - if ($host = $input->getOption('host')) { - $core->setServers([['url' => $host]]); - } + // Cache + $enableCache = $input->getOption('cache'); + if ($enableCache === false && isset($config['cache'])) { + $enableCache = (bool)$config['cache']; + } + if ($enableCache) { + $cacheFile = $input->getOption('cache-file'); + if ($cacheFile === './.phpswag-cache' && isset($config['cache_file'])) { + $cacheFile = $config['cache_file']; + } + $core->enableCache($cacheFile ?: './.phpswag-cache'); + } - $format = strtolower($input->getOption('format')); - if ($format === 'json') { - $result = $core->generateJson($paths); - } else { - $result = $core->generateYaml($paths); - } + if ($title = $input->getOption('title')) { + $core->setTitle($title); + } + if ($apiVersion = $input->getOption('api-version')) { + $core->setApiVersion($apiVersion); + } + if ($description = $input->getOption('description')) { + $core->setDescription($description); + } + if ($host = $input->getOption('host')) { + $core->setServers([['url' => $host]]); + } - $outputPath = $input->getOption('output'); - if ($outputPath) { - file_put_contents($outputPath, $result); - $output->writeln(sprintf('Documentation generated to %s', $outputPath)); - } else { - $output->write($result); - } + // Format + $format = strtolower($input->getOption('format')); + if ($format === 'yaml' && isset($config['format'])) { + $format = strtolower($config['format']); + } + + if ($format === 'json') { + $result = $core->generateJson($paths); + } else { + $result = $core->generateYaml($paths); + } - return Command::SUCCESS; + // Output destination + $outputPath = $input->getOption('output'); + if ($outputPath === null && isset($config['output'])) { + $outputPath = $config['output']; + } + + if ($outputPath) { + file_put_contents($outputPath, $result); + $output->writeln(sprintf('Documentation generated to %s', $outputPath)); + } else { + $output->write($result); + } + + return Command::SUCCESS; + } catch (\PhpSwag\Exception\DiagnosticException $e) { + $output->writeln(''); + $output->writeln(' ❌ Lỗi Phân Tích (Analysis Error) '); + $output->writeln(sprintf(' %s ', $e->getMessage())); + if ($e->getFilePath()) { + $realPath = realpath($e->getFilePath()) ?: $e->getFilePath(); + $link = 'file://' . $realPath; + if ($e->getLineNumber()) { + $link .= '#L' . $e->getLineNumber(); + $output->writeln(sprintf( + 'Vị trí lỗi: %s:%d', + $link, + $realPath, + $e->getLineNumber() + )); + } else { + $output->writeln(sprintf( + 'Vị trí lỗi: %s', + $link, + $realPath + )); + } + } + $output->writeln(''); + return Command::FAILURE; + } } } diff --git a/src/CLI/InitCommand.php b/src/CLI/InitCommand.php new file mode 100644 index 0000000..2456e5d --- /dev/null +++ b/src/CLI/InitCommand.php @@ -0,0 +1,135 @@ +setName('init') + ->setDescription('Initialize a new phpswag configuration file (phpswag.yaml)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + /** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $configFile = 'phpswag.yaml'; + + if (file_exists($configFile)) { + $question = new ConfirmationQuestion( + sprintf('File "%s" already exists. Overwrite? (y/N): ', $configFile), + false + ); + + if (!$helper->ask($input, $output, $question)) { + $output->writeln('Initialization cancelled.'); + return Command::SUCCESS; + } + } + + $output->writeln([ + '', + '==================================================', + ' phpswag Configuration Wizard ', + '==================================================', + '', + ]); + + // 1. Paths to scan + $pathsQuestion = new Question( + 'Enter path(s) to scan for annotations (comma-separated) [src]: ', + 'src' + ); + $pathsStr = $helper->ask($input, $output, $pathsQuestion); + $paths = array_map('trim', explode(',', $pathsStr)); + + // 2. OpenAPI Version + $versionQuestion = new ChoiceQuestion( + 'Select OpenAPI version [3.0.0]:', + ['3.0.0', '3.1.0'], + 0 + ); + $openapiVersion = $helper->ask($input, $output, $versionQuestion); + + // 3. Output format + $formatQuestion = new ChoiceQuestion( + 'Select default output format [yaml]:', + ['yaml', 'json'], + 0 + ); + $format = $helper->ask($input, $output, $formatQuestion); + + // 4. Output destination path + $defaultOutput = 'swagger.' . $format; + $outputQuestion = new Question( + sprintf('Enter output destination file path [%s]: ', $defaultOutput), + $defaultOutput + ); + $outputPath = $helper->ask($input, $output, $outputQuestion); + + // 5. Filter unused schemas + $filterQuestion = new ConfirmationQuestion( + 'Filter out unused schemas from the generated documentation? (Y/n): ', + true + ); + $filterUnused = $helper->ask($input, $output, $filterQuestion); + + // 6. Enable caching + $cacheQuestion = new ConfirmationQuestion( + 'Enable caching to speed up subsequent generations? (y/N): ', + false + ); + $cache = $helper->ask($input, $output, $cacheQuestion); + + // 7. Watch settings (Host & Port) + $watchHostQuestion = new Question( + 'Enter host for live preview server [localhost]: ', + 'localhost' + ); + $watchHost = $helper->ask($input, $output, $watchHostQuestion); + + $watchPortQuestion = new Question( + 'Enter port for live preview server [8080]: ', + '8080' + ); + $watchPort = $helper->ask($input, $output, $watchPortQuestion); + + $config = [ + 'paths' => $paths, + 'openapi_version' => $openapiVersion, + 'format' => $format, + 'output' => $outputPath, + 'filter_unused' => $filterUnused, + 'cache' => $cache, + 'watch_host' => $watchHost, + 'watch_port' => (int)$watchPort, + ]; + + if ($cache) { + $config['cache_file'] = './.phpswag-cache'; + } + + file_put_contents($configFile, Yaml::dump($config, 4, 2)); + + $output->writeln([ + '', + sprintf('Successfully created configuration file: %s', $configFile), + 'You can now run phpswag generate without any arguments to generate documentation.', + '', + ]); + + return Command::SUCCESS; + } +} diff --git a/src/CLI/WatchCommand.php b/src/CLI/WatchCommand.php new file mode 100644 index 0000000..79866a3 --- /dev/null +++ b/src/CLI/WatchCommand.php @@ -0,0 +1,282 @@ +setName('watch') + ->setDescription('Start a live preview server and watch for file changes') + ->addOption('path', 'p', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Path(s) to scan') + ->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Output file path (default: swagger.yaml)') + ->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (yaml or json)', 'yaml') + ->addOption('host', null, InputOption::VALUE_REQUIRED, 'Host for the server', 'localhost') + ->addOption('port', null, InputOption::VALUE_REQUIRED, 'Port for the server', '8080'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $paths = $input->getOption('path'); + $configFile = 'phpswag.yaml'; + $config = []; + if (file_exists($configFile)) { + try { + $config = \Symfony\Component\Yaml\Yaml::parseFile($configFile); + if (!is_array($config)) { + $config = []; + } + } catch (\Exception $e) { + $output->writeln(sprintf( + 'Error parsing configuration file "%s": %s', + $configFile, + $e->getMessage() + )); + return Command::FAILURE; + } + } + + if (empty($paths)) { + $paths = $config['paths'] ?? []; + } + + if (empty($paths)) { + $output->writeln('At least one --path is required or defined in phpswag.yaml.'); + return Command::FAILURE; + } + + // Output destination + $outputPath = $input->getOption('output'); + if ($outputPath === null) { + $outputPath = $config['output'] ?? 'swagger.yaml'; + } + + // Format + $format = strtolower($input->getOption('format')); + if ($format === 'yaml' && isset($config['format'])) { + $format = strtolower($config['format']); + } + + $host = $input->getOption('host'); + if ($host === 'localhost' && isset($config['watch_host'])) { + $host = $config['watch_host']; + } + + $port = $input->getOption('port'); + if ($port === '8080' && isset($config['watch_port'])) { + $port = (int)$config['watch_port']; + } else { + $port = (int)$port; + } + + // Temp change file for SSE signaling + $changeFile = tempnam(sys_get_temp_dir(), 'phpswag_changed_'); + if ($changeFile === false) { + $changeFile = __DIR__ . '/../../.phpswag-changed'; + } + touch($changeFile); + + $output->writeln('Generating initial specification...'); + $this->generateSpec($paths, $outputPath, $format, $config, $output); + + // Start Built-in PHP Web Server + $routerPath = __DIR__ . '/router.php'; + $cmd = sprintf( + '%s -S %s:%d %s', + escapeshellarg(PHP_BINARY), + $host, + $port, + escapeshellarg($routerPath) + ); + + $env = array_merge($_ENV, [ + 'PHPSWAG_SPEC_FILE' => realpath($outputPath) ?: $outputPath, + 'PHPSWAG_CHANGE_FILE' => realpath($changeFile) ?: $changeFile, + ]); + + $output->writeln(sprintf('Starting Live Preview Server on http://%s:%d...', $host, $port)); + + $devNull = DIRECTORY_SEPARATOR === '\\' ? 'NUL' : '/dev/null'; + $process = proc_open($cmd, [ + 0 => ['pipe', 'r'], + 1 => ['file', $devNull, 'w'], + 2 => ['pipe', 'w'], + ], $pipes, null, $env); + + if (!is_resource($process)) { + $output->writeln('Failed to start PHP Built-in Server.'); + return Command::FAILURE; + } + + // Non-blocking pipes + stream_set_blocking($pipes[2], false); + + $output->writeln('Watcher started. Watching for changes... Press Ctrl+C to stop.'); + + $lastFiles = $this->getFiles($paths); + + try { + while (true) { + // Check if process is still running + $status = proc_get_status($process); + if (!$status['running']) { + $output->writeln('PHP Server stopped unexpectedly.'); + $stderr = stream_get_contents($pipes[2]); + if (!empty($stderr)) { + $output->writeln('Server Error Log:'); + $output->writeln(sprintf('%s', trim($stderr))); + } + break; + } + + // Scan files for changes + $currentFiles = $this->getFiles($paths); + $hasChanged = false; + + if (count($currentFiles) !== count($lastFiles)) { + $hasChanged = true; + } else { + foreach ($currentFiles as $file => $mtime) { + if (!isset($lastFiles[$file]) || $lastFiles[$file] !== $mtime) { + $hasChanged = true; + break; + } + } + } + + if ($hasChanged) { + $output->writeln(sprintf( + '[%s] Change detected, regenerating spec...', + date('H:i:s') + )); + if ($this->generateSpec($paths, $outputPath, $format, $config, $output)) { + touch($changeFile); + $output->writeln('Spec updated successfully.'); + } + $lastFiles = $currentFiles; + } + + if (getenv('PHPSWAG_TEST_LOOP') === '1') { + break; + } + + usleep(500000); // Sleep for 500ms + } + } finally { + $output->writeln('Cleaning up and shutting down...'); + if (file_exists($changeFile)) { + unlink($changeFile); + } + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + if (is_resource($process)) { + proc_terminate($process); + proc_close($process); + } + } + + return Command::SUCCESS; + } + + /** + * @param array $paths + * @param string $outputPath + * @param string $format + * @param array $config + * @param OutputInterface $output + */ + private function generateSpec( + array $paths, + string $outputPath, + string $format, + array $config, + OutputInterface $output + ): bool { + try { + $core = Core::createDefault(); + + // OpenAPI Version + $openapiVersion = $config['openapi_version'] ?? '3.0.0'; + $core->setOpenApiVersion($openapiVersion); + + // Filter unused schemas + $filterUnused = isset($config['filter_unused']) ? (bool)$config['filter_unused'] : true; + $core->setFilterUnusedSchemas($filterUnused); + + // Cache + if (isset($config['cache']) && $config['cache']) { + $core->enableCache($config['cache_file'] ?? './.phpswag-cache'); + } + + if ($format === 'json') { + $result = $core->generateJson($paths); + } else { + $result = $core->generateYaml($paths); + } + + file_put_contents($outputPath, $result); + return true; + } catch (\PhpSwag\Exception\DiagnosticException $e) { + $output->writeln(''); + $output->writeln(' ❌ Lỗi Phân Tích (Analysis Error) '); + $output->writeln(sprintf(' %s ', $e->getMessage())); + if ($e->getFilePath()) { + $realPath = realpath($e->getFilePath()) ?: $e->getFilePath(); + $link = 'file://' . $realPath; + if ($e->getLineNumber()) { + $link .= '#L' . $e->getLineNumber(); + $output->writeln(sprintf( + 'Vị trí lỗi: %s:%d', + $link, + $realPath, + $e->getLineNumber() + )); + } else { + $output->writeln(sprintf( + 'Vị trí lỗi: %s', + $link, + $realPath + )); + } + } + $output->writeln(''); + return false; + } + } + + /** + * @param array $paths + * @return array + */ + private function getFiles(array $paths): array + { + $files = []; + foreach ($paths as $path) { + if (is_file($path)) { + $files[realpath($path)] = filemtime($path); + } elseif (is_dir($path)) { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path) + ); + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $files[$file->getRealPath()] = $file->getMTime(); + } + } + } + } + return $files; + } +} diff --git a/src/CLI/router.php b/src/CLI/router.php new file mode 100644 index 0000000..8d39714 --- /dev/null +++ b/src/CLI/router.php @@ -0,0 +1,105 @@ + $lastSeen) { + $changed = true; + } + } + + echo json_encode([ + 'changed' => $changed, + 'last' => $mtime ?: time(), + ]); + exit; +} + +// Serve the generated swagger file +if ($requestPath === '/swagger.yaml' || $requestPath === '/swagger.json') { + if (file_exists($specFile)) { + header('Content-Type: ' . (str_ends_with($specFile, '.json') ? 'application/json' : 'text/yaml')); + header('Access-Control-Allow-Origin: *'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + readfile($specFile); + } else { + header('HTTP/1.1 404 Not Found'); + echo "Specification file not found."; + } + exit; +} + +// Serve Swagger UI HTML +if ($requestPath === '/' || $requestPath === '/index.html') { + header('Content-Type: text/html'); + ?> + + + + + + + phpswag Live Preview + + + + +
+ + + + + */ private array $schemaTagParsers = []; - /** @var array */ + /** @var array */ private array $discoveredClasses = []; private bool $isAnalyzed = false; @@ -402,7 +403,12 @@ private function applyGlobalMetadata(): void private function discoverStatement(Node $stmt, NameResolver $nameResolver): void { - if ($stmt instanceof Class_ || $stmt instanceof Trait_ || $stmt instanceof Enum_) { + if ( + $stmt instanceof Class_ + || $stmt instanceof Trait_ + || $stmt instanceof Enum_ + || $stmt instanceof Interface_ + ) { $fqcn = $nameResolver->resolve($stmt->name->toString()); $this->discoveredClasses[$fqcn] = [ 'node' => $stmt, @@ -457,7 +463,7 @@ traits: $traits, } } - private function analyzeClass(string $fqcn, Class_|Trait_|Enum_ $stmt, NameResolver $nameResolver): void + private function analyzeClass(string $fqcn, Class_|Trait_|Enum_|Interface_ $stmt, NameResolver $nameResolver): void { $schema = $this->schemaRegistry->get($fqcn); if (function_exists('enum_exists') && enum_exists($fqcn)) { diff --git a/src/DocBlockCollector.php b/src/DocBlockCollector.php index e041ed9..fe0657c 100644 --- a/src/DocBlockCollector.php +++ b/src/DocBlockCollector.php @@ -37,7 +37,7 @@ public function collectTags(string $docComment, ?int $startLine = null, ?string $tagName = $matches[1]; $value = isset($matches[2]) ? trim($matches[2]) : ''; - if (in_array($tagName, ['@property', '@var', '@return', '@path', '@query', '@header', '@cookie'])) { + if (in_array($tagName, ['@property', '@var', '@path', '@query', '@header', '@cookie'])) { try { // For @path, @query, etc., we treat them similarly to @param for parsing convenience $parseTagName = in_array($tagName, ['@path', '@query', '@header', '@cookie']) @@ -87,15 +87,13 @@ public function collectTags(string $docComment, ?int $startLine = null, ?string } } catch (\Exception $e) { throw new \PhpSwag\Exception\DiagnosticException(sprintf( - "Invalid syntax for tag '%s' in %s%s: expected format is '%s TYPE %s\$name%s', got '%s'", + "Invalid syntax for tag '%s': expected format is '%s TYPE %s\$name%s', got '%s'", $tagName, - $filePath ?? 'unknown', - $currentLineNum !== null ? " on line $currentLineNum" : "", $tagName, $tagName === '@var' ? '[' : '', $tagName === '@var' ? ']' : '', $value - ), 0, $e); + ), 0, $e, $filePath, $currentLineNum); } } elseif ($tagName === '@body') { // @body [Type] [Description] @@ -118,12 +116,10 @@ public function collectTags(string $docComment, ?int $startLine = null, ?string ]; } else { throw new \PhpSwag\Exception\DiagnosticException(sprintf( - "Invalid syntax for tag '@body' in %s%s: " + "Invalid syntax for tag '@body': " . "expected format is '@body TYPE [description]', got '%s'", - $filePath ?? 'unknown', - $currentLineNum !== null ? " on line $currentLineNum" : "", $value - )); + ), 0, null, $filePath, $currentLineNum); } } else { $tags[] = [ diff --git a/src/Exception/DiagnosticException.php b/src/Exception/DiagnosticException.php index 878907c..0e8740d 100644 --- a/src/Exception/DiagnosticException.php +++ b/src/Exception/DiagnosticException.php @@ -4,4 +4,50 @@ class DiagnosticException extends \RuntimeException { + private ?string $filePath = null; + private ?int $lineNumber = null; + + public function __construct( + string $message, + int $code = 0, + ?\Throwable $previous = null, + ?string $filePath = null, + ?int $lineNumber = null + ) { + if ($filePath !== null || $lineNumber !== null) { + $suffix = ''; + if ($filePath !== null) { + $suffix .= " in " . $filePath; + } + if ($lineNumber !== null) { + $suffix .= " on line " . $lineNumber; + } + if (!str_contains($message, ' in ') && !str_contains($message, ' on line ')) { + $message .= $suffix; + } + } + parent::__construct($message, $code, $previous); + $this->filePath = $filePath; + $this->lineNumber = $lineNumber; + } + + public function getFilePath(): ?string + { + return $this->filePath; + } + + public function setFilePath(?string $filePath): void + { + $this->filePath = $filePath; + } + + public function getLineNumber(): ?int + { + return $this->lineNumber; + } + + public function setLineNumber(?int $lineNumber): void + { + $this->lineNumber = $lineNumber; + } } diff --git a/src/Metadata/GlobalMetadataDiscoverer.php b/src/Metadata/GlobalMetadataDiscoverer.php index 0ff2ae8..d2b8d23 100644 --- a/src/Metadata/GlobalMetadataDiscoverer.php +++ b/src/Metadata/GlobalMetadataDiscoverer.php @@ -105,13 +105,17 @@ public function discover( 'name' => $matches[3] ]; } else { - throw new DiagnosticException(sprintf( - "Invalid syntax for tag '@securityDefinitions.apikey' in %s%s: " - . "expected format is '@securityDefinitions.apikey NAME IN KEY', got '%s'", + throw new DiagnosticException( + sprintf( + "Invalid syntax for tag '@securityDefinitions.apikey': " + . "expected format is '@securityDefinitions.apikey NAME IN KEY', got '%s'", + $tag['value'] + ), + 0, + null, $tag['file'] ?? $filePath, - isset($tag['line']) ? " on line " . $tag['line'] : "", - $tag['value'] - )); + $tag['line'] ?? null + ); } } elseif ($tagName === '@securityDefinitions.jwt') { if (trim($tag['value']) !== '') { @@ -121,12 +125,14 @@ public function discover( 'bearerFormat' => 'JWT' ]; } else { - throw new DiagnosticException(sprintf( - "Invalid syntax for tag '@securityDefinitions.jwt' in %s%s: " + throw new DiagnosticException( + "Invalid syntax for tag '@securityDefinitions.jwt': " . "expected format is '@securityDefinitions.jwt NAME', got empty value", + 0, + null, $tag['file'] ?? $filePath, - isset($tag['line']) ? " on line " . $tag['line'] : "" - )); + $tag['line'] ?? null + ); } } elseif ($tagName === '@securityDefinitions.basic') { if (trim($tag['value']) !== '') { @@ -135,12 +141,14 @@ public function discover( 'scheme' => 'basic' ]; } else { - throw new DiagnosticException(sprintf( - "Invalid syntax for tag '@securityDefinitions.basic' in %s%s: " + throw new DiagnosticException( + "Invalid syntax for tag '@securityDefinitions.basic': " . "expected format is '@securityDefinitions.basic NAME', got empty value", + 0, + null, $tag['file'] ?? $filePath, - isset($tag['line']) ? " on line " . $tag['line'] : "" - )); + $tag['line'] ?? null + ); } } elseif ($tagName === '@security') { $globalSecurity = array_merge( @@ -159,13 +167,17 @@ public function discover( } $globalTags[$name] = $tagData; } else { - throw new DiagnosticException(sprintf( - "Invalid syntax for tag '@tag.name' in %s%s: " - . "expected format is '@tag.name NAME [description]', got '%s'", + throw new DiagnosticException( + sprintf( + "Invalid syntax for tag '@tag.name': " + . "expected format is '@tag.name NAME [description]', got '%s'", + $tag['value'] + ), + 0, + null, $tag['file'] ?? $filePath, - isset($tag['line']) ? " on line " . $tag['line'] : "", - $tag['value'] - )); + $tag['line'] ?? null + ); } } } diff --git a/src/TagParser/ResponseTagParser.php b/src/TagParser/ResponseTagParser.php index 8bdae4b..cc05598 100644 --- a/src/TagParser/ResponseTagParser.php +++ b/src/TagParser/ResponseTagParser.php @@ -50,14 +50,18 @@ public function parse(array $tagData, RouteContext $context, TypeResolver $typeR ); $context->responseDescriptions[$code] = $respDesc; } else { - throw new DiagnosticException(sprintf( - "Invalid syntax for tag '%s' in %s%s: expected format is '%s CODE TYPE [description]', got '%s'", - $tagName, - $tagData['file'] ?? 'unknown', - isset($tagData['line']) ? " on line " . $tagData['line'] : "", - $tagName, - $value - )); + throw new DiagnosticException( + sprintf( + "Invalid syntax for tag '%s': expected format is '%s CODE TYPE [description]', got '%s'", + $tagName, + $tagName, + $value + ), + 0, + null, + $tagData['file'] ?? null, + $tagData['line'] ?? null + ); } } } diff --git a/src/TagParser/RouteTagParser.php b/src/TagParser/RouteTagParser.php index e59089f..9a3de75 100644 --- a/src/TagParser/RouteTagParser.php +++ b/src/TagParser/RouteTagParser.php @@ -18,12 +18,13 @@ public function parse(array $tagData, RouteContext $context, TypeResolver $typeR if (preg_match('/^(GET|POST|PUT|DELETE|PATCH)\s+(\S+)/i', $value, $matches)) { $context->routeTag = strtoupper($matches[1]) . ' ' . $matches[2]; } else { - throw new DiagnosticException(sprintf( - "Invalid syntax for tag '@route' in %s%s: expected format is '@route METHOD PATH', got '%s'", - $tagData['file'] ?? 'unknown', - isset($tagData['line']) ? " on line " . $tagData['line'] : "", - $value - )); + throw new DiagnosticException( + sprintf("Invalid syntax for tag '@route': expected format is '@route METHOD PATH', got '%s'", $value), + 0, + null, + $tagData['file'] ?? null, + $tagData['line'] ?? null + ); } } } diff --git a/src/TypeResolver.php b/src/TypeResolver.php index 95355cb..4e04bca 100644 --- a/src/TypeResolver.php +++ b/src/TypeResolver.php @@ -136,12 +136,13 @@ private function resolveIdentifier(string $name, ?int $line = null, ?string $fil } if (!$this->schemaRegistry->has($fqcn)) { - throw new \PhpSwag\Exception\DiagnosticException(sprintf( - "Unresolved class '%s'%s%s", - $fqcn, - $file !== null ? " in $file" : "", - $line !== null ? " on line $line" : "" - )); + throw new \PhpSwag\Exception\DiagnosticException( + sprintf("Unresolved class '%s'", $fqcn), + 0, + null, + $file, + $line + ); } return [ diff --git a/tests/DiagnosticsTest.php b/tests/DiagnosticsTest.php index ec71fc7..48c912a 100644 --- a/tests/DiagnosticsTest.php +++ b/tests/DiagnosticsTest.php @@ -218,4 +218,34 @@ public function testInvalidSecurityBasicThrowsException() unlink($tempFile); } } + + public function testDiagnosticExceptionProperties() + { + $core = new Core(); + $code = <<<'PHP' +generateYaml([$tempFile]); + $this->fail("Expected DiagnosticException was not thrown."); + } catch (DiagnosticException $e) { + $this->assertEquals($tempFile, $e->getFilePath()); + $this->assertEquals(7, $e->getLineNumber()); + } finally { + unlink($tempFile); + } + } } diff --git a/tests/GenerateCommandTest.php b/tests/GenerateCommandTest.php index fb2faf2..ac152e6 100644 --- a/tests/GenerateCommandTest.php +++ b/tests/GenerateCommandTest.php @@ -10,15 +10,28 @@ class GenerateCommandTest extends TestCase { private CommandTester $commandTester; + private bool $hasBackup = false; protected function setUp(): void { + if (file_exists('phpswag.yaml')) { + rename('phpswag.yaml', 'phpswag.yaml.bak'); + $this->hasBackup = true; + } + $application = new Application(); $application->add(new GenerateCommand()); $command = $application->find('generate'); $this->commandTester = new CommandTester($command); } + protected function tearDown(): void + { + if ($this->hasBackup && file_exists('phpswag.yaml.bak')) { + rename('phpswag.yaml.bak', 'phpswag.yaml'); + } + } + public function testExecuteWithoutPathFails() { $this->commandTester->execute([]); @@ -73,4 +86,31 @@ public function testExecuteWithFilterUnusedAsFlag() unlink('test-swagger-flag.yaml'); } } + + public function testGenerateCommandLoadsFromYamlConfig() + { + $configFile = 'phpswag.yaml'; + $config = [ + 'paths' => ['examples/App'], + 'openapi_version' => '3.0.0', + 'format' => 'yaml', + 'output' => 'test-swagger-config.yaml', + 'filter_unused' => true, + ]; + file_put_contents($configFile, \Symfony\Component\Yaml\Yaml::dump($config)); + + try { + $this->commandTester->execute([]); + $this->assertEquals(0, $this->commandTester->getStatusCode()); + $this->assertStringContainsString('Documentation generated to test-swagger-config.yaml', $this->commandTester->getDisplay()); + $this->assertFileExists('test-swagger-config.yaml'); + } finally { + if (file_exists($configFile)) { + unlink($configFile); + } + if (file_exists('test-swagger-config.yaml')) { + unlink('test-swagger-config.yaml'); + } + } + } } diff --git a/tests/InitCommandTest.php b/tests/InitCommandTest.php new file mode 100644 index 0000000..4db097c --- /dev/null +++ b/tests/InitCommandTest.php @@ -0,0 +1,75 @@ +hasBackup = true; + } + } + + protected function tearDown(): void + { + if (file_exists('phpswag.yaml')) { + unlink('phpswag.yaml'); + } + if ($this->hasBackup && file_exists('phpswag.yaml.bak')) { + rename('phpswag.yaml.bak', 'phpswag.yaml'); + } + } + + public function testInitCommandGeneratesYamlConfig() + { + $application = new Application(); + $application->add(new InitCommand()); + $command = $application->find('init'); + $commandTester = new CommandTester($command); + + // Simulate interactive console inputs: + // 1. Paths to scan [src] + // 2. OpenAPI version [3.0.0] + // 3. Output format [yaml] + // 4. Output destination path [swagger.yaml] + // 5. Filter unused schemas [Y/n] + // 6. Enable caching [y/N] + $commandTester->setInputs([ + 'src/Controllers, src/Models', // Paths to scan + '1', // Choice index 1 = 3.1.0 + '0', // Choice index 0 = yaml + 'public/docs.yaml', // Output path + 'y', // Filter unused + 'y', // Enable cache + '127.0.0.1', // Watch host + '9000', // Watch port + ]); + + $commandTester->execute([]); + + $this->assertEquals(0, $commandTester->getStatusCode()); + $this->assertStringContainsString('Successfully created configuration file: phpswag.yaml', $commandTester->getDisplay()); + $this->assertFileExists('phpswag.yaml'); + + $config = Yaml::parseFile('phpswag.yaml'); + $this->assertEquals(['src/Controllers', 'src/Models'], $config['paths']); + $this->assertEquals('3.1.0', $config['openapi_version']); + $this->assertEquals('yaml', $config['format']); + $this->assertEquals('public/docs.yaml', $config['output']); + $this->assertTrue($config['filter_unused']); + $this->assertTrue($config['cache']); + $this->assertEquals('./.phpswag-cache', $config['cache_file']); + $this->assertEquals('127.0.0.1', $config['watch_host']); + $this->assertEquals(9000, $config['watch_port']); + } +} diff --git a/tests/WatchCommandTest.php b/tests/WatchCommandTest.php new file mode 100644 index 0000000..bcedba1 --- /dev/null +++ b/tests/WatchCommandTest.php @@ -0,0 +1,85 @@ +hasBackup = true; + } + + $application = new Application(); + $application->add(new WatchCommand()); + $command = $application->find('watch'); + $this->commandTester = new CommandTester($command); + + putenv('PHPSWAG_TEST_LOOP=1'); + } + + protected function tearDown(): void + { + putenv('PHPSWAG_TEST_LOOP'); + + if ($this->hasBackup && file_exists('phpswag.yaml.bak')) { + rename('phpswag.yaml.bak', 'phpswag.yaml'); + } + + if (file_exists('test-watch-swagger.yaml')) { + unlink('test-watch-swagger.yaml'); + } + } + + public function testWatchCommandRunsAndGeneratesInitialSpec() + { + $this->commandTester->execute([ + '--path' => ['examples/App'], + '--output' => 'test-watch-swagger.yaml', + '--port' => '8999' + ]); + + $this->assertEquals(0, $this->commandTester->getStatusCode()); + $this->assertStringContainsString('Generating initial specification...', $this->commandTester->getDisplay()); + $this->assertStringContainsString('Starting Live Preview Server', $this->commandTester->getDisplay()); + $this->assertStringContainsString('Watcher started', $this->commandTester->getDisplay()); + $this->assertFileExists('test-watch-swagger.yaml'); + } + + public function testWatchCommandLoadsFromYamlConfig() + { + $configFile = 'phpswag.yaml'; + $config = [ + 'paths' => ['examples/App'], + 'openapi_version' => '3.0.0', + 'format' => 'yaml', + 'output' => 'test-watch-config.yaml', + 'watch_host' => '127.0.0.2', + 'watch_port' => 9999, + ]; + file_put_contents($configFile, \Symfony\Component\Yaml\Yaml::dump($config)); + + try { + $this->commandTester->execute([]); + $this->assertEquals(0, $this->commandTester->getStatusCode()); + $this->assertStringContainsString('Starting Live Preview Server on http://127.0.0.2:9999...', $this->commandTester->getDisplay()); + $this->assertFileExists('test-watch-config.yaml'); + } finally { + if (file_exists($configFile)) { + unlink($configFile); + } + if (file_exists('test-watch-config.yaml')) { + unlink('test-watch-config.yaml'); + } + } + } +} From f650a941d9679a869842dc4edc556734efcc57d7 Mon Sep 17 00:00:00 2001 From: tolawho Date: Tue, 9 Jun 2026 09:59:18 +0700 Subject: [PATCH 24/27] feat: Add PHP 8+ Attributes support, Laravel/Symfony bridges, and OpenAPI Validator --- .agent/skills/safe_framework/SKILL.md | 2 +- README.md | 171 +++++++++ SAFE_BACKLOG.md | 90 ++++- TECHNICAL_ANALYSIS.md | 49 +++ composer.json | 8 +- phpstan.neon | 3 +- src/Attributes/AbstractParameter.php | 24 ++ src/Attributes/AttributeMapper.php | 195 +++++++++++ src/Attributes/AttributeParser.php | 164 +++++++++ src/Attributes/BaseParameter.php | 38 ++ src/Attributes/CookieParam.php | 10 + src/Attributes/Delete.php | 14 + src/Attributes/Deprecated.php | 13 + src/Attributes/Get.php | 14 + src/Attributes/HeaderParam.php | 10 + src/Attributes/OperationId.php | 14 + src/Attributes/PathParam.php | 10 + src/Attributes/Post.php | 14 + src/Attributes/Property.php | 42 +++ src/Attributes/Put.php | 14 + src/Attributes/QueryParam.php | 10 + src/Attributes/RequestBody.php | 40 +++ src/Attributes/Response.php | 19 + src/Attributes/Route.php | 15 + src/Attributes/Schema.php | 15 + src/Attributes/Tag.php | 15 + .../Laravel/Commands/GenerateCommand.php | 107 ++++++ .../Laravel/PhpSwagServiceProvider.php | 97 +++++ src/Bridges/Laravel/config/phpswag.php | 62 ++++ .../Symfony/Command/GenerateCommand.php | 138 ++++++++ .../DependencyInjection/Configuration.php | 56 +++ .../DependencyInjection/PhpSwagExtension.php | 32 ++ src/Bridges/Symfony/PhpSwagBundle.php | 9 + src/CLI/GenerateCommand.php | 18 +- src/Core.php | 330 ++++++++++++++++-- src/Generator.php | 8 + src/Validation/Validator.php | 78 +++++ tests/AttributesTest.php | 237 +++++++++++++ tests/FrameworkBridgesTest.php | 143 ++++++++ tests/LinterValidatorTest.php | 123 +++++++ tests/phpstan-bootstrap.php | 49 +++ 41 files changed, 2472 insertions(+), 28 deletions(-) create mode 100644 src/Attributes/AbstractParameter.php create mode 100644 src/Attributes/AttributeMapper.php create mode 100644 src/Attributes/AttributeParser.php create mode 100644 src/Attributes/BaseParameter.php create mode 100644 src/Attributes/CookieParam.php create mode 100644 src/Attributes/Delete.php create mode 100644 src/Attributes/Deprecated.php create mode 100644 src/Attributes/Get.php create mode 100644 src/Attributes/HeaderParam.php create mode 100644 src/Attributes/OperationId.php create mode 100644 src/Attributes/PathParam.php create mode 100644 src/Attributes/Post.php create mode 100644 src/Attributes/Property.php create mode 100644 src/Attributes/Put.php create mode 100644 src/Attributes/QueryParam.php create mode 100644 src/Attributes/RequestBody.php create mode 100644 src/Attributes/Response.php create mode 100644 src/Attributes/Route.php create mode 100644 src/Attributes/Schema.php create mode 100644 src/Attributes/Tag.php create mode 100644 src/Bridges/Laravel/Commands/GenerateCommand.php create mode 100644 src/Bridges/Laravel/PhpSwagServiceProvider.php create mode 100644 src/Bridges/Laravel/config/phpswag.php create mode 100644 src/Bridges/Symfony/Command/GenerateCommand.php create mode 100644 src/Bridges/Symfony/DependencyInjection/Configuration.php create mode 100644 src/Bridges/Symfony/DependencyInjection/PhpSwagExtension.php create mode 100644 src/Bridges/Symfony/PhpSwagBundle.php create mode 100644 src/Validation/Validator.php create mode 100644 tests/AttributesTest.php create mode 100644 tests/FrameworkBridgesTest.php create mode 100644 tests/LinterValidatorTest.php create mode 100644 tests/phpstan-bootstrap.php diff --git a/.agent/skills/safe_framework/SKILL.md b/.agent/skills/safe_framework/SKILL.md index 9802c79..4075449 100644 --- a/.agent/skills/safe_framework/SKILL.md +++ b/.agent/skills/safe_framework/SKILL.md @@ -5,7 +5,7 @@ description: Guide for applying Essential SAFe practices (PI Planning, Iteration # SAFE Framework Skill -This skill guides you in applying Essential SAFe practices within the `wfxp.api` project. +This skill guides you in applying Essential SAFe practices within the `phpswag` project. ## 1. Core Concepts - **ART (Agile Release Train)**: A virtual organization of 5-12 teams (50-125+ people) that plans, commits, and executes together. diff --git a/README.md b/README.md index 27ae83e..53f1e71 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AS - **OpenAPI 3.0 & 3.1**: Supports both versions, with automatic conversion of nullable types for 3.1. - **Schema Registry**: Handles circular references and avoids duplicate definitions. - **Native PHP Enum Support (PHP 8.1+)**: Automatically extracts enum cases and types for both `BackedEnum` (string/int) and `UnitEnum`. +- **PHP 8+ Attributes Support**: Declare routing, schema, and parameter metadata directly using native PHP 8 attributes (e.g., `#[Get]`, `#[Property]`, `#[QueryParam]`, `#[Response]`). +- **OpenAPI Linter & Validator**: Detect specification integrity issues and unresolved model references with the `--validate` option. +- **Framework Integrations**: Seamless bridges for Laravel Service Providers and Symfony Bundle DI configurations. ## Installation @@ -326,6 +329,63 @@ public function show(int $id, string $status) {} - **Metadata**: Support `enum(a,b,c)` and `default(value)` in descriptions. - **Auto-inference**: If no tags are provided, parameters are inferred from the method signature. Primitive types match path/query, and class types match the request body. +### PHP 8+ Attributes Support + +You can document your endpoints and models using modern PHP 8+ Attributes instead of (or alongside) PHPDoc comments. Attributes offer IDE autocomplete, static checking, and clean syntax. + +#### Available Attributes + +All attributes are located under the `PhpSwag\Attributes` namespace: + +- **Routing & Tags**: + - `#[Route(method: string, path: string)]` or method shortcuts: `#[Get(path)]`, `#[Post(path)]`, `#[Put(path)]`, `#[Delete(path)]`. + - `#[Tag(name: string, description: ?string = null)]` (Repeatable, can be on class/method). + - `#[OperationId(id: string)]` (On method). + - `#[Deprecated]` (On method). +- **Parameters & Body**: + - `#[QueryParam]`, `#[PathParam]`, `#[HeaderParam]`, `#[CookieParam]`: Specify custom path, query, header, or cookie parameters. + - `#[RequestBody(type: string, description: ?string = null, ...validation)]`: Define endpoint's request body schema. +- **Response & Schema**: + - `#[Response(code: int|string, type: string, description: ?string = null)]` (Repeatable, on method). + - `#[Schema(title: ?string = null, description: ?string = null)]` (On class). + - `#[Property(name: ?string = null, type: ?string = null, description: ?string = null, ...validation, required: ?bool = null)]` (Repeatable on class, or single on class property). + +#### Example Usage + +```php +use PhpSwag\Attributes\Get; +use PhpSwag\Attributes\Tag; +use PhpSwag\Attributes\QueryParam; +use PhpSwag\Attributes\Response; +use PhpSwag\Attributes\Property; +use PhpSwag\Attributes\Schema; + +#[Schema(description: "User Response Model")] +class User { + #[Property(description: "User unique ID", minimum: 1)] + public int $id; + + #[Property(description: "User email", format: "email")] + public string $email; +} + +#[Tag("Users")] +class UserController { + #[Get("/users/{id}")] + #[QueryParam("status", type: "string", description: "Filter by status", enum: ["active", "inactive"])] + #[Response(200, User::class, description: "Success response")] + public function show(int $id) {} +} +``` + +#### Smart Merge Strategy (Parallel Usage) + +When both PHPDoc and PHP 8 Attributes are present: +1. **Single-value properties** (e.g., `summary`, `description`, `operationId`): Values in **Attributes** override PHPDoc. PHPDoc is used as a fallback if not declared in Attributes. +2. **Collections** (e.g., tags, security): Values from both sources are **merged** together. +3. **Keyed Collections** (e.g., query params with matching names, response codes): Attributes **override** PHPDoc for that specific key/parameter. Unmatched keys from both sources are merged. + + ## Support Tags - **Global Metadata**: @@ -421,6 +481,7 @@ Or, specify options on the command line (which will override values in the confi - `--host`: API Host/Server URL override. - `--cache`: Enable caching to speed up generation. - `--cache-file`: Cache file path. Default: `./.phpswag-cache`. +- `--validate`: Run validation and linter checks on the generated specification (checks for missing title/version, structural integrity, and unresolved `$ref` schemas). #### 3. Live Preview & Hot Reload (Watch Mode) @@ -452,3 +513,113 @@ filter_unused: true cache: true cache_file: ./.phpswag-cache ``` + +--- + +## PHP 8+ Attributes Support + +In addition to PHPDoc annotations, `phpswag` fully supports native PHP 8 Attributes. Attributes can be used side-by-side with PHPDoc annotations and follow a **Smart Merge & Override** strategy: +- Single-value metadata (e.g. `summary`, `description`, etc.) defined in Attributes will override PHPDoc definitions. +- Parameter definitions are matched by name; Attributes override PHPDoc definitions for the same parameter name. +- Collection tags (e.g. `@tag`, `@security`) defined in both places are merged. + +### Example + +```php +use PhpSwag\Attributes\Get; +use PhpSwag\Attributes\Tag; +use PhpSwag\Attributes\QueryParam; +use PhpSwag\Attributes\Response; +use PhpSwag\Attributes\Schema; +use PhpSwag\Attributes\Property; + +#[Tag("Users")] +class UserController { + #[Get("/users/{id}")] + #[QueryParam("status", type: "string", description: "Filter by user status", enum: ["active", "inactive"])] + #[Response(200, User::class, description: "Returns the requested user")] + public function show(int $id) {} +} + +#[Schema(title: "User", description: "User representation")] +class User { + #[Property(description: "Unique identifier")] + public int $id; // Native type hint 'int' is automatically inferred as 'integer'! + + #[Property(description: "User email address", format: "email")] + public string $email; +} +``` + +--- + +## Framework Bridges + +`phpswag` includes out-of-the-box integrations for Laravel and Symfony. + +### Laravel Integration + +The Laravel bridge registers config, Artisan commands, and automatic Swagger UI route mappings. + +#### 1. Registration +Add the Service Provider in `config/app.php` (if not auto-discovered): +```php +'providers' => [ + // ... + PhpSwag\Bridges\Laravel\PhpSwagServiceProvider::class, +]; +``` + +#### 2. Configuration +Publish the configuration file: +```bash +php artisan vendor:publish --tag=phpswag-config +``` +This generates `config/phpswag.php` where you can customize directories to scan, output path, API metadata, and Swagger UI routes. + +#### 3. Generation & Validation +Run the Artisan command to generate the spec: +```bash +php artisan phpswag:generate +``` +Pass the `--validate` flag to validate schema references and spec completeness: +```bash +php artisan phpswag:generate --validate +``` + +--- + +### Symfony Integration + +The Symfony bridge provides a Bundle to load parameters into the Dependency Injection container and registers Symfony console commands. + +#### 1. Registration +Register the bundle in `config/bundles.php`: +```php +return [ + // ... + PhpSwag\Bridges\Symfony\PhpSwagBundle::class => ['all' => true], +]; +``` + +#### 2. Configuration +Create a configuration file `config/packages/phpswag.yaml`: +```yaml +phpswag: + paths: + - '%kernel.project_dir%/src/Controller' + - '%kernel.project_dir%/src/Entity' + output: '%kernel.project_dir%/public/swagger.yaml' + title: 'My Symfony API' + version: '1.0.0' +``` + +#### 3. Generation & Validation +Run the console command: +```bash +php bin/console phpswag:generate +``` +To validate the schema: +```bash +php bin/console phpswag:generate --validate +``` diff --git a/SAFE_BACKLOG.md b/SAFE_BACKLOG.md index 3ffd8fb..b9504b4 100644 --- a/SAFE_BACKLOG.md +++ b/SAFE_BACKLOG.md @@ -25,7 +25,7 @@ - Xử lý được tham chiếu vòng (Circular References). ### [Epic 3] Tích hợp CLI và Tối ưu hóa Hiệu năng -- **Trạng thái:** In Progress +- **Trạng thái:** Done - **Chủ sở hữu:** Fullstack Developer (User) - **Tóm tắt:** Hoàn thiện công cụ dưới dạng CLI, hỗ trợ nhiều định dạng xuất bản và cơ chế bộ nhớ đệm (Caching). - **Giả thuyết Lợi ích (Benefit Hypothesis):** Biến thư viện thành một công cụ dòng lệnh chuyên nghiệp dễ dàng tích hợp vào quy trình CI/CD, đồng thời đảm bảo tốc độ xử lý nhanh cho các dự án lớn. @@ -36,7 +36,7 @@ - Có tài liệu hướng dẫn sử dụng (README) hoàn chỉnh cho cộng đồng. ### [Epic 4] Professional API Documentation & Advanced Controls -- **Trạng thái:** To Do +- **Trạng thái:** Done - **Chủ sở hữu:** Fullstack Developer (User) - **Tóm tắt:** Mở rộng các tính năng lấy cảm hứng từ swaggo để hoàn thiện tài liệu API chuyên nghiệp. - **Giả thuyết Lợi ích (Benefit Hypothesis):** Giúp tạo ra tài liệu OpenAPI đầy đủ thông tin nhất, hỗ trợ bảo mật, validation và các tùy chỉnh nâng cao, giúp frontend và các bên liên quan dễ dàng tích hợp. @@ -107,7 +107,7 @@ - [x] **[S4.5.3] x- Extension Support:** Hỗ trợ trích xuất và xuất các extension OpenAPI tùy chỉnh bắt đầu bằng "x-". ### [Epic 5] Developer Experience & Modern PHP Support -- **Trạng thái:** To Do +- **Trạng thái:** Done - **Chủ sở hữu:** Fullstack Developer (User) - **Tóm tắt:** Tập trung vào việc tối ưu hóa quy trình viết code, tận dụng các tính năng hiện đại của PHP (Enums, Attributes-like inference) và cung cấp thông báo lỗi minh bạch. - **Giả thuyết Lợi ích (Benefit Hypothesis):** Giảm thiểu mã lặp lại, tận dụng tối đa sức mạnh của ngôn ngữ PHP hiện đại và giúp lập trình viên phát hiện lỗi cấu hình API ngay lập tức, từ đó tăng tốc độ phát triển. @@ -151,3 +151,87 @@ - [x] **[S5.5.1] Built-in Date/Time Mapping:** Map `DateTimeInterface` sang `string/date-time`. - [x] **[S5.5.2] External Library Support (Optional):** Hỗ trợ mapping cho Uuid (Ramsey/Symfony) nếu class tồn tại. - [x] **[S5.5.3] Binary/File Mapping:** Map các class UploadedFile phổ biến sang `string/binary`. + +### [Epic 6] Hỗ trợ PHP 8+ Attributes (Attributes-based Annotation) +- **Trạng thái:** Done +- **Chủ sở hữu:** Fullstack Developer (User) +- **Tóm tắt:** Bổ sung cơ chế khai báo metadata sử dụng PHP 8 Attributes song hành cùng PHPDoc. +- **Giả thuyết Lợi ích (Benefit Hypothesis):** Giúp lập trình viên viết code chuẩn PHP hiện đại, tận dụng tính năng tự động gợi ý (autocomplete) của IDE, phát hiện sớm lỗi chính tả và loại bỏ sự phụ thuộc quá lớn vào việc phân tích chuỗi text trong DocBlock. +- **Tiêu chí chấp nhận (Acceptance Criteria):** + - Định nghĩa đầy đủ các class Attributes tương đương với các PHPDoc tags hiện tại. + - Bộ phân tích AST thu thập chính xác Attributes từ Class, Method, Property và Method Parameters. + - Thực hiện đúng chiến lược gộp thông minh (Smart Merge) khi khai báo song song. + - Toàn bộ unit tests viết cho Attributes và cơ chế gộp đều pass. + +## 2. Program Backlog (Features) (Tiếp theo) + +### Features cho [Epic 6] PHP 8+ Attributes Support +- [x] **[F6.1] Attribute Definitions:** Định nghĩa các class Attribute tương ứng trong `src/Attributes/`. +- [x] **[F6.2] AST Attribute Extraction:** Trích xuất các Attribute từ Node AST thông qua PHP-Parser. +- [x] **[F6.3] Smart Merge Engine:** Engine gộp dữ liệu từ PHPDoc và Attributes theo thứ tự ưu tiên. + +## 3. Team Backlog (User Stories) (Tiếp theo) + +### Stories cho [F6.1] Attribute Definitions +- [x] **[S6.1.1] Core Routing Attributes:** Định nghĩa `#[Route]`, `#[Tag]`, `#[OperationId]`, `#[Deprecated]`. +- [x] **[S6.1.2] Parameter Attributes:** Định nghĩa `#[QueryParam]`, `#[PathParam]`, `#[HeaderParam]`, `#[CookieParam]`, `#[RequestBody]`. +- [x] **[S6.1.3] Response & Schema Attributes:** Định nghĩa `#[Response]`, `#[Property]`, `#[Schema]`. + + +### Stories cho [F6.2] AST Attribute Extraction +- [x] **[S6.2.1] Class & Property Attribute Parser:** Quét và phân tích Attribute ở cấp Class (Controller/Model) và Class Property. +- [x] **[S6.2.2] Method & Parameter Attribute Parser:** Quét và phân tích Attribute ở cấp Method và Parameter của Method. + +### Stories cho [F6.3] Smart Merge Engine +- [x] **[S6.3.1] Override Engine for Single Values:** Ghi đè các trường đơn (summary, description, v.v.). +- [x] **[S6.3.2] Merge Engine for Collections:** Gộp các tag, security schemes. +- [x] **[S6.3.3] Keyed Collection Override:** Ghi đè các phần tử trùng lặp (trùng mã code 200, trùng tên tham số). + +### [Epic 7] Tạo các Framework Bridge (Laravel & Symfony Integration) +- **Trạng thái:** Done +- **Chủ sở hữu:** Fullstack Developer (User) +- **Tóm tắt:** Xây dựng cầu nối tích hợp với Laravel và Symfony giúp lập trình viên chạy phpswag mượt mà trên framework của họ. +- **Giả thuyết Lợi ích (Benefit Hypothesis):** Giúp giảm thiểu cấu hình thủ công cho dự án dùng Laravel/Symfony, tự động đăng ký route xem tài liệu (Swagger UI) và lệnh command line tích hợp. +- **Tiêu chí chấp nhận (Acceptance Criteria):** + - Laravel Bridge hỗ trợ Artisan command, config file và Route UI render `/api/docs`. + - Symfony Bridge hỗ trợ Console command và Bundle DI. + +### [Epic 8] Tích hợp Linter & OpenAPI Validator +- **Trạng thái:** Done +- **Chủ sở hữu:** Fullstack Developer (User) +- **Tóm tắt:** Bổ sung cơ chế validate đặc tả OpenAPI sinh ra để phát hiện sớm các lỗi cấu trúc nghiêm trọng. +- **Giả thuyết Lợi ích (Benefit Hypothesis):** Đảm bảo spec sinh ra luôn đúng chuẩn OpenAPI 3.0/3.1 trước khi xuất bản hoặc tích hợp vào hệ thống khác. +- **Tiêu chí chấp nhận (Acceptance Criteria):** + - Có cờ `--validate` tích hợp vào lệnh `generate`. + - Phát hiện lỗi logic (trùng route path, tham chiếu vòng sai cách, thiếu info bắt buộc) và hiển thị thông tin lỗi chi tiết. + +## 2. Program Backlog (Features) (Tiếp theo) + +### Features cho [Epic 7] Framework Bridges +- [x] **[F7.1] Laravel Integration:** Tích hợp ServiceProvider, config, Artisan command và route Swagger UI. +- [x] **[F7.2] Symfony Bundle:** Tích hợp Bundle, console command và DI container setup. + +### Features cho [Epic 8] Linter & Validator +- [x] **[F8.1] Native Structural Validator:** Tự kiểm tra các lỗi logic cấu trúc spec trong quá trình tạo. +- [x] **[F8.2] CLI Linter Integration:** Thêm cờ `--validate` trong CLI để validate đặc tả OpenAPI. + +## 3. Team Backlog (User Stories) (Tiếp theo) + +### Stories cho [F7.1] Laravel Integration +- [x] **[S7.1.1] Service Provider & Config:** Tạo `PhpSwagServiceProvider` và file cấu hình mẫu `phpswag.php`. +- [x] **[S7.1.2] Artisan Commands:** Đăng ký các command `phpswag:generate` và `phpswag:watch`. +- [x] **[S7.1.3] Swagger UI Controller:** Đăng ký route và render giao diện Swagger UI HTML trực tiếp. + +### Stories cho [F7.2] Symfony Bundle +- [x] **[S7.2.1] Symfony Bundle Setup:** Tạo class `PhpSwagBundle` và cấu hình DI extension. +- [x] **[S7.2.2] Symfony Console Command:** Tạo command tương đương `phpswag:generate` trong Symfony Console. + +### Stories cho [F8.1] Native Structural Validator +- [x] **[S8.1.1] Spec Integrity Check:** Kiểm tra tính toàn vẹn của YAML/JSON sinh ra (thiếu title, trùng endpoint). +- [x] **[S8.1.2] Class Reference Verification:** Kiểm tra xem các class DTO/Resource được dùng làm ref có thực sự tồn tại trong registry hay không. + +### Stories cho [F8.2] CLI Linter Integration +- [x] **[S8.2.1] Validate CLI Flag:** Cài đặt cờ `--validate` trong Console Command của phpswag. +- [x] **[S8.2.2] Diagnostic Output:** Định dạng và hiển thị kết quả kiểm lỗi rõ ràng cho lập trình viên. + + diff --git a/TECHNICAL_ANALYSIS.md b/TECHNICAL_ANALYSIS.md index 8ec16c9..750e992 100644 --- a/TECHNICAL_ANALYSIS.md +++ b/TECHNICAL_ANALYSIS.md @@ -255,3 +255,52 @@ class SchemaRegistry { - `array` hoặc `User[]` -> `type: array, items: { $ref: '#/components/schemas/User' }` - `string|null` -> `type: string, nullable: true` (OpenAPI 3.0) hoặc `type: [string, null]` (OpenAPI 3.1) - `User|Admin` -> `oneOf: [ { $ref: 'User' }, { $ref: 'Admin' } ]` + +--- + +## 10. Thiết kế kiến trúc các Tính năng Mới (Epic 6, 7, 8) + +### 10.1. PHP 8+ Attributes Support (Epic 6) + +#### a. Cấu trúc Attribute Classes +Hỗ trợ song song cả lớp cốt lõi và các lớp phím tắt: +- `#[Route(string $method, string $path)]` +- Lớp phím tắt: `#[Get(string $path)]`, `#[Post(string $path)]`, `#[Put(string $path)]`, `#[Delete(string $path)]` +- Định nghĩa tham số: `#[QueryParam(string $name, ?string $type = null, ?string $description = null, ...$validationConstraints)]` (Tương tự cho PathParam, HeaderParam, CookieParam). +- Định nghĩa Body & Response: `#[RequestBody(string $type, ?string $description = null)]`, `#[Response(int $code, string $type, ?string $description = null)]`. +- Ràng buộc Validation được truyền dưới dạng **named arguments** trực tiếp vào constructor của Attribute để tận dụng tối đa IDE autocomplete. + +#### b. Ánh xạ Attribute từ các thư viện ngoài (External Mapping) +Để tránh lập trình viên phải khai báo trùng lặp Attribute khi dùng framework, `phpswag` sẽ hỗ trợ phân tích và ánh xạ trực tiếp các Attribute Route của Symfony (`Symfony\Component\Routing\Annotation\Route`) sang cấu trúc OpenAPI tương ứng của mình. + +#### c. Chiến lược Gộp và Ưu tiên (Smart Merge Strategy) +Khi khai báo cả PHPDoc và Attributes: +- **Thuộc tính đơn** (summary, description, operationId, deprecated): Attributes ghi đè hoàn toàn PHPDoc. +- **Mảng/Danh sách** (tag, security): Gộp chung cả hai nguồn. +- **Keyed Collection** (query params trùng tên, code response trùng): Attribute ghi đè PHPDoc tại khóa (key) bị trùng lặp. + +### 10.2. Framework Bridges (Epic 7) [ĐÃ TRIỂN KHAI] + +#### a. Laravel Bridge (`src/Bridges/Laravel`) +- Cung cấp `PhpSwagServiceProvider` tự động đăng ký: + - Cấu hình từ file `config/phpswag.php` (cho phép tùy biến paths, output, format, cache, server UI path). + - Artisan commands: `php artisan phpswag:generate` (hỗ trợ cờ `--validate`). + - Route phục vụ Swagger UI (mặc định `/api/docs`). +- **Cơ chế render UI:** + - Route được đăng ký tự động trả về một trang Swagger UI HTML sử dụng thư viện CDN giúp giao diện hiển thị nhanh, đẹp và đồng nhất. + +#### b. Symfony Bridge (`src/Bridges/Symfony`) +- Cung cấp `PhpSwagBundle` tự động đăng ký Dependency Injection. +- Cung cấp Console Command `bin/console phpswag:generate` (hỗ trợ cờ `--validate`). + +### 10.3. Linter & OpenAPI Validator (Epic 8) [ĐÃ TRIỂN KHAI] + +#### a. Native Structural Validator (Bộ kiểm lỗi nội bộ - `src/Validation/Validator.php`) +- Tự động chạy kiểm tra tính toàn vẹn của spec: + - Kiểm tra xem các Class tham chiếu trong `@response` hay `@body` (thông qua `$ref`) có thực sự tồn tại trong registry/components.schemas hay không. + - Kiểm tra các trường thông tin bắt buộc của OpenAPI spec (như `openapi`, `info`, `title`, `version`). + - Cảnh báo nếu không định nghĩa endpoint nào trong file spec. + +#### b. Xử lý lỗi linh hoạt (Contextual Error Handling) +- Khi chạy lệnh **`generate`** kèm cờ `--validate`: In chi tiết các lỗi phát hiện và dừng quá trình biên dịch (trả về mã lỗi khác `0`) nếu phát hiện spec không hợp lệ. + diff --git a/composer.json b/composer.json index 8685356..b92d95e 100644 --- a/composer.json +++ b/composer.json @@ -22,9 +22,15 @@ "symfony/yaml": "^6.0" }, "require-dev": { + "illuminate/console": "^10.0", + "illuminate/routing": "^10.0", + "illuminate/support": "^10.0", "phpstan/phpstan": "^2.2", "phpunit/phpunit": "^10.0", - "squizlabs/php_codesniffer": "^4.0" + "squizlabs/php_codesniffer": "^4.0", + "symfony/config": "^6.0", + "symfony/dependency-injection": "^6.0", + "symfony/http-kernel": "^6.0" }, "config": { "sort-packages": true diff --git a/phpstan.neon b/phpstan.neon index 219ffc8..69ec8ff 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,6 @@ parameters: paths: - src - level: 7 + bootstrapFiles: + - tests/phpstan-bootstrap.php diff --git a/src/Attributes/AbstractParameter.php b/src/Attributes/AbstractParameter.php new file mode 100644 index 0000000..905dcce --- /dev/null +++ b/src/Attributes/AbstractParameter.php @@ -0,0 +1,24 @@ +|null $enum + */ + public function __construct( + public ?string $type = null, + public ?string $description = null, + public ?float $minimum = null, + public ?float $maximum = null, + public ?int $minLength = null, + public ?int $maxLength = null, + public ?string $pattern = null, + public ?string $format = null, + public mixed $default = null, + public mixed $example = null, + public ?array $enum = null + ) { + } +} diff --git a/src/Attributes/AttributeMapper.php b/src/Attributes/AttributeMapper.php new file mode 100644 index 0000000..0490711 --- /dev/null +++ b/src/Attributes/AttributeMapper.php @@ -0,0 +1,195 @@ +docCollector = $docCollector; + } + + /** + * Maps a parsed PHP 8 Attribute into a normalized tag data array. + * + * @param array{class: string, arguments: array, line: int, file: string} $attr + * @return array> + */ + public function map(array $attr): array + { + $class = $attr['class']; + $args = $attr['arguments']; + $file = $attr['file']; + $line = $attr['line']; + + $tags = []; + + switch ($class) { + case Route::class: + $method = $args['method'] ?? 'GET'; + $path = $args['path'] ?? '/'; + $tags[] = [ + 'name' => '@route', + 'value' => strtoupper($method) . ' ' . $path, + 'file' => $file, + 'line' => $line + ]; + break; + + case Get::class: + case Post::class: + case Put::class: + case Delete::class: + $method = strtoupper(basename(str_replace('\\', '/', $class))); + $path = $args['path'] ?? '/'; + $tags[] = [ + 'name' => '@route', + 'value' => "$method $path", + 'file' => $file, + 'line' => $line + ]; + break; + + case 'Symfony\Component\Routing\Annotation\Route': + case 'Symfony\Component\Routing\Attribute\Route': + $path = $args['path'] ?? '/'; + $methods = $args['methods'] ?? ['GET']; + if (is_string($methods)) { + $methods = [$methods]; + } + $method = !empty($methods) ? $methods[0] : 'GET'; + $tags[] = [ + 'name' => '@route', + 'value' => strtoupper($method) . ' ' . $path, + 'file' => $file, + 'line' => $line + ]; + break; + + case Tag::class: + $name = $args['name'] ?? ''; + $tags[] = [ + 'name' => '@tag', + 'value' => $name, + 'file' => $file, + 'line' => $line + ]; + break; + + case OperationId::class: + $tags[] = [ + 'name' => '@operationId', + 'value' => $args['id'] ?? '', + 'file' => $file, + 'line' => $line + ]; + break; + + case Deprecated::class: + $tags[] = [ + 'name' => '@deprecated', + 'value' => '', + 'file' => $file, + 'line' => $line + ]; + break; + + case Response::class: + $code = $args['code'] ?? '200'; + $type = $args['type'] ?? 'string'; + $desc = $args['description'] ?? ''; + $value = "$code $type"; + if ($desc !== '') { + $value .= ' ' . $desc; + } + $tags[] = [ + 'name' => '@response', + 'value' => $value, + 'file' => $file, + 'line' => $line + ]; + break; + + case RequestBody::class: + $descArray = ['description' => $args['description'] ?? '']; + foreach ($args as $k => $v) { + if (!in_array($k, ['type', 'description'], true) && $v !== null) { + $descArray[$k] = $v; + } + } + $tags[] = [ + 'name' => '@body', + 'type' => $this->docCollector->parseType($args['type'] ?? 'mixed'), + 'description' => $descArray, + 'file' => $file, + 'line' => $line + ]; + break; + + case QueryParam::class: + case PathParam::class: + case HeaderParam::class: + case CookieParam::class: + $paramName = substr(basename(str_replace('\\', '/', $class)), 0, -5); + $tagName = '@' . strtolower($paramName); + $tag = [ + 'name' => $tagName, + 'type' => $this->docCollector->parseType($args['type'] ?? 'string'), + 'propertyName' => $args['name'] ?? '', + 'description' => $args['description'] ?? '', + 'file' => $file, + 'line' => $line + ]; + foreach ($args as $k => $v) { + if (!in_array($k, ['name', 'type', 'description'], true) && $v !== null) { + $tag[$k] = $v; + } + } + $tags[] = $tag; + break; + + case Property::class: + $descArray = ['description' => $args['description'] ?? '']; + foreach ($args as $k => $v) { + if (!in_array($k, ['name', 'type', 'description', 'required'], true) && $v !== null) { + $descArray[$k] = $v; + } + } + $tags[] = [ + 'name' => '@property', + 'type' => $this->docCollector->parseType($args['type'] ?? 'mixed'), + 'propertyName' => $args['name'] ?? null, + 'description' => $descArray, + 'explicitRequired' => $args['required'] ?? null, + 'file' => $file, + 'line' => $line + ]; + break; + + case Schema::class: + if (isset($args['title'])) { + $tags[] = [ + 'name' => '@title', + 'value' => $args['title'], + 'file' => $file, + 'line' => $line + ]; + } + if (isset($args['description'])) { + $tags[] = [ + 'name' => '@description', + 'value' => $args['description'], + 'file' => $file, + 'line' => $line + ]; + } + break; + } + + return $tags; + } +} diff --git a/src/Attributes/AttributeParser.php b/src/Attributes/AttributeParser.php new file mode 100644 index 0000000..f282ceb --- /dev/null +++ b/src/Attributes/AttributeParser.php @@ -0,0 +1,164 @@ +> */ + private static array $parameterMaps = [ + 'PhpSwag\Attributes\Route' => ['method', 'path'], + 'PhpSwag\Attributes\Get' => ['path'], + 'PhpSwag\Attributes\Post' => ['path'], + 'PhpSwag\Attributes\Put' => ['path'], + 'PhpSwag\Attributes\Delete' => ['path'], + 'PhpSwag\Attributes\Tag' => ['name', 'description'], + 'PhpSwag\Attributes\OperationId' => ['id'], + 'PhpSwag\Attributes\Deprecated' => [], + 'PhpSwag\Attributes\Response' => ['code', 'type', 'description'], + 'PhpSwag\Attributes\RequestBody' => [ + 'type', 'description', 'minimum', 'maximum', 'minLength', + 'maxLength', 'pattern', 'format', 'default', 'example', 'enum' + ], + 'PhpSwag\Attributes\QueryParam' => [ + 'name', 'type', 'description', 'minimum', 'maximum', 'minLength', + 'maxLength', 'pattern', 'format', 'default', 'example', 'enum' + ], + 'PhpSwag\Attributes\PathParam' => [ + 'name', 'type', 'description', 'minimum', 'maximum', 'minLength', + 'maxLength', 'pattern', 'format', 'default', 'example', 'enum' + ], + 'PhpSwag\Attributes\HeaderParam' => [ + 'name', 'type', 'description', 'minimum', 'maximum', 'minLength', + 'maxLength', 'pattern', 'format', 'default', 'example', 'enum' + ], + 'PhpSwag\Attributes\CookieParam' => [ + 'name', 'type', 'description', 'minimum', 'maximum', 'minLength', + 'maxLength', 'pattern', 'format', 'default', 'example', 'enum' + ], + 'PhpSwag\Attributes\Property' => [ + 'name', 'type', 'description', 'minimum', 'maximum', 'minLength', + 'maxLength', 'pattern', 'format', 'default', 'example', 'enum', 'required' + ], + 'PhpSwag\Attributes\Schema' => ['title', 'description'], + 'Symfony\Component\Routing\Annotation\Route' => ['path'], + 'Symfony\Component\Routing\Attribute\Route' => ['path'], + ]; + + /** + * @param array $attrGroups + * @return array, line: int, file: string}> + */ + public function parse(array $attrGroups, NameResolver $nameResolver, string $filePath): array + { + $parsed = []; + + foreach ($attrGroups as $group) { + foreach ($group->attrs as $attr) { + if ($attr->name instanceof \PhpParser\Node\Name\FullyQualified) { + $fqcn = $attr->name->toString(); + } else { + $fqcn = $nameResolver->resolve($attr->name->toString()); + } + + if (!isset(self::$parameterMaps[$fqcn])) { + // Skip attributes that are not owned or mapped by PhpSwag + continue; + } + + $arguments = []; + $paramMap = self::$parameterMaps[$fqcn]; + + foreach ($attr->args as $index => $arg) { + $value = $this->evaluateExpression($arg->value, $nameResolver); + if ($arg->name !== null) { + // Named argument + $arguments[$arg->name->toString()] = $value; + } else { + // Positional argument + $paramName = $paramMap[$index] ?? null; + if ($paramName !== null) { + $arguments[$paramName] = $value; + } + } + } + + $parsed[] = [ + 'class' => $fqcn, + 'arguments' => $arguments, + 'line' => $attr->getStartLine(), + 'file' => $filePath + ]; + } + } + + return $parsed; + } + + private function evaluateExpression(Expr $expr, NameResolver $nameResolver): mixed + { + if ($expr instanceof String_) { + return $expr->value; + } + if ($expr instanceof LNumber) { + return $expr->value; + } + if ($expr instanceof DNumber) { + return $expr->value; + } + if ($expr instanceof ConstFetch) { + $name = strtolower($expr->name->toString()); + if ($name === 'true') { + return true; + } + if ($name === 'false') { + return false; + } + if ($name === 'null') { + return null; + } + } + if ($expr instanceof Array_) { + $result = []; + foreach ($expr->items as $item) { + if ($item === null) { + continue; + } + $val = $this->evaluateExpression($item->value, $nameResolver); + if ($item->key !== null) { + $key = $this->evaluateExpression($item->key, $nameResolver); + $result[$key] = $val; + } else { + $result[] = $val; + } + } + return $result; + } + if ($expr instanceof ClassConstFetch) { + $isName = $expr->class instanceof \PhpParser\Node\Name; + $isClassId = $expr->name instanceof Identifier; + if ($isName && $isClassId && strtolower($expr->name->toString()) === 'class') { + return $nameResolver->resolve($expr->class->toString()); + } + } + if ($expr instanceof UnaryMinus) { + return -$this->evaluateExpression($expr->expr, $nameResolver); + } + if ($expr instanceof UnaryPlus) { + return $this->evaluateExpression($expr->expr, $nameResolver); + } + return null; + } +} diff --git a/src/Attributes/BaseParameter.php b/src/Attributes/BaseParameter.php new file mode 100644 index 0000000..001873e --- /dev/null +++ b/src/Attributes/BaseParameter.php @@ -0,0 +1,38 @@ +|null $enum + */ + public function __construct( + public string $name, + ?string $type = null, + ?string $description = null, + ?float $minimum = null, + ?float $maximum = null, + ?int $minLength = null, + ?int $maxLength = null, + ?string $pattern = null, + ?string $format = null, + mixed $default = null, + mixed $example = null, + ?array $enum = null + ) { + parent::__construct( + type: $type, + description: $description, + minimum: $minimum, + maximum: $maximum, + minLength: $minLength, + maxLength: $maxLength, + pattern: $pattern, + format: $format, + default: $default, + example: $example, + enum: $enum + ); + } +} diff --git a/src/Attributes/CookieParam.php b/src/Attributes/CookieParam.php new file mode 100644 index 0000000..e964674 --- /dev/null +++ b/src/Attributes/CookieParam.php @@ -0,0 +1,10 @@ +|null $enum + */ + public function __construct( + public ?string $name = null, + ?string $type = null, + ?string $description = null, + ?float $minimum = null, + ?float $maximum = null, + ?int $minLength = null, + ?int $maxLength = null, + ?string $pattern = null, + ?string $format = null, + mixed $default = null, + mixed $example = null, + ?array $enum = null, + public ?bool $required = null + ) { + parent::__construct( + type: $type, + description: $description, + minimum: $minimum, + maximum: $maximum, + minLength: $minLength, + maxLength: $maxLength, + pattern: $pattern, + format: $format, + default: $default, + example: $example, + enum: $enum + ); + } +} diff --git a/src/Attributes/Put.php b/src/Attributes/Put.php new file mode 100644 index 0000000..f1eb346 --- /dev/null +++ b/src/Attributes/Put.php @@ -0,0 +1,14 @@ +|null $enum + */ + public function __construct( + ?string $type = null, + ?string $description = null, + ?float $minimum = null, + ?float $maximum = null, + ?int $minLength = null, + ?int $maxLength = null, + ?string $pattern = null, + ?string $format = null, + mixed $default = null, + mixed $example = null, + ?array $enum = null + ) { + parent::__construct( + type: $type, + description: $description, + minimum: $minimum, + maximum: $maximum, + minLength: $minLength, + maxLength: $maxLength, + pattern: $pattern, + format: $format, + default: $default, + example: $example, + enum: $enum + ); + } +} diff --git a/src/Attributes/Response.php b/src/Attributes/Response.php new file mode 100644 index 0000000..8119b00 --- /dev/null +++ b/src/Attributes/Response.php @@ -0,0 +1,19 @@ +error('Laravel phpswag configuration not found. Did you publish it?'); + return Command::FAILURE; + } + + $paths = $config['paths'] ?? []; + if (empty($paths)) { + $this->error('No paths defined in phpswag configuration.'); + return Command::FAILURE; + } + + $outputPath = $config['output'] ?? public_path('swagger.yaml'); + $format = strtolower($config['format'] ?? 'yaml'); + + try { + $core = Core::createDefault(); + + // Apply global metadata from config if not defined in code + if (!empty($config['title'])) { + $core->setTitle($config['title']); + } + if (!empty($config['version'])) { + $core->setApiVersion($config['version']); + } + if (!empty($config['description'])) { + $core->setDescription($config['description']); + } + if (!empty($config['host'])) { + $core->setServers([['url' => $config['host']]]); + } + + // Apply Cache configuration + if (!empty($config['cache'])) { + $cacheFile = $config['cache_file'] ?? storage_path('framework/cache/phpswag-cache'); + $core->enableCache($cacheFile); + } + + // Perform validation if flag is set + if ($this->option('validate')) { + $specArray = $core->generateSpecArray($paths); + $validator = new Validator(); + $errors = $validator->validate($specArray); + if (!empty($errors)) { + $this->error('❌ OpenAPI Validation Failed:'); + foreach ($errors as $error) { + $this->line(" - $error"); + } + return Command::FAILURE; + } + $this->info('✅ OpenAPI Validation Passed!'); + } + + // Generate output + if ($format === 'json') { + $result = $core->generateJson($paths); + } else { + $result = $core->generateYaml($paths); + } + + // Write output file + $dir = dirname($outputPath); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + file_put_contents($outputPath, $result); + $this->info("Documentation generated successfully to $outputPath"); + + return Command::SUCCESS; + } catch (\Exception $e) { + $this->error("Error generating documentation: " . $e->getMessage()); + return Command::FAILURE; + } + } +} diff --git a/src/Bridges/Laravel/PhpSwagServiceProvider.php b/src/Bridges/Laravel/PhpSwagServiceProvider.php new file mode 100644 index 0000000..e5d4111 --- /dev/null +++ b/src/Bridges/Laravel/PhpSwagServiceProvider.php @@ -0,0 +1,97 @@ +app->runningInConsole()) { + $this->publishes([ + __DIR__ . '/config/phpswag.php' => config_path('phpswag.php'), + ], 'phpswag-config'); + } + + $this->registerRoutes(); + } + + /** + * Register services. + * + * @return void + */ + public function register(): void + { + $this->mergeConfigFrom( + __DIR__ . '/config/phpswag.php', + 'phpswag' + ); + + $this->commands([ + GenerateCommand::class, + ]); + } + + /** + * Register Swagger UI routes. + * + * @return void + */ + protected function registerRoutes(): void + { + $enabled = config('phpswag.swagger_ui', true); + if (!$enabled) { + return; + } + + $path = config('phpswag.swagger_ui_path', '/api/docs'); + $specOutput = config('phpswag.output'); + + // Try to figure out relative URL for swagger spec file + // e.g. if output is public_path('swagger.yaml'), URL is /swagger.yaml + $specUrl = '/swagger.yaml'; + if ($specOutput && is_string($specOutput)) { + $publicPath = public_path(); + if (str_starts_with($specOutput, $publicPath)) { + $relative = substr($specOutput, strlen($publicPath)); + $specUrl = '/' . ltrim(str_replace('\\', '/', $relative), '/'); + } + } + + Route::get($path, function () use ($specUrl) { + $html = << + + + + + API Documentation + + + +
+ + + + +HTML; + return response($html, 200, ['Content-Type' => 'text/html']); + }); + } +} diff --git a/src/Bridges/Laravel/config/phpswag.php b/src/Bridges/Laravel/config/phpswag.php new file mode 100644 index 0000000..c7737a2 --- /dev/null +++ b/src/Bridges/Laravel/config/phpswag.php @@ -0,0 +1,62 @@ + [ + app_path(), + ], + + /* + |-------------------------------------------------------------------------- + | Output Configuration + |-------------------------------------------------------------------------- + | + | Specify the output path and file format ('yaml' or 'json'). + | + */ + 'output' => public_path('swagger.yaml'), + 'format' => 'yaml', + + /* + |-------------------------------------------------------------------------- + | API Metadata Defaults + |-------------------------------------------------------------------------- + | + | These values are used if not defined explicitly in your docblocks or attributes. + | + */ + 'title' => env('APP_NAME', 'Laravel API'), + 'version' => '1.0.0', + 'description' => 'Laravel API Documentation generated by phpswag', + 'host' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Caching Configuration + |-------------------------------------------------------------------------- + | + | Enable caching to speed up the specification generation on subsequent runs. + | + */ + 'cache' => false, + 'cache_file' => storage_path('framework/cache/phpswag-cache'), + + /* + |-------------------------------------------------------------------------- + | Swagger UI Settings + |-------------------------------------------------------------------------- + | + | Enable the built-in Swagger UI route to view your API documentation. + | + */ + 'swagger_ui' => true, + 'swagger_ui_path' => '/api/docs', +]; diff --git a/src/Bridges/Symfony/Command/GenerateCommand.php b/src/Bridges/Symfony/Command/GenerateCommand.php new file mode 100644 index 0000000..b08978f --- /dev/null +++ b/src/Bridges/Symfony/Command/GenerateCommand.php @@ -0,0 +1,138 @@ +parameterBag = $parameterBag; + } + + /** + * Configures the current command. + * + * @return void + */ + protected function configure(): void + { + $this + ->setName('phpswag:generate') + ->setDescription('Generate OpenAPI documentation from PHP source code') + ->addOption('validate', null, InputOption::VALUE_NONE, 'Validate the generated specification'); + } + + /** + * Executes the current command. + * + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + if (!$this->parameterBag->has('phpswag.paths')) { + $output->writeln('phpswag configuration not loaded.'); + return Command::FAILURE; + } + + /** @var array $paths */ + $paths = $this->parameterBag->get('phpswag.paths'); + /** @var string $outputPath */ + $outputPath = $this->parameterBag->get('phpswag.output'); + /** @var string $formatVal */ + $formatVal = $this->parameterBag->get('phpswag.format'); + $format = strtolower($formatVal); + + if (empty($paths)) { + $output->writeln('No paths defined in phpswag configuration.'); + return Command::FAILURE; + } + + try { + $core = Core::createDefault(); + + // Set metadata from configuration if defined + if ($this->parameterBag->has('phpswag.title')) { + /** @var string $title */ + $title = $this->parameterBag->get('phpswag.title'); + $core->setTitle($title); + } + if ($this->parameterBag->has('phpswag.version')) { + /** @var string $version */ + $version = $this->parameterBag->get('phpswag.version'); + $core->setApiVersion($version); + } + if ($this->parameterBag->has('phpswag.description')) { + /** @var string|null $description */ + $description = $this->parameterBag->get('phpswag.description'); + $core->setDescription($description); + } + if ($this->parameterBag->has('phpswag.host')) { + /** @var string $host */ + $host = $this->parameterBag->get('phpswag.host'); + $core->setServers([['url' => $host]]); + } + + // Set cache if enabled + if ($this->parameterBag->get('phpswag.cache')) { + /** @var string $cacheFile */ + $cacheFile = $this->parameterBag->get('phpswag.cache_file'); + $core->enableCache($cacheFile); + } + + // Validate if requested + if ($input->getOption('validate')) { + $specArray = $core->generateSpecArray($paths); + $validator = new Validator(); + $errors = $validator->validate($specArray); + if (!empty($errors)) { + $output->writeln(' ❌ OpenAPI Validation Failed: '); + foreach ($errors as $error) { + $output->writeln(sprintf(' - %s ', $error)); + } + return Command::FAILURE; + } + $output->writeln(' ✅ OpenAPI Validation Passed! '); + } + + // Generate specification + if ($format === 'json') { + $result = $core->generateJson($paths); + } else { + $result = $core->generateYaml($paths); + } + + // Write output file + $dir = dirname($outputPath); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + file_put_contents($outputPath, $result); + $output->writeln(sprintf('Documentation generated successfully to %s', $outputPath)); + + return Command::SUCCESS; + } catch (\Exception $e) { + $output->writeln(sprintf('Error generating documentation: %s', $e->getMessage())); + return Command::FAILURE; + } + } +} diff --git a/src/Bridges/Symfony/DependencyInjection/Configuration.php b/src/Bridges/Symfony/DependencyInjection/Configuration.php new file mode 100644 index 0000000..ff718c1 --- /dev/null +++ b/src/Bridges/Symfony/DependencyInjection/Configuration.php @@ -0,0 +1,56 @@ +getRootNode(); + + $rootNode + ->children() + ->arrayNode('paths') + ->defaultValue(['%kernel.project_dir%/src']) + ->prototype('scalar')->end() + // @phpstan-ignore-next-line + ->end() + ->scalarNode('output') + ->defaultValue('%kernel.project_dir%/public/swagger.yaml') + ->end() + ->scalarNode('format') + ->defaultValue('yaml') + ->end() + ->scalarNode('title') + ->defaultValue('Symfony API') + ->end() + ->scalarNode('version') + ->defaultValue('1.0.0') + ->end() + ->scalarNode('description') + ->defaultValue('Symfony API Documentation generated by phpswag') + ->end() + ->scalarNode('host') + ->defaultValue('http://localhost') + ->end() + ->booleanNode('cache') + ->defaultFalse() + ->end() + ->scalarNode('cache_file') + ->defaultValue('%kernel.project_dir%/var/cache/phpswag-cache') + ->end() + ->end(); + + return $treeBuilder; + } +} diff --git a/src/Bridges/Symfony/DependencyInjection/PhpSwagExtension.php b/src/Bridges/Symfony/DependencyInjection/PhpSwagExtension.php new file mode 100644 index 0000000..0dcc7bb --- /dev/null +++ b/src/Bridges/Symfony/DependencyInjection/PhpSwagExtension.php @@ -0,0 +1,32 @@ + $configs + * @param ContainerBuilder $container + * @return void + */ + public function load(array $configs, ContainerBuilder $container): void + { + $configuration = new Configuration(); + $config = $this->processConfiguration($configuration, $configs); + + $container->setParameter('phpswag.paths', $config['paths']); + $container->setParameter('phpswag.output', $config['output']); + $container->setParameter('phpswag.format', $config['format']); + $container->setParameter('phpswag.title', $config['title']); + $container->setParameter('phpswag.version', $config['version']); + $container->setParameter('phpswag.description', $config['description']); + $container->setParameter('phpswag.host', $config['host']); + $container->setParameter('phpswag.cache', $config['cache']); + $container->setParameter('phpswag.cache_file', $config['cache_file']); + } +} diff --git a/src/Bridges/Symfony/PhpSwagBundle.php b/src/Bridges/Symfony/PhpSwagBundle.php new file mode 100644 index 0000000..36f9ac3 --- /dev/null +++ b/src/Bridges/Symfony/PhpSwagBundle.php @@ -0,0 +1,9 @@ +addOption('description', null, InputOption::VALUE_REQUIRED, 'API Description') ->addOption('host', null, InputOption::VALUE_REQUIRED, 'API Host/Server URL') ->addOption('cache', null, InputOption::VALUE_NONE, 'Enable caching to speed up generation') - ->addOption('cache-file', null, InputOption::VALUE_REQUIRED, 'Cache file path', './.phpswag-cache'); + ->addOption('cache-file', null, InputOption::VALUE_REQUIRED, 'Cache file path', './.phpswag-cache') + ->addOption('validate', null, InputOption::VALUE_NONE, 'Validate the generated OpenAPI specification'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -117,6 +118,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int $core->setServers([['url' => $host]]); } + // Validate if requested + if ($input->getOption('validate')) { + $specArray = $core->generateSpecArray($paths); + $validator = new \PhpSwag\Validation\Validator(); + $errors = $validator->validate($specArray); + if (!empty($errors)) { + $output->writeln(' ❌ OpenAPI Validation Failed: '); + foreach ($errors as $error) { + $output->writeln(sprintf(' - %s ', $error)); + } + return Command::FAILURE; + } + $output->writeln(' ✅ OpenAPI Validation Passed! '); + } + // Format $format = strtolower($input->getOption('format')); if ($format === 'yaml' && isset($config['format'])) { diff --git a/src/Core.php b/src/Core.php index 111e799..e16c028 100644 --- a/src/Core.php +++ b/src/Core.php @@ -13,6 +13,7 @@ use PhpParser\NodeTraverser; use PhpSwag\IR\RouteDefinition; use PhpSwag\IR\SchemaDefinition; +use PhpSwag\Attributes\AttributeParser; class Core { @@ -25,6 +26,7 @@ class Core private ?Cache\CacheInterface $cache = null; private TypeAnalyzer $typeAnalyzer; private Metadata\GlobalMetadataDiscoverer $metadataDiscoverer; + private AttributeParser $attributeParser; /** @var array */ private array $tagParsers = []; @@ -70,6 +72,7 @@ public function __construct( $this->generator = $generator ?? new Generator($this->schemaRegistry); $this->typeAnalyzer = $typeAnalyzer ?? new TypeAnalyzer(); $this->metadataDiscoverer = $metadataDiscoverer ?? new Metadata\GlobalMetadataDiscoverer($this->docCollector); + $this->attributeParser = new AttributeParser(); $this->registerTagParser(new TagParser\RouteTagParser()); $this->registerTagParser(new TagParser\ResponseTagParser($this->typeAnalyzer, $this->docCollector)); @@ -159,6 +162,16 @@ public function generateJson(array $paths): string return $this->generator->generateJson(); } + /** + * @param array $paths + * @return array + */ + public function generateSpecArray(array $paths): array + { + $this->analyze($paths); + return $this->generator->getSpecArray(); + } + /** * @param array $paths */ @@ -505,7 +518,28 @@ private function analyzeClass(string $fqcn, Class_|Trait_|Enum_|Interface_ $stmt $docComment = $stmt->getDocComment()?->getText() ?? ''; $docStartLine = $stmt->getDocComment()?->getStartLine(); - $tags = $this->docCollector->collectTags($docComment, $docStartLine, $this->currentlyAnalyzingFile); + $phpDocTags = $this->docCollector->collectTags($docComment, $docStartLine, $this->currentlyAnalyzingFile); + + $attrs = $this->attributeParser->parse($stmt->attrGroups, $nameResolver, $this->currentlyAnalyzingFile); + foreach ($attrs as $attr) { + if ($attr['class'] === 'PhpSwag\Attributes\Tag') { + $tagName = $attr['arguments']['name'] ?? ''; + $tagDesc = $attr['arguments']['description'] ?? null; + if ($tagName !== '') { + $tagData = ['name' => $tagName]; + if ($tagDesc !== null && $tagDesc !== '') { + $tagData['description'] = $tagDesc; + } + $hasTag = isset($this->globalTags[$tagName]); + $hasDesc = isset($tagData['description']); + $hasExistingDesc = isset($this->globalTags[$tagName]['description']); + if (!$hasTag || ($hasDesc && !$hasExistingDesc)) { + $this->globalTags[$tagName] = $tagData; + } + } + } + } + $tags = $this->mergeTagsAndAttributes($phpDocTags, $attrs); $context = new TagParser\SchemaContext($schema, $nameResolver); @@ -529,9 +563,34 @@ private function analyzeClass(string $fqcn, Class_|Trait_|Enum_|Interface_ $stmt // Third pass: property definitions from class member variables foreach ($stmt->stmts as $member) { if ($member instanceof Property) { - $propDoc = $member->getDocComment()?->getText() ?? ''; - $propStartLine = $member->getDocComment()?->getStartLine(); - $propTags = $this->docCollector->collectTags($propDoc, $propStartLine, $this->currentlyAnalyzingFile); + $propDoc = $member->getDocComment()?->getText() ?? ''; + $propStartLine = $member->getDocComment()?->getStartLine(); + $propPhpDocTags = $this->docCollector->collectTags( + $propDoc, + $propStartLine, + $this->currentlyAnalyzingFile + ); + + $propAttrs = $this->attributeParser->parse( + $member->attrGroups, + $nameResolver, + $this->currentlyAnalyzingFile + ); + + $propName = $member->props[0]->name->toString(); + foreach ($propAttrs as &$pAttr) { + if ($pAttr['class'] === Attributes\Property::class) { + if (!isset($pAttr['arguments']['name'])) { + $pAttr['arguments']['name'] = $propName; + } + if (!isset($pAttr['arguments']['type']) && $member->type !== null) { + $pAttr['arguments']['type'] = $this->resolveTypeHint($member->type, $nameResolver); + } + } + } + unset($pAttr); + + $propTags = $this->mergeTagsAndAttributes($propPhpDocTags, $propAttrs); $explicitRequired = null; foreach ($propTags as $t) { @@ -542,14 +601,17 @@ private function analyzeClass(string $fqcn, Class_|Trait_|Enum_|Interface_ $stmt } foreach ($propTags as $pTag) { - if ($pTag['name'] === '@var') { - $pTag['explicitRequired'] = $explicitRequired; + if ($pTag['name'] === '@var' || $pTag['name'] === '@property') { + $pTag['explicitRequired'] = $explicitRequired ?? $pTag['explicitRequired'] ?? null; $pTag['hasDefault'] = ($member->props[0]->default !== null); $pTag['typeHint'] = $member->type; - $pTag['propertyName'] = $member->props[0]->name->toString(); + if (empty($pTag['propertyName'])) { + $pTag['propertyName'] = $propName; + } - if (isset($this->schemaTagParsers['@var'])) { - $this->schemaTagParsers['@var']->parse($pTag, $context, $typeResolver); + $parserName = isset($this->schemaTagParsers[$pTag['name']]) ? $pTag['name'] : '@var'; + if (isset($this->schemaTagParsers[$parserName])) { + $this->schemaTagParsers[$parserName]->parse($pTag, $context, $typeResolver); } } } @@ -559,6 +621,7 @@ private function analyzeClass(string $fqcn, Class_|Trait_|Enum_|Interface_ $stmt $this->analyzeMethod( $member, $typeResolver, + $nameResolver, $context->classTags, $context->classSecurity, $context->classAccept, @@ -579,6 +642,7 @@ private function analyzeClass(string $fqcn, Class_|Trait_|Enum_|Interface_ $stmt private function analyzeMethod( ClassMethod $member, TypeResolver $typeResolver, + NameResolver $nameResolver, array $classTags = [], array $classSecurity = [], ?string $classAccept = null, @@ -586,7 +650,89 @@ private function analyzeMethod( ): void { $methodDoc = $member->getDocComment()?->getText() ?? ''; $methodStartLine = $member->getDocComment()?->getStartLine(); - $tags = $this->docCollector->collectTags($methodDoc, $methodStartLine, $this->currentlyAnalyzingFile); + $methodPhpDocTags = $this->docCollector->collectTags( + $methodDoc, + $methodStartLine, + $this->currentlyAnalyzingFile + ); + + $methodAttrs = $this->attributeParser->parse( + $member->attrGroups, + $nameResolver, + $this->currentlyAnalyzingFile + ); + + foreach ($methodAttrs as $attr) { + if ($attr['class'] === 'PhpSwag\Attributes\Tag') { + $tagName = $attr['arguments']['name'] ?? ''; + $tagDesc = $attr['arguments']['description'] ?? null; + if ($tagName !== '') { + $tagData = ['name' => $tagName]; + if ($tagDesc !== null && $tagDesc !== '') { + $tagData['description'] = $tagDesc; + } + $hasTag = isset($this->globalTags[$tagName]); + $hasDesc = isset($tagData['description']); + $hasExistingDesc = isset($this->globalTags[$tagName]['description']); + if (!$hasTag || ($hasDesc && !$hasExistingDesc)) { + $this->globalTags[$tagName] = $tagData; + } + } + } + } + + foreach ($member->params as $param) { + if (!$param->var instanceof Node\Expr\Variable || !is_string($param->var->name)) { + continue; + } + $paramName = $param->var->name; + $paramAttrs = $this->attributeParser->parse( + $param->attrGroups, + $nameResolver, + $this->currentlyAnalyzingFile + ); + + $type = $this->resolveTypeHint($param->type, $nameResolver); + + foreach ($paramAttrs as &$pAttr) { + if ( + in_array($pAttr['class'], [ + Attributes\QueryParam::class, + Attributes\PathParam::class, + Attributes\HeaderParam::class, + Attributes\CookieParam::class + ]) + ) { + if (!isset($pAttr['arguments']['name'])) { + $pAttr['arguments']['name'] = $paramName; + } + } + if ($pAttr['class'] === Attributes\RequestBody::class) { + if (!isset($pAttr['arguments']['type'])) { + $pAttr['arguments']['type'] = $type; + } + } + if ( + in_array($pAttr['class'], [ + Attributes\QueryParam::class, + Attributes\PathParam::class, + Attributes\HeaderParam::class, + Attributes\CookieParam::class + ]) + ) { + if (!isset($pAttr['arguments']['type'])) { + $pAttr['arguments']['type'] = $type; + } + } + } + unset($pAttr); + + foreach ($paramAttrs as $pAttr) { + $methodAttrs[] = $pAttr; + } + } + + $tags = $this->mergeTagsAndAttributes($methodPhpDocTags, $methodAttrs); $context = new TagParser\RouteContext($classTags); @@ -633,6 +779,15 @@ private function analyzeMethod( } $paramName = $param->var->name; + $paramAttrs = $this->attributeParser->parse( + $param->attrGroups, + $nameResolver, + $this->currentlyAnalyzingFile + ); + if (!empty($paramAttrs)) { + continue; + } + // Skip if already defined by explicit tags $exists = false; foreach ($context->parameters as $p) { @@ -645,17 +800,7 @@ private function analyzeMethod( continue; } - $type = 'mixed'; - if ($param->type instanceof Node\Identifier) { - $type = $param->type->toString(); - } elseif ($param->type instanceof Node\Name) { - $resolved = $param->type->getAttribute('resolvedName'); - if ($resolved) { - $type = '\\' . $resolved->toString(); - } else { - $type = $param->type->toString(); - } - } + $type = $this->resolveTypeHint($param->type, $nameResolver); $schema = $typeResolver->resolve( $this->docCollector->parseType($type), @@ -708,6 +853,108 @@ private function analyzeMethod( } } + /** + * Merges PHPDoc tags and parsed PHP 8 Attributes applying the smart merge strategy. + * + * @param array> $phpDocTags + * @param array, line: int, file: string}> $attrs + * @return array> + */ + private function mergeTagsAndAttributes(array $phpDocTags, array $attrs): array + { + $attributeMapper = new Attributes\AttributeMapper($this->docCollector); + $attrTags = []; + foreach ($attrs as $attr) { + $mapped = $attributeMapper->map($attr); + foreach ($mapped as $t) { + $attrTags[] = $t; + } + } + + if (empty($attrTags)) { + return $phpDocTags; + } + + $overriddenSingleValues = []; + $overriddenResponses = []; + $overriddenParameters = []; + $overriddenProperties = []; + $hasAttrRequestBody = false; + + foreach ($attrTags as $tag) { + $name = $tag['name']; + + if (in_array($name, ['@summary', '@description', '@operationId', '@deprecated', '@title'])) { + $overriddenSingleValues[$name] = true; + } + + if (in_array($name, ['@response', '@success', '@failure'])) { + if (preg_match('/^(default|\d+)/i', $tag['value'], $matches)) { + $overriddenResponses[strtolower($matches[1])] = true; + } + } + + if (in_array($name, ['@query', '@path', '@header', '@cookie'])) { + if (!empty($tag['propertyName'])) { + $overriddenParameters[$tag['propertyName']] = true; + } + } + + if (in_array($name, ['@property', '@var'])) { + if (!empty($tag['propertyName'])) { + $overriddenProperties[$tag['propertyName']] = true; + } + } + + if ($name === '@body') { + $hasAttrRequestBody = true; + } + } + + $filteredPhpDocTags = array_filter($phpDocTags, function ($tag) use ( + $overriddenSingleValues, + $overriddenResponses, + $overriddenParameters, + $overriddenProperties, + $hasAttrRequestBody + ) { + $name = $tag['name']; + + if (isset($overriddenSingleValues[$name])) { + return false; + } + + if (in_array($name, ['@response', '@success', '@failure'])) { + if (preg_match('/^(default|\d+)/i', $tag['value'], $matches)) { + $code = strtolower($matches[1]); + if (isset($overriddenResponses[$code])) { + return false; + } + } + } + + if (in_array($name, ['@query', '@path', '@header', '@cookie'])) { + if (!empty($tag['propertyName']) && isset($overriddenParameters[$tag['propertyName']])) { + return false; + } + } + + if (in_array($name, ['@property', '@var'])) { + if (!empty($tag['propertyName']) && isset($overriddenProperties[$tag['propertyName']])) { + return false; + } + } + + if ($name === '@body' && $hasAttrRequestBody) { + return false; + } + + return true; + }); + + return array_merge(array_values($filteredPhpDocTags), $attrTags); + } + public function setTitle(string $title): void { $this->cliOverrides['title'] = $title; @@ -751,6 +998,47 @@ public function setServers(array $servers): void } } + private function resolveTypeHint(?Node $typeNode, NameResolver $nameResolver): string + { + if ($typeNode === null) { + return 'mixed'; + } + if ($typeNode instanceof Node\Identifier) { + return $typeNode->toString(); + } + if ($typeNode instanceof Node\Name) { + $resolved = $typeNode->getAttribute('resolvedName'); + if ($resolved instanceof Node\Name) { + return '\\' . $resolved->toString(); + } + return $typeNode->toString(); + } + if ($typeNode instanceof Node\NullableType) { + return $this->resolveTypeHint($typeNode->type, $nameResolver); + } + if ($typeNode instanceof Node\UnionType) { + $types = []; + foreach ($typeNode->types as $subType) { + $resolvedSub = $this->resolveTypeHint($subType, $nameResolver); + if ($resolvedSub !== 'null') { + $types[] = $resolvedSub; + } + } + if (empty($types)) { + return 'mixed'; + } + return implode('|', $types); + } + if ($typeNode instanceof Node\IntersectionType) { + $types = []; + foreach ($typeNode->types as $subType) { + $types[] = $this->resolveTypeHint($subType, $nameResolver); + } + return implode('&', $types); + } + return 'mixed'; + } + public function setCache(Cache\CacheInterface $cache): void { $this->cache = $cache; diff --git a/src/Generator.php b/src/Generator.php index b99f6ce..c83b676 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -121,6 +121,14 @@ public function getRoutes(): array return $this->routes; } + /** + * @return array + */ + public function getSpecArray(): array + { + return $this->generateSpec(); + } + public function generateYaml(): string { $yaml = Yaml::dump($this->generateSpec(), 10, 2, Yaml::DUMP_NUMERIC_KEY_AS_STRING); diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php new file mode 100644 index 0000000..446a50e --- /dev/null +++ b/src/Validation/Validator.php @@ -0,0 +1,78 @@ + $spec + * @return array List of validation error/warning messages + */ + public function validate(array $spec): array + { + $errors = []; + + // 1. Basic Spec Integrity Check + if (empty($spec['openapi'])) { + $errors[] = "Missing 'openapi' version field."; + } + if (!isset($spec['info']) || !is_array($spec['info'])) { + $errors[] = "Missing 'info' object."; + } else { + if (empty($spec['info']['title'])) { + $errors[] = "Missing 'info.title' field."; + } + if (empty($spec['info']['version'])) { + $errors[] = "Missing 'info.version' field."; + } + } + + if (empty($spec['paths'])) { + $errors[] = "Warning: No paths/endpoints defined in the API specification."; + } + + // 2. Class Reference Verification ($ref check) + $refs = $this->collectRefs($spec); + $definedSchemas = array_keys($spec['components']['schemas'] ?? []); + + foreach ($refs as $ref) { + if (str_starts_with($ref, '#/components/schemas/')) { + $schemaName = substr($ref, strlen('#/components/schemas/')); + if (!in_array($schemaName, $definedSchemas, true)) { + $errors[] = sprintf( + "Unresolved schema reference: '%s' is referenced but not defined in components/schemas.", + $ref + ); + } + } + } + + return $errors; + } + + /** + * Recursively collects all $ref values from the spec array. + * + * @param mixed $value + * @return array + */ + private function collectRefs(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $refs = []; + foreach ($value as $k => $v) { + if ($k === '$ref' && is_string($v)) { + $refs[] = $v; + } else { + $refs = array_merge($refs, $this->collectRefs($v)); + } + } + + return $refs; + } +} diff --git a/tests/AttributesTest.php b/tests/AttributesTest.php new file mode 100644 index 0000000..77516a4 --- /dev/null +++ b/tests/AttributesTest.php @@ -0,0 +1,237 @@ +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + + // Check path and operations + $this->assertArrayHasKey('/users/{id}', $spec['paths']); + $getOp = $spec['paths']['/users/{id}']['get']; + $this->assertEquals(['Users'], $getOp['tags']); + + // Check query parameter + $params = $getOp['parameters']; + $statusParam = array_values(array_filter($params, fn($p) => $p['name'] === 'status'))[0]; + $this->assertEquals('query', $statusParam['in']); + $this->assertEquals('string', $statusParam['schema']['type']); + $this->assertEquals(['active', 'inactive'], $statusParam['schema']['enum']); + + // Check response mapping + $this->assertArrayHasKey('200', $getOp['responses']); + $this->assertEquals('Success response', $getOp['responses']['200']['description']); + $this->assertEquals( + '#/components/schemas/App_Models_User', + $getOp['responses']['200']['content']['application/json']['schema']['$ref'] + ); + + // Check schemas + $this->assertArrayHasKey('App_Models_User', $spec['components']['schemas']); + $userSchema = $spec['components']['schemas']['App_Models_User']; + $this->assertContains('id', $userSchema['required']); + + $props = $userSchema['properties']; + $this->assertEquals('integer', $props['id']['type']); + $this->assertEquals(1, $props['id']['minimum']); + $this->assertEquals('string', $props['email']['type']); + $this->assertEquals('email', $props['email']['format']); + } + + public function testAutoInferenceOnMethodSignature() + { + $core = new Core(); + $code = <<<'PHP' +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + + $postOp = $spec['paths']['/users/{id}']['post']; + + // Path param name inferred as 'id', type inferred as 'integer' + $params = $postOp['parameters']; + $this->assertCount(1, $params); + $idParam = $params[0]; + $this->assertEquals('id', $idParam['name']); + $this->assertEquals('path', $idParam['in']); + $this->assertEquals('integer', $idParam['schema']['type']); + $this->assertEquals('The path parameter ID', $idParam['description']); + + // RequestBody type inferred as App_Models_UserRequest + $this->assertArrayHasKey('requestBody', $postOp); + $this->assertEquals('Request body data', $postOp['requestBody']['description']); + $this->assertEquals( + '#/components/schemas/App_Models_UserRequest', + $postOp['requestBody']['content']['application/json']['schema']['$ref'] + ); + } + + public function testSmartMergeAndOverride() + { + $core = new Core(); + $code = <<<'PHP' +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + + $getOp = $spec['paths']['/users']['get']; + + // Single-value: summary and description are preserved from docblock if not in Attribute, + // but wait! Summary/description are not declared in attribute, so they fallback to PHPDoc. + $this->assertEquals('Old Summary', $getOp['summary']); + $this->assertEquals('Old description', $getOp['description']); + + // Collections: tags are merged + $this->assertContains('Accounts', $getOp['tags']); + $this->assertContains('ControllerTag', $getOp['tags']); + $this->assertContains('Users', $getOp['tags']); + + // Keyed override: response 200 is overridden by Attribute, response 400 is preserved from PHPDoc + $this->assertArrayHasKey('200', $getOp['responses']); + $this->assertEquals('New response description', $getOp['responses']['200']['description']); + $this->assertEquals( + '#/components/schemas/App_Models_User', + $getOp['responses']['200']['content']['application/json']['schema']['$ref'] + ); + + $this->assertArrayHasKey('400', $getOp['responses']); + $this->assertEquals('Bad Request', $getOp['responses']['400']['description']); + } + + public function testSymfonyRouteMapping() + { + $core = new Core(); + $code = <<<'PHP' +generateYaml([$tempFile]); + unlink($tempFile); + + $spec = Yaml::parse($yaml); + + $this->assertArrayHasKey('/items', $spec['paths']); + $this->assertArrayHasKey('get', $spec['paths']['/items']); + } +} diff --git a/tests/FrameworkBridgesTest.php b/tests/FrameworkBridgesTest.php new file mode 100644 index 0000000..ece6b46 --- /dev/null +++ b/tests/FrameworkBridgesTest.php @@ -0,0 +1,143 @@ + [ + 'paths' => ['src/'], + 'output' => 'web/swagger.yaml', + 'title' => 'Test Symfony Title' + ] + ]; + + $processed = $processor->processConfiguration($configuration, $configs); + + $this->assertEquals(['src/'], $processed['paths']); + $this->assertEquals('web/swagger.yaml', $processed['output']); + $this->assertEquals('Test Symfony Title', $processed['title']); + $this->assertEquals('yaml', $processed['format']); + $this->assertFalse($processed['cache']); + } + + public function testSymfonyExtensionLoadsParameters() + { + $container = new ContainerBuilder(); + $extension = new PhpSwagExtension(); + + $configs = [ + 'phpswag' => [ + 'paths' => ['src/Controllers'], + 'output' => 'public/spec.json', + 'format' => 'json' + ] + ]; + + $extension->load($configs, $container); + + $this->assertTrue($container->hasParameter('phpswag.paths')); + $this->assertEquals(['src/Controllers'], $container->getParameter('phpswag.paths')); + $this->assertEquals('public/spec.json', $container->getParameter('phpswag.output')); + $this->assertEquals('json', $container->getParameter('phpswag.format')); + } + + public function testSymfonyGenerateCommand() + { + $bag = $this->createMock(ParameterBagInterface::class); + $bag->method('has')->willReturnMap([ + ['phpswag.paths', true], + ['phpswag.title', true], + ['phpswag.version', true], + ['phpswag.description', true], + ['phpswag.host', true] + ]); + $bag->method('get')->willReturnMap([ + ['phpswag.paths', ['examples/App']], + ['phpswag.output', 'test-symfony-spec.yaml'], + ['phpswag.format', 'yaml'], + ['phpswag.title', 'Symfony Spec'], + ['phpswag.version', '2.0.0'], + ['phpswag.description', 'Generated in tests'], + ['phpswag.host', 'http://localhost:8000'], + ['phpswag.cache', false] + ]); + + $command = new SymfonyGenerateCommand($bag); + $application = new SymfonyApplication(); + $application->add($command); + + $tester = new CommandTester($application->find('phpswag:generate')); + $tester->execute([]); + + $this->assertEquals(0, $tester->getStatusCode()); + $this->assertStringContainsString('Documentation generated successfully to test-symfony-spec.yaml', $tester->getDisplay()); + + if (file_exists('test-symfony-spec.yaml')) { + unlink('test-symfony-spec.yaml'); + } + } + + public function testSymfonyGenerateCommandWithValidation() + { + $bag = $this->createMock(ParameterBagInterface::class); + $bag->method('has')->willReturnMap([ + ['phpswag.paths', true], + ['phpswag.title', true], + ['phpswag.version', true], + ['phpswag.description', true], + ['phpswag.host', true] + ]); + $bag->method('get')->willReturnMap([ + ['phpswag.paths', ['examples/App']], + ['phpswag.output', 'test-symfony-spec-val.yaml'], + ['phpswag.format', 'yaml'], + ['phpswag.title', 'Symfony Spec'], + ['phpswag.version', '2.0.0'], + ['phpswag.description', 'Generated in tests'], + ['phpswag.host', 'http://localhost:8000'], + ['phpswag.cache', false] + ]); + + $command = new SymfonyGenerateCommand($bag); + $application = new SymfonyApplication(); + $application->add($command); + + $tester = new CommandTester($application->find('phpswag:generate')); + $tester->execute(['--validate' => true]); + + // Should pass since examples/App is a valid OpenAPI specification + $this->assertEquals(0, $tester->getStatusCode()); + $this->assertStringContainsString('OpenAPI Validation Passed!', $tester->getDisplay()); + + if (file_exists('test-symfony-spec-val.yaml')) { + unlink('test-symfony-spec-val.yaml'); + } + } + + public function testLaravelServiceProviderInstantiation() + { + // Mock Laravel application container + $app = $this->createMock(\Illuminate\Contracts\Foundation\Application::class); + $app->method('runningInConsole')->willReturn(true); + + $provider = new \PhpSwag\Bridges\Laravel\PhpSwagServiceProvider($app); + + $this->assertInstanceOf(\Illuminate\Support\ServiceProvider::class, $provider); + } +} diff --git a/tests/LinterValidatorTest.php b/tests/LinterValidatorTest.php new file mode 100644 index 0000000..7dc5b8b --- /dev/null +++ b/tests/LinterValidatorTest.php @@ -0,0 +1,123 @@ + '3.0.0', + 'info' => [ + 'title' => 'Test API', + 'version' => '1.0.0', + ], + 'paths' => [ + '/users' => [ + 'get' => [ + 'responses' => [ + '200' => [ + 'description' => 'Success', + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/User' + ] + ] + ] + ] + ] + ] + ] + ], + 'components' => [ + 'schemas' => [ + 'User' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'] + ] + ] + ] + ] + ]; + + $validator = new Validator(); + $errors = $validator->validate($spec); + + $this->assertEmpty($errors); + } + + public function testMissingInfoAndOpenApiVersion() + { + $spec = [ + 'paths' => [] + ]; + + $validator = new Validator(); + $errors = $validator->validate($spec); + + $this->assertContains("Missing 'openapi' version field.", $errors); + $this->assertContains("Missing 'info' object.", $errors); + } + + public function testMissingTitleAndVersionInInfo() + { + $spec = [ + 'openapi' => '3.0.0', + 'info' => [], + 'paths' => [] + ]; + + $validator = new Validator(); + $errors = $validator->validate($spec); + + $this->assertContains("Missing 'info.title' field.", $errors); + $this->assertContains("Missing 'info.version' field.", $errors); + } + + public function testUnresolvedSchemaReference() + { + $spec = [ + 'openapi' => '3.0.0', + 'info' => [ + 'title' => 'Test API', + 'version' => '1.0.0', + ], + 'paths' => [ + '/users' => [ + 'get' => [ + 'responses' => [ + '200' => [ + 'description' => 'Success', + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/NonExistentModel' + ] + ] + ] + ] + ] + ] + ] + ], + 'components' => [ + 'schemas' => [ + 'User' => [ + 'type' => 'object' + ] + ] + ] + ]; + + $validator = new Validator(); + $errors = $validator->validate($spec); + + $this->assertCount(1, $errors); + $this->assertStringContainsString("Unresolved schema reference: '#/components/schemas/NonExistentModel'", $errors[0]); + } +} diff --git a/tests/phpstan-bootstrap.php b/tests/phpstan-bootstrap.php new file mode 100644 index 0000000..3e39253 --- /dev/null +++ b/tests/phpstan-bootstrap.php @@ -0,0 +1,49 @@ + $headers + * @return mixed + */ + function response(string $content = '', int $status = 200, array $headers = []): mixed + { + return null; + } +} From 70a50ed0208b346df4ee5af1dbcdcf4f5b8cf42f Mon Sep 17 00:00:00 2001 From: tolawho Date: Tue, 9 Jun 2026 12:03:23 +0700 Subject: [PATCH 25/27] ci: github workflow ci (#28) --- .github/workflows/ci.yml | 60 +++++ README.md | 6 + SAFE_BACKLOG.md | 237 ----------------- SAFE_STRATEGY.md | 42 --- TECHNICAL_ANALYSIS.md | 306 ---------------------- phpunit.xml | 5 + src/Core.php | 1 + tests/NativeEnumSupportTest.php | 12 +- tests/fixtures/enums/BackedIntEnum.php | 2 +- tests/fixtures/enums/BackedStringEnum.php | 2 +- tests/fixtures/enums/PureUnitEnum.php | 2 +- 11 files changed, 81 insertions(+), 594 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 SAFE_BACKLOG.md delete mode 100644 SAFE_STRATEGY.md delete mode 100644 TECHNICAL_ANALYSIS.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eab4ee8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main, dev ] + +permissions: + contents: read + +jobs: + tests: + name: PHP ${{ matrix.php-version }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-version: ['8.1', '8.2', '8.3', '8.4'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: pcov + tools: composer:v2 + + - name: Get Composer Cache Directory + id: composer-cache + run: | + echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run Linter & Static Analysis + run: composer run-script lint + + - name: Run Test Suite + run: vendor/bin/phpunit --coverage-clover=coverage.xml + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index 53f1e71..f429384 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # PHP Swagger Generator +[![CI Status](https://github.com/tolawho/phpswag/actions/workflows/ci.yml/badge.svg)](https://github.com/tolawho/phpswag/actions/workflows/ci.yml) +[![Codecov Coverage](https://codecov.io/gh/tolawho/phpswag/branch/main/graph/badge.svg)](https://codecov.io/gh/tolawho/phpswag) +[![PHP Version](https://img.shields.io/badge/php-%3E%3D%208.0-blue.svg)](https://packagist.org/packages/phpswag/phpswag) +[![PHPStan Level 7](https://img.shields.io/badge/PHPStan-level%207-brightgreen.svg)](https://github.com/phpstan/phpstan) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AST) and PHPDoc. This library scans your source code and generates OpenAPI 3.0 or 3.1 specifications automatically. ## Features diff --git a/SAFE_BACKLOG.md b/SAFE_BACKLOG.md deleted file mode 100644 index b9504b4..0000000 --- a/SAFE_BACKLOG.md +++ /dev/null @@ -1,237 +0,0 @@ -# SAFe Backlog - PHP Swagger Generator Project - -## 1. Portfolio Backlog (Epics) - -### [Epic 1] Xây dựng Core Engine cho PHP Swagger Generator -- **Trạng thái:** Done -- **Chủ sở hữu:** Fullstack Developer (User) -- **Tóm tắt:** Xây dựng bộ khung cơ bản có khả năng quét mã nguồn PHP và trích xuất các thông tin route cơ bản thông qua AST. -- **Giả thuyết Lợi ích (Benefit Hypothesis):** Cung cấp một công cụ mã nguồn mở giúp lập trình viên PHP tự động hóa việc tạo tài liệu Swagger từ mã nguồn mà không cần cấu hình thủ công phức tạp, từ đó giảm sai sót và tiết kiệm thời gian bảo trì tài liệu. -- **Tiêu chí chấp nhận (Acceptance Criteria):** - - Có khả năng quét thư mục và tìm kiếm file .php. - - Phân tích được cấu trúc AST và giải quyết được Namespace/Use statements. - - Nhận diện và trích xuất được các tag cơ bản: @route, @summary, @property. - - Xuất ra cấu trúc dữ liệu trung gian (IR). - -### [Epic 2] Nâng cấp Hệ thống Type System (Pro) -- **Trạng thái:** Done -- **Chủ sở hữu:** Fullstack Developer (User) -- **Tóm tắt:** Mở rộng khả năng phân tích kiểu dữ liệu phức tạp bao gồm Generics, Union types và xử lý thừa kế. -- **Giả thuyết Lợi ích (Benefit Hypothesis):** Cho phép thư viện hỗ trợ các dự án PHP hiện đại sử dụng cấu trúc dữ liệu phức tạp (như Collection, DTO kế thừa), tăng tính chính xác và độ phủ của tài liệu API được sinh ra. -- **Tiêu chí chấp nhận (Acceptance Criteria):** - - Hỗ trợ cú pháp Generics trong PHPDoc (ví dụ: `ApiResponse`). - - Xử lý được Union types (`string|null`, `User|Admin`). - - Có cơ chế Recursive Parsing để thu thập thuộc tính từ class cha và Trait. - - Xử lý được tham chiếu vòng (Circular References). - -### [Epic 3] Tích hợp CLI và Tối ưu hóa Hiệu năng -- **Trạng thái:** Done -- **Chủ sở hữu:** Fullstack Developer (User) -- **Tóm tắt:** Hoàn thiện công cụ dưới dạng CLI, hỗ trợ nhiều định dạng xuất bản và cơ chế bộ nhớ đệm (Caching). -- **Giả thuyết Lợi ích (Benefit Hypothesis):** Biến thư viện thành một công cụ dòng lệnh chuyên nghiệp dễ dàng tích hợp vào quy trình CI/CD, đồng thời đảm bảo tốc độ xử lý nhanh cho các dự án lớn. -- **Tiêu chí chấp nhận (Acceptance Criteria):** - - Cung cấp lệnh `php-swag generate` dễ sử dụng. - - Xuất ra định dạng YAML và JSON chuẩn OpenAPI 3.0/3.1. - - Tích hợp cơ chế Caching dựa trên file hash để tăng tốc độ quét lần sau. - - Có tài liệu hướng dẫn sử dụng (README) hoàn chỉnh cho cộng đồng. - -### [Epic 4] Professional API Documentation & Advanced Controls -- **Trạng thái:** Done -- **Chủ sở hữu:** Fullstack Developer (User) -- **Tóm tắt:** Mở rộng các tính năng lấy cảm hứng từ swaggo để hoàn thiện tài liệu API chuyên nghiệp. -- **Giả thuyết Lợi ích (Benefit Hypothesis):** Giúp tạo ra tài liệu OpenAPI đầy đủ thông tin nhất, hỗ trợ bảo mật, validation và các tùy chỉnh nâng cao, giúp frontend và các bên liên quan dễ dàng tích hợp. -- **Tiêu chí chấp nhận (Acceptance Criteria):** - - Trích xuất tự động thông tin Global API (Title, Version, Host, etc.) từ PHPDoc. - - Hỗ trợ định nghĩa Security và áp dụng cho từng endpoint. - - Hỗ trợ đầy đủ các thẻ Validation cho thuộc tính và tham số. - - Cho phép tùy chỉnh MIME Types và OpenAPI Extensions (x-). - - Hỗ trợ alias @success, @failure cho tính rõ ràng. - -## 2. Program Backlog (Features) - -### Features cho [Epic 1] Core Engine -- [x] **[F1.1] File Scanner & Finder:** Tìm kiếm đệ quy tất cả các file .php trong các thư mục được cấu hình. -- [x] **[F1.2] AST Parser Integration:** Tích hợp `nikic/php-parser` để đọc cấu trúc code. -- [x] **[F1.3] Namespace Resolver:** Xác định chính xác FQCN dựa trên `namespace` và `use` statements. -- [x] **[F1.4] Basic DocBlock Collector:** Thu thập và phân tích các tag đơn giản (@route, @summary, @property). - -### Features cho [Epic 2] Type System Pro -- [x] **[F2.1] Advanced Type Resolver:** Hỗ trợ các kiểu dữ liệu phức tạp (Union, Nullable, Arrays). -- [x] **[F2.2] Generics Support:** Phân tích cú pháp template cho các kiểu dữ liệu generic. -- [x] **[F2.3] Inheritance & Trait Merger:** Gộp các thuộc tính từ các class cha và traits. -- [x] **[F2.4] Schema Registry:** Quản lý tập trung các định nghĩa Model và xử lý tham chiếu vòng. -- [x] **[F2.5] Advanced Route Parameter Handling:** Hỗ trợ tag riêng biệt (@path, @query, @header, @cookie, @body) và tự động suy luận (Auto-inference). - -### Features cho [Epic 3] Integration & CLI -- [x] **[F3.1] CLI Command Interface:** Cung cấp giao diện dòng lệnh cho người dùng. -- [x] **[F3.2] OpenAPI Spec Generator:** Chuyển đổi dữ liệu IR thành file chuẩn OpenAPI. -- [x] **[F3.3] Performance Caching:** Lưu trữ kết quả phân tích để tăng tốc cho các lần chạy sau. -- [x] **[F3.4] README & Documentation:** Hướng dẫn cộng đồng cách sử dụng và đóng góp. - -### Features cho [Epic 4] Professional API Documentation -- [x] **[F4.1] Global API Metadata Discovery:** Tự động trích xuất @title, @version, @description, @contact.*, @license.*, và @host từ toàn bộ project. -- [x] **[F4.2] Security & Authentication Support:** Định nghĩa @securityDefinitions (ApiKey/JWT) toàn cục và @security cho endpoint. -- [x] **[F4.3] Comprehensive Schema Validation:** Hỗ trợ các tag @minimum, @maximum, @minLength, @maxLength, @pattern, @format, @example cho Model properties và Route parameters. -- [x] **[F4.4] MIME Types & Response Alias:** Hỗ trợ @accept, @produce (mặc định application/json) và alias @success/@failure. -- [x] **[F4.5] Advanced Operation Metadata:** Hỗ trợ @operationId, @deprecated và OpenAPI Extensions (x-). - -## 3. Team Backlog (User Stories) - -### Stories cho [F2.5] Advanced Route Parameter Handling -- [x] **[S2.5.1] Explicit Parameter Tags:** Hỗ trợ @path, @query, @header, @cookie với cú pháp chuyên sâu. -- [x] **[S2.5.2] Request Body Tag:** Hỗ trợ tag @body để định nghĩa body request một cách tường minh. -- [x] **[S2.5.3] Auto-inference from Signature:** Tự động nhận diện tham số path/query và body từ type-hint của method. -- [x] **[S2.5.4] Extra Metadata Parsing:** Trích xuất enum() và default() ngay từ chuỗi mô tả trong PHPDoc. - -### Stories cho [F4.1] Global API Metadata -- [x] **[S4.1.1] Global DocBlock Scanner:** Cơ chế quét và tìm kiếm khối thông tin chung của API trong toàn bộ project. -- [x] **[S4.1.2] Info Object Mapping:** Ánh xạ các tag @title, @version, @description, @contact, @license vào Info Object của OpenAPI. -- [x] **[S4.1.3] Host & BasePath Support:** Xử lý tag @host để xác định URL cơ sở. - -### Stories cho [F4.2] Security & Authentication -- [x] **[S4.2.1] Security Definitions Parser:** Phân tích các định nghĩa bảo mật (API Key, Bearer JWT) từ PHPDoc. -- [x] **[S4.2.2] Security Requirement Tag:** Áp dụng tag @security cho từng endpoint để chỉ định phương thức bảo mật cần thiết. - -### Stories cho [F4.3] Comprehensive Schema Validation -- [x] **[S4.3.1] Validation Tag Extraction:** Trích xuất các ràng buộc (minimum, maxLength, pattern, format, etc.) từ mô tả PHPDoc hoặc tag riêng biệt. -- [x] **[S4.3.2] Validation Mapping to OpenAPI:** Chuyển đổi các ràng buộc kỹ thuật sang Schema Object tương ứng. -- [x] **[S4.3.3] Example Tag Support:** Hỗ trợ tag @example để hiển thị dữ liệu mẫu trong UI. - -### Stories cho [F4.4] MIME Types & Response Alias -- [x] **[S4.4.1] MIME Type Tags (@accept, @produce):** Cho phép định nghĩa kiểu nội dung cho request/response. -- [x] **[S4.4.2] Success/Failure Aliases:** Xử lý @success và @failure như các alias của @response để tăng tính trực quan. - -### Stories cho [F4.5] Advanced Operation Metadata -- [x] **[S4.5.1] Operation ID Support:** Cho phép đặt tên thủ công cho operation qua tag @operationId. -- [x] **[S4.5.2] Deprecation Support:** Đánh dấu operation lỗi thời thông qua tag @deprecated. -- [x] **[S4.5.3] x- Extension Support:** Hỗ trợ trích xuất và xuất các extension OpenAPI tùy chỉnh bắt đầu bằng "x-". - -### [Epic 5] Developer Experience & Modern PHP Support -- **Trạng thái:** Done -- **Chủ sở hữu:** Fullstack Developer (User) -- **Tóm tắt:** Tập trung vào việc tối ưu hóa quy trình viết code, tận dụng các tính năng hiện đại của PHP (Enums, Attributes-like inference) và cung cấp thông báo lỗi minh bạch. -- **Giả thuyết Lợi ích (Benefit Hypothesis):** Giảm thiểu mã lặp lại, tận dụng tối đa sức mạnh của ngôn ngữ PHP hiện đại và giúp lập trình viên phát hiện lỗi cấu hình API ngay lập tức, từ đó tăng tốc độ phát triển. -- **Tiêu chí chấp nhận (Acceptance Criteria):** - - Hỗ trợ khai báo metadata ở cấp Controller (Class). - - Tự động suy luận các thuộc tính bắt buộc (required) mà không cần khai báo thủ công. - - Tích hợp sâu với Native Enums (PHP 8.1+). - - Cung cấp cơ chế mapping thông minh cho các kiểu dữ liệu phổ biến (DateTime, UUID). - - Thông báo lỗi chi tiết kèm vị trí file/dòng code. - -## 2. Program Backlog (Features) (Tiếp theo) - -### Features cho [Epic 5] Developer Experience -- [x] **[F5.1] Controller-level Metadata Support:** Hỗ trợ @tag, @security, @accept, @produce ở cấp Class. -- [x] **[F5.2] Enhanced Diagnostics & Error Reporting:** Cải thiện thông báo lỗi với đầy đủ thông tin ngữ cảnh (file, line). -- [x] **[F5.3] Intelligent Schema Inference:** Tự động xác định `required` fields dựa trên type-hint và giá trị mặc định. -- [x] **[F5.4] Native PHP Enum Support:** Tự động trích xuất các case từ PHP 8.1+ Enums. -- [x] **[F5.5] Smart Type Mapping Registry:** Map các class phổ biến (DateTime, Uuid, UploadedFile) sang kiểu dữ liệu OpenAPI tương ứng. - -## 3. Team Backlog (User Stories) (Tiếp theo) - -### Stories cho [F5.1] Controller-level Metadata -- [x] **[S5.1.1] Class-level Tag Collection:** Thu thập @tag từ class docblock và gộp với tags ở method. -- [x] **[S5.1.2] Class-level Security & Content-Type:** Áp dụng @security, @accept, @produce từ class làm mặc định cho tất cả method bên trong, cho phép method ghi đè. - -### Stories cho [F5.2] Enhanced Diagnostics -- [x] **[S5.2.1] Source Location Tracking:** Lưu trữ thông tin file và dòng code trong quá trình parse. -- [x] **[S5.2.2] User-Friendly Error Messages:** Hiển thị lỗi chi tiết khi không phân giải được class hoặc tag sai cú pháp. - -### Stories cho [F5.3] Intelligent Schema Inference -- [x] **[S5.3.1] Nullable-based Required Detection:** Tự động đánh dấu `required: true` nếu type-hint không nullable. -- [x] **[S5.3.2] Default Value Inference:** Thuộc tính có giá trị mặc định được coi là optional. -- [x] **[S5.3.3] Explicit @required Tag:** Hỗ trợ tag @required để ghi đè logic suy luận. - -### Stories cho [F5.4] Native PHP Enum Support -- [x] **[S5.4.1] Enum Detection logic:** Nhận diện class là Enum thông qua Reflection. -- [x] **[S5.4.2] BackedEnum Value Extraction:** Tự động lấy `value` cho BackedEnum (string/int). -- [x] **[S5.4.3] UnitEnum Name Extraction:** Tự động lấy `name` cho UnitEnum. - -### Stories cho [F5.5] Smart Type Mapping -- [x] **[S5.5.1] Built-in Date/Time Mapping:** Map `DateTimeInterface` sang `string/date-time`. -- [x] **[S5.5.2] External Library Support (Optional):** Hỗ trợ mapping cho Uuid (Ramsey/Symfony) nếu class tồn tại. -- [x] **[S5.5.3] Binary/File Mapping:** Map các class UploadedFile phổ biến sang `string/binary`. - -### [Epic 6] Hỗ trợ PHP 8+ Attributes (Attributes-based Annotation) -- **Trạng thái:** Done -- **Chủ sở hữu:** Fullstack Developer (User) -- **Tóm tắt:** Bổ sung cơ chế khai báo metadata sử dụng PHP 8 Attributes song hành cùng PHPDoc. -- **Giả thuyết Lợi ích (Benefit Hypothesis):** Giúp lập trình viên viết code chuẩn PHP hiện đại, tận dụng tính năng tự động gợi ý (autocomplete) của IDE, phát hiện sớm lỗi chính tả và loại bỏ sự phụ thuộc quá lớn vào việc phân tích chuỗi text trong DocBlock. -- **Tiêu chí chấp nhận (Acceptance Criteria):** - - Định nghĩa đầy đủ các class Attributes tương đương với các PHPDoc tags hiện tại. - - Bộ phân tích AST thu thập chính xác Attributes từ Class, Method, Property và Method Parameters. - - Thực hiện đúng chiến lược gộp thông minh (Smart Merge) khi khai báo song song. - - Toàn bộ unit tests viết cho Attributes và cơ chế gộp đều pass. - -## 2. Program Backlog (Features) (Tiếp theo) - -### Features cho [Epic 6] PHP 8+ Attributes Support -- [x] **[F6.1] Attribute Definitions:** Định nghĩa các class Attribute tương ứng trong `src/Attributes/`. -- [x] **[F6.2] AST Attribute Extraction:** Trích xuất các Attribute từ Node AST thông qua PHP-Parser. -- [x] **[F6.3] Smart Merge Engine:** Engine gộp dữ liệu từ PHPDoc và Attributes theo thứ tự ưu tiên. - -## 3. Team Backlog (User Stories) (Tiếp theo) - -### Stories cho [F6.1] Attribute Definitions -- [x] **[S6.1.1] Core Routing Attributes:** Định nghĩa `#[Route]`, `#[Tag]`, `#[OperationId]`, `#[Deprecated]`. -- [x] **[S6.1.2] Parameter Attributes:** Định nghĩa `#[QueryParam]`, `#[PathParam]`, `#[HeaderParam]`, `#[CookieParam]`, `#[RequestBody]`. -- [x] **[S6.1.3] Response & Schema Attributes:** Định nghĩa `#[Response]`, `#[Property]`, `#[Schema]`. - - -### Stories cho [F6.2] AST Attribute Extraction -- [x] **[S6.2.1] Class & Property Attribute Parser:** Quét và phân tích Attribute ở cấp Class (Controller/Model) và Class Property. -- [x] **[S6.2.2] Method & Parameter Attribute Parser:** Quét và phân tích Attribute ở cấp Method và Parameter của Method. - -### Stories cho [F6.3] Smart Merge Engine -- [x] **[S6.3.1] Override Engine for Single Values:** Ghi đè các trường đơn (summary, description, v.v.). -- [x] **[S6.3.2] Merge Engine for Collections:** Gộp các tag, security schemes. -- [x] **[S6.3.3] Keyed Collection Override:** Ghi đè các phần tử trùng lặp (trùng mã code 200, trùng tên tham số). - -### [Epic 7] Tạo các Framework Bridge (Laravel & Symfony Integration) -- **Trạng thái:** Done -- **Chủ sở hữu:** Fullstack Developer (User) -- **Tóm tắt:** Xây dựng cầu nối tích hợp với Laravel và Symfony giúp lập trình viên chạy phpswag mượt mà trên framework của họ. -- **Giả thuyết Lợi ích (Benefit Hypothesis):** Giúp giảm thiểu cấu hình thủ công cho dự án dùng Laravel/Symfony, tự động đăng ký route xem tài liệu (Swagger UI) và lệnh command line tích hợp. -- **Tiêu chí chấp nhận (Acceptance Criteria):** - - Laravel Bridge hỗ trợ Artisan command, config file và Route UI render `/api/docs`. - - Symfony Bridge hỗ trợ Console command và Bundle DI. - -### [Epic 8] Tích hợp Linter & OpenAPI Validator -- **Trạng thái:** Done -- **Chủ sở hữu:** Fullstack Developer (User) -- **Tóm tắt:** Bổ sung cơ chế validate đặc tả OpenAPI sinh ra để phát hiện sớm các lỗi cấu trúc nghiêm trọng. -- **Giả thuyết Lợi ích (Benefit Hypothesis):** Đảm bảo spec sinh ra luôn đúng chuẩn OpenAPI 3.0/3.1 trước khi xuất bản hoặc tích hợp vào hệ thống khác. -- **Tiêu chí chấp nhận (Acceptance Criteria):** - - Có cờ `--validate` tích hợp vào lệnh `generate`. - - Phát hiện lỗi logic (trùng route path, tham chiếu vòng sai cách, thiếu info bắt buộc) và hiển thị thông tin lỗi chi tiết. - -## 2. Program Backlog (Features) (Tiếp theo) - -### Features cho [Epic 7] Framework Bridges -- [x] **[F7.1] Laravel Integration:** Tích hợp ServiceProvider, config, Artisan command và route Swagger UI. -- [x] **[F7.2] Symfony Bundle:** Tích hợp Bundle, console command và DI container setup. - -### Features cho [Epic 8] Linter & Validator -- [x] **[F8.1] Native Structural Validator:** Tự kiểm tra các lỗi logic cấu trúc spec trong quá trình tạo. -- [x] **[F8.2] CLI Linter Integration:** Thêm cờ `--validate` trong CLI để validate đặc tả OpenAPI. - -## 3. Team Backlog (User Stories) (Tiếp theo) - -### Stories cho [F7.1] Laravel Integration -- [x] **[S7.1.1] Service Provider & Config:** Tạo `PhpSwagServiceProvider` và file cấu hình mẫu `phpswag.php`. -- [x] **[S7.1.2] Artisan Commands:** Đăng ký các command `phpswag:generate` và `phpswag:watch`. -- [x] **[S7.1.3] Swagger UI Controller:** Đăng ký route và render giao diện Swagger UI HTML trực tiếp. - -### Stories cho [F7.2] Symfony Bundle -- [x] **[S7.2.1] Symfony Bundle Setup:** Tạo class `PhpSwagBundle` và cấu hình DI extension. -- [x] **[S7.2.2] Symfony Console Command:** Tạo command tương đương `phpswag:generate` trong Symfony Console. - -### Stories cho [F8.1] Native Structural Validator -- [x] **[S8.1.1] Spec Integrity Check:** Kiểm tra tính toàn vẹn của YAML/JSON sinh ra (thiếu title, trùng endpoint). -- [x] **[S8.1.2] Class Reference Verification:** Kiểm tra xem các class DTO/Resource được dùng làm ref có thực sự tồn tại trong registry hay không. - -### Stories cho [F8.2] CLI Linter Integration -- [x] **[S8.2.1] Validate CLI Flag:** Cài đặt cờ `--validate` trong Console Command của phpswag. -- [x] **[S8.2.2] Diagnostic Output:** Định dạng và hiển thị kết quả kiểm lỗi rõ ràng cho lập trình viên. - - diff --git a/SAFE_STRATEGY.md b/SAFE_STRATEGY.md deleted file mode 100644 index c1804cb..0000000 --- a/SAFE_STRATEGY.md +++ /dev/null @@ -1,42 +0,0 @@ -# SAFe Transformation Strategy - PHP Swagger Generator - -## 1. Xác định Cấp độ (Levels) -Do đặc thù dự án có 1 nhân sự (Fullstack), chúng ta áp dụng mô hình **Essential SAFe** tinh gọn: -- **Team Level:** Bạn đóng vai trò là Agile Team (Scrum/Kanban) thực thi các Stories. -- **Program Level (ART):** Bạn đóng vai trò Product Management/System Architect để điều phối Release Train. -- **Portfolio Level:** Bạn đóng vai trò Epic Owner để định hướng giá trị lâu dài cho thư viện. - -## 2. Ánh xạ Cấu trúc (Mapping) -- **Epics:** 3 Giai đoạn lớn trong TECHNICAL_ANALYSIS.md. -- **Capabilities:** (Không áp dụng vì chưa đạt quy mô Large Solution). -- **Features:** Các module chức năng lớn (Parser, Resolver, CLI). -- **Stories:** Các đơn vị công việc nhỏ có thể hoàn thành trong 1-2 ngày. - -## 3. Đánh giá Vận hành (Assessment) -- **Flow:** Sử dụng Kanban để tối ưu hóa dòng chảy, hạn chế WIP (Work In Progress) để tránh quá tải cho 1 người. -- **Predictability:** Đo lường qua "Velocity" cá nhân sau mỗi Iteration (2 tuần). -- **Quality:** Áp dụng Built-in Quality thông qua Unit Testing và Static Analysis (PHPStan). -- **Dependency:** Hiện tại không có phụ thuộc bên ngoài (External Dependencies), chủ yếu là phụ thuộc kỹ thuật (Technical Debt/Enablers). - -## 4. Các Sự kiện SAFe (Events) -- **PI Planning:** Thực hiện định kỳ mỗi 8-12 tuần để nhìn lại Roadmap. -- **Iteration Planning:** Thực hiện vào đầu mỗi 2 tuần. -- **System Demo:** Tự kiểm thử và chạy thử các ví dụ (Example code) để xác nhận tính năng đã hoàn thiện. -- **Inspect & Adapt (I&A):** Đánh giá lại quy trình sau mỗi PI để cải tiến năng suất. - -## 5. Roadmap Triển khai (Proposed) - -### PI 1: Hoàn thiện Professional API Documentation -- **Iteration 1: Foundation & Global Metadata** - - Thực hiện [F4.1] Global API Metadata Discovery. - - Xây dựng cơ chế quét PHPDoc toàn project cho Info Object. -- **Iteration 2: Security & MIME Types** - - Thực hiện [F4.2] Security & Authentication Support. - - Thực hiện [F4.4] MIME Types & Response Alias. -- **Iteration 3: Validation & Advanced Metadata** - - Thực hiện [F4.3] Comprehensive Schema Validation. - - Thực hiện [F4.5] Advanced Operation Metadata. -- **Iteration 4: Optimization & Polish** - - Thực hiện [F3.3] Performance Caching. - - Cập nhật [F3.4] README & Documentation. - - System Demo & Release v1.0.0. diff --git a/TECHNICAL_ANALYSIS.md b/TECHNICAL_ANALYSIS.md deleted file mode 100644 index 750e992..0000000 --- a/TECHNICAL_ANALYSIS.md +++ /dev/null @@ -1,306 +0,0 @@ -# BÁO CÁO PHÂN TÍCH KỸ THUẬT: THƯ VIỆN PHPSWAG (SWAGGO FOR PHP) - -## 1. Đánh giá tính khả thi (Feasibility Analysis) - -Việc xây dựng một thư viện tạo Swagger/OpenAPI bằng cách phân tích tĩnh (Static Analysis) PHPDocs là **hoàn toàn khả thi** trong hệ sinh thái PHP hiện nay, nhờ vào các công cụ mạnh mẽ sau: - -### 1.1. Phân tích mã nguồn với `nikic/php-parser` -- **Khả thi:** Rất cao. -- **Vai trò:** Chuyển đổi mã nguồn PHP thành Cây cú pháp trừu tượng (AST). Giúp trích xuất cấu trúc Class, Method, Property và các thuộc tính liên quan mà không cần chạy code (Runtime). -- **Ưu điểm:** Hỗ trợ đầy đủ các phiên bản PHP mới nhất, có khả năng đọc được cả Comments và Attributes. - -### 1.2. Phân tích PHPDoc với `phpstan/phpdoc-parser` -- **Khả thi:** Rất cao. -- **Vai trò:** Đây là thư viện tiêu chuẩn để parse các PHPDoc phức tạp. Nó không chỉ đọc text thô mà còn hiểu được cấu trúc của các kiểu dữ liệu nâng cao như Generics (`Collection`), Union types (`User|Admin`), và Intersection types. -- **Ưu điểm:** Độ chính xác cực cao, được tin dùng bởi các công cụ lớn như PHPStan và Rector. - -### 1.3. Khả năng suy luận kiểu (Type Inference) -- **Khả thi:** Trung bình - Cao. -- **Cơ chế:** Kết hợp thông tin từ Type-hint gốc của PHP (ví dụ: `public string $name`) và thông tin bổ sung từ PHPDoc (ví dụ: `@var array`). -- **Thách thức:** Cần bộ giải mã (Resolver) để ánh xạ các Class Name ngắn (ví dụ: `User`) thành Full-Qualified Class Name (FQCN) (ví dụ: `App\Models\User`) dựa trên các câu lệnh `use` trong file. - -## 2. Các khó khăn kỹ thuật trọng tâm (Technical Challenges) - -### 2.1. Phân giải Namespace và Use Statements -Khi phân tích tĩnh, thư viện phải tự mình hiểu được ngữ cảnh của file để biết `User` thực sự là class nào. -- **Giải pháp:** Xây dựng `NameResolver` đi kèm với bộ quét AST để lưu trữ bản đồ các alias `use`. - -### 2.2. Xử lý kiểu dữ liệu Generic và lồng nhau -Cú pháp `ApiResponse>` không tồn tại trong PHP thuần nhưng lại phổ biến trong Swagger. -- **Khó khăn:** OpenAPI 3.0 không hỗ trợ Generics thực thụ. -- **Giải pháp:** Sử dụng cơ chế "Flattening" hoặc tạo các Schema trung gian (ví dụ: `UserListApiResponse`) trong quá trình sinh tài liệu. - -### 2.3. Quét Route toàn cục (Global Route Discovery) -Quét toàn bộ thư mục để tìm `@route` yêu cầu hiệu năng tốt. -- **Khó khăn:** Project lớn có thể có hàng nghìn file. -- **Giải pháp:** Sử dụng `Symfony Finder` hoặc `RecursiveDirectoryIterator` kết hợp với việc lọc nhanh nội dung file (regex sơ bộ) trước khi đưa vào bộ Parse AST chính thức. - -### 2.4. Tham chiếu vòng (Circular References) -Class `User` chứa `Post`, và `Post` lại chứa `User`. -- **Khó khăn:** Gây ra lặp vô tận khi xây dựng Schema. -- **Giải pháp:** Sử dụng `Schema Registry` để lưu trữ các model đã được xử lý và sử dụng `$ref` trong OpenAPI để trỏ đến nhau. - - -## 3. Thiết kế kiến trúc chi tiết (Architectural Design) - -### 3.1. Mô hình Pipeline xử lý - -Thư viện sẽ hoạt động theo quy trình 5 bước: - -1. **Scanner (Bộ quét):** Tìm kiếm tất cả các file `.php` trong thư mục cấu hình. Lọc nhanh các file có chứa từ khóa `@route`. -2. **AST Parser (Phân tích cú pháp):** Sử dụng `nikic/php-parser` để bóc tách cấu trúc class, method và lấy khối PHPDoc tương ứng. -3. **DocBlock Analyzer (Phân tích tài liệu):** Sử dụng `phpstan/phpdoc-parser` để chuyển đổi PHPDoc thô thành các Object định nghĩa kiểu (Nodes). -4. **Type Resolver & Schema Registry (Giải mã kiểu):** - - Giải mã tên class (FQCN). - - Phân tích các thuộc tính (Properties) của Model để tạo ra các OpenAPI Schema. - - Đưa vào Registry để quản lý trùng lặp và tham chiếu. -5. **OpenAPI Generator (Sinh tài liệu):** Chuyển đổi dữ liệu trung gian thành định dạng YAML hoặc JSON tuân thủ chuẩn OpenAPI 3.0/3.1. - -### 3.2. Cấu trúc dữ liệu trung gian (Intermediate Representation - IR) - -Để tránh phụ thuộc quá nhiều vào cấu trúc của OpenAPI ngay từ đầu, dữ liệu sau khi parse sẽ được lưu vào một cấu trúc IR thuần túy: -- `RouteDefinition`: path, method, summary, parameters, response_ref. -- `SchemaDefinition`: name, type, properties (array of PropertyDefinition). -- `PropertyDefinition`: name, type, is_nullable, description. - -## 4. Đặc tả các PHPDoc Tags hỗ trợ - -### 4.1. Endpoint Annotations (Dành cho Controller Method) -- `@route [METHOD] [PATH]` (Bắt buộc): Định nghĩa endpoint. -- `@summary [TEXT]`: Mô tả ngắn gọn. -- `@description [TEXT]`: Mô tả chi tiết. -- `@tag [NAME]`: Nhóm các API. -- `@request [CLASS_NAME]`: Định nghĩa Body request (suy luận từ class). -- `@response [CODE] [CLASS_NAME]`: Định nghĩa response. -- `@query [NAME] [TYPE] [DESCRIPTION]`: Tham số URL. - -### 4.2. Schema Annotations (Dành cho Model/DTO) -- `@property [TYPE] $[NAME] [DESCRIPTION]`: Định nghĩa thuộc tính. -- `@var [TYPE]`: Dùng cho thuộc tính trong class. - - -## 5. Ví dụ minh họa (The "Magic" Experience) - -**Code người dùng viết:** - -```php -namespace App\Controllers; - -use App\Resources\UserResource; - -class UserController { - /** - * @route GET /api/users/{id} - * @summary Lấy thông tin chi tiết người dùng - * @response 200 UserResource - */ - public function show(int $id) { ... } -} - -namespace App\Resources; - -/** - * @property int $id ID người dùng - * @property string $name Tên hiển thị - * @property string|null $email - */ -class UserResource { } -``` - -**Thư viện tự suy luận:** -- Path parameter `id` có kiểu `integer` (từ type-hint của hàm `show`). -- Response 200 sử dụng Schema `UserResource`. -- Schema `UserResource` có 3 trường, trong đó `email` là `nullable`. - -## 6. Kết luận & Đề xuất Lộ trình (Roadmap) - -Dự án này mang tính thực tiễn cao, giúp giảm thiểu sự trùng lặp code (DRY) và giữ tài liệu luôn đi kèm với code. - -**Giai đoạn 1: Core Engine** -- Xây dựng bộ quét AST và giải mã Namespace. -- Hỗ trợ các Tag cơ bản: `@route`, `@summary`, `@property`. - -**Giai đoạn 2: Type System Pro** -- Xử lý Generics (`Collection`) và Union types. -- Tự động tìm kiếm Class định nghĩa trong toàn bộ project. - -**Giai đoạn 3: Integration & CLI** -- Xây dựng CLI tool `phpswag`. -- Xuất file `swagger.yaml` hoặc `swagger.json`. - - -## 7. Phân tích sâu các khó khăn kỹ thuật (Deep Dive into Technical Challenges) - -### 7.1. Dependency Resolution & Autoloading Mapping -Khi gặp một class `UserResource`, bộ phân tích tĩnh cần biết file vật lý của nó ở đâu để đọc PHPDoc. -- **Vấn đề:** PHP không có cấu trúc file cố định cho namespace (dù PSR-4 là phổ biến). -- **Giải pháp:** - - Đọc file `composer.json` để lấy thông tin `autoload` (PSR-4 mapping). - - Xây dựng một `ClassIndex` (bản đồ Class FQCN -> File Path) trước khi bắt đầu parse chi tiết. - -### 7.2. Hiệu năng & Bộ nhớ (Performance) -Việc parse AST là một tiến trình tiêu tốn CPU và RAM. -- **Vấn đề:** Project lớn có thể làm treo quá trình generate. -- **Giải pháp:** - - **Caching:** Lưu trữ kết quả parse của từng file (hash của nội dung file). Chỉ parse lại những file có thay đổi. - - **Lazy Loading:** Chỉ parse các Model Schema khi chúng thực sự được tham chiếu bởi một `@route`. - -### 7.3. Xử lý Thừa kế (Inheritance & Traits) -Một Model có thể kế thừa từ một Base Model hoặc sử dụng Traits chứa các `@property`. -- **Vấn đề:** Nếu chỉ parse class hiện tại, ta sẽ mất các trường dữ liệu từ class cha. -- **Giải pháp:** Cần một cơ chế "Recursive Parsing" để duyệt ngược lên các class cha và gộp (merge) các định nghĩa thuộc tính. - -### 7.4. Mâu thuẫn giữa Type-hint và PHPDoc -```php -public int $status; // PHP Type-hint -/** @var string */ // PHPDoc mâu thuẫn -public $status; -``` -- **Nguyên tắc xử lý:** PHPDoc luôn có độ ưu tiên cao hơn (vì nó cho phép mô tả chi tiết hơn như `string|null`, `regex`, v.v.), nhưng nếu PHPDoc không có, sẽ lấy Type-hint làm fallback. - - -## 8. Thiết kế kiến trúc chi tiết (Architectural Design) - -### 8.1. Sơ đồ thành phần (Component Diagram) - -```text -+----------------+ +-------------------+ +---------------------+ -| CLI / Core |----->| Scanner |----->| Finder | -+----------------+ +-------------------+ +---------------------+ - | | - v v -+----------------+ +-------------------+ +---------------------+ -| Registry |<-----| AST Collector |----->| nikic/php-parser | -+----------------+ +-------------------+ +---------------------+ - | | - v v -+----------------+ +-------------------+ +---------------------+ -| Type Resolver |<-----| DocBlock Parser |----->| phpstan/doc-parser | -+----------------+ +-------------------+ +---------------------+ - | - v -+----------------+ +-------------------+ -| Generator |----->| OpenAPI Spec (YAML)| -+----------------+ +-------------------+ -``` - -### 8.2. Các Interface quan trọng (Internal API Design) - -#### a. `CollectorInterface` -Chịu trách nhiệm duyệt qua AST và tìm kiếm các thông tin liên quan. -```php -interface Collector { - public function collect(Node $node): void; - public function getResults(): array; -} -``` - -#### b. `TypeResolverInterface` -Chịu trách nhiệm chuyển đổi một chuỗi tên type (ví dụ: `User[]`) thành một Object định nghĩa kiểu. -```php -interface TypeResolver { - public function resolve(string $type, Context $context): TypeDefinition; -} -``` - -#### c. `SchemaRegistry` -Nơi lưu trữ tập trung các Model. Đảm bảo mỗi model chỉ được parse một lần. -```php -class SchemaRegistry { - private array $schemas = []; - public function register(string $fqcn): Reference; - public function getDefinitions(): array; -} -``` - -### 8.3. Luồng dữ liệu (Data Flow) - -1. **Giai đoạn Thu thập (Collection Phase):** - - `Scanner` tìm file -> `AST Collector` tìm các class có `@route`. - - `AST Collector` cũng thu thập thông tin về `use` statements để tạo `Context`. -2. **Giai đoạn Phân giải (Resolution Phase):** - - Khi gặp một Class trong `@response`, `TypeResolver` sẽ tra cứu FQCN. - - Nếu Class đó chưa có trong `Registry`, tiến trình Parse Model sẽ được kích hoạt cho file chứa Class đó. -3. **Giai đoạn Sinh mã (Generation Phase):** - - `Generator` duyệt qua danh sách Route đã thu thập. - - Map các `TypeDefinition` sang cấu trúc `components/schemas` của OpenAPI. - - Xuất file kết quả. - - -## 9. Định nghĩa bộ đặc tả PHPDoc (PHPDoc Specification) - -### 9.1. Tags cho Controller (Endpoints) - -| Tag | Tham số | Ví dụ | OpenAPI Mapping | -|:---|:---|:---|:---| -| `@route` | `[METHOD] [PATH]` | `@route POST /users` | `paths -> /users -> post` | -| `@summary` | `[STRING]` | `@summary Tạo user mới` | `summary` | -| `@description`| `[STRING]` | `@description Mô tả chi tiết` | `description` | -| `@tag` | `[STRING]` | `@tag User Management` | `tags` | -| `@request` | `[CLASS]` | `@request CreateUserDto` | `requestBody` | -| `@response` | `[CODE] [CLASS]` | `@response 200 UserDto` | `responses -> 200` | -| `@query` | `[NAME] [TYPE] [DESC]`| `@query page int Số trang` | `parameters (in: query)` | -| `@path` | `[NAME] [TYPE] [DESC]`| `@path id string ID User` | `parameters (in: path)` | - -### 9.2. Tags cho Model (Schemas) - -| Tag | Cú pháp | Ví dụ | -|:---|:---|:---| -| `@property` | `[TYPE] $[NAME] [DESC]`| `@property string $name Tên` | -| `@var` | `[TYPE]` | `@var int` | -| `@template` | `[NAME]` | `@template T` (Dùng cho Generics) | - -### 9.3. Xử lý Kiểu dữ liệu đặc biệt - -- `array` hoặc `User[]` -> `type: array, items: { $ref: '#/components/schemas/User' }` -- `string|null` -> `type: string, nullable: true` (OpenAPI 3.0) hoặc `type: [string, null]` (OpenAPI 3.1) -- `User|Admin` -> `oneOf: [ { $ref: 'User' }, { $ref: 'Admin' } ]` - ---- - -## 10. Thiết kế kiến trúc các Tính năng Mới (Epic 6, 7, 8) - -### 10.1. PHP 8+ Attributes Support (Epic 6) - -#### a. Cấu trúc Attribute Classes -Hỗ trợ song song cả lớp cốt lõi và các lớp phím tắt: -- `#[Route(string $method, string $path)]` -- Lớp phím tắt: `#[Get(string $path)]`, `#[Post(string $path)]`, `#[Put(string $path)]`, `#[Delete(string $path)]` -- Định nghĩa tham số: `#[QueryParam(string $name, ?string $type = null, ?string $description = null, ...$validationConstraints)]` (Tương tự cho PathParam, HeaderParam, CookieParam). -- Định nghĩa Body & Response: `#[RequestBody(string $type, ?string $description = null)]`, `#[Response(int $code, string $type, ?string $description = null)]`. -- Ràng buộc Validation được truyền dưới dạng **named arguments** trực tiếp vào constructor của Attribute để tận dụng tối đa IDE autocomplete. - -#### b. Ánh xạ Attribute từ các thư viện ngoài (External Mapping) -Để tránh lập trình viên phải khai báo trùng lặp Attribute khi dùng framework, `phpswag` sẽ hỗ trợ phân tích và ánh xạ trực tiếp các Attribute Route của Symfony (`Symfony\Component\Routing\Annotation\Route`) sang cấu trúc OpenAPI tương ứng của mình. - -#### c. Chiến lược Gộp và Ưu tiên (Smart Merge Strategy) -Khi khai báo cả PHPDoc và Attributes: -- **Thuộc tính đơn** (summary, description, operationId, deprecated): Attributes ghi đè hoàn toàn PHPDoc. -- **Mảng/Danh sách** (tag, security): Gộp chung cả hai nguồn. -- **Keyed Collection** (query params trùng tên, code response trùng): Attribute ghi đè PHPDoc tại khóa (key) bị trùng lặp. - -### 10.2. Framework Bridges (Epic 7) [ĐÃ TRIỂN KHAI] - -#### a. Laravel Bridge (`src/Bridges/Laravel`) -- Cung cấp `PhpSwagServiceProvider` tự động đăng ký: - - Cấu hình từ file `config/phpswag.php` (cho phép tùy biến paths, output, format, cache, server UI path). - - Artisan commands: `php artisan phpswag:generate` (hỗ trợ cờ `--validate`). - - Route phục vụ Swagger UI (mặc định `/api/docs`). -- **Cơ chế render UI:** - - Route được đăng ký tự động trả về một trang Swagger UI HTML sử dụng thư viện CDN giúp giao diện hiển thị nhanh, đẹp và đồng nhất. - -#### b. Symfony Bridge (`src/Bridges/Symfony`) -- Cung cấp `PhpSwagBundle` tự động đăng ký Dependency Injection. -- Cung cấp Console Command `bin/console phpswag:generate` (hỗ trợ cờ `--validate`). - -### 10.3. Linter & OpenAPI Validator (Epic 8) [ĐÃ TRIỂN KHAI] - -#### a. Native Structural Validator (Bộ kiểm lỗi nội bộ - `src/Validation/Validator.php`) -- Tự động chạy kiểm tra tính toàn vẹn của spec: - - Kiểm tra xem các Class tham chiếu trong `@response` hay `@body` (thông qua `$ref`) có thực sự tồn tại trong registry/components.schemas hay không. - - Kiểm tra các trường thông tin bắt buộc của OpenAPI spec (như `openapi`, `info`, `title`, `version`). - - Cảnh báo nếu không định nghĩa endpoint nào trong file spec. - -#### b. Xử lý lỗi linh hoạt (Contextual Error Handling) -- Khi chạy lệnh **`generate`** kèm cờ `--validate`: In chi tiết các lỗi phát hiện và dừng quá trình biên dịch (trả về mã lỗi khác `0`) nếu phát hiện spec không hợp lệ. - diff --git a/phpunit.xml b/phpunit.xml index 6fb2c52..55c80ed 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,4 +8,9 @@ tests + + + src + + diff --git a/src/Core.php b/src/Core.php index e16c028..d07ad67 100644 --- a/src/Core.php +++ b/src/Core.php @@ -487,6 +487,7 @@ private function analyzeClass(string $fqcn, Class_|Trait_|Enum_|Interface_ $stmt $enumValues = []; if ($isBacked) { + /** @var \ReflectionNamedType $backingType */ $backingType = $reflection->getBackingType(); $backingTypeName = $backingType->getName(); $enumType = $backingTypeName === 'int' ? 'integer' : 'string'; diff --git a/tests/NativeEnumSupportTest.php b/tests/NativeEnumSupportTest.php index b882073..6d987f7 100644 --- a/tests/NativeEnumSupportTest.php +++ b/tests/NativeEnumSupportTest.php @@ -22,7 +22,7 @@ public function testBackedStringEnumSupport() class TestController { /** * @route GET /test-string - * @response 200 \PhpSwag\Tests\Fixtures\Enums\BackedStringEnum + * @response 200 \PhpSwag\Tests\fixtures\enums\BackedStringEnum */ public function getTest() {} } @@ -39,7 +39,7 @@ public function getTest() {} unlink($tempFile); $spec = Yaml::parse($yaml); - $schema = $spec['components']['schemas']['PhpSwag_Tests_Fixtures_Enums_BackedStringEnum']; + $schema = $spec['components']['schemas']['PhpSwag_Tests_fixtures_enums_BackedStringEnum']; $this->assertEquals('string', $schema['type']); $this->assertEquals(['H', 'S'], $schema['enum']); @@ -59,7 +59,7 @@ public function testBackedIntEnumSupport() class TestController { /** * @route GET /test-int - * @response 200 \PhpSwag\Tests\Fixtures\Enums\BackedIntEnum + * @response 200 \PhpSwag\Tests\fixtures\enums\BackedIntEnum */ public function getTest() {} } @@ -75,7 +75,7 @@ public function getTest() {} unlink($tempFile); $spec = Yaml::parse($yaml); - $schema = $spec['components']['schemas']['PhpSwag_Tests_Fixtures_Enums_BackedIntEnum']; + $schema = $spec['components']['schemas']['PhpSwag_Tests_fixtures_enums_BackedIntEnum']; $this->assertEquals('integer', $schema['type']); $this->assertEquals([1, 0], $schema['enum']); @@ -95,7 +95,7 @@ public function testPureUnitEnumSupport() class TestController { /** * @route GET /test-unit - * @response 200 \PhpSwag\Tests\Fixtures\Enums\PureUnitEnum + * @response 200 \PhpSwag\Tests\fixtures\enums\PureUnitEnum */ public function getTest() {} } @@ -111,7 +111,7 @@ public function getTest() {} unlink($tempFile); $spec = Yaml::parse($yaml); - $schema = $spec['components']['schemas']['PhpSwag_Tests_Fixtures_Enums_PureUnitEnum']; + $schema = $spec['components']['schemas']['PhpSwag_Tests_fixtures_enums_PureUnitEnum']; $this->assertEquals('string', $schema['type']); $this->assertEquals(['Pending', 'Approved', 'Rejected'], $schema['enum']); diff --git a/tests/fixtures/enums/BackedIntEnum.php b/tests/fixtures/enums/BackedIntEnum.php index 6ff1525..9044f50 100644 --- a/tests/fixtures/enums/BackedIntEnum.php +++ b/tests/fixtures/enums/BackedIntEnum.php @@ -1,6 +1,6 @@ Date: Tue, 9 Jun 2026 13:39:32 +0700 Subject: [PATCH 26/27] feat: support nikic/php-parser v5 --- composer.json | 2 +- src/Attributes/AttributeParser.php | 1 + src/Parser.php | 9 ++++++++- tests/NameResolverTest.php | 7 ++++++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index b92d95e..f441539 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ }, "require": { "php": ">=8.0", - "nikic/php-parser": "^4.15", + "nikic/php-parser": "^4.15 || ^5.0", "phpstan/phpdoc-parser": "^1.24", "symfony/console": "^6.0", "symfony/finder": "^6.0", diff --git a/src/Attributes/AttributeParser.php b/src/Attributes/AttributeParser.php index f282ceb..34797b6 100644 --- a/src/Attributes/AttributeParser.php +++ b/src/Attributes/AttributeParser.php @@ -133,6 +133,7 @@ private function evaluateExpression(Expr $expr, NameResolver $nameResolver): mix if ($expr instanceof Array_) { $result = []; foreach ($expr->items as $item) { + // @phpstan-ignore-next-line if ($item === null) { continue; } diff --git a/src/Parser.php b/src/Parser.php index 6b9a4d6..a051676 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -13,7 +13,14 @@ class Parser public function __construct() { - $this->parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); + $factory = new ParserFactory(); + // @phpstan-ignore-next-line + if (method_exists($factory, 'createForNewestSupportedVersion')) { + $this->parser = $factory->createForNewestSupportedVersion(); + } else { + // @phpstan-ignore-next-line + $this->parser = $factory->create(ParserFactory::PREFER_PHP7); + } } /** diff --git a/tests/NameResolverTest.php b/tests/NameResolverTest.php index 2105086..15c1610 100644 --- a/tests/NameResolverTest.php +++ b/tests/NameResolverTest.php @@ -18,7 +18,12 @@ public function testResolveClassName() class UserController {} '; - $parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7); + $factory = new ParserFactory(); + if (method_exists($factory, 'createForNewestSupportedVersion')) { + $parser = $factory->createForNewestSupportedVersion(); + } else { + $parser = $factory->create(ParserFactory::PREFER_PHP7); + } $stmts = $parser->parse($code); $resolver = new NameResolver(); From 50979caa90d16c4c92076197857e61ac632a6f45 Mon Sep 17 00:00:00 2001 From: tolawho Date: Tue, 9 Jun 2026 14:21:15 +0700 Subject: [PATCH 27/27] ci: update github workflow ci --- .github/workflows/ci.yml | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eab4ee8..6916355 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['8.1', '8.2', '8.3', '8.4'] + php-version: ['8.1', '8.3'] steps: - name: Checkout code diff --git a/composer.json b/composer.json index f441539..cde7a32 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ } }, "require": { - "php": ">=8.0", + "php": ">=8.1", "nikic/php-parser": "^4.15 || ^5.0", "phpstan/phpdoc-parser": "^1.24", "symfony/console": "^6.0",