Skip to content

Latest commit

 

History

History
290 lines (237 loc) · 7.83 KB

File metadata and controls

290 lines (237 loc) · 7.83 KB

Tutorial: Build a JSON API

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.

What we are building

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

1. Routes

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']);
});

2. The controller

php tide make:controller Api/V1/PostsController --api
namespace 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();
    }
}

3. The ApiResponse envelope

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).

4. JSON-only middleware

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);

5. OpenAPI document

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.json

A Swagger UI is served at /api/v1/docs.

6. Tests

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);
    }
}

7. Authentication (optional)

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 ?Model

Add an Authorization: Bearer <plain> middleware that calls TokenGuard::check on every request and rejects with 401 if the header is missing or invalid.

8. Rate limiting

Apply the throttle:60 named middleware to limit each IP to 60 requests per minute:

Wave::group('/api', function () { /* routes */ })
    ->middleware('throttle:60');

9. What's next

  • Add HATEOAS-style links in meta for every record.
  • Add cursor pagination for large datasets.
  • Add a /api/v1/posts/{id}/comments sub-resource.
  • Publish the OpenAPI spec to a public URL for client codegen.