fishyboat21/simple-api
Composer 安装命令:
composer require fishyboat21/simple-api
包简介
A minimal PHP 8.4+ library for building JSON HTTP APIs with per-file routing
README 文档
README
A minimal, zero-dependency PHP 8.4+ library for building JSON HTTP APIs using a per-file endpoint pattern. The filesystem is your routing table — no manual route registration, no YAML, no annotations.
Requirements
- PHP >= 8.4
- Composer
Installation
composer require fishyboat21/simple-api
Quick Start
1. Create the directory structure
your-project/
├── public/
│ └── index.php # Front controller
├── src/
│ └── Api/ # Your endpoint classes live here
├── storage/ # Generated route cache
├── vendor/
└── composer.json
Add the PSR-4 autoload mapping for your API namespace in composer.json:
{
"autoload": {
"psr-4": {
"App\\Api\\": "src/Api/"
}
},
"scripts": {
"route:generate": "simple-api route:generate"
}
}
Run composer dump-autoload after adding the mapping.
2. Create your first endpoint
src/Api/Health/Index.php
<?php namespace App\Api\Health; use Fishyboat21\SimpleApi\Attribute\Route; use Fishyboat21\SimpleApi\Enum\Method; use Fishyboat21\SimpleApi\Http\Request; use Fishyboat21\SimpleApi\Http\Response; use Fishyboat21\SimpleApi\Interface\ApiHandler; #[Route(Method::GET)] class Index implements ApiHandler { public function handle(Request $request): Response { return Response::ok(['status' => 'healthy']); } }
3. Generate the route cache
composer route:generate
# Output: Route cache generated: 1 route(s) written to storage/routes.php
4. Create the front controller
public/index.php
<?php require __DIR__ . '/../vendor/autoload.php'; use Fishyboat21\SimpleApi\SimpleApi; $api = new SimpleApi( routeCacheFile: __DIR__ . '/../storage/routes.php', ); $api->run();
5. Start the server
php -S localhost:8000 -t public/ # → GET http://localhost:8000/api/health # → {"status":200,"message":"OK","data":{"status":"healthy"}}
Routing Convention
The filesystem path is the URL path. Create a file — it becomes a route.
| File | URL | Method |
|---|---|---|
src/Api/Health/Index.php |
GET /api/health |
Declared via #[Route] |
src/Api/Users/Index.php |
GET,POST /api/users |
Multiple #[Route] on one class |
src/Api/Users/_id/Index.php |
GET /api/users/{id} |
Params via _ prefix |
Rules
- Files go in
src/Api/(configurable via--scan) Index.phpis the collection root — stripped from the URL_underscoreprefix creates a path parameter —_idbecomes{id}- Directories become URL path segments, all lowercased
#[Route]attribute declares the HTTP method(s)- Regenerate the cache after adding or changing any endpoint:
composer route:generate
Path parameters
Path parameter values are accessed on the Request object:
// src/Api/Users/_id/Index.php → GET /api/users/42 #[Route(Method::GET)] class Index implements ApiHandler { public function handle(Request $request): Response { $userId = $request->getAttribute('id'); // '42' return Response::ok([ 'userId' => $userId, ]); } }
Multiple parameters are supported — just nest _-prefixed directories:
src/Api/Organizations/_orgId/Members/_memberId/Index.php
→ GET /api/organizations/{orgId}/members/{memberId}
Creating Endpoints
Single-method endpoint
A class-level #[Route] dispatches to a handle() method by convention:
<?php namespace App\Api\Products; use Fishyboat21\SimpleApi\Attribute\Route; use Fishyboat21\SimpleApi\Enum\Method; use Fishyboat21\SimpleApi\Http\Request; use Fishyboat21\SimpleApi\Http\Response; use Fishyboat21\SimpleApi\Interface\ApiHandler; #[Route(Method::GET)] class Index implements ApiHandler { public function handle(Request $request): Response { return Response::ok([ 'products' => [/* ... */], ]); } }
Multi-method endpoint (separate methods per verb)
Use method-level #[Route] attributes to dispatch directly to named methods:
<?php namespace App\Api\Users; use Fishyboat21\SimpleApi\Attribute\Route; use Fishyboat21\SimpleApi\Enum\Method; use Fishyboat21\SimpleApi\Http\Request; use Fishyboat21\SimpleApi\Http\Response; use Fishyboat21\SimpleApi\Interface\ApiHandler; class Index implements ApiHandler { #[Route(Method::GET)] public function list(Request $request): Response { return Response::ok([/* all users */]); } #[Route(Method::POST)] public function create(Request $request): Response { $name = $request->body('name'); // ... create user ... return Response::created(['id' => 1], 'User created'); } }
Request Handling
The Request object provides access to every part of the incoming request:
public function handle(Request $request): Response { // Query string: /api/search?q=hello&page=2 $query = $request->query('q'); // 'hello' $page = $request->query('page', '1'); // '2' (with default) $all = $request->queryParams(); // ['q' => 'hello', 'page' => '2'] // JSON body (parsed lazily on first access) $name = $request->body('name'); // single field $all = $request->bodyData(); // entire parsed body as array $raw = $request->rawBody(); // raw unparsed body string // Headers (case-insensitive) $auth = $request->header('Authorization'); $type = $request->header('Content-Type'); // Path parameters (set by the router) $id = $request->getAttribute('id'); // from /api/users/{id} // Request metadata $method = $request->method(); // 'GET', 'POST', etc. $uri = $request->uri(); // '/api/users/42?include=posts' $path = $request->path(); // '/api/users/42' (no query string) return Response::ok(/* ... */); }
Response Helpers
// 200 OK with data Response::ok(['user' => $user]); // 200 OK with custom message Response::ok(['user' => $user], 'User retrieved'); // 201 Created Response::created(['id' => 42], 'User created'); // 204 No Content (no body) Response::noContent(); // 404 Not Found Response::notFound('User not found'); // Custom error status Response::error(422, 'Validation failed'); // Custom response with headers Response::ok(['data' => $value]) ->setHeader('X-Custom', 'value') ->setHeader('Cache-Control', 'no-cache'); // Manual construction new Response(status: 200, message: 'OK', data: ['key' => 'value']);
JSON response format
// Success (data present) {"status":200,"message":"OK","data":{...}} // Success (data null — key omitted) {"status":200,"message":"OK"} // Error {"status":404,"message":"Not Found"}
CORS Configuration
CORS is configurable per-origin, with safe defaults (allow all origins, common methods).
<?php // public/index.php require __DIR__ . '/../vendor/autoload.php'; use Fishyboat21\SimpleApi\Config\CorsConfig; use Fishyboat21\SimpleApi\SimpleApi; $cors = new CorsConfig( allowedOrigins: ['https://myapp.com', 'https://admin.myapp.com'], allowedMethods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'], allowCredentials: true, maxAge: 3600, // preflight cache in seconds ); $api = new SimpleApi( routeCacheFile: __DIR__ . '/../storage/routes.php', corsConfig: $cors, ); $api->run();
The default CorsConfig allows all origins (*), all common HTTP methods, and common headers. Pass a custom CorsConfig to restrict origins, enable credentials, or limit allowed methods.
Note: Using
allowedOrigins: ['*']together withallowCredentials: truewill throw aRuntimeException— the CORS spec forbids this combination.
Error Handling & Logging
Error responses
The framework automatically returns proper JSON error responses:
| Condition | Status | Body |
|---|---|---|
| Route not found | 404 | {"status":404,"message":"Not Found"} |
| Method not allowed | 405 | {"status":405,"message":"Method Not Allowed"} + Allow header |
| Unhandled exception | 500 | {"status":500,"message":"Internal Server Error"} |
Custom HttpException |
varies | Status from exception, safe message exposed |
Security: Unhandled exceptions (500) return a generic
"Internal Server Error"message to the client. The real exception message is never exposed — instead, it is passed to the configured logger.
Attaching a logger
$api = new SimpleApi(routeCacheFile: '...'); $api->setLogger($myPsr3Logger); // any object with an error() method $api->run();
Custom HTTP exceptions in handlers
use Fishyboat21\SimpleApi\Exception\HttpException; // Throw custom HTTP errors from your handlers throw new \Fishyboat21\SimpleApi\Exception\NotFoundException('User not found'); // → 404 {"status":404,"message":"User not found"} // Or create your own exception class: class UnprocessableEntityException extends HttpException { public function __construct(string $message = 'Unprocessable Entity') { parent::__construct($message, 422); } public function getStatusCode(): int { return 422; } }
Route Cache Commands
# Generate the cache (default paths) composer route:generate # Custom paths vendor/bin/simple-api route:generate \ --scan=src/Api \ --namespace="App\\Api" \ --output=storage/routes.php \ --base-url=api
Re-run this command whenever you add, remove, or rename endpoint files. The cache is a plain PHP array loaded via require and cached by OPcache in production.
Custom Base Path
By default, routes are prefixed with /api. Change this in the constructor:
// No prefix — routes at / $api = new SimpleApi(routeCacheFile: '...', baseUrlPath: ''); // Different prefix $api = new SimpleApi(routeCacheFile: '...', baseUrlPath: 'v2'); // Routes at /v2/health, /v2/users, etc.
Complete Example
src/Api/Tasks/_id/Index.php
<?php namespace App\Api\Tasks\_id; use Fishyboat21\SimpleApi\Attribute\Route; use Fishyboat21\SimpleApi\Enum\Method; use Fishyboat21\SimpleApi\Exception\NotFoundException; use Fishyboat21\SimpleApi\Http\Request; use Fishyboat21\SimpleApi\Http\Response; use Fishyboat21\SimpleApi\Interface\ApiHandler; class Index implements ApiHandler { #[Route(Method::GET)] public function show(Request $request): Response { $taskId = (int) $request->getAttribute('id'); $task = $this->findTask($taskId); if ($task === null) { throw new NotFoundException("Task {$taskId} not found"); } return Response::ok($task); } #[Route(Method::PUT)] public function update(Request $request): Response { $taskId = (int) $request->getAttribute('id'); $title = $request->body('title'); $done = $request->body('done', false); $task = $this->updateTask($taskId, $title, $done); return Response::ok($task, 'Task updated'); } #[Route(Method::DELETE)] public function delete(Request $request): Response { $taskId = (int) $request->getAttribute('id'); $this->removeTask($taskId); return Response::noContent(); } // ... data access methods ... }
After creating the file, run composer route:generate and the following routes are live:
GET /api/tasks/42 → App\Api\Tasks\_id\Index::show()
PUT /api/tasks/42 → App\Api\Tasks\_id\Index::update()
DELETE /api/tasks/42 → App\Api\Tasks\_id\Index::delete()
Performance
- Route cache loaded via
require— OPcache caches the trie in shared memory - O(depth) route matching — typically 2–5 array lookups per request, no regex, no linear scan
- Lazy handler loading — the matched handler class is autoloaded only after a successful match
- Lazy request body parsing —
php://inputis read only if the handler callsbody()orbodyData() - Zero filesystem scanning at runtime — the route cache is pre-built
- No middleware stack overhead — fixed pipeline: CORS → match → dispatch → send
License
MIT
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 0
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-07-08