Skip to content

feat(products): bulk import endpoint (EVO-1555 S1)#147

Open
marcelogorutuba wants to merge 1 commit into
developfrom
marcelo/evo-1555-bulk-product-endpoint
Open

feat(products): bulk import endpoint (EVO-1555 S1)#147
marcelogorutuba wants to merge 1 commit into
developfrom
marcelo/evo-1555-bulk-product-endpoint

Conversation

@marcelogorutuba

@marcelogorutuba marcelogorutuba commented Jun 13, 2026

Copy link
Copy Markdown
Member

Summary

Adds POST /api/v1/products/bulk accepting up to 500 products in a single ACID transaction (S1 da EVO-1555). Reaproveita 100% da modelagem entregue na EVO-1109 — apenas adiciona o pipeline de ingestão em lote.

  • Endpoint novo no Api::V1::ProductsController (require_permissions(bulk: 'products.create'))
  • Service object Products::BulkImporter encapsula a transação, dedup intra-batch de SKU, normalização e tratamento de erros
  • Rate-limit dedicado no Rack::Attack: 10 req/min/key, discriminator = SHA256(Authorization Bearer) → SHA256(api_access_token) → IP (credenciais nunca caem em chave de Redis)
  • Resposta 422 estruturada com array de erros indexados por posição quando algo falha — sempre rollback total

v1 entrega só fail na política de conflito de SKU

A AC original do card Linear cita skip / overwrite / fail como política configurável. Esta story (S1) entrega apenas fail (rollback total em qualquer conflito de SKU vs DB ou intra-batch). skip e overwrite são out-of-scope por decisão do tech-spec — fast-follow se virar demanda real. CSV UI (S2) e webhook ERP (S3) também ficam para stories filhas.

Hardening rounds 1+2 (revisão antes do PR)

  • H1: Gemfile.lock revertido (poluído por bundle install em container Linux durante test run)
  • M1: discriminator do throttle agora hasheia o token em vez de usar header literal
  • M2: elemento não-Hash no array vira 422 estruturado, não 500
  • M3: sku: '' normalizado pra nil antes do save (evita PG::UniqueViolation no índice parcial)
  • M5: pre-validação unifica type-check + dedup SKU numa única passada — cliente recebe todos os erros estruturais juntos

Validation

  • evo-ai-crm-community: docker exec evo-crm-community-evo-crm-1 sh -lc "cd /app && DATABASE_NAME=evo_community_test POSTGRES_DATABASE=evo_community_test bundle exec rspec spec/requests/api/v1/products_bulk_spec.rb"18/18 verde em 8.81s (cobre AC1–AC13 + M2/M5)
  • evo-ai-crm-community: bundle exec rubocop app/controllers/api/v1/products_controller.rb app/services/products/bulk_importer.rb config/initializers/rack_attack.rb config/routes.rb spec/requests/api/v1/products_bulk_spec.rb → limpo no diff (2 offenses pré-existentes em rack_attack.rb:187,200 fora do escopo)

Smoke manual (a fazer pré-merge)

Endpoint requer container rodando + auth-service. Comandos sugeridos:

  • curl POST /api/v1/products/bulk com 1 item válido → 201
  • curl POST /api/v1/products/bulk com 501 itens → 422 LIMIT_EXCEEDED
  • curl POST /api/v1/products/bulk 11x em <1min → 11ª deve voltar 429 (Rack::Attack)

Changed Files

  • app/controllers/api/v1/products_controller.rb
  • app/services/products/bulk_importer.rb (NEW)
  • config/routes.rb
  • config/initializers/rack_attack.rb
  • spec/requests/api/v1/products_bulk_spec.rb (NEW — primeiro spec de Products no repo)

Linked Issue

  • EVO-1555 (S1)

cc @davidson

🤖 Generated with Claude Code

Summary by Sourcery

Add a bulk products import API endpoint that creates multiple products in a single transactional operation with structured validation errors and rate limiting.

New Features:

  • Introduce POST /api/v1/products/bulk endpoint to create up to 500 products per request under products.create permissions.
  • Add Products::BulkImporter service to handle transactional batch creation, validation, and label assignment for products.

Enhancements:

  • Add request-level validations for bulk import payload shape, item count limits, and intra-batch SKU conflicts with unified error reporting.
  • Normalize blank SKUs to nil during bulk import to align with partial unique index semantics.
  • Configure a dedicated Rack::Attack throttle for the bulk products import endpoint keyed by hashed credentials or IP for abuse protection.

Tests:

  • Add request specs for the bulk products import endpoint covering success, validation failures, limits, and rate limiting behavior.

POST /api/v1/products/bulk accepting up to 500 products in a single ACID
transaction. Reuses existing Product validations, Labelable, and RBAC
(products.create). Pre-validates item types + intra-batch SKU dupes before
opening the transaction so the client gets the full error set in one 422.

- Rate limit: 10 req/min/key via Rack::Attack, discriminator hashes the
  Authorization Bearer (or api_access_token) so credentials never land in
  Redis keys; falls back to IP.
- Blank SKU normalized to nil to avoid PG::UniqueViolation on the partial
  unique index (WHERE sku IS NOT NULL).
- Non-Hash array elements yield a structured 422, not a 500.
- Service object (Products::BulkImporter) keeps controller thin and
  rubocop-clean without disable comments.

v1 ships only the 'fail' SKU-conflict policy (rollback total); skip/overwrite
are out-of-scope per tech-spec — fast-follow if real demand surfaces.

Tests: 18 request specs covering AC1-AC13 + M2/M5 hardening. AC12 driven via
Rack::MockRequest because Rails request specs bypass Rack::Attack throttles
at the middleware level.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements a new POST /api/v1/products/bulk endpoint that ingests up to 500 products per request in a single database transaction, with a dedicated BulkImporter service handling pre-validation, intra-batch SKU deduplication, normalization, and error aggregation, plus a Rack::Attack rate limit and request specs covering success, validation, and throttling behavior.

Sequence diagram for POST /api/v1/products/bulk success and failure flows

sequenceDiagram
  actor Client
  participant RackAttack
  participant ProductsController
  participant BulkImporter
  participant ActiveRecord

  Client->>RackAttack: POST /api/v1/products/bulk
  RackAttack->>RackAttack: throttle api_v1_products_bulk
  RackAttack-->>ProductsController: forward request

  ProductsController->>ProductsController: extract_bulk_items
  alt [items invalid or limit exceeded]
    ProductsController-->>Client: error_response VALIDATION_ERROR or LIMIT_EXCEEDED (422)
  else [items valid]
    ProductsController->>BulkImporter: call
    BulkImporter->>BulkImporter: pre_validate_items
    alt [pre validation errors]
      BulkImporter-->>ProductsController: raise BulkImportError
      ProductsController-->>Client: error_response VALIDATION_ERROR (422)
    else [pre validation OK]
      BulkImporter->>ActiveRecord: transaction
      loop each item
        BulkImporter->>ActiveRecord: save Product
      end
      alt [any product invalid]
        ActiveRecord-->>BulkImporter: rollback
        BulkImporter-->>ProductsController: raise BulkImportError
        ProductsController-->>Client: error_response VALIDATION_ERROR (422)
      else [all products valid]
        ActiveRecord-->>BulkImporter: commit
        BulkImporter-->>ProductsController: created products
        ProductsController-->>Client: success_response created (201)
      end
    end
  end
Loading

File-Level Changes

Change Details Files
Add bulk products endpoint in ProductsController with input extraction, limit enforcement, and structured error handling.
  • Extend controller permissions mapping to include the bulk action under products.create permission.
  • Define bulk action that delegates to Products::BulkImporter, returns 201 with serialized products on success, and 422 with structured errors on failures.
  • Introduce helper methods to extract and validate the products array, enforce MAX_ITEMS limit, and standardize 422 responses for missing/empty arrays and limit exceeded errors.
  • Adjust strong parameters for variants to use hash syntax for attributes_data to satisfy strong params requirements.
app/controllers/api/v1/products_controller.rb
Introduce Products::BulkImporter service to encapsulate transactional bulk creation, pre-validation, and normalization logic.
  • Define MAX_ITEMS and SCALAR_ATTRS constants to control payload size and allowed scalar attributes.
  • Implement BulkImportError exception carrying a positional errors_payload for structured 422 responses.
  • Add pre_validate_items pass to collect type errors and intra-batch SKU duplicates before hitting the database.
  • Implement import_one to build and save Product records, attach labels, and accumulate per-item validation errors within a single transaction.
  • Normalize input via ActionController::Parameters, permit scalar and metadata attributes, convert blank SKU to nil to avoid partial unique index violations, and coerce labels to a string array.
  • Provide hash_like? helper to treat non-hash items as structural errors instead of 500s.
app/services/products/bulk_importer.rb
Expose the bulk endpoint in routing and protect it with a dedicated Rack::Attack rate limit using hashed credentials.
  • Add POST /api/v1/products/bulk collection route under products resources.
  • Require digest library and configure a throttle for api/v1/products/bulk with limit configurable via RATE_LIMIT_PRODUCTS_BULK env var (default 10 req/min).
  • Derive throttle discriminator from SHA256 hashes of Authorization Bearer or API access token headers, falling back to IP, so raw credentials are not stored in Redis.
config/routes.rb
config/initializers/rack_attack.rb
Add request specs to cover bulk import behavior, including validation rules and throttling.
  • Create request specs for successful bulk creation, limit exceeded errors when sending more than MAX_ITEMS, and validation errors for non-hash items and SKU conflicts.
  • Verify transactional behavior where any per-item error results in no products being created and a structured 422 payload indexed by item position.
  • Test Rack::Attack throttle behavior for repeated bulk calls beyond the configured rate limit.
spec/requests/api/v1/products_bulk_spec.rb

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • BulkImporter#call pre-validates non-hash items but still passes all @items to import_one, so a non-hash element will hit item_params and call raw_item.to_h (e.g. on a String) and raise; consider either filtering invalid entries out after pre_validate_items or short-circuiting the second pass when any pre-validation errors exist so non-hash items never reach import_one.
  • The controller’s extract_bulk_items allows params[:products] to be an ActionController::Parameters and then calls Array(raw_items), which for a params hash gives [ [key, value], ... ] pairs rather than the expected product hashes; it would be safer to explicitly handle the Rails array payload shape (e.g. params.require(:products).to_a) instead of relying on Array() for both arrays and params.
  • The max-items invariant is currently enforced only in Api::V1::ProductsController#extract_bulk_items; if Products::BulkImporter might ever be reused from other entry points, consider enforcing MAX_ITEMS inside the service as well to keep the limit close to the logic it protects.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- `BulkImporter#call` pre-validates non-hash items but still passes all `@items` to `import_one`, so a non-hash element will hit `item_params` and call `raw_item.to_h` (e.g. on a String) and raise; consider either filtering invalid entries out after `pre_validate_items` or short-circuiting the second pass when any pre-validation errors exist so non-hash items never reach `import_one`.
- The controller’s `extract_bulk_items` allows `params[:products]` to be an `ActionController::Parameters` and then calls `Array(raw_items)`, which for a params hash gives `[ [key, value], ... ]` pairs rather than the expected product hashes; it would be safer to explicitly handle the Rails array payload shape (e.g. `params.require(:products).to_a`) instead of relying on `Array()` for both arrays and params.
- The max-items invariant is currently enforced only in `Api::V1::ProductsController#extract_bulk_items`; if `Products::BulkImporter` might ever be reused from other entry points, consider enforcing `MAX_ITEMS` inside the service as well to keep the limit close to the logic it protects.

## Individual Comments

### Comment 1
<location path="app/controllers/api/v1/products_controller.rb" line_range="109-111" />
<code_context>
     )
   end

+  def extract_bulk_items
+    raw_items = params[:products]
+    items = raw_items.is_a?(Array) || raw_items.is_a?(ActionController::Parameters) ? Array(raw_items) : []
+    return reject_bulk(ApiErrorCodes::VALIDATION_ERROR, 'products array is required and must not be empty') if items.empty?
+    return reject_bulk_limit(items.size) if items.size > Products::BulkImporter::MAX_ITEMS
</code_context>
<issue_to_address>
**issue (bug_risk):** The coercion of `params[:products]` when it is `ActionController::Parameters` may not yield the intended array of items.

When `raw_items` is an `ActionController::Parameters` with numeric keys, `Array(raw_items)` returns `[key, value]` pairs rather than product hashes. That means downstream code receives arrays instead of the expected hashes/JSON objects, and you lose a clear validation error on invalid input shape. Consider explicitly normalizing here, e.g.:

```ruby
def extract_bulk_items
  raw_items = params[:products]
  items =
    if raw_items.is_a?(Array)
      raw_items
    elsif raw_items.is_a?(ActionController::Parameters)
      raw_items.values
    else
      []
    end

  return reject_bulk(ApiErrorCodes::VALIDATION_ERROR, 'products array is required and must not be empty') if items.empty?
  return reject_bulk_limit(items.size) if items.size > Products::BulkImporter::MAX_ITEMS
end
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +109 to +111
def extract_bulk_items
raw_items = params[:products]
items = raw_items.is_a?(Array) || raw_items.is_a?(ActionController::Parameters) ? Array(raw_items) : []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The coercion of params[:products] when it is ActionController::Parameters may not yield the intended array of items.

When raw_items is an ActionController::Parameters with numeric keys, Array(raw_items) returns [key, value] pairs rather than product hashes. That means downstream code receives arrays instead of the expected hashes/JSON objects, and you lose a clear validation error on invalid input shape. Consider explicitly normalizing here, e.g.:

def extract_bulk_items
  raw_items = params[:products]
  items =
    if raw_items.is_a?(Array)
      raw_items
    elsif raw_items.is_a?(ActionController::Parameters)
      raw_items.values
    else
      []
    end

  return reject_bulk(ApiErrorCodes::VALIDATION_ERROR, 'products array is required and must not be empty') if items.empty?
  return reject_bulk_limit(items.size) if items.size > Products::BulkImporter::MAX_ITEMS
end

@dpaes dpaes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved (code) — EVO-1555 S1

Deep re-review across service+controller, rate-limit security, and spec coverage. No code blockers — the bulk endpoint is atomic, RBAC-gated, and the rate-limit's core security property holds.

AC coverage (S1 backend)

  • AC1 (single transaction, full rollback): ✅ met & well-tested. Products::BulkImporter wraps the whole batch in one ActiveRecord::Base.transaction; any row save failure raises BulkImportError and rolls the batch back. Specs assert zero persistence on a mid-batch failure, including tagging rollback (ActsAsTaggableOn::Tagging.count unchanged).
  • AC4 (validation mirrors the Product model): ✅ correct in code — builds a real Product.new + save, so all 9 model validations run identically to single-create. But the spec exercises only 2 of them (see caveat 1).
  • AC5 (RBAC products.create): ✅ met both directions — 403 (permission denied), 401 (unauthenticated), success (granted). Reuses the same before_action as single create.
  • AC2 (dry-run) / AC3 (skip-overwrite policy): correctly out of scope for S1.

Rate-limit security

Core claim CONFIRMED — the Bearer/api_token is SHA256-hashed before it becomes the Redis throttle key (never raw in the key; not logged — Authorization isn't logged, api_token is masked). Path+POST scope is exact (== '/api/v1/products/bulk' && req.post?), and the endpoint is auth-gated independently of the throttle, so the IP fallback opens no useful bypass.

Non-blocking caveats (worth firming up while the PR is parked for the bundle)

  1. AC4 under-tested. The code mirrors the model, but the spec covers only name presence + sku uniqueness — kind/status/currency/default_price/stock_quantity/purchase_url/length are untested. If a permitted attr were dropped from SCALAR_ATTRS or a model rule diverged, no test would catch it. ~6 cases close the gap.
  2. Response has no created/updated/skipped counts. The success payload is { data: [...], message } — the structured counts the future S2 preview UI ("create X / update Y / skip Z") will consume don't exist yet. Worth aligning that contract now so S2 isn't blocked later.
  3. Rate-limit tested outside the real Rails stack (Rack::MockRequest, not the middleware chain) and only via the api_token discriminator branch — the bearer: branch is untested. Recommend the manual 11× smoke (or a real integration test).
  4. CI runs no RSpec here — "18/18 green" is self-reported (local Docker), and there's no Product factory (first Products spec). Recommend an independent bundle exec rspec before relying on the count.
  5. Low / follow-up: L4 (SKU uniqueness race → generic 409 via the global rescue_from, not a 500), L5 (variants/images dropped — scalar-only by design; contract diverges from single-create), L6 (reload + N+1 on the success path; reload is removable), M1 (uncapped metadata jsonb per row), 429 without the API error envelope / Retry-After (pre-existing repo convention).

Merge held

Per the product decision (Davidson — deliver the complete experience together) + the bundle plan, this S1 is not merged standalone — it ships bundled with S1.1 (EVO-1736) + S2 (EVO-1734), with a rebase when the batch is assembled. Approving the code; merge stays gated on the bundle.

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