Skip to content

Latest commit

 

History

23 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

RESTstop

Bootstrap a full REST API from a PostgreSQL database with advanced relationship management, filtering, and validation rule exposure.

Quick Start

# 1. Copy and configure environment
cp .env.example .env
# Edit .env to set database credentials, USER_EMAIL & USER_PASSWORD, etc.

# 2. Start containers
docker compose up -d

# 3. Import your schema (from SQL schema export)
./yii migrate/import-schema your-schema.sql --force --run

# 4. Generate API with Faker data
./yii setup/generate-all --seed=100

That's it. Your API is now running. Controllers are auto-discovered and routed and your complete HTTP spec is available in requests/.


Table of Contents


Commands

./yii migrate/import-schema <file> [--force] [--run]

Parses a postgres schema dump and splits it into migration files:

  • 00000_create_schemas.sql - Schema definitions
  • 00001_create_*.sql - Tables and sequences
  • 09999_add_foreign_keys.sql - Constraints

Options:

  • --force - Overwrite existing migration files
  • --run - Execute the generated migrations immediately against the database

./yii model/generate-models

Generates ActiveRecord models from database tables.

Options:

  • --overwrite=1 - Overwrite existing files
  • --excludeTables=x,y - Skip specific tables
  • --specificTables=x,y - Only generate for specific tables

./yii controller/generate-controllers

Generates REST controllers for each model.

Options:

  • --overwrite=1 - Overwrite existing files
  • --excludeTables=x,y - Skip specific tables
  • --specificTables=x,y - Only generate for specific tables

./yii http/generate-all

Generates .http files in requests/ with examples for all endpoints:

  • CRUD operations
  • Filtering (?filter[field][like]=value)
  • Sorting (?sort=-field)
  • Pagination (?per-page=10)
  • Relation expansion (?expand=relation)

Options:

  • --outputPath=/path - Custom output directory
  • --excludeTables=x,y - Skip specific tables
  • --specificTables=x,y - Only generate for specific tables

./yii module/create <schema> [--module-name=<name>]

Creates a new module for a database schema.

# Create 'my_module' module for 'my_module' schema
./yii module/create my_module 

# Create 'orders' module mapped to 'my_module' schema
./yii module/create my_module --module-name=orders

./yii module/list

Lists all existing modules with their schema mappings, controller counts, and model counts.

./yii module/sync

Detects all schemas in the database and creates modules for any that don't have one. Useful when working with multi-schema databases.

./yii seeder/analyze

Analyzes all tables and reports how each column will be seeded. Shows confidence levels and suggests @faker hints for low-confidence columns.

Options:

  • --schema=x,y - Analyze specific schemas only
  • --excludeTables=x,y - Skip specific tables

./yii seeder/seed

Seeds tables with realistic faker data. Tables are seeded in foreign key dependency order (parents before children).

Options:

  • --count=100 - Records per table (default: 10)
  • --schema=x,y - Seed specific schemas only
  • --table=schema.table - Seed specific table only
  • --truncate - Truncate tables before seeding
  • --locale=de_DE - Faker locale (default: en_US)
  • --excludeTables=x,y - Skip specific tables
  • --interactive=0 - Non-interactive mode

Note: Use --truncate when re-seeding to avoid duplicate key errors.

./yii seeder/truncate

Clears all data from tables (in reverse dependency order to respect foreign keys).

Options:

  • --schema=x,y - Truncate specific schemas only
  • --table=schema.table - Truncate specific table only
  • --interactive=0 - Non-interactive mode

./yii setup/generate-all --seed=100

The unified pipeline now supports seeding. Add --seed=N to seed N records per table after generation:

# Generate API + seed 100 records per table
./yii setup/generate-all --seed=100

# Generate for specific schema with seeding
./yii setup/generate-all --schema=sales --seed=50

# With German locale
./yii setup/generate-all --seed=100 --seedLocale=de_DE

Database Seeding

RESTstop includes a powerful seeder that generates realistic fake data for any database schema with minimal configuration.

How It Works

The seeder uses a priority chain to determine the appropriate faker method for each column:

  1. Column comment @faker hint (highest priority) - explicit control via database comments
  2. Foreign key detection - automatically picks random IDs from referenced tables
  3. Column name pattern matching - recognizes common names like email, first_name, phone
  4. Type-based fallback (lowest priority) - generic data based on column type

Quick Start

# See how columns will be seeded
./yii seeder/analyze

# Seed all tables with 100 records each
./yii seeder/seed --count=100

# Or use the unified pipeline
./yii setup/generate-all --seed=100

@faker Hints

For columns that can't be auto-detected, add hints directly in your database schema using PostgreSQL comments. These hints travel with your migrations and pg_dump:

-- Basic faker method
COMMENT ON COLUMN public.user.email IS '@faker=email';
COMMENT ON COLUMN public.company.name IS '@faker=company';
COMMENT ON COLUMN public.address.city IS '@faker=city';

-- Methods with arguments
COMMENT ON COLUMN products.price IS '@faker=randomFloat(2,10,1000)';
COMMENT ON COLUMN users.age IS '@faker=numberBetween(18,80)';

-- Array/enum values
COMMENT ON COLUMN users.status IS '@faker=randomElement([0,1,2])';
COMMENT ON COLUMN orders.priority IS '@faker=randomElement([low,medium,high])';

