Skip to content

Conversation

@itrider-gh
Copy link
Contributor

This PR extends the work introduced in PR #1771, which added massStore and massUpdate support for the Activity API.

I’ve applied the same pattern to all API controllers, following a consistent and predictable structure:

  • MassStore<Model>Request and MassUpdate<Model>Request generated for every resource
  • Dynamic validation based on corresponding Store<Model>Request / Update<Model>Request
  • Automatic handling of array-based relation fields (via $request->input(...))
  • Consistent permission checks (<model>_create / <model>_edit)
  • Assumes all models now live under the namespace
    Mercator\Core\Models\

I have not been able to run full tests yet, as I believe the new global model namespace refactor is not fully implemented across the project (please correct me if I’m wrong).
Happy to adjust anything needed.

Open to any feedback or required modifications.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 28, 2025

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

📝 Customizable high-level summaries are now available in beta!

You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. 📝 Description — Summarize the main change in 50–60 words, explaining what was done.
  2. 📓 References — List relevant issues, discussions, documentation, or related PRs.
  3. 📦 Dependencies & Requirements — Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. 📊 Contributor Summary — Include a Markdown table showing contributions:
    | Contributor | Lines Added | Lines Removed | Files Changed |
  5. ✔️ Additional Notes — Add any extra reviewer context.
    Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dbarzin
Copy link
Owner

dbarzin commented Nov 28, 2025

I reviewed the controller implementation, and I think we can avoid repeating the same logic across all API resources by introducing a shared ApiController.

Proposed alternative: ApiController

Instead of duplicating the filtering logic, CRUD operations, and mass actions in every controller, we can extract all common behavior into a reusable abstract controller:

  • define $modelClass for the Eloquent model

  • define $abilityPrefix for Gate permissions

  • reuse generic methods for:

    • index with filters
    • store, update, destroy
    • massStore, massUpdate, massDestroy

Each resource controller (e.g., ActorController) would only keep the model-specific FormRequests and set the two properties above.

This approach removes most of the repeated code across 30+ API controllers and ensures consistent behavior and validation everywhere.

1. New Controller : ApiController

<?php

namespace App\Http\Controllers\API;

use App\Http\Controllers\Controller;
use Gate;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Symfony\Component\HttpFoundation\Response;

abstract class ApiController extends Controller
{
    /**
     * FQCN du modèle Eloquent (ex: Actor::class)
     */
    protected string $modelClass;

    /**
     * Préfixe des permissions Gate (ex: 'actor' → actor_access, actor_create, …)
     */
    protected string $abilityPrefix;

    protected function newQuery(): Builder
    {
        $modelClass = $this->modelClass;

        return $modelClass::query();
    }

    protected function newModelInstance(): Model
    {
        $modelClass = $this->modelClass;

        return new $modelClass();
    }

    protected function authorizeAbility(string $suffix, int $status = Response::HTTP_FORBIDDEN): void
    {
        $ability = $this->abilityPrefix . '_' . $suffix;

        abort_if(Gate::denies($ability), $status, '403 Forbidden');
    }

    /**
     * Retourne la liste des champs autorisés aux filtres.
     * Par défaut : ::$searchable + ['id'] si la propriété existe,
     * sinon : $fillable + ['id'].
     */
    protected function getAllowedFilterFields(): array
    {
        $modelClass = $this->modelClass;

        // On privilégie une propriété statique $searchable si elle existe,
        // sinon on retombe sur les fillable.
        if (property_exists($modelClass, 'searchable')) {
            /** @phpstan-ignore-next-line */
            return array_merge($modelClass::$searchable ?? [], ['id']);
        }

        $model = $this->newModelInstance();

        return array_merge($model->getFillable(), ['id']);
    }

    /**
     * Applique les filtres de l’URL sous la forme field, field__operator.
     */
    protected function applyFilters(Request $request, Builder $query): Builder
    {
        $allowedFields = $this->getAllowedFilterFields();
        $params        = $request->query();

        foreach ($params as $key => $value) {
            if ($value === null || $value === '') {
                continue;
            }

            // field ou field__operator
            [$field, $operator] = array_pad(explode('__', $key, 2), 2, 'exact');

            if (! in_array($field, $allowedFields, true)) {
                continue; // ignore les champs non autorisés
            }

            switch ($operator) {
                case 'exact':
                    $query->where($field, $value);
                    break;

                case 'contains':
                    $query->where($field, 'LIKE', '%' . $value . '%');
                    break;

                case 'startswith':
                    $query->where($field, 'LIKE', $value . '%');
                    break;

                case 'endswith':
                    $query->where($field, 'LIKE', '%' . $value);
                    break;

                case 'lt':
                    $query->where($field, '<', $value);
                    break;

                case 'lte':
                    $query->where($field, '<=', $value);
                    break;

                case 'gt':
                    $query->where($field, '>', $value);
                    break;

                case 'gte':
                    $query->where($field, '>=', $value);
                    break;

                default:
                    // Opérateur inconnu → traité comme un filtre exact
                    $query->where($field, $value);
            }
        }

        return $query;
    }

    /**
     * Index générique avec filtres.
     */
    protected function indexResource(Request $request)
    {
        $query = $this->newQuery();
        $this->applyFilters($request, $query);

        $items = $query->get();

        return response()->json($items);
    }

    /**
     * Création générique.
     */
    protected function storeResource(array $data): Model
    {
        $modelClass = $this->modelClass;

        /** @var Model $model */
        $model = $modelClass::query()->create($data);

        return $model;
    }

    /**
     * Mise à jour générique.
     */
    protected function updateResource(Model $model, array $data): void
    {
        $model->update($data);
    }

    /**
     * Suppression générique.
     */
    protected function destroyResource(Model $model): void
    {
        $model->delete();
    }

    /**
     * Suppression en masse à partir d'une liste d'IDs.
     */
    protected function massDestroyByIds(array $ids): void
    {
        if (empty($ids)) {
            return;
        }

        $this->newQuery()->whereIn('id', $ids)->delete();
    }

    /**
     * Création en masse. Retourne les IDs créés.
     */
    protected function massStoreItems(array $items): array
    {
        $model     = $this->newModelInstance();
        $fillable  = $model->getFillable();
        $createdIds = [];

        foreach ($items as $item) {
            $attributes = collect($item)
                ->only($fillable)
                ->toArray();

            /** @var Model $created */
            $created = $this->newQuery()->create($attributes);

            $createdIds[] = $created->getKey();
        }

        return $createdIds;
    }

    /**
     * Mise à jour en masse. Chaque item doit contenir un 'id'.
     */
    protected function massUpdateItems(array $items): void
    {
        if (empty($items)) {
            return;
        }

        $model    = $this->newModelInstance();
        $fillable = $model->getFillable();

        foreach ($items as $rawItem) {
            if (! isset($rawItem['id'])) {
                continue;
            }

            $id = $rawItem['id'];

            /** @var Model $instance */
            $instance = $this->newQuery()->findOrFail($id);

            $attributes = collect($rawItem)
                ->except(['id'])
                ->only($fillable)
                ->toArray();

            if (! empty($attributes)) {
                $instance->update($attributes);
            }
        }
    }

    /**
     * Wrapper générique pour un show simple.
     * Tu peux l’utiliser dans les enfants avec le type du modèle concret.
     */
    protected function asJsonResource(Model $model): JsonResource
    {
        return new JsonResource($model);
    }
}

2. ActorController simplifié qui étend ApiController

On garde tes FormRequest spécifiques, mais toute la logique est renvoyée vers les méthodes génériques.

<?php

namespace App\Http\Controllers\API;

use App\Http\Controllers\API\ApiController;
use App\Http\Requests\MassDestroyActorRequest;
use App\Http\Requests\MassStoreActorRequest;
use App\Http\Requests\MassUpdateActorRequest;
use App\Http\Requests\StoreActorRequest;
use App\Http\Requests\UpdateActorRequest;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Mercator\Core\Models\Actor;
use Symfony\Component\HttpFoundation\Response;

class ActorController extends ApiController
{
    protected string $modelClass     = Actor::class;
    protected string $abilityPrefix  = 'actor';

    public function index(Request $request)
    {
        $this->authorizeAbility('access');

        return $this->indexResource($request);
    }

    public function store(StoreActorRequest $request)
    {
        $this->authorizeAbility('create');

        $actor = $this->storeResource($request->validated());

        return response()->json($actor, Response::HTTP_CREATED);
    }

    public function show(Actor $actor): JsonResource
    {
        $this->authorizeAbility('show');

        // On encapsule le modèle dans une JsonResource pour rester cohérent
        return $this->asJsonResource($actor);
    }

    public function update(UpdateActorRequest $request, Actor $actor)
    {
        $this->authorizeAbility('edit');

        $this->updateResource($actor, $request->validated());

        return response()->json();
    }

    public function destroy(Actor $actor)
    {
        $this->authorizeAbility('delete');

        $this->destroyResource($actor);

        return response()->json();
    }

    public function massDestroy(MassDestroyActorRequest $request)
    {
        $this->authorizeAbility('delete');

        $this->massDestroyByIds($request->input('ids', []));

        return response(null, Response::HTTP_NO_CONTENT);
    }

    public function massStore(MassStoreActorRequest $request)
    {
        // L’authorize() du FormRequest gère déjà actor_create
        $data       = $request->validated();
        $createdIds = $this->massStoreItems($data['items']);

        return response()->json([
            'status' => 'ok',
            'count'  => count($createdIds),
            'ids'    => $createdIds,
        ], Response::HTTP_CREATED);
    }

    public function massUpdate(MassUpdateActorRequest $request)
    {
        // L’authorize() du FormRequest gère déjà actor_edit
        $data = $request->validated();

        $this->massUpdateItems($data['items']);

        return response()->json([
            'status' => 'ok',
        ]);
    }
}

You keep:

  • The FormRequest statements specific to each resource (validation + policy).

  • The Gate permissions aligned with $abilityPrefix.

Everything else (filters, massStore, massUpdate, massDestroy, etc.) is centralized in ApiController.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants