Skip to content

r3rc/spine

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Spine

Minimal PSR-15 micro-framework for REST APIs.

Installation

Add the repository to your composer.json:

{
    "repositories": [
        {
            "type": "vcs",
            "url": "https://github.com/r3rc/spine"
        }
    ],
    "require": {
        "r3rc/spine": "dev-main"
    }
}

Then run:

composer install

Quick Start

<?php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use Spine\Core\Application;
use Spine\Core\Env;
use Spine\Http\ErrorMiddleware;
use Spine\Identity\Jwt;
use Spine\Identity\ResolveIdentity;

// Load environment
Env::load(__DIR__ . '/../.env');

// Configure JWT
Jwt::configure(Env::get('JWT_SECRET'));

// Create application
$app = Application::create();

// Middleware pipeline
$app->pipe(ErrorMiddleware::class);
$app->pipe(ResolveIdentity::class);

// Routes
$app->router->get('/health', fn () => ['status' => 'ok']);

$app->router->post('/auth/login', App\Auth\Login::class);

$app->router->group('/users', function ($r) {
    $r->get('', App\Users\ListUsers::class);
    $r->post('', App\Users\CreateUser::class);
    $r->get('/{id}', App\Users\GetUser::class);
})->pipe(RequireIdentity::class);

// Run
$app->run();

Handlers

Extend Handler for type-safe request handling:

<?php

use Psr\Http\Message\ResponseInterface;
use Spine\Http\Handler;
use Spine\Validation\Validator;

final class CreateUser extends Handler
{
    protected function execute(): ResponseInterface
    {
        $data = Validator::validate($this->body(), [
            'email' => 'required|email',
            'name' => 'required|min:2',
            'password' => 'required|min:8',
        ]);

        // Create user...

        return $this->created(['id' => $user['id']]);
    }
}

Handler Methods

// Response helpers
$this->ok($data);           // 200
$this->created($data);      // 201
$this->accepted($data);     // 202
$this->noContent();         // 204
$this->json($data, $status);

// Request helpers
$this->param('id');         // Route parameter
$this->body();              // Parsed body as array
$this->query('page', 1);    // Query parameter with default
$this->request();           // PSR-7 ServerRequestInterface

Errors

<?php

use Spine\Http\HttpException;

// Define domain errors
final class UserErrors
{
    public static function notFound(): HttpException
    {
        return HttpException::notFound('user.not_found', 'User not found')();
    }

    public static function emailTaken(): HttpException
    {
        return HttpException::conflict('user.email_taken', 'Email already exists')();
    }
}

// Usage
throw UserErrors::notFound();
throw UserErrors::emailTaken();

Available Error Factories

HttpException::badRequest($code, $message);      // 400
HttpException::unauthorized($code, $message);    // 401
HttpException::forbidden($code, $message);       // 403
HttpException::notFound($code, $message);        // 404
HttpException::conflict($code, $message);        // 409
HttpException::unprocessable($code, $message);   // 422
HttpException::tooManyRequests($code, $message); // 429
HttpException::internal($code, $message);        // 500
HttpException::serviceUnavailable($code, $message); // 503

// Validation errors
HttpException::validation([
    ['code' => 'validation.required', 'message' => 'Email is required', 'target' => 'email'],
]);

Error Response Format

{
    "code": "user.not_found",
    "message": "User not found"
}
{
    "code": "validation.failed",
    "message": "Validation failed",
    "details": [
        {
            "code": "validation.invalid",
            "message": "The email is not valid",
            "target": "email"
        }
    ]
}

Identity (JWT)

<?php

use Spine\Identity\Identity;
use Spine\Identity\IdentityContext;
use Spine\Identity\Jwt;

// Configure (once at bootstrap)
Jwt::configure('your-secret-key', 'HS256');

// Create token
$identity = new Identity(
    id: $user['id'],
    role: $user['role'],
    claims: ['company_id' => $user['company_id']],
);

$token = Jwt::sign($identity, expiresIn: 86400);

// Access identity (after ResolveIdentity middleware)
$identity = IdentityContext::get();
$identity->id;
$identity->role;
$identity->claim('company_id');
$identity->hasRole('admin');
$identity->hasAnyRole('admin', 'supervisor');

Identity Middlewares

use Spine\Identity\ResolveIdentity;
use Spine\Identity\RequireIdentity;
use Spine\Identity\RequireRole;

// Global: extract identity from Authorization header
$app->pipe(ResolveIdentity::class);

// Route-level: require authenticated user
$router->group('/users', fn ($r) => ...)
    ->pipe(RequireIdentity::class);

// Route-level: require specific role
$router->group('/admin', fn ($r) => ...)
    ->pipe(new RequireRole('admin'));

// Multiple roles allowed
$router->group('/management', fn ($r) => ...)
    ->pipe(new RequireRole('admin', 'supervisor'));

Validation

Uses rakit/validation syntax:

<?php

use Spine\Validation\Validator;

$data = Validator::validate($this->body(), [
    'email' => 'required|email',
    'name' => 'required|min:2|max:100',
    'age' => 'required|integer|min:18',
    'role' => 'required|in:user,admin,supervisor',
    'website' => 'url',
    'tags' => 'array',
    'tags.*' => 'string|max:50',
]);

// $data contains only validated fields
// Throws HttpException::validation() on failure

Database

<?php

use PDO;
use Spine\Database\Database;
use Spine\Database\DatabaseContext;

// Setup (once at bootstrap)
$pdo = new PDO($dsn, $user, $password, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$db = new Database($pdo);
DatabaseContext::set($db);

// Usage anywhere
$db = DatabaseContext::get();

// Single row
$user = $db->one('SELECT * FROM users WHERE id = ?', [$id]);

// Multiple rows
$users = $db->many('SELECT * FROM users WHERE role = ?', ['admin']);

// Execute without return
$db->run('UPDATE users SET name = ? WHERE id = ?', [$name, $id]);

// Check existence
$exists = $db->exists('SELECT 1 FROM users WHERE email = ?', [$email]);

// Count
$total = $db->count('SELECT COUNT(*) FROM users');

// Transactions
$db->transaction(function (Database $tx) {
    $tx->run('INSERT INTO users (email) VALUES (?)', ['a@b.com']);
    $tx->run('INSERT INTO profiles (user_id) VALUES (?)', [$userId]);
});

// Access PDO directly
$pdo = $db->pdo();

Environment

<?php

use Spine\Core\Env;

// Load .env file
Env::load(__DIR__ . '/../.env');

// Get values
Env::get('DB_HOST');              // Throws if not set
Env::find('DB_HOST', 'localhost'); // Default if not set
Env::int('DB_PORT', 5432);         // As integer
Env::bool('APP_DEBUG', false);     // As boolean
Env::has('DB_HOST');               // Check if set

Context Pattern

Create typed contexts for dependency injection:

<?php

use Spine\Core\Context;

final class ConfigContext extends Context
{
    public static function set(Config $config): void
    {
        self::store($config);
    }

    public static function get(): Config
    {
        $config = self::retrieve();

        if (!$config instanceof Config) {
            throw new RuntimeException('Invalid config type');
        }

        return $config;
    }
}

// Usage
ConfigContext::set($config);
$config = ConfigContext::get();
ConfigContext::has();
ConfigContext::clear();
Context::clearAll();

Routing

<?php

$router = $app->router;

// Basic routes
$router->get('/users', ListUsers::class);
$router->post('/users', CreateUser::class);
$router->get('/users/{id}', GetUser::class);
$router->put('/users/{id}', UpdateUser::class);
$router->patch('/users/{id}', PatchUser::class);
$router->delete('/users/{id}', DeleteUser::class);

// Callable handlers
$router->get('/health', fn () => ['status' => 'ok']);

// Groups with prefix
$router->group('/api/v1', function ($r) {
    $r->get('/users', ListUsers::class);
    $r->post('/users', CreateUser::class);
});

// Groups with middleware
$router->group('/admin', function ($r) {
    $r->get('/stats', AdminStats::class);
})->pipe(new RequireRole('admin'));

// Nested groups
$router->group('/api', function ($r) {
    $r->group('/users', function ($r) {
        $r->get('', ListUsers::class);
        $r->post('', CreateUser::class);
    })->pipe(RequireIdentity::class);
});

// List all routes
$routes = $router->getRoutes();

Requirements

  • PHP 8.3+
  • PDO extension (for Database)

Dependencies

  • nikic/fast-route — Routing
  • nyholm/psr7 — PSR-7 implementation
  • nyholm/psr7-server — Request factory
  • psr/http-server-middleware — PSR-15 interfaces
  • firebase/php-jwt — JWT handling
  • rakit/validation — Validation

License

MIT

About

Minimal PSR-15 micro-framework for REST APIs

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages