UNPKG

opencode-agent-kit

Version:

Multi-stack OpenCode agent toolkit — 33+ specialized AI agents, 200+ skills, 46 commands, 8 MCP servers (Nuxt, React, Node.js, Laravel, CI3, Android, Flutter, DevOps, SEO, SonarQube, and more)

1,414 lines (1,150 loc) 44.3 kB
--- name: ci3-rest-api description: >- CodeIgniter 3 REST API patterns and conventions: MVC structure (controllers/api/, models/, views/), RESTful endpoints with chriskacerguis/RestServer library, JWT authentication using firebase/php-jwt, full CRUD patterns, form validation, database query builder, error handling, and security (XSS filtering, CSRF protection, SQL injection prevention). Designed for the code-igniter-3-fullstack agent building production-grade CI3 REST APIs with consistent response envelopes and secure defaults. version: "1.0.0" author: opencode-agent-kit license: MIT metadata: target_agent: code-igniter-3-fullstack stack: - CodeIgniter 3 - chriskacerguis/RestServer - firebase/php-jwt - MySQL - PostgreSQL - MVC Monolith tags: - codeigniter - rest-api - jwt - crud - mvc --- # CI3 REST API Skill **Target Agent:** @code-igniter-3-fullstack **Stack:** CodeIgniter 3 · RestServer · firebase/php-jwt · MySQL/PostgreSQL · MVC Monolith Comprehensive reference for building production-grade REST APIs with CodeIgniter 3, covering project structure, controller patterns, JWT authentication flow, CRUD conventions, validation, security hardening, and error handling. --- ## 1. Project Structure Standard CI3 MVC layout for REST API projects: ``` project_root/ ├── application/ │ ├── controllers/ │ │ ├── api/ # REST API controllers │ │ └── web/ # Optional web controllers │ ├── models/ # Data access layer │ ├── views/ # Templates (JSON views or HTML) │ ├── config/ # Configuration files │ │ ├── rest.php # RestServer config │ │ ├── jwt.php # JWT config (custom) │ │ └── routes.php # Route definitions │ ├── libraries/ # Custom libraries (JWT helper, etc.) │ ├── helpers/ # Custom helpers │ ├── hooks/ # Hooks (auth middleware, etc.) │ ├── third_party/ # RestServer library │ └── logs/ # Error logs ├── assets/ # Static assets (if any) ├── system/ # CI3 system core └── composer.json # Dependency management (php-jwt, etc.) ``` ### Rationale - **`controllers/api/`** — All REST endpoints live here, separated from web controllers - **`models/`** — Single responsibility: one model per entity/table - **`views/`** — Can serve JSON via `$this->output->set_content_type('application/json')` or render HTML - **`libraries/`** — Custom classes like `JWT_library` or `Auth_library` - **`config/rest.php`** — RestServer configuration (auth methods, rate limiting, response formats) - **`config/jwt.php`** — Custom config for JWT secret, expiry, algorithms --- ## 2. RestServer Library Setup ### Installation 1. Download `chriskacerguis/codeigniter-restserver` or add via Composer: ```bash composer require chriskacerguis/codeigniter-restserver ``` 2. Extend `RestController` instead of `CI_Controller` in API controllers: ```php defined('BASEPATH') OR exit('No direct script access allowed'); require_once APPPATH . '/libraries/REST_Controller.php'; use RestServer\REST_Controller; class Users extends REST_Controller { // ... } ``` ### REST Controller Configuration (`application/config/rest.php`) Key configuration options: | Config Key | Description | Recommended Value | |---|---|---| | `$config['rest_default_format']` | Default response format | `'json'` | | `$config['rest_supported_formats']` | Allowed formats | `['json', 'xml']` | | `$config['rest_auth']` | Authentication method | `'basic'` or `'digest'` | | `$config['rest_valid_logins']` | Valid credentials for basic auth | `[]` (use JWT instead) | | `$config['rest_ip_whitelist_enabled']` | IP whitelist | `FALSE` | | `$config['rest_ip_whitelist']` | Allowed IPs | `[]` | | `$config['rest_enable_logging']` | Log API requests | `TRUE` in production | | `$config['rest_enable_keys']` | API key system | `FALSE` (use JWT) | | `$config['rest_limits_method']` | Rate limiting method | `'ROUTED_URL'` | | `$config['rest_limits']` | Rate limits per method | `[]` | ### Core Methods | RestController Method | HTTP Verb | Purpose | |---|---|---| | `$this->get('key')` | GET | Retrieve input from query string | | `$this->post('key')` | POST | Retrieve input from POST body | | `$this->put('key')` | PUT | Retrieve input from PUT body | | `$this->delete('key')` | DELETE | Retrieve input from DELETE body | | `$this->patch('key')` | PATCH | Retrieve input from PATCH body | | `$this->options('key')` | OPTIONS | Retrieve input from OPTIONS body | | `$this->response($data, $status)` | Any | Send JSON response with status code | | `$this->set_response($data, $status)` | Any | Alternate response method | | `$this->verify_request()` | Any | Validate auth (custom override) | --- ## 3. JWT Authentication ### Dependencies ```bash composer require firebase/php-jwt ``` ### JWT Configuration (`application/config/jwt.php`) ```php <?php defined('BASEPATH') OR exit('No direct script access allowed'); $config['jwt_key'] = getenv('JWT_SECRET') ?: 'your-secret-key-change-in-production'; $config['jwt_algorithm'] = 'HS256'; $config['jwt_expiry'] = 3600; // 1 hour in seconds $config['jwt_refresh_expiry'] = 604800; // 7 days in seconds $config['jwt_issuer'] = 'ci3-api'; ``` ### JWT Library (`application/libraries/JWT_library.php`) ```php <?php defined('BASEPATH') OR exit('No direct script access allowed'); use Firebase\JWT\JWT; use Firebase\JWT\Key; use Firebase\JWT\ExpiredException; class JWT_library { protected $CI; protected $key; protected $algorithm; public function __construct() { $this->CI =& get_instance(); $this->CI->load->config('jwt', TRUE); $this->key = $this->CI->config->item('jwt_key', 'jwt'); $this->algorithm = $this->CI->config->item('jwt_algorithm', 'jwt'); } /** * Generate a JWT token for a given user. */ public function encode(array $payload): string { $issuedAt = time(); $expiry = $this->CI->config->item('jwt_expiry', 'jwt'); $token = [ 'iss' => $this->CI->config->item('jwt_issuer', 'jwt'), 'iat' => $issuedAt, 'exp' => $issuedAt + $expiry, 'data' => $payload, ]; return JWT::encode($token, $this->key, $this->algorithm); } /** * Decode and validate a JWT token. * Returns the payload data or throws on failure. */ public function decode(string $token): object { try { $decoded = JWT::decode($token, new Key($this->key, $this->algorithm)); return $decoded->data; } catch (ExpiredException $e) { throw new \RuntimeException('Token has expired'); } catch (\Exception $e) { throw new \RuntimeException('Invalid token: ' . $e->getMessage()); } } /** * Generate a refresh token with longer expiry. */ public function encodeRefresh(array $payload): string { $issuedAt = time(); $refreshExpiry = $this->CI->config->item('jwt_refresh_expiry', 'jwt'); $token = [ 'iss' => $this->CI->config->item('jwt_issuer', 'jwt'), 'iat' => $issuedAt, 'exp' => $issuedAt + $refreshExpiry, 'data' => $payload, 'type' => 'refresh', ]; return JWT::encode($token, $this->key, $this->algorithm); } } ``` ### JWT Validation Hook / Middleware Option A — Per-controller `_is_logged_in()` method: ```php /** * Verify JWT token from Authorization header. * Called at the start of protected methods. */ private function _is_logged_in(): bool { $this->load->library('JWT_library'); $authHeader = $this->input->get_request_header('Authorization'); if (!$authHeader) { $this->response([ 'status' => false, 'message' => 'Authorization header is missing', ], REST_Controller::HTTP_UNAUTHORIZED); return false; } $parts = explode(' ', $authHeader); if (count($parts) !== 2 || strtolower($parts[0]) !== 'bearer') { $this->response([ 'status' => false, 'message' => 'Invalid authorization format. Use: Bearer <token>', ], REST_Controller::HTTP_UNAUTHORIZED); return false; } try { $this->jwt_data = $this->jwt_library->decode($parts[1]); return true; } catch (\RuntimeException $e) { $this->response([ 'status' => false, 'message' => $e->getMessage(), ], REST_Controller::HTTP_UNAUTHORIZED); return false; } } ``` Option B — CI3 Hook (global middleware): In `application/config/hooks.php`: ```php $hook['post_controller_constructor'][] = [ 'class' => 'Auth_hook', 'function' => 'authenticate', 'filename' => 'Auth_hook.php', 'filepath' => 'hooks', ]; ``` ### Login / Token Endpoint ```php <?php defined('BASEPATH') OR exit('No direct script access allowed'); require_once APPPATH . '/libraries/REST_Controller.php'; use RestServer\REST_Controller; class Auth extends REST_Controller { public function __construct() { parent::__construct(); $this->load->model('User_model'); } /** * POST /api/auth/login * Authenticate user and return JWT token. */ public function login_post() { $this->load->library('form_validation'); $this->form_validation->set_data([ 'email' => $this->post('email'), 'password' => $this->post('password'), ]); $this->form_validation->set_rules('email', 'Email', 'required|valid_email|trim'); $this->form_validation->set_rules('password', 'Password', 'required|trim'); if ($this->form_validation->run() === FALSE) { $this->response([ 'status' => false, 'message' => 'Validation failed', 'errors' => $this->form_validation->error_array(), ], REST_Controller::HTTP_UNPROCESSABLE_ENTITY); return; } $user = $this->User_model->get_by_email($this->post('email')); if (!$user || !password_verify($this->post('password'), $user->password)) { $this->response([ 'status' => false, 'message' => 'Invalid email or password', ], REST_Controller::HTTP_UNAUTHORIZED); return; } $this->load->library('JWT_library'); $tokenPayload = [ 'user_id' => $user->id, 'email' => $user->email, 'role' => $user->role ?? 'user', ]; $token = $this->jwt_library->encode($tokenPayload); $refreshToken = $this->jwt_library->encodeRefresh(['user_id' => $user->id]); $this->response([ 'status' => true, 'message' => 'Login successful', 'data' => [ 'token' => $token, 'refresh_token' => $refreshToken, 'expires_in' => $this->config->item('jwt_expiry', 'jwt'), 'user' => [ 'id' => $user->id, 'email' => $user->email, 'name' => $user->name ?? '', 'role' => $user->role ?? 'user', ], ], ], REST_Controller::HTTP_OK); } /** * POST /api/auth/refresh * Issue a new access token using a refresh token. */ public function refresh_post() { $refreshToken = $this->post('refresh_token'); if (!$refreshToken) { $this->response([ 'status' => false, 'message' => 'Refresh token is required', ], REST_Controller::HTTP_BAD_REQUEST); return; } $this->load->library('JWT_library'); try { $data = $this->jwt_library->decode($refreshToken); $user = $this->User_model->get($data->user_id); if (!$user) { throw new \RuntimeException('User not found'); } $newToken = $this->jwt_library->encode([ 'user_id' => $user->id, 'email' => $user->email, 'role' => $user->role ?? 'user', ]); $this->response([ 'status' => true, 'message' => 'Token refreshed', 'data' => [ 'token' => $newToken, 'expires_in' => $this->config->item('jwt_expiry', 'jwt'), ], ], REST_Controller::HTTP_OK); } catch (\RuntimeException $e) { $this->response([ 'status' => false, 'message' => $e->getMessage(), ], REST_Controller::HTTP_UNAUTHORIZED); } } } ``` --- ## 4. CRUD Patterns ### Base CRUD Controller Template ```php <?php defined('BASEPATH') OR exit('No direct script access allowed'); require_once APPPATH . '/libraries/REST_Controller.php'; use RestServer\REST_Controller; class Products extends REST_Controller { protected $model_name = 'Product_model'; protected $validation_rules = []; public function __construct() { parent::__construct(); $this->load->model($this->model_name); $this->load->library('form_validation'); // Set validation rules $this->validation_rules = [ [ 'field' => 'name', 'label' => 'Name', 'rules' => 'required|min_length[3]|max_length[255]|trim|xss_clean', ], [ 'field' => 'price', 'label' => 'Price', 'rules' => 'required|numeric|greater_than[0]|trim', ], [ 'field' => 'description', 'label' => 'Description', 'rules' => 'trim|xss_clean', ], [ 'field' => 'category_id', 'label' => 'Category', 'rules' => 'required|integer|trim', ], ]; } /** * GET /api/products * List all products with optional filtering and pagination. */ public function index_get() { $this->is_logged_in(); // JWT gate — see Section 3 $page = (int) $this->get('page') ?: 1; $limit = (int) $this->get('limit') ?: 20; $offset = ($page - 1) * $limit; $filters = []; if ($this->get('search')) $filters['search'] = $this->get('search'); if ($this->get('category_id')) $filters['category_id'] = $this->get('category_id'); if ($this->get('status')) $filters['status'] = $this->get('status'); $result = $this->{$this->model_name}->get_all($filters, $limit, $offset); $total = $this->{$this->model_name}->count_all($filters); $this->response([ 'status' => true, 'message' => 'Products retrieved successfully', 'data' => [ 'items' => $result, 'total' => (int) $total, 'page' => $page, 'per_page' => $limit, 'pages' => ceil($total / $limit), ], ], REST_Controller::HTTP_OK); } /** * GET /api/products/:id * Get a single product by ID. */ public function show_get($id) { $this->is_logged_in(); $product = $this->{$this->model_name}->get($id); if (!$product) { $this->response([ 'status' => false, 'message' => 'Product not found', ], REST_Controller::HTTP_NOT_FOUND); return; } $this->response([ 'status' => true, 'message' => 'Product retrieved successfully', 'data' => $product, ], REST_Controller::HTTP_OK); } /** * POST /api/products * Create a new product. */ public function create_post() { $this->is_logged_in(); $input = [ 'name' => $this->post('name'), 'price' => $this->post('price'), 'description' => $this->post('description'), 'category_id' => $this->post('category_id'), 'status' => $this->post('status') ?: 'active', ]; $this->form_validation->set_data($input); $this->form_validation->set_rules($this->validation_rules); if ($this->form_validation->run() === FALSE) { $this->response([ 'status' => false, 'message' => 'Validation failed', 'errors' => $this->form_validation->error_array(), ], REST_Controller::HTTP_UNPROCESSABLE_ENTITY); return; } $id = $this->{$this->model_name}->create($input); if (!$id) { $this->response([ 'status' => false, 'message' => 'Failed to create product', ], REST_Controller::HTTP_INTERNAL_SERVER_ERROR); return; } $product = $this->{$this->model_name}->get($id); $this->response([ 'status' => true, 'message' => 'Product created successfully', 'data' => $product, ], REST_Controller::HTTP_CREATED); } /** * PUT /api/products/:id * Update an existing product. */ public function update_put($id) { $this->is_logged_in(); $existing = $this->{$this->model_name}->get($id); if (!$existing) { $this->response([ 'status' => false, 'message' => 'Product not found', ], REST_Controller::HTTP_NOT_FOUND); return; } $input = [ 'name' => $this->put('name'), 'price' => $this->put('price'), 'description' => $this->put('description'), 'category_id' => $this->put('category_id'), 'status' => $this->put('status'), ]; // Remove nulls to allow partial updates $input = array_filter($input, function ($value) { return $value !== null; }); if (!empty($input)) { $this->form_validation->set_data($input); $this->form_validation->set_rules($this->validation_rules); if ($this->form_validation->run() === FALSE) { $this->response([ 'status' => false, 'message' => 'Validation failed', 'errors' => $this->form_validation->error_array(), ], REST_Controller::HTTP_UNPROCESSABLE_ENTITY); return; } $updated = $this->{$this->model_name}->update($id, $input); if (!$updated) { $this->response([ 'status' => false, 'message' => 'Failed to update product', ], REST_Controller::HTTP_INTERNAL_SERVER_ERROR); return; } } $product = $this->{$this->model_name}->get($id); $this->response([ 'status' => true, 'message' => 'Product updated successfully', 'data' => $product, ], REST_Controller::HTTP_OK); } /** * DELETE /api/products/:id * Delete a product (soft or hard). */ public function delete_delete($id) { $this->is_logged_in(); $existing = $this->{$this->model_name}->get($id); if (!$existing) { $this->response([ 'status' => false, 'message' => 'Product not found', ], REST_Controller::HTTP_NOT_FOUND); return; } $deleted = $this->{$this->model_name}->delete($id); if (!$deleted) { $this->response([ 'status' => false, 'message' => 'Failed to delete product', ], REST_Controller::HTTP_INTERNAL_SERVER_ERROR); return; } $this->response([ 'status' => true, 'message' => 'Product deleted successfully', ], REST_Controller::HTTP_OK); } /** * Validate JWT token. Halts execution on failure. */ private function is_logged_in(): void { $this->load->library('JWT_library'); $authHeader = $this->input->get_request_header('Authorization'); if (!$authHeader) { $this->response([ 'status' => false, 'message' => 'Authorization header is missing', ], REST_Controller::HTTP_UNAUTHORIZED); exit; } $parts = explode(' ', $authHeader); if (count($parts) !== 2 || strtolower($parts[0]) !== 'bearer') { $this->response([ 'status' => false, 'message' => 'Invalid authorization format. Use: Bearer <token>', ], REST_Controller::HTTP_UNAUTHORIZED); exit; } try { $this->jwt_data = $this->jwt_library->decode($parts[1]); } catch (\RuntimeException $e) { $this->response([ 'status' => false, 'message' => $e->getMessage(), ], REST_Controller::HTTP_UNAUTHORIZED); exit; } } } ``` ### CRUD Method-to-Route Mapping | HTTP Verb | URI Pattern | Controller Method | Purpose | |---|---|---|---| | GET | /api/resource | `index_get()` | List with pagination/filters | | GET | /api/resource/:id | `show_get($id)` | Single resource detail | | POST | /api/resource | `create_post()` | Create new resource | | PUT | /api/resource/:id | `update_put($id)` | Full or partial update | | PATCH | /api/resource/:id | `update_patch($id)` | Partial update (alt) | | DELETE | /api/resource/:id | `delete_delete($id)` | Delete resource | ### Routing (`application/config/routes.php`) ```php $route['api/products'] = 'api/products/index'; $route['api/products/(:num)'] = 'api/products/show/$1'; $route['api/products/create'] = 'api/products/create'; $route['api/products/update/(:num)'] = 'api/products/update/$1'; $route['api/products/delete/(:num)'] = 'api/products/delete/$1'; // Or use RestServer's built-in routing (auto-detect HTTP method) $route['api/example'] = 'api/example'; $route['api/example/(:num)'] = 'api/example/show/$1'; ``` --- ## 5. Model Patterns (Database Query Builder) ### Base Model Template ```php <?php defined('BASEPATH') OR exit('No direct script access allowed'); class Product_model extends CI_Model { protected $table = 'products'; protected $primary_key = 'id'; protected $soft_delete = TRUE; protected $allowed_fields = ['name', 'price', 'description', 'category_id', 'status']; public function __construct() { parent::__construct(); $this->load->database(); } /** * Get all records with filters, pagination, and ordering. */ public function get_all(array $filters = [], int $limit = 20, int $offset = 0, string $order_by = 'created_at DESC'): array { $this->_apply_filters($filters); if ($this->soft_delete) { $this->db->where('deleted_at IS NULL'); } $this->db->order_by($order_by); $this->db->limit($limit, $offset); $query = $this->db->get($this->table); return $query->result(); } /** * Get a single record by primary key. */ public function get($id): ?object { $this->db->where($this->primary_key, $id); if ($this->soft_delete) { $this->db->where('deleted_at IS NULL'); } $query = $this->db->get($this->table); return $query->row() ?: null; } /** * Get by any column value. */ public function get_by(string $field, $value): ?object { $this->db->where($field, $value); if ($this->soft_delete) { $this->db->where('deleted_at IS NULL'); } $query = $this->db->get($this->table); return $query->row() ?: null; } /** * Get by email (convenience for auth). */ public function get_by_email(string $email): ?object { return $this->get_by('email', $email); } /** * Create a new record. Returns inserted ID. */ public function create(array $data): ?int { $data = $this->_filter_allowed($data); $data['created_at'] = date('Y-m-d H:i:s'); $data['updated_at'] = date('Y-m-d H:i:s'); if ($this->soft_delete) { $data['deleted_at'] = null; } return $this->db->insert($this->table, $data) ? $this->db->insert_id() : null; } /** * Update an existing record. Returns affected rows. */ public function update($id, array $data): bool { $data = $this->_filter_allowed($data); $data['updated_at'] = date('Y-m-d H:i:s'); $this->db->where($this->primary_key, $id); return $this->db->update($this->table, $data); } /** * Delete a record. Soft delete if enabled, hard delete otherwise. */ public function delete($id): bool { if ($this->soft_delete) { return $this->update($id, ['deleted_at' => date('Y-m-d H:i:s')]); } $this->db->where($this->primary_key, $id); return $this->db->delete($this->table); } /** * Count total records (with optional filters). */ public function count_all(array $filters = []): int { $this->_apply_filters($filters); if ($this->soft_delete) { $this->db->where('deleted_at IS NULL'); } return $this->db->count_all_results($this->table); } /** * Apply safe WHERE conditions from filters. */ protected function _apply_filters(array $filters): void { foreach ($filters as $field => $value) { if (in_array($field, $this->allowed_fields)) { if ($field === 'search') { // Flexible search across multiple columns $this->db->group_start(); $this->db->like('name', $value); $this->db->or_like('description', $value); $this->db->group_end(); } else { $this->db->where($field, $value); } } } } /** * Keep only allowed fields to prevent mass-assignment. */ protected function _filter_allowed(array $data): array { return array_intersect_key($data, array_flip($this->allowed_fields)); } } ``` ### Query Builder Best Practices | Pattern | Description | Example | |---|---|---| | **Parameterized** | Always use Query Builder — never raw string interpolation | `$this->db->where('id', $id)` | | **Whitelist fields** | Prevent mass-assignment via `_filter_allowed()` | `array_intersect_key($data, array_flip($allowed_fields))` | | **Soft delete** | Use `deleted_at` column instead of hard delete | `$this->db->where('deleted_at IS NULL')` | | **Joins** | Use Query Builder `join()` with escaped names | `$this->db->join('categories', 'categories.id = products.category_id')` | | **Subqueries** | Use `$this->db->select()` with subquery | `$this->db->select('(SELECT COUNT(*) FROM orders WHERE product_id = products.id) as order_count')` | | **Aggregates** | Safe COUNT, SUM, AVG | `$this->db->select_sum('price')` | | **Transactions** | Wrap multi-table writes in transactions | See error handling section | --- ## 6. Form Validation ### Validation Rules Reference CI3 built-in rules commonly used in REST APIs: | Rule | Usage | Example | |---|---|---| | `required` | Field must not be empty | `'rules' => 'required'` | | `valid_email` | Must be a valid email | `'rules' => 'required\|valid_email'` | | `numeric` | Must be numeric | `'rules' => 'required\|numeric'` | | `integer` | Must be an integer | `'rules' => 'required\|integer'` | | `min_length[N]` | Min length N characters | `'rules' => 'required\|min_length[3]'` | | `max_length[N]` | Max length N characters | `'rules' => 'required\|max_length[255]'` | | `exact_length[N]` | Exactly N characters | `'rules' => 'exact_length[10]'` | | `greater_than[N]` | Must be > N | `'rules' => 'greater_than[0]'` | | `less_than[N]` | Must be < N | `'rules' => 'less_than[1000000]'` | | `alpha` | Only alphabetic chars | `'rules' => 'alpha'` | | `alpha_numeric` | Alpha + digits | `'rules' => 'alpha_numeric'` | | `alpha_dash` | Alpha + digits + - _ | `'rules' => 'alpha_dash'` | | `matches[field]` | Must match another field | `'rules' => 'matches[password_confirm]'` | | `is_unique[table.field]` | Unique in DB | `'rules' => 'is_unique[users.email]'` | | `xss_clean` | XSS filtering | `'rules' => 'xss_clean'` | | `trim` | Strip whitespace | `'rules' => 'trim'` | | `callback_*` | Custom validation method | `'rules' => 'callback_check_stock'` | ### Validation Pattern for REST Controllers ```php // 1. Set input data explicitly (do not rely on $this->input->post()) $this->form_validation->set_data([ 'name' => $this->post('name'), 'email' => $this->post('email'), ]); // 2. Define rules as array $this->form_validation->set_rules([ [ 'field' => 'name', 'label' => 'Name', 'rules' => 'required|min_length[3]|max_length[255]|trim|xss_clean', ], [ 'field' => 'email', 'label' => 'Email', 'rules' => 'required|valid_email|trim|xss_clean|is_unique[users.email]', ], [ 'field' => 'password', 'label' => 'Password', 'rules' => 'required|min_length[8]|trim', ], ]); // 3. Run validation if ($this->form_validation->run() === FALSE) { // Return structured error response $this->response([ 'status' => false, 'message' => 'Validation failed', 'errors' => $this->form_validation->error_array(), ], REST_Controller::HTTP_UNPROCESSABLE_ENTITY); // 422 return; } ``` ### Custom Validation Callback ```php /** * Custom callback: check that category exists. * Usage: 'rules' => 'callback_valid_category' */ public function valid_category($category_id): bool { $this->load->model('Category_model'); $exists = $this->Category_model->get($category_id); if (!$exists) { $this->form_validation->set_message('valid_category', 'The selected category does not exist.'); return false; } return true; } ``` --- ## 7. Error Handling ### Consistent Error Response Envelope **Success:** ```json { "status": true, "message": "Products retrieved successfully", "data": { ... } } ``` **Error (general):** ```json { "status": false, "message": "Product not found" } ``` **Error (validation):** ```json { "status": false, "message": "Validation failed", "errors": { "email": "The Email field must contain a valid email address.", "name": "The Name field is required." } } ``` **Error (server):** ```json { "status": false, "message": "An internal error occurred. Please try again later." } ``` ### HTTP Status Code Usage | Code | Constant | When to Use | |---|---|---| | 200 | `HTTP_OK` | Successful GET, PUT, DELETE | | 201 | `HTTP_CREATED` | Successful POST (resource created) | | 204 | `HTTP_NO_CONTENT` | Successful DELETE (no body needed) | | 400 | `HTTP_BAD_REQUEST` | Missing required parameters or malformed request | | 401 | `HTTP_UNAUTHORIZED` | Missing/invalid/expired JWT token | | 403 | `HTTP_FORBIDDEN` | Valid token but insufficient permissions | | 404 | `HTTP_NOT_FOUND` | Resource does not exist | | 409 | `HTTP_CONFLICT` | Duplicate resource or state conflict | | 422 | `HTTP_UNPROCESSABLE_ENTITY` | Validation errors | | 429 | `HTTP_TOO_MANY_REQUESTS` | Rate limit exceeded | | 500 | `HTTP_INTERNAL_SERVER_ERROR` | Unexpected server error | ### Database Transaction with Try-Catch ```php /** * Example: create an order with items in a transaction. */ public function create_order_post() { $this->is_logged_in(); $this->load->model('Order_model'); $this->load->model('Order_item_model'); $this->db->trans_start(); // Begin transaction try { $orderId = $this->Order_model->create([ 'user_id' => $this->jwt_data->user_id, 'total' => $this->post('total'), 'status' => 'pending', ]); if (!$orderId) { throw new \RuntimeException('Failed to create order'); } $items = $this->post('items') ?: []; foreach ($items as $item) { $itemCreated = $this->Order_item_model->create([ 'order_id' => $orderId, 'product_id' => $item['product_id'], 'quantity' => $item['quantity'], 'price' => $item['price'], ]); if (!$itemCreated) { throw new \RuntimeException('Failed to create order item'); } } $this->db->trans_complete(); // Commit $this->response([ 'status' => true, 'message' => 'Order created successfully', 'data' => ['order_id' => $orderId], ], REST_Controller::HTTP_CREATED); } catch (\Exception $e) { $this->db->trans_rollback(); // Rollback on failure log_message('error', 'Order creation failed: ' . $e->getMessage()); $this->response([ 'status' => false, 'message' => 'Failed to create order. Please try again.', ], REST_Controller::HTTP_INTERNAL_SERVER_ERROR); } } ``` ### Global Exception Handling In `application/config/config.php`, enable hook-based error handling: ```php $config['log_threshold'] = 1; // Log error messages only ``` Create `application/hooks/App_hook.php`: ```php <?php defined('BASEPATH') OR exit('No direct script access allowed'); class App_hook { /** * Catch 404 errors and return JSON for API routes. */ public function handle_404() { $ci =& get_instance(); // Only intercept API routes if (strpos($_SERVER['REQUEST_URI'], '/api/') !== false) { $ci->load->library('REST_Controller'); $ci->response([ 'status' => false, 'message' => 'Endpoint not found', ], REST_Controller::HTTP_NOT_FOUND); exit; } } } ``` --- ## 8. Security ### 8.1 XSS Prevention | Layer | Implementation | Notes | |---|---|---| | **Input filtering** | Use `xss_clean` in validation rules | `'rules' => 'required\|xss_clean'` | | **Global XSS filter** | `$config['global_xss_filtering'] = TRUE;` in `config.php` | Filters all `$_GET`, `$_POST`, `$_COOKIE` (deprecated but still works in CI3) | | **Output encoding** | Use `htmlspecialchars()` or `$this->security->xss_clean()` | Never trust user data in output | | **Security helper** | `$this->load->helper('security');` | Provides `xss_clean()` function | ### 8.2 CSRF Protection For REST APIs, CSRF is typically **disabled** because APIs use token-based auth (JWT). However, if your API also serves web views: ```php // In application/config/config.php $config['csrf_protection'] = TRUE; $config['csrf_token_name'] = 'csrf_token'; $config['csrf_cookie_name'] = 'csrf_cookie'; $config['csrf_expire'] = 7200; // Exempt API routes from CSRF (in application/config/config.php or custom hook) $config['csrf_exclude_uris'] = [ 'api/.*', // Regex to exempt all API routes ]; ``` ### 8.3 SQL Injection Prevention CI3's Query Builder automatically escapes values. **Never bypass it.** ```php // ✅ SAFE — Query Builder escapes automatically $this->db->where('email', $email); $this->db->get('users'); // ✅ SAFE — Parameterized with bind markers $this->db->query('SELECT * FROM users WHERE email = ?', [$email]); // ❌ DANGEROUS — Raw string interpolation $this->db->query("SELECT * FROM users WHERE email = '$email'"); // ❌ DANGEROUS — Unescaped like/where with user input concatenated $this->db->where("name LIKE '%" . $search . "%'"); // Use $this->db->like() instead ``` **Anti-pattern checklist:** | Anti-pattern | Fix | |---|---| | `$this->db->query("WHERE id = $id")` | `$this->db->where('id', $id)` | | `"email = '$email'"` | `$this->db->where('email', $email)` | | `LIKE '%$term%'` | `$this->db->like('name', $term)` | | `IN ($ids)` | `$this->db->where_in('id', $ids_array)` | | `ORDER BY $_GET['sort']` | Whitelist allowed sort columns | ### 8.4 Additional Security Measures **Password hashing:** ```php // Registration $data['password'] = password_hash($this->post('password'), PASSWORD_BCRYPT, ['cost' => 12]); // Verification if (password_verify($this->post('password'), $user->password)) { /* valid */ } ``` **Rate limiting (RestServer built-in):** ```php // In application/config/rest.php $config['rest_limits'] = [ 'api/products/index' => 60, // 60 requests per hour 'api/products/create' => 30, 'api/auth/login' => 10, // Limit login attempts ]; ``` **Input sanitization before database:** ```php // Filter only allowed fields (prevents mass assignment) $allowed = ['name', 'email', 'role']; $data = array_intersect_key($input, array_flip($allowed)); // Strip null bytes $data = array_map(function ($value) { return is_string($value) ? str_replace("\0", '', $value) : $value; }, $data); ``` **CORS headers (for client-side apps):** ```php // In application/config/hooks.php $hook['post_controller_constructor'] = [ 'class' => 'Cors_hook', 'function' => 'set_headers', 'filename' => 'Cors_hook.php', 'filepath' => 'hooks', ]; // application/hooks/Cors_hook.php class Cors_hook { public function set_headers() { header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With'); header('Access-Control-Max-Age: 86400'); if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; } } } ``` **Environment-based secret management:** ```php // .env (not committed) JWT_SECRET=your-production-secret-here DB_PASSWORD=secure-db-pass // application/config/jwt.php $config['jwt_key'] = getenv('JWT_SECRET') ?: 'fallback-dev-only'; ``` --- ## 9. Response Envelope (Standard Contract) Every API response **must** follow this contract: ```json { "status": "<boolean> true | false", "message": "<string> human-readable summary", "data": "<object|array|null> payload (omitted on simple errors)" } ``` For validation errors, include an `errors` object: ```json { "status": false, "message": "Validation failed", "errors": { "field_name": "Error message for that field" } } ``` For paginated lists, `data` contains: ```json { "status": true, "message": "Products retrieved successfully", "data": { "items": [ ... ], "total": 42, "page": 1, "per_page": 20, "pages": 3 } } ``` --- ## 10. Verification & Testing ### Curl Commands for Manual Testing ```bash # Login curl -X POST http://localhost:8000/api/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"user@example.com","password":"secret123"}' # List products (with JWT) curl http://localhost:8000/api/products?page=1&limit=20 \ -H "Authorization: Bearer <TOKEN>" # Get single product curl http://localhost:8000/api/products/1 \ -H "Authorization: Bearer <TOKEN>" # Create product curl -X POST http://localhost:8000/api/products \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <TOKEN>" \ -d '{"name":"New Product","price":29.99,"category_id":1}' # Update product curl -X PUT http://localhost:8000/api/products/1 \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <TOKEN>" \ -d '{"name":"Updated Name","price":39.99}' # Delete product curl -X DELETE http://localhost:8000/api/products/1 \ -H "Authorization: Bearer <TOKEN>" # Refresh token curl -X POST http://localhost:8000/api/auth/refresh \ -H "Content-Type: application/json" \ -d '{"refresh_token":"<REFRESH_TOKEN>"}' ``` ### Postman Collection If the IT Leader's delegation includes `postmanSync: true`, use the `api-documentation` skill to sync a collection with: 1. Auth endpoints (login, refresh) 2. CRUD endpoints (list, detail, create, update, delete) 3. Error scenarios (401, 404, 422, 500) 4. Environment variables (`base_url`, `token`, `refresh_token`) --- ## 11. Quick Reference — File Creation Checklist When building a new CRUD resource: | Step | File | Action | |---|---|---| | 1 | `application/controllers/api/<Resource>.php` | Create REST controller extending `REST_Controller` | | 2 | `application/models/<Resource>_model.php` | Create model with Query Builder methods | | 3 | `application/config/routes.php` | Add route entries for the resource | | 4 | — | Ensure JWT library is available in `application/libraries/JWT_library.php` | | 5 | — | Add validation rules matching the resource schema | | 6 | — | Test each endpoint with curl | --- ## 12. Do's and Don'ts **Do:** - Always use Query Builder for database interactions - Always validate input with form_validation - Always hash passwords with `password_hash(PASSWORD_BCRYPT)` - Always return consistent response envelopes - Always handle missing resources with 404 - Always log errors server-side with `log_message()` - Always filter allowed fields before insert/update - Use transactions for multi-table writes **Don't:** - Never use raw SQL with string interpolation - Never expose stack traces or debug output in responses - Never bypass JWT validation on protected endpoints - Never store plaintext passwords - Never trust user input without validation - Never hardcode secrets in config files (use environment variables) - Never allow mass-assignment without field whitelisting - Never use `extract()` or `parse_str()` on user input - Never commit `.env` files to version control --- ## 13. Common Mistakes | Mistake | Consequence | Fix | |---|---|---| | Forgetting JWT check on create/update/delete | Unauthenticated access | Call `$this->is_logged_in()` at start of protected methods | | Using `$this->input->post()` instead of `$this->post()` | Mixed input sources | Use RestServer's `$this->post('key')` | | Not filtering allowed fields | Mass-assignment vulnerability | Always use `_filter_allowed()` in models | | Returning raw DB error messages | Information leakage to client | Always wrap DB operations and return safe messages | | Missing pagination on list endpoints | Performance issues for large datasets | Always implement `limit`/`offset` with `page`/`per_page` | | Hardcoding JWT secret | Security breach | Use `getenv('JWT_SECRET')` with fallback | | Not setting CORS headers | Frontend CORS errors | Add CORS hook for client-side apps | | Using `$config['global_xss_filtering']` = TRUE | Modifies POST/PUT data unexpectedly | Use per-field `xss_clean` rule instead | --- ## 14. Skill Usage Examples ``` @ci3-rest-api Create GET /api/categories endpoint with JWT auth and pagination @ci3-rest-api Add form validation for product create/update @ci3-rest-api Implement JWT login/register flow @ci3-rest-api Build soft-delete pattern for the inventory model @ci3-rest-api Set up CORS and rate limiting for public endpoints ``` --- _This skill provides complete CI3 REST API development patterns: RestServer controllers, JWT auth, CRUD models, form validation, security hardening, and consistent response contracts. Every endpoint follows the same predictable structure for maintainability and developer experience._