This tutorial walks through building a versioned JSON API for a
posts resource. It covers routing, request validation, status codes,
pagination, error envelopes, OpenAPI generation, and tests.
GET /api/v1/posts List posts (paginated)
POST /api/v1/posts Create a post
GET /api/v1/posts/{id} Show one post
PUT /api/v1/posts/{id} Update a post
DELETE /api/v1/posts/{id} Delete a post
GET /api/v1/docs.json OpenAPI 3.0 document
GET /api/v1/docs Swagger UI
Open routes/api.php:
use Wave\Core\Wave;
use App\Controllers\Api\V1\PostsController;
Wave::group('/api/v1', function () {
Wave::get ('/posts', [PostsController::class, 'index']);
Wave::post ('/posts', [PostsController::class, 'store']);
Wave::get ('/posts/{id}', [PostsController::class, 'show']);
Wave::put ('/posts/{id}', [PostsController::class, 'update']);
Wave::delete('/posts/{id}', [PostsController::class, 'destroy']);
});php tide make:controller Api/V1/PostsController --apinamespace App\Controllers\Api\V1;
use Wave\Core\Http\Request;
use Wave\Core\Http\Response;
use Wave\API\ApiResponse;
use App\Models\Post;
use Wave\Validation\Validator;
class PostsController
{
public function index(Request $request): Response
{
$page = max(1, (int) $request->input('page', 1));
$perPage = min(100, max(1, (int) $request->input('per_page', 20)));
$total = Post::count();
$rows = Post::orderBy('id', 'desc')
->limit($perPage)
->offset(($page - 1) * $perPage)
->get();
return ApiResponse::make($rows->all(), 'OK', 200, [
'pagination' => [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
],
])->toResponse();
}
public function show(int $id): Response
{
$post = Post::find($id);
if ($post === null) {
return ApiResponse::error('Not found', 404)->toResponse();
}
return ApiResponse::make($post, 'OK', 200)->toResponse();
}
public function store(Request $request): Response
{
$v = new Validator($request->all(), [
'title' => 'required|string|max:200',
'body' => 'required|string|min:10',
]);
if ($v->fails()) {
return ApiResponse::error('Validation failed', 422)
->withErrors($v->errors())
->toResponse();
}
$post = new Post($v->validated());
$post->save();
return ApiResponse::make($post, 'Created', 201)->toResponse();
}
public function update(int $id, Request $request): Response
{
$post = Post::find($id);
if ($post === null) {
return ApiResponse::error('Not found', 404)->toResponse();
}
$v = new Validator($request->all(), [
'title' => 'string|max:200',
'body' => 'string|min:10',
]);
if ($v->fails()) {
return ApiResponse::error('Validation failed', 422)
->withErrors($v->errors())
->toResponse();
}
$post->fill($v->validated());
$post->save();
return ApiResponse::make($post, 'Updated', 200)->toResponse();
}
public function destroy(int $id): Response
{
$post = Post::find($id);
if ($post === null) {
return ApiResponse::error('Not found', 404)->toResponse();
}
$post->delete();
return ApiResponse::make(null, 'Deleted', 200)->toResponse();
}
}Every response follows this shape:
{
"status": "OK",
"code": 200,
"data": { ... },
"meta": { "pagination": { ... } },
"errors": []
}null fields are omitted. Error responses omit data. The headers
include Content-Type: application/json and an X-Request-Id (when
the request middleware sets one).
Force every /api/* request to receive JSON, even on errors raised
before the controller runs:
namespace App\Middleware;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ResponseInterface;
use Wave\API\ApiResponse;
class JsonOnly implements MiddlewareInterface
{
public function process(ServerRequestInterface $req, RequestHandlerInterface $h): ResponseInterface
{
$res = $h->handle($req);
if (!$res->hasHeader('Content-Type')) {
$res = $res->withHeader('Content-Type', 'application/json');
}
return $res;
}
}Wave::group('/api', function () { /* routes */ })->middleware(JsonOnly::class);Add doc comments to each controller method. They are picked up by the OpenAPI generator:
/**
* @api GET /api/v1/posts
* @apiSummary List posts
* @apiQuery page int Page number (1-based)
* @apiQuery per_page int Items per page (1-100)
* @apiResponse 200 {object} Post[]
*/
public function index(Request $request): Response { /* ... */ }Generate the spec:
curl http://127.0.0.1:8000/api/v1/docs.jsonA Swagger UI is served at /api/v1/docs.
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\Post;
use Wave\Database\Model;
use Wave\Database\Query\Connection;
class PostsApiTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$conn = new Connection(['driver' => 'sqlite', 'database' => ':memory:']);
Model::setConnection($conn);
$conn->execute(
'CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, body TEXT, slug TEXT, created_at TEXT, updated_at TEXT, published_at TEXT)'
);
}
public function testIndexReturns200(): void
{
$this->get('/api/v1/posts')->assertStatus(200);
}
public function testStoreValidatesTitle(): void
{
$this->post('/api/v1/posts', ['body' => 'long-enough-body'])
->assertStatus(422);
}
public function testStoreCreatesPost(): void
{
$this->post('/api/v1/posts', ['title' => 'A', 'body' => 'aaaaaaaaaa'])
->assertStatus(201)
->assertJsonPath('data.title', 'A');
}
public function testShowReturns404ForMissing(): void
{
$this->get('/api/v1/posts/9999')->assertStatus(404);
}
public function testUpdateAndDeleteRoundtrip(): void
{
$p = Post::create(['title' => 'A', 'body' => 'aaaaaaaaaa']);
$this->put('/api/v1/posts/' . $p->id, ['title' => 'B'])
->assertStatus(200)
->assertJsonPath('data.title', 'B');
$this->delete('/api/v1/posts/' . $p->id)->assertStatus(200);
$this->get('/api/v1/posts/' . $p->id)->assertStatus(404);
}
}Most APIs need a token. The simplest path is Wave\Auth\Token\TokenGuard:
use Wave\Auth\Token\TokenGuard;
$plain = TokenGuard::issue($user, 'mobile');
$user = TokenGuard::check($plain, $auth); // returns ?ModelAdd an Authorization: Bearer <plain> middleware that calls
TokenGuard::check on every request and rejects with 401 if the
header is missing or invalid.
Apply the throttle:60 named middleware to limit each IP to 60
requests per minute:
Wave::group('/api', function () { /* routes */ })
->middleware('throttle:60');- Add HATEOAS-style
linksinmetafor every record. - Add cursor pagination for large datasets.
- Add a
/api/v1/posts/{id}/commentssub-resource. - Publish the OpenAPI spec to a public URL for client codegen.