-- Skip seeding (computed columns, etc)
COMMENT ON COLUMN users.full_name IS '@faker=skip';

-- Existing comments are preserved
COMMENT ON COLUMN users.email IS 'Primary contact email @faker=email';

Complete @faker Reference

Text & Strings

-- Names
@faker=firstName
@faker=lastName
@faker=name                          -- Full name
@faker=userName
@faker=title                         -- Mr., Mrs., etc.

-- Text content
@faker=sentence                      -- Single sentence
@faker=sentence(10)                  -- 10 words
@faker=paragraph
@faker=paragraph(3)                  -- 3 sentences
@faker=text                          -- ~200 chars
@faker=text(500)                     -- 500 chars
@faker=word
@faker=words(5)                      -- Array of 5 words
@faker=slug

-- Formatted strings
@faker=lexify(?????)                 -- Random letters: "abcde"
@faker=numerify(###-###)             -- Random numbers: "123-456"
@faker=bothify(??-###)               -- Mixed: "ab-123"
@faker=regexify([A-Z]{3}[0-9]{4})    -- From regex: "ABC1234"

Numbers

@faker=randomNumber                  -- Random integer
@faker=randomNumber(5)               -- Max 5 digits
@faker=numberBetween(1,100)          -- Range
@faker=randomFloat(2,0,1000)         -- 2 decimals, 0-1000
@faker=randomDigit                   -- 0-9
@faker=randomDigitNot(0)             -- 1-9
@faker=boolean                       -- true/false
@faker=boolean(70)                   -- 70% chance true

Date & Time

@faker=date                          -- "2024-03-15"
@faker=date(Y-m-d,now)               -- Format, max date
@faker=time                          -- "14:32:05"
@faker=dateTime                      -- DateTime object → "2024-03-15 14:32:05"
@faker=dateTimeBetween(-1 year,now)  -- Range
@faker=dateTimeThisYear
@faker=dateTimeThisMonth
@faker=year
@faker=month
@faker=dayOfWeek
@faker=unixTime
@faker=iso8601
@faker=timezone

Internet & Contact

@faker=email
@faker=safeEmail                     -- @example.com domain
@faker=freeEmail                     -- @gmail.com, etc.
@faker=companyEmail                  -- @company domain
@faker=url
@faker=domainName
@faker=ipv4
@faker=ipv6
@faker=macAddress
@faker=userAgent
@faker=slug
@faker=password                      -- Hashed password
@faker=sha256                        -- SHA-256 hash
@faker=uuid

Address

@faker=address                       -- Full address
@faker=streetAddress
@faker=streetName
@faker=buildingNumber
@faker=city
@faker=state
@faker=stateAbbr
@faker=postcode
@faker=country
@faker=countryCode
@faker=latitude
@faker=longitude

Company & Commerce

@faker=company
@faker=companySuffix                 -- Inc, LLC, etc.
@faker=jobTitle
@faker=bs                            -- Business buzzwords
@faker=catchPhrase

-- Commerce
@faker=productName
@faker=department
@faker=ean13                         -- Barcode
@faker=isbn13
@faker=currencyCode                  -- USD, EUR, etc.
@faker=creditCardNumber
@faker=creditCardType
@faker=iban
@faker=swiftBicNumber

Phone Numbers

@faker=phoneNumber
@faker=e164PhoneNumber               -- +12025551234
@faker=tollFreePhoneNumber

Files & Images

@faker=mimeType
@faker=fileExtension
@faker=imageUrl                      -- Placeholder image URL
@faker=imageUrl(640,480)             -- With dimensions
@faker=imageUrl(640,480,cats)        -- With category

Miscellaneous

@faker=hexColor                      -- #fa3b2c
@faker=rgbColor                      -- 255,128,0
@faker=colorName                     -- blue, red
@faker=locale                        -- en_US
@faker=languageCode                  -- en, de, fr
@faker=countryISOAlpha3              -- USA, DEU
@faker=emoji
@faker=md5
@faker=sha1
@faker=sha256

-- Randomness
@faker=randomElement([a,b,c])        -- Pick from array
@faker=randomElements([a,b,c],2)     -- Pick 2 from array
@faker=shuffle([a,b,c])              -- Shuffle array
@faker=unique(email)                 -- Guarantee uniqueness

Built-in Pattern Recognition

The seeder automatically recognizes these column name patterns without needing @faker hints:

Exact matches:

Column Name Faker Method
email email
first_name, firstname firstName
last_name, lastname lastName
phone, phone_number phoneNumber
city city
state state
country country
zip, postal_code, postcode postcode
address, street_address streetAddress
company, company_name company
username, user_name userName
url, website url
description, bio, notes text
title jobTitle
ip, ip_address ipv4
uuid, guid uuid
latitude, lat latitude
longitude, lng longitude
password hashed "password"
auth_key, access_token random token

Suffix patterns:

Pattern Faker Method
*_at (created_at, updated_at) dateTime
*_date date
*_time time
*_email email
*_url url
*_token, *_hash SHA-256 hash
*_ip ipv4
*_count, *_number numberBetween

Prefix patterns:

Pattern Faker Method
is_*, has_*, can_* boolean
min_* numberBetween(0,50)
max_* numberBetween(50,100)
total_*, count_* numberBetween(0,1000)

Foreign Key Handling

Foreign key columns are automatically detected and populated with random IDs from the referenced table:

# Automatic FK resolution
user.company_id → picks random id from company table
post.author_id  → picks random id from user table

Tables are seeded in dependency order (parents first), so referenced records always exist.

Self-Referencing Tables

For self-referencing foreign keys (e.g., user.manager_iduser.id):

  • First half of records get NULL for the self-reference
  • Second half get random IDs from previously created records

Analyze Before Seeding

Always run analyze first to check detection accuracy:

./yii seeder/analyze --schema=public

Output shows each column with its detected method, source, and confidence:

Schema: public
──────────────────────────────────────────────────────────────────────

  Table: user
  ┌────────────────────┬────────────────────┬───────────────┬──────────┐
  │Column              │Method              │Source         │Confidence│
  ├────────────────────┼────────────────────┼───────────────┼──────────┤
  │id                  │(auto-increment)    │skip           │N/A       │
  │email               │email               │exact match    │HIGH      │
  │first_name          │firstName           │exact match    │HIGH      │
  │password_hash       │sha256              │suffix pattern │MEDIUM    │
  │company_id          │[FK] company.id     │foreign key    │HIGH      │
  │status              │numberBetween       │type fallback  │LOW       │
  └────────────────────┴────────────────────┴───────────────┴──────────┘

  Suggested hints:
    COMMENT ON COLUMN user.status IS '@faker=randomElement([0,1,2])';

Add @faker hints for any LOW confidence columns, then seed:

./yii seeder/seed --count=100

Locale Support

Generate localized fake data by specifying a locale:

# German names, addresses, phone numbers
./yii seeder/seed --count=100 --locale=de_DE

# French
./yii seeder/seed --count=100 --locale=fr_FR

# Japanese
./yii seeder/seed --count=100 --locale=ja_JP

Available locales: en_US, en_GB, de_DE, fr_FR, es_ES, it_IT, pt_BR, ja_JP, zh_CN, ko_KR, ru_RU, and many more.


Modules

RESTstop supports multi-schema PostgreSQL databases through a module system. Each database schema maps to an API module containing its own models and controllers.

Auto-Discovery

Modules and controllers are auto-discovered at runtime:

  • Modules are detected from src/api/modules/*/Module.php
  • Controllers are detected from each module's controllers/ directory
  • URL routes are automatically generated as /{module}/{controller}

Schema-to-Module Mapping

By default, modules map 1:1 with database schemas (e.g., my_module schema → my_module module). For custom mappings, define $dbSchema in the Module class:

// src/api/modules/orders/Module.php
namespace api\modules\orders;

class Module extends \yii\base\Module
{
    public $controllerNamespace = 'api\modules\orders\controllers';

    // Maps this module to the 'my_module' database schema
    public $dbSchema = 'my_module';
}

The generators (model/generate-models, controller/generate-controllers, http/generate-all) automatically route tables to the correct module based on their schema.

Module Structure

Each module follows a standard structure:

src/api/modules/{module}/
├── Module.php           # Module configuration
├── controllers/         # REST controllers
│   └── ProductController.php
└── models/              # ActiveRecord models
    └── Product.php

Working with Multiple Schemas

# 1. Sync modules with database schemas
./yii module/sync

# 2. Generate modules, models, controllers and HTTP request spec for all database tables
./yii setup/generate-all

That's it. You now have a fully function REST API mirroring your entire database.

Tables from schema my_module will generate models/controllers in src/api/modules/my_module/, accessible at /my_module/{controller}.


Project Structure

src/
├── api/
│   ├── config/
│   │   └── main.php          # Auto-discovers modules and controllers
│   └── modules/
│       ├── public/           # Default module (public schema)
│       │   ├── Module.php
│       │   ├── models/
│       │   └── controllers/
│       └── {schema}/         # Additional modules per schema
│           ├── Module.php
│           ├── models/
│           └── controllers/
├── console/
│   ├── controllers/
│   │   ├── ModelController.php       # ./yii model/*
│   │   ├── ControllerController.php  # ./yii controller/*
│   │   ├── HttpController.php        # ./yii http/*
│   │   ├── ModuleController.php      # ./yii module/*
│   │   └── SeederController.php      # ./yii seeder/*
│   └── components/
│       └── seeder/                   # Seeder infrastructure
│           ├── FakerResolver.php
│           ├── PatternDictionary.php
│           ├── TableDependencyResolver.php
│           └── ColumnAnalyzer.php
├── common/
│   ├── actions/              # Custom REST actions
│   │   └── RulesAction.php
│   ├── behaviors/            # Model behaviors
│   │   ├── LinkBehavior.php
│   │   └── RelationsBehavior.php
│   ├── controllers/          # Base controllers
│   │   └── BaseController.php
│   ├── errorHandler/         # Custom error handling
│   │   └── CustomErrorHandler.php
│   ├── exceptions/           # Custom exceptions
│   │   ├── BaseHttpException.php
│   │   └── LinkException.php
│   ├── models/               # Base models
│   │   ├── BaseActiveRecord.php
│   │   ├── BaseActiveQuery.php
│   │   └── SearchableModel.php
│   └── traits/               # Reusable traits
│       ├── SearchableTrait.php
│       └── VirtualAttributesTrait.php
docker/postgres/migrations/   # Generated SQL migrations
requests/                     # Generated .http files

Database Schema Example

The code examples throughout this documentation reference the following schema. It demonstrates common patterns: user authentication, role-based permissions via junction tables, and content with tagging.

erDiagram
    User ||--o{ UserRole : has
    Role ||--o{ UserRole : assigned_to
    User ||--o{ Post : writes
    Post ||--o{ PostTag : has
    Tag ||--o{ PostTag : applied_to

    User {
        int id PK
        string email UK
        string password_hash
        string access_token
        timestamp created_at
        timestamp updated_at
    }

    Role {
        int id PK
        string name UK
        string description
    }

    UserRole {
        int user_id FK
        int role_id FK
        boolean is_primary
        timestamp assigned_at
    }

    Post {
        int id PK
        int user_id FK
        string title
        text content
        string status
        timestamp published_at
    }

    Tag {
        int id PK
        string name UK
        string color
    }

    PostTag {
        int post_id FK
        int tag_id FK
        int weight
    }
Loading

SQL Schema:

CREATE TABLE "user" (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(60) NOT NULL,
    access_token VARCHAR(255),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE role (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) NOT NULL UNIQUE,
    description TEXT
);

CREATE TABLE user_role (
    user_id INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
    role_id INTEGER NOT NULL REFERENCES role(id) ON DELETE CASCADE,
    is_primary BOOLEAN DEFAULT false,
    assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (user_id, role_id)
);

CREATE TABLE post (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES "user"(id),
    title VARCHAR(255) NOT NULL,
    content TEXT,
    status VARCHAR(20) DEFAULT 'draft',
    published_at TIMESTAMP
);

CREATE TABLE tag (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) NOT NULL UNIQUE,
    color VARCHAR(7)
);

CREATE TABLE post_tag (
    post_id INTEGER NOT NULL REFERENCES post(id) ON DELETE CASCADE,
    tag_id INTEGER NOT NULL REFERENCES tag(id) ON DELETE CASCADE,
    weight INTEGER DEFAULT 0,
    PRIMARY KEY (post_id, tag_id)
);

Behaviors

LinkBehavior

Handles many-to-many relationship linking/unlinking on UPDATE events with transaction support.

Location: src/common/behaviors/LinkBehavior.php

Features:

  • Atomic transactions with configurable rollback
  • Array syntax: [12, 15, -3] (link 12 & 15, unlink 3)
  • Object syntax with junction data: {"12": {"weight": 5}, "-3": null}
  • Error collection for partial failure handling

Model Setup:

namespace api\modules\public\models;

use common\models\BaseActiveRecord;
use common\behaviors\LinkBehavior;

class Post extends BaseActiveRecord
{
    public function behaviors()
    {
        return [
            'link' => [
                'class' => LinkBehavior::class,
                'rollbackOnError' => true, // All-or-nothing (default)
            ],
        ];
    }

    public function extraFields()
    {
        return ['tags']; // Relations that can be linked via API
    }

    public function getTags()
    {
        return $this->hasMany(Tag::class, ['id' => 'tag_id'])
            ->viaTable('post_tag', ['post_id' => 'id']);
    }

    // Junction relation for LinkBehavior (convention: get{OwnerClass}{RelationPlural})
    public function getPostTags()
    {
        return $this->hasMany(PostTag::class, ['post_id' => 'id']);
    }
}

API Usage:

### Link tags to a post (array syntax)
PUT /api/posts/1
Content-Type: application/json

{
    "title": "Updated Post",
    "tags": [5, 8, 12]
}

### Link tags with junction data (object syntax)
PUT /api/posts/1
Content-Type: application/json

{
    "tags": {
        "5": {"weight": 10},
        "8": {"weight": 5},
        "-3": null
    }
}

### Mixed: link some, unlink others
PUT /api/posts/1
Content-Type: application/json

{
    "tags": [5, 8, -3, -7]
}

Response on Error:

{
    "name": "Unprocessable Entity",
    "message": {
        "tags": {
            "999": "Record with ID 999 not found for relation tags"
        }
    },
    "code": 0,
    "status": 422
}

RelationsBehavior

Handles many-to-many linking on both INSERT and UPDATE events. Complements LinkBehavior by supporting POST (create) operations.

Location: src/common/behaviors/RelationsBehavior.php

Features:

  • Works on both INSERT and UPDATE events
  • Same array/object syntax as LinkBehavior
  • Optional deferral to LinkBehavior for UPDATE events
  • Configurable relation filtering

Model Setup:

namespace api\modules\public\models;

use common\models\BaseActiveRecord;
use common\behaviors\RelationsBehavior;
use common\behaviors\LinkBehavior;

class User extends BaseActiveRecord
{
    public function behaviors()
    {
        return [
            'relations' => [
                'class' => RelationsBehavior::class,
                'relations' => ['roles'], // Optional: specific relations only
                'deferUpdatesToLinkBehavior' => true, // Let LinkBehavior handle UPDATE
            ],
            'link' => [
                'class' => LinkBehavior::class,
            ],
        ];
    }

    public function extraFields()
    {
        return ['roles'];
    }

    public function getRoles()
    {
        return $this->hasMany(Role::class, ['id' => 'role_id'])
            ->viaTable('user_role', ['user_id' => 'id']);
    }

    public function getUserRoles()
    {
        return $this->hasMany(UserRole::class, ['user_id' => 'id']);
    }
}

API Usage:

### Create user with roles (RelationsBehavior handles INSERT)
POST /api/users
Content-Type: application/json

{
    "email": "admin@example.com",
    "password": "secret123",
    "roles": [1, 2]
}

### Create with junction data
POST /api/users
Content-Type: application/json

{
    "email": "admin@example.com",
    "password": "secret123",
    "roles": {
        "1": {"is_primary": true},
        "2": {"is_primary": false}
    }
}

Traits

SearchableTrait

Provides standardized search/filter capability for REST controllers using Yii2's ActiveDataFilter.

Location: src/common/traits/SearchableTrait.php

Features:

  • Query-string based filtering with operators
  • Attribute whitelisting for security
  • PostgreSQL ILIKE support
  • Configurable pagination and sorting

Supported Operators:

Operator SQL Example
eq = value ?filter[status]=active
neq <> value ?filter[status][neq]=deleted
lt < value ?filter[price][lt]=100
lte <= value ?filter[price][lte]=100
gt > value ?filter[price][gt]=50
gte >= value ?filter[price][gte]=50
like LIKE %value% ?filter[name][like]=john
in IN (...) ?filter[id][in][]=1&filter[id][in][]=2

Controller Setup:

namespace api\modules\public\controllers;

use common\controllers\BaseController;
use common\traits\SearchableTrait;

class PostController extends BaseController
{
    use SearchableTrait;

    public $modelClass = 'api\modules\public\models\Post';

    // Whitelist filterable attributes (security)
    public $filterableAttributes = ['title', 'status', 'user_id', 'published_at'];

    public function actions()
    {
        $actions = parent::actions();
        $actions['index']['prepareDataProvider'] = [$this, 'prepareSearchDataProvider'];
        return $actions;
    }

    // Optional: customize pagination
    protected function getPaginationConfig(): array
    {
        return [
            'params' => \Yii::$app->request->getQueryParams(),
            'defaultPageSize' => 25,
            'pageSizeLimit' => [1, 100],
        ];
    }

    // Optional: customize sorting
    protected function getSortConfig(): array
    {
        return [
            'params' => \Yii::$app->request->getQueryParams(),
            'defaultOrder' => ['published_at' => SORT_DESC],
        ];
    }
}

API Usage:

### Filter by exact value
GET /api/posts?filter[status]=published

### Filter with operator
GET /api/posts?filter[title][like]=tutorial

### Multiple filters
GET /api/posts?filter[status]=published&filter[user_id]=5

### Numeric comparisons
GET /api/posts?filter[published_at][gte]=2024-01-01

### Sorting (- prefix for descending)
GET /api/posts?sort=-published_at,title

### Pagination
GET /api/posts?page=2&per-page=25

### Combined
GET /api/posts?filter[status]=published&sort=-published_at&page=1&per-page=10

VirtualAttributesTrait

Provides dynamic computed attributes not backed by database columns.

Location: src/common/traits/VirtualAttributesTrait.php

Features:

  • Seamless ActiveRecord integration
  • Magic method support (__get, __set, __isset, __unset)
  • Bulk operations for virtual attributes
  • Does not interfere with model saving

Model Setup:

namespace api\modules\public\models;

use common\models\BaseActiveRecord;
use common\traits\VirtualAttributesTrait;

class User extends BaseActiveRecord
{
    use VirtualAttributesTrait;

    public function afterFind()
    {
        parent::afterFind();

        // Computed attribute
        $this->fullName = trim($this->first_name . ' ' . $this->last_name);

        // Aggregated data
        $this->postCount = $this->getPosts()->count();
    }

    public function fields()
    {
        $fields = parent::fields();

        // Include virtual attributes in API response
        $fields['fullName'] = function ($model) {
            return $model->fullName ?? null;
        };
        $fields['postCount'] = function ($model) {
            return $model->postCount ?? 0;
        };

        return $fields;
    }
}

Usage:

$user = User::findOne(1);

// Set virtual attributes
$user->fullName = 'John Doe';
$user->permissions = ['read', 'write'];

// Get virtual attributes
echo $user->fullName; // 'John Doe'

// Check existence
if ($user->hasVirtualAttribute('fullName')) {
    // ...
}

// Bulk operations
$user->setVirtualAttributes([
    'fullName' => 'John Doe',
    'isAdmin' => true,
]);

$virtuals = $user->getVirtualAttributes();
// ['fullName' => 'John Doe', 'isAdmin' => true]

// Real attributes still work normally
$user->email = 'john@example.com';
$user->save(); // Only saves email, not virtual attributes

API Response:

{
    "id": 1,
    "email": "john@example.com",
    "first_name": "John",
    "last_name": "Doe",
    "fullName": "John Doe",
    "postCount": 42
}

Actions

RulesAction

Exposes model validation rules via API endpoint, enabling frontend clients to build dynamic validated forms.

Location: src/common/actions/RulesAction.php

Features:

  • Parses all Yii2 validator types
  • Extracts constraints (min, max, pattern, etc.)
  • Groups rules by field name
  • Filters internal validator config

Controller Setup:

RulesAction is automatically registered by BaseController, but can be customized:

namespace api\modules\public\controllers;

use common\controllers\BaseController;

class UserController extends BaseController
{
    public $modelClass = 'api\modules\public\models\User';

    public function actions()
    {
        $actions = parent::actions();

        // Customize rules action
        $actions['rules']['checkAccess'] = [$this, 'checkAccess'];

        return $actions;
    }
}

Model Validation Rules:

class User extends BaseActiveRecord
{
    public function rules()
    {
        return [
            [['email', 'password_hash'], 'required'],
            [['email'], 'email'],
            [['email'], 'string', 'max' => 255],
            [['password_hash'], 'string', 'min' => 8, 'max' => 60],
            [['status'], 'integer', 'min' => 0, 'max' => 10],
            [['email'], 'unique'],
        ];
    }
}

API Request:

GET /api/users/rules

API Response:

{
    "email": {
        "rules": ["required", "email", "string", "unique"],
        "constraints": {
            "string": {"max": 255}
        }
    },
    "password_hash": {
        "rules": ["required", "string"],
        "constraints": {
            "string": {"min": 8, "max": 60}
        }
    },
    "status": {
        "rules": ["integer"],
        "constraints": {
            "integer": {"min": 0, "max": 10}
        }
    }
}

Frontend Usage Example (JavaScript):

async function loadFormRules(resource) {
    const response = await fetch(`/api/${resource}/rules`);
    const rules = await response.json();

    // Apply rules to form fields
    Object.entries(rules).forEach(([field, config]) => {
        const input = document.querySelector(`[name="${field}"]`);
        if (!input) return;

        if (config.rules.includes('required')) {
            input.required = true;
        }

        if (config.constraints?.string?.max) {
            input.maxLength = config.constraints.string.max;
        }

        if (config.constraints?.string?.min) {
            input.minLength = config.constraints.string.min;
        }

        if (config.rules.includes('email')) {
            input.type = 'email';
        }
    });
}

Base Classes

BaseActiveRecord

Base model providing junction field integration for many-to-many relationships.

Location: src/common/models/BaseActiveRecord.php

Features:

  • Merges junction table fields into model output
  • Request-aware field expansion
  • Integrates with BaseActiveQuery

Model Setup:

namespace api\modules\public\models;

use common\models\BaseActiveRecord;
use common\models\BaseActiveQuery;

class Tag extends BaseActiveRecord
{
    // Define junction fields to include when this model is expanded
    public static function junctionFields()
    {
        return [
            'posts' => ['weight'], // Include 'weight' from post_tag junction
        ];
    }

    public function getTags()
    {
        return $this->hasMany(Tag::class, ['id' => 'tag_id'])
            ->viaTable('post_tag', ['post_id' => 'id']);
    }

    // Use BaseActiveQuery for junction data support
    public static function find()
    {
        return new BaseActiveQuery(get_called_class());
    }
}

API Usage:

### Expand tags with junction data
GET /api/posts/1?expand=tags

Response:

{
    "id": 1,
    "title": "My Post",
    "tags": [
        {
            "id": 5,
            "name": "PHP",
            "weight": 10
        },
        {
            "id": 8,
            "name": "Tutorial",
            "weight": 5
        }
    ]
}

BaseActiveQuery

Custom ActiveQuery for automatic junction data inclusion.

Location: src/common/models/BaseActiveQuery.php

Features:

  • Automatic junction field aliasing
  • Smart JOIN clause generation
  • Transparent viaTable integration

Usage:

// In model relation definition
public function getTags()
{
    return $this->hasMany(Tag::class, ['id' => 'tag_id'])
        ->viaTable('post_tag', ['post_id' => 'id']); // Automatically includes junction data
}

// Manual junction data inclusion
$tags = $post->getTags()->withJunctionData()->all();

BaseController

Base REST controller providing CORS support and automatic RulesAction registration.

Location: src/common/controllers/BaseController.php

Features:

  • Automatic CORS configuration
  • Built-in RulesAction
  • Bearer token extraction helper

Configuration:

// config/params.php
return [
    'cors' => [
        'allowedOrigins' => ['http://localhost:3000', 'https://app.example.com'],
        'allowedHeaders' => ['Content-Type', 'Authorization', 'X-Requested-With'],
        'exposedHeaders' => ['X-Pagination-Total-Count', 'X-Pagination-Page-Count'],
        'allowCredentials' => true,
        'maxAge' => 86400,
    ],
];

Controller Usage:

namespace api\modules\public\controllers;

use common\controllers\BaseController;

class ProductController extends BaseController
{
    public $modelClass = 'api\modules\public\models\Product';

    // RulesAction is automatically available at /api/products/rules

    // Access Bearer token if needed
    public function actionCustom()
    {
        $token = $this->getAccessTokenFromRequest();
        // ...
    }
}

Models

SearchableModel

Dynamic model for search/filter operations without creating custom search classes.

Location: src/common/models/SearchableModel.php

Features:

  • Dynamic attribute definition
  • Multiple filter operators
  • Automatic validation rules
  • Schema type inference

Direct Usage:

use common\models\SearchableModel;

// Create search model with attributes
$searchModel = new SearchableModel();
$searchModel->defineAttributes(['name', 'status', 'price']);
$searchModel->defineTypesFromModel(Product::class);

// Set search values
$searchModel->name = 'shirt';
$searchModel->price = 50;

// Apply to query with operators
$query = Product::find();
$searchModel->search($query, [
    'name' => 'like',   // WHERE name LIKE '%shirt%'
    'price' => 'lt',    // AND price < 50
]);

$products = $query->all();

With SearchableTrait:

SearchableTrait automatically creates and uses SearchableModel when no custom search class is specified:

class ProductController extends BaseController
{
    use SearchableTrait;

    public $modelClass = Product::class;
    public $filterableAttributes = ['name', 'status', 'price', 'category_id'];
    // SearchableModel is created automatically with these attributes
}

Fluent Interface:

$searchModel = (new SearchableModel())
    ->defineAttribute('name', 'John')
    ->defineAttribute('status', 'active')
    ->setAttributeType('price', 'number');

Error Handling

CustomErrorHandler

Custom error handler that converts exceptions with error details into structured JSON responses.

Location: src/common/errorHandler/CustomErrorHandler.php

Configuration:

// config/web.php
return [
    'components' => [
        'errorHandler' => [
            'class' => 'common\errorHandler\CustomErrorHandler',
        ],
    ],
];

Response Format:

{
    "name": "Unprocessable Entity",
    "message": {
        "tags": {
            "999": "Record not found"
        }
    },
    "code": 0,
    "status": 422
}

Debug Mode Response (YII_DEBUG=true):

{
    "name": "Unprocessable Entity",
    "message": { ... },
    "code": 0,
    "status": 422,
    "type": "common\\exceptions\\LinkException",
    "file": "/app/src/common/behaviors/LinkBehavior.php",
    "line": 256,
    "stack-trace": [ ... ]
}

LinkException

Custom exception for relationship operations with detailed error collection.

Location: src/common/exceptions/LinkException.php

Usage:

use common\exceptions\LinkException;

// Throw with errors
throw new LinkException(
    422,
    'Link operation failed',
    [
        'tags' => [
            '999' => 'Record not found',
            '888' => 'Already linked'
        ]
    ]
);

Integration Patterns

Pattern 1: Complete REST Resource with Search and Relations

namespace api\modules\public\models;

use common\models\BaseActiveRecord;
use common\behaviors\LinkBehavior;
use common\behaviors\RelationsBehavior;
use common\traits\VirtualAttributesTrait;

class Post extends BaseActiveRecord
{
    use VirtualAttributesTrait;

    public static function tableName()
    {
        return 'post';
    }

    public function behaviors()
    {
        return [
            'relations' => [
                'class' => RelationsBehavior::class,
                'deferUpdatesToLinkBehavior' => true,
            ],
            'link' => [
                'class' => LinkBehavior::class,
                'rollbackOnError' => true,
            ],
        ];
    }

    public function rules()
    {
        return [
            [['title', 'user_id'], 'required'],
            [['title'], 'string', 'max' => 255],
            [['content'], 'string'],
            [['status'], 'in', 'range' => ['draft', 'published', 'archived']],
            [['user_id'], 'integer'],
        ];
    }

    public function fields()
    {
        $fields = parent::fields();
        $fields['authorName'] = fn($model) => $model->authorName ?? null;
        return $fields;
    }

    public function extraFields()
    {
        return ['tags', 'author'];
    }

    public static function junctionFields()
    {
        return [
            'tags' => ['weight'],
        ];
    }

    public function afterFind()
    {
        parent::afterFind();
        $this->authorName = $this->author->fullName ?? null;
    }

    public function getTags()
    {
        return $this->hasMany(Tag::class, ['id' => 'tag_id'])
            ->viaTable('post_tag', ['post_id' => 'id']);
    }

    public function getPostTags()
    {
        return $this->hasMany(PostTag::class, ['post_id' => 'id']);
    }

    public function getAuthor()
    {
        return $this->hasOne(User::class, ['id' => 'user_id']);
    }
}
namespace api\modules\public\controllers;

use common\controllers\BaseController;
use common\traits\SearchableTrait;

class PostController extends BaseController
{
    use SearchableTrait;

    public $modelClass = 'api\modules\public\models\Post';
    public $filterableAttributes = ['title', 'status', 'user_id', 'published_at'];

    public function actions()
    {
        $actions = parent::actions();
        $actions['index']['prepareDataProvider'] = [$this, 'prepareSearchDataProvider'];
        return $actions;
    }

    protected function getSortConfig(): array
    {
        return [
            'params' => \Yii::$app->request->getQueryParams(),
            'defaultOrder' => ['published_at' => SORT_DESC],
            'attributes' => ['title', 'status', 'published_at', 'created_at'],
        ];
    }
}

Pattern 2: User Model with Authentication and Relations

namespace api\modules\public\models;

use Yii;
use yii\web\IdentityInterface;
use common\models\BaseActiveRecord;
use common\behaviors\RelationsBehavior;
use common\traits\VirtualAttributesTrait;

class User extends BaseActiveRecord implements IdentityInterface
{
    use VirtualAttributesTrait;

    public static function tableName()
    {
        return '{{%user}}';
    }

    public function behaviors()
    {
        return [
            'relations' => [
                'class' => RelationsBehavior::class,
            ],
        ];
    }

    public function rules()
    {
        return [
            [['email', 'password_hash'], 'required'],
            [['email'], 'email'],
            [['email'], 'string', 'max' => 255],
            [['email'], 'unique'],
        ];
    }

    public function fields()
    {
        $fields = parent::fields();
        // Exclude sensitive fields
        unset($fields['password_hash'], $fields['auth_key'], $fields['access_token']);

        // Add computed fields
        $fields['fullName'] = fn($model) => $model->fullName ?? null;
        $fields['roleNames'] = fn($model) => $model->roleNames ?? [];

        return $fields;
    }

    public function extraFields()
    {
        return ['roles', 'posts'];
    }

    public function afterFind()
    {
        parent::afterFind();
        $this->fullName = trim(($this->first_name ?? '') . ' ' . ($this->last_name ?? ''));
        $this->roleNames = array_map(fn($r) => $r->name, $this->roles);
    }

    public function getRoles()
    {
        return $this->hasMany(Role::class, ['id' => 'role_id'])
            ->viaTable('user_role', ['user_id' => 'id']);
    }

    public function getUserRoles()
    {
        return $this->hasMany(UserRole::class, ['user_id' => 'id']);
    }

    public function getPosts()
    {
        return $this->hasMany(Post::class, ['user_id' => 'id']);
    }

    // IdentityInterface implementation...
    public static function findIdentity($id)
    {
        return static::findOne(['id' => $id, 'status' => self::STATUS_ACTIVE]);
    }

    public static function findIdentityByAccessToken($token, $type = null)
    {
        return static::findOne(['access_token' => $token]);
    }

    public function getId() { return $this->id; }
    public function getAuthKey() { return $this->auth_key; }
    public function validateAuthKey($authKey) { return $this->auth_key === $authKey; }

    public function validatePassword($password)
    {
        return Yii::$app->security->validatePassword($password, $this->password_hash);
    }

    public function setPassword($password)
    {
        $this->password_hash = Yii::$app->security->generatePasswordHash($password);
    }
}

API Examples

Complete CRUD with Filtering and Relations

### Create a post with tags
POST /api/posts
Content-Type: application/json

{
    "title": "Getting Started with RESTstop",
    "content": "This tutorial covers the basics...",
    "status": "draft",
    "user_id": 1,
    "tags": {
        "1": {"weight": 10},
        "3": {"weight": 5}
    }
}

### List posts with filtering
GET /api/posts?filter[status]=published&filter[title][like]=tutorial&sort=-published_at&per-page=10

### Get single post with expanded relations
GET /api/posts/1?expand=tags,author

### Update post and modify tags
PUT /api/posts/1
Content-Type: application/json

{
    "status": "published",
    "published_at": "2024-01-15T10:00:00Z",
    "tags": [1, 5, -3]
}

### Get validation rules for form building
GET /api/posts/rules

### Delete a post
DELETE /api/posts/1

Filtering Examples

### Text search (case-insensitive on PostgreSQL)
GET /api/users?filter[email][like]=@example.com

### Exact match
GET /api/posts?filter[status]=published

### Numeric comparison
GET /api/products?filter[price][gte]=10&filter[price][lte]=100

### Multiple values (IN clause)
GET /api/posts?filter[user_id][in][]=1&filter[user_id][in][]=2&filter[user_id][in][]=3

### Date range
GET /api/posts?filter[created_at][gte]=2024-01-01&filter[created_at][lt]=2024-02-01

### Combined with sorting and pagination
GET /api/posts?filter[status]=published&filter[user_id]=5&sort=-published_at,title&page=1&per-page=20

Relation Expansion

### Single relation
GET /api/posts/1?expand=author

### Multiple relations
GET /api/posts/1?expand=author,tags

### Response includes junction data when configured
GET /api/posts/1?expand=tags
# Returns tags with 'weight' from post_tag junction table

API Documentation (Swagger)

Interactive API documentation is available via Swagger UI, auto-generated from controllers and models.

Endpoints

Endpoint Description
GET /swagger Interactive Swagger UI
GET /swagger/spec OpenAPI 3.0 JSON specification
GET /swagger/yaml OpenAPI 3.0 YAML specification

Console Commands

# Generate OpenAPI spec to stdout
./yii openapi/generate

# Generate as YAML
./yii openapi/generate --format=yaml

# Save to file
./yii openapi/generate --output=openapi.json

# Validate the specification
./yii openapi/validate

# Show spec info and statistics
./yii openapi/info

# Clear cached specification
./yii openapi/clear-cache

Configuration

Default configuration is in src/common/config/swagger.php. Project-specific overrides go in src/api/config/swagger-local.php:

// src/api/config/swagger-local.php
return [
    'info' => [
        'title' => 'My API',
        'description' => 'API description',
        'version' => '1.0.0',
    ],
    'servers' => [
        ['url' => 'http://localhost:8080', 'description' => 'Development'],
        ['url' => 'https://api.example.com', 'description' => 'Production'],
    ],
];

Auto-Generated Documentation

The Swagger implementation automatically documents:

  • All REST endpoints (index, view, create, update, delete)
  • Model schemas from validation rules()
  • Request/response body schemas
  • Filtering, sorting, and pagination parameters
  • Security requirements (Bearer token)
  • Expandable relations

No annotations required for standard REST controllers.


License

MIT

About

The one stop you'd ever need for RESTful development

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages