Skip to content

Guida: Creare una Nuova API

Questa guida spiega come creare una nuova API REST per ERP seguendo i pattern stabiliti.

Checklist Rapida

[ ] 1. Model      → src/models/erp/{module}/{Resource}_api_model.php
[ ] 2. Library    → src/libraries/erp/{module}/{Resource}_api_lib.php
[ ] 3. Controller → src/controllers/erp/{module}/{Resource}.php
[ ] 4. Response   → src/response/contexts/erp/Erp{Resource}Responses.php
[ ] 5. Factory    → Aggiungere metodo in ErpApiResponseFactory.php
[ ] 6. Routes     → Aggiungere in src/routes/erp/{module}.php

Database Access Pattern

ADOdb - Non Query Builder

Questo progetto usa ADOdb con query SQL raw, NON il query builder di CodeIgniter.

Connessioni Database

ConnessioneUsoMetodi
$this->db_slaveLettura (SELECT)GetArray(), GetRow(), GetOne()
$this->dbScrittura (INSERT/UPDATE/DELETE)Execute(), Insert_ID()

Metodi ADOdb

php
// Lista di record
$rows = $this->db_slave->GetArray($query);  // array di array

// Singolo record
$row = $this->db_slave->GetRow($query);     // array o false

// Singolo valore
$value = $this->db_slave->GetOne($query);   // scalar o false

// Esecuzione query (INSERT/UPDATE/DELETE)
$result = $this->db->Execute($query);       // true/false

// ID dell'ultimo insert
$id = $this->db->Insert_ID();               // integer

Escaping Parametri

Usa sempre la funzione escape() per prevenire SQL injection:

php
escape($value, 'integer')  // Per numeri interi
escape($value, 'string')   // Per stringhe (aggiunge apici)

Esempio: API Categories

Creiamo l'API per gestire le categorie (/erp/admin/categories).


Step 1: Model

Il model gestisce l'accesso al database usando ADOdb con query SQL raw.

php
// src/models/erp/admin/Categories_api_model.php
<?php

defined('BASEPATH') or exit('No direct script access allowed');

class Categories_api_model extends CI_Model
{
    private const TABLE = 'COM_CATEGORIES';
    private const STATUS_ACTIVE = 1;
    private const STATUS_DELETED = 0;

    /**
     * Get all categories for a company
     */
    public function getAll(int $companyId, ?string $search, bool $includeDeleted): array
    {
        $query = "
            SELECT
                ID_CATEGORY AS id,
                CODE AS code,
                DESCRIPTION AS description,
                STATUS AS status,
                INS_USER AS createdBy,
                INS_DATE AS createdAt,
                CASE WHEN STATUS = 0 THEN 1 ELSE 0 END AS isDeleted
            FROM " . self::TABLE . "
            WHERE ID_COMPANY = " . escape($companyId, 'integer');

        if (!$includeDeleted) {
            $query .= " AND STATUS = " . self::STATUS_ACTIVE;
        }

        if (!empty($search)) {
            $searchEscaped = escape('%' . strtolower((string) $search) . '%', 'string');
            $query .= " AND (LOWER(CODE) LIKE " . $searchEscaped;
            $query .= " OR LOWER(DESCRIPTION) LIKE " . $searchEscaped . ")";
        }

        $query .= " ORDER BY DESCRIPTION ASC";

        return $this->db_slave->GetArray($query);
    }

    /**
     * Get single category by ID
     */
    public function getById(int $companyId, int $id): ?array
    {
        $query = "
            SELECT
                ID_CATEGORY AS id,
                CODE AS code,
                DESCRIPTION AS description,
                STATUS AS status,
                INS_USER AS createdBy,
                INS_DATE AS createdAt,
                CASE WHEN STATUS = 0 THEN 1 ELSE 0 END AS isDeleted
            FROM " . self::TABLE . "
            WHERE ID_COMPANY = " . escape($companyId, 'integer') . "
            AND ID_CATEGORY = " . escape($id, 'integer');

        $result = $this->db_slave->GetRow($query);

        return $result ?: null;
    }

    /**
     * Check if code exists
     */
    public function existsByCode(int $companyId, string $code, ?int $excludeId = null): bool
    {
        $query = "
            SELECT 1
            FROM " . self::TABLE . "
            WHERE ID_COMPANY = " . escape($companyId, 'integer') . "
            AND CODE = " . escape($code, 'string') . "
            AND STATUS = " . self::STATUS_ACTIVE;

        if ($excludeId !== null) {
            $query .= " AND ID_CATEGORY != " . escape($excludeId, 'integer');
        }

        return $this->db_slave->GetOne($query) !== false;
    }

    /**
     * Insert new category
     */
    public function insert(int $companyId, int $userId, string $code, string $description): ?array
    {
        $query = "
            INSERT INTO " . self::TABLE . " (
                ID_COMPANY,
                CODE,
                DESCRIPTION,
                INS_USER,
                INS_DATE,
                STATUS
            ) VALUES (
                " . escape($companyId, 'integer') . ",
                " . escape($code, 'string') . ",
                " . escape($description, 'string') . ",
                " . escape($userId, 'integer') . ",
                NOW(),
                " . self::STATUS_ACTIVE . "
            )
        ";

        $result = $this->db->Execute($query);

        if ($result === false) {
            return null;
        }

        $insertId = $this->db->Insert_ID();

        return $this->getById($companyId, $insertId);
    }

    /**
     * Update category
     */
    public function update(int $id, ?string $code, ?string $description): ?array
    {
        $updates = [];

        if ($code !== null) {
            $updates[] = "CODE = " . escape($code, 'string');
        }
        if ($description !== null) {
            $updates[] = "DESCRIPTION = " . escape($description, 'string');
        }

        if (empty($updates)) {
            $query = "SELECT ID_COMPANY FROM " . self::TABLE . " WHERE ID_CATEGORY = " . escape($id, 'integer');
            $companyId = $this->db->GetOne($query);
            return $companyId ? $this->getById((int) $companyId, $id) : null;
        }

        $updates[] = "LAST_MOD_DATE = NOW()";

        $query = "
            UPDATE " . self::TABLE . "
            SET " . implode(', ', $updates) . "
            WHERE ID_CATEGORY = " . escape($id, 'integer');

        $result = $this->db->Execute($query);

        if ($result === false) {
            return null;
        }

        $query = "SELECT ID_COMPANY FROM " . self::TABLE . " WHERE ID_CATEGORY = " . escape($id, 'integer');
        $companyId = $this->db->GetOne($query);

        return $companyId ? $this->getById((int) $companyId, $id) : null;
    }

    /**
     * Soft delete category
     */
    public function softDelete(int $id, int $userId): ?array
    {
        $query = "
            UPDATE " . self::TABLE . "
            SET
                STATUS = " . self::STATUS_DELETED . ",
                LAST_MOD_USER = " . escape($userId, 'integer') . ",
                LAST_MOD_DATE = NOW()
            WHERE ID_CATEGORY = " . escape($id, 'integer');

        $result = $this->db->Execute($query);

        if ($result === false) {
            return null;
        }

        $query = "SELECT ID_COMPANY FROM " . self::TABLE . " WHERE ID_CATEGORY = " . escape($id, 'integer');
        $companyId = $this->db->GetOne($query);

        return $companyId ? $this->getById((int) $companyId, $id) : null;
    }
}

Step 2: Library

La library contiene la business logic.

php
// src/libraries/erp/admin/Categories_api_lib.php
<?php

defined('BASEPATH') or exit('No direct script access allowed');

class Categories_api_lib
{
    private $CI;
    private const CONTEXT = 'categories';

    public function __construct()
    {
        $this->CI = &get_instance();
        $this->CI->load->model('erp/admin/Categories_api_model');
    }

    public function list(int $companyId, ?string $search, bool $includeDeleted): ServiceResponse
    {
        $categories = $this->CI->categories_api_model->getAll($companyId, $search, $includeDeleted);

        return ServiceResponseFactory::ok($categories, self::CONTEXT, 'list');
    }

    public function get(int $companyId, int $id): ServiceResponse
    {
        $category = $this->CI->categories_api_model->getById($companyId, $id);

        if (!$category) {
            return ServiceResponseFactory::fail(
                self::CONTEXT,
                'notFound',
                ['id' => $id],
                ServiceErrorType::NotFound,
                'Categoria non trovata'
            );
        }

        return ServiceResponseFactory::ok($category, self::CONTEXT, 'get');
    }

    public function create(int $companyId, int $userId, string $code, string $description): ServiceResponse
    {
        // Validate uniqueness
        if ($this->CI->categories_api_model->existsByCode($companyId, $code)) {
            return ServiceResponseFactory::fail(
                self::CONTEXT,
                'duplicateCode',
                ['field' => 'code', 'value' => $code],
                ServiceErrorType::Validation,
                'Codice categoria gia esistente'
            );
        }

        $category = $this->CI->categories_api_model->insert($companyId, $userId, $code, $description);

        if (!$category) {
            return ServiceResponseFactory::failDbInsert(self::CONTEXT);
        }

        return ServiceResponseFactory::ok($category, self::CONTEXT, 'create');
    }

    public function update(int $companyId, int $id, ?string $code, ?string $description): ServiceResponse
    {
        $category = $this->CI->categories_api_model->getById($companyId, $id);

        if (!$category) {
            return ServiceResponseFactory::fail(
                self::CONTEXT,
                'notFound',
                ['id' => $id],
                ServiceErrorType::NotFound,
                'Categoria non trovata'
            );
        }

        if ($category['isDeleted']) {
            return ServiceResponseFactory::fail(
                self::CONTEXT,
                'alreadyDeleted',
                ['id' => $id],
                ServiceErrorType::Conflict,
                'Impossibile modificare una categoria eliminata'
            );
        }

        // Validate uniqueness (excluding current)
        if ($code && $this->CI->categories_api_model->existsByCode($companyId, $code, $id)) {
            return ServiceResponseFactory::fail(
                self::CONTEXT,
                'duplicateCode',
                ['field' => 'code', 'value' => $code],
                ServiceErrorType::Validation,
                'Codice categoria gia esistente'
            );
        }

        $updated = $this->CI->categories_api_model->update($id, $code, $description);

        return ServiceResponseFactory::ok($updated, self::CONTEXT, 'update');
    }

    public function delete(int $companyId, int $userId, int $id): ServiceResponse
    {
        $category = $this->CI->categories_api_model->getById($companyId, $id);

        if (!$category) {
            return ServiceResponseFactory::fail(
                self::CONTEXT,
                'notFound',
                ['id' => $id],
                ServiceErrorType::NotFound,
                'Categoria non trovata'
            );
        }

        if ($category['isDeleted']) {
            return ServiceResponseFactory::fail(
                self::CONTEXT,
                'alreadyDeleted',
                ['id' => $id],
                ServiceErrorType::Conflict,
                'Categoria gia eliminata'
            );
        }

        $this->CI->categories_api_model->softDelete($id, $userId);

        return ServiceResponseFactory::ok(['id' => $id], self::CONTEXT, 'delete');
    }
}

Step 3: Controller

Il controller gestisce HTTP request/response.

php
// src/controllers/erp/admin/Categories.php
<?php

defined('BASEPATH') or exit('No direct script access allowed');

require APPPATH . 'libraries/erp/Erp_controller.php';

class Categories extends Erp_controller
{
    private $lib;
    protected $ctx;

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

        $this->CI->load->library('Session_context');
        $this->ctx = $this->CI->session_context;

        $this->CI->load->library('erp/admin/Categories_api_lib');
        $this->lib = &$this->CI->categories_api_lib;
    }

    /**
     * GET /erp/admin/categories
     */
    public function index_get()
    {
        $params = $this->get();

        $response = $this->lib->list(
            $this->ctx->getCompanyId(),
            $params['search'] ?? null,
            filter_var($params['includeDeleted'] ?? false, FILTER_VALIDATE_BOOLEAN)
        );

        ErpApiResponseFactory::categories()->auto($response)->toJson();
    }

    /**
     * POST /erp/admin/categories
     */
    public function index_post()
    {
        $params = $this->post();

        $schema = QueryStringValidator::merge(
            QueryStringValidator::requiredString('code'),
            QueryStringValidator::requiredString('description')
        );

        QueryStringValidator::validate($params, $schema, ErpApiResponseFactory::categories());

        $response = $this->lib->create(
            $this->ctx->getCompanyId(),
            $this->ctx->getUserId(),
            trim($params['code']),
            trim($params['description'])
        );

        ErpApiResponseFactory::categories()->auto($response)->toJson();
    }

    /**
     * GET /erp/admin/categories/{id}
     */
    public function id_get($id)
    {
        $response = $this->lib->get(
            $this->ctx->getCompanyId(),
            (int) $id
        );

        ErpApiResponseFactory::categories()->auto($response)->toJson();
    }

    /**
     * PUT /erp/admin/categories/{id}
     */
    public function id_put($id)
    {
        $params = $this->put();

        $response = $this->lib->update(
            $this->ctx->getCompanyId(),
            (int) $id,
            isset($params['code']) ? trim($params['code']) : null,
            isset($params['description']) ? trim($params['description']) : null
        );

        ErpApiResponseFactory::categories()->auto($response)->toJson();
    }

    /**
     * DELETE /erp/admin/categories/{id}
     */
    public function id_delete($id)
    {
        $response = $this->lib->delete(
            $this->ctx->getCompanyId(),
            $this->ctx->getUserId(),
            (int) $id
        );

        ErpApiResponseFactory::categories()->auto($response)->toJson();
    }
}

Step 4: Response Context

php
// src/response/contexts/erp/ErpCategoriesResponses.php
<?php

class ErpCategoriesResponses extends ErpCommonResponses
{
    protected string $responseContext = 'categories';
}

Step 5: Response Factory

Aggiungere il metodo in ErpApiResponseFactory.php:

php
// src/response/ErpApiResponseFactory.php

public static function categories()
{
    require_once 'contexts/erp/ErpCategoriesResponses.php';
    return new ErpCategoriesResponses();
}

Step 6: Routes

Aggiungere le route in src/routes/erp/admin.php:

php
// Categories API
$route['erp/admin/categories/(:num)'] = 'erp/admin/categories/id/$1';

Nota sulle Routes

Solo le route con parametri URL richiedono definizione esplicita. index_get e index_post funzionano automaticamente su /erp/admin/categories.


Pattern Comuni

Validazione Input

php
$schema = QueryStringValidator::merge(
    QueryStringValidator::requiredString('code'),
    QueryStringValidator::requiredString('description'),
    QueryStringValidator::requiredInt('parentId')
);

QueryStringValidator::validate($params, $schema, ErpApiResponseFactory::categories());

Check Dipendenze prima di Delete

php
public function checkDependencies(int $id): array
{
    // Count articles using this category
    $articlesQuery = "
        SELECT COUNT(*) AS cnt
        FROM COM_ARTICLES
        WHERE ID_CATEGORY = " . escape($id, 'integer');

    $articlesCount = (int) $this->db_slave->GetOne($articlesQuery);

    return [
        'total' => $articlesCount,
        'articlesCount' => $articlesCount,
    ];
}

Soft Delete

php
// Nel model
public function softDelete(int $id, int $userId): ?array
{
    $query = "
        UPDATE " . self::TABLE . "
        SET
            STATUS = " . self::STATUS_DELETED . ",
            LAST_MOD_USER = " . escape($userId, 'integer') . ",
            LAST_MOD_DATE = NOW()
        WHERE ID_CATEGORY = " . escape($id, 'integer');

    $result = $this->db->Execute($query);

    if ($result === false) {
        return null;
    }

    // Fetch and return updated record
    $query = "SELECT ID_COMPANY FROM " . self::TABLE . " WHERE ID_CATEGORY = " . escape($id, 'integer');
    $companyId = $this->db->GetOne($query);

    return $companyId ? $this->getById((int) $companyId, $id) : null;
}

Restore

php
public function restore(int $id, int $userId): ?array
{
    $query = "
        UPDATE " . self::TABLE . "
        SET
            STATUS = " . self::STATUS_ACTIVE . ",
            RESTORED_BY = " . escape($userId, 'integer') . ",
            RESTORED_AT = NOW()
        WHERE ID_CATEGORY = " . escape($id, 'integer');

    $result = $this->db->Execute($query);

    if ($result === false) {
        return null;
    }

    $query = "SELECT ID_COMPANY FROM " . self::TABLE . " WHERE ID_CATEGORY = " . escape($id, 'integer');
    $companyId = $this->db->GetOne($query);

    return $companyId ? $this->getById((int) $companyId, $id) : null;
}

Best Practices

1. Sempre filtrare per Company

php
// SEMPRE includere ID_COMPANY nelle query
$query = "SELECT * FROM " . self::TABLE . " WHERE ID_COMPANY = " . escape($companyId, 'integer');

2. Validare unicita escludendo il record corrente

php
// Nel model
public function existsByCode(int $companyId, string $code, ?int $excludeId = null): bool
{
    $query = "
        SELECT 1
        FROM " . self::TABLE . "
        WHERE ID_COMPANY = " . escape($companyId, 'integer') . "
        AND CODE = " . escape($code, 'string') . "
        AND STATUS = " . self::STATUS_ACTIVE;

    if ($excludeId !== null) {
        $query .= " AND ID_CATEGORY != " . escape($excludeId, 'integer');
    }

    return $this->db_slave->GetOne($query) !== false;
}

// Nella library
if ($this->CI->categories_api_model->existsByCode($companyId, $code, $excludeId)) {
    return ServiceResponseFactory::fail('categories', 'duplicateCode', ...);
}

3. Usare ServiceResponseFactory per le risposte

php
// Success
return ServiceResponseFactory::ok($data, 'context', 'action');

// Error
return ServiceResponseFactory::fail('context', 'errorCode', $data, ServiceErrorType::NotFound);

4. Trim degli input stringa

php
trim($params['code'])

5. Cast esplicito degli ID

php
(int) $id
(int) $row['ID_CATEGORY']

6. Tracciare utente nelle modifiche

php
// Nelle query INSERT/UPDATE
$query = "
    INSERT INTO " . self::TABLE . " (
        ...,
        INS_USER,
        INS_DATE
    ) VALUES (
        ...,
        " . escape($userId, 'integer') . ",
        NOW()
    )
";

// Per UPDATE
$query = "
    UPDATE " . self::TABLE . "
    SET
        ...,
        LAST_MOD_USER = " . escape($userId, 'integer') . ",
        LAST_MOD_DATE = NOW()
    WHERE ...
";

7. Usare db_slave per le letture

php
// Lettura - usa sempre db_slave
$data = $this->db_slave->GetArray($query);

// Scrittura - usa db
$result = $this->db->Execute($query);

Accesso Cross-Library (Pattern Filtri)

Quando una libreria necessita di dati da un'altra anagrafica (es. brands per filtri articoli), utilizzare il pattern centralizzato invece di duplicare le query SQL.

Pattern Corretto

  1. Ogni libreria anagrafica DEVE esporre un metodo getForFilters()
  2. Le altre librerie caricano la libreria e usano il metodo

Metodo Standard getForFilters()

Ogni libreria anagrafica deve implementare:

php
// Nel model (es. Brands_api_model.php)
public function getActive(int $companyId): array
{
    $query = "
        SELECT
            ID_BRAND AS id,
            C_BRAND AS code,
            D_BRAND AS description
        FROM COM_BRANDS
        WHERE ID_COMPANY = " . escape($companyId, 'integer') . "
        AND STATUS = " . self::STATUS_ACTIVE . "
        ORDER BY D_BRAND ASC";

    return $this->db_slave->GetArray($query);
}

// Nella library (es. Brands_api_lib.php)
public function getForFilters(int $companyId): array
{
    return $this->CI->brands_api_model->getActive($companyId);
}

Utilizzo da Altra Libreria

php
// In un'altra libreria (es. Articles_api_lib.php)
class Articles_api_lib
{
    public function __construct()
    {
        $this->CI = &get_instance();

        // Carica librerie per accesso centralizzato ai filtri
        $this->CI->load->library('erp/admin/Brands_api_lib', [], 'brands_lib');
        // $this->CI->load->library('erp/admin/Categories_api_lib', [], 'categories_lib');
    }

    public function getFilters(int $companyId): ServiceResponse
    {
        $filters = [
            'brands' => $this->CI->brands_lib->getForFilters($companyId),
            // 'categories' => $this->CI->categories_lib->getForFilters($companyId),
        ];

        return ServiceResponseFactory::ok($filters, 'articles', 'filters');
    }
}

Vantaggi

AspettoPattern CorrettoPattern Errato
QueryCentralizzata in un puntoDuplicata in ogni libreria
ManutenzioneModifica in un solo fileModifica in N file
LogicaSoft-delete gestito automaticamenteRischio di dimenticare STATUS = 1
PerformanceQuery ottimizzata (solo campi necessari)Query pesante con tutti i campi

File Esempio

Vedi src/libraries/erp/examples/Filters_example_lib.php per un esempio completo commentato.

Documentazione interna Elerama