fix: [TESIS-133] answer 400 instead of 500 to a malformed body - #93
Conversation
`params.require(:x)` returns whatever sits under the key, so `permit` on a
String raised NoMethodError and the API answered 500 to a body like
`{"product": "x"}`. TESIS-82 fixed it in WarehousesController with
`params.expect`; the same shape was still live in products, stock transfers,
product mappings and orders.
Stock transfers and product mappings move to `params.expect`, which rejects
anything that is not an object.
Products and orders keep `permit`, behind a new `body_of` guard in
ApplicationController. `expect` treats an all-filtered-out hash as missing, and
a PUT that only carries `stocks` or `items` is a valid request: those two parts
are walked line by line, each with its own 422 and message, so they never reach
the permit list and the filtered hash comes back empty. `body_of` does the same
shape check without the filtering.
The order wrapper used to answer 422 through a hand-rolled RecordNotSaved. Its
shape is contract, not business, so it is now a 400 like the rest.
ParameterMissing is rescued once in ApplicationController so the 400 carries
`{"error": "..."}` (ADR-015) instead of Rails' own body; the three controllers
that declared it themselves drop their copy.
A company_id in the body is still dropped in silence: `expect` filters
unpermitted keys exactly like `permit` does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp
Sanntinat
left a comment
There was a problem hiding this comment.
Revisión — TESIS-133 (PR #93) · Responder 400 y no 500 a un body mal formado
Revisión de TESIS-133-migrate-strong-params-to-expect (899dd7c), contra origin/master de proyecto-api.
| Check | Resultado |
|---|---|
| CI (GitHub Actions) | lint, scan_ruby, test y validate-pr-title en verde |
Rama vs master |
Al día. Mergea limpio |
| Suite local (sobre #93 + #94 mergeados) | 1567 ejemplos · 0 fallas · cobertura 99,89 % línea / 99,81 % rama |
| RuboCop / Brakeman | 281 archivos sin ofensas · 0 advertencias |
| Conflictos con otros PRs | Con #94 (TESIS-136), ninguno: tocan archivos distintos, y la suite de arriba es la de los dos juntos |
✅ La desviación de la card está bien fundada, y la comprobé
La card pedía params.expect en los cuatro controllers, y el PR lo usa sólo en transferencias y mapeos. Para productos y órdenes usa un guard, body_of. Verifiqué el motivo con un spec descartable contra la rama:
ActionController::Parameters.new(product: { stocks: [...] }).expect(product: %i[sku name])levantaParameterMissing.expecttoma como faltante un filtrado que queda vacío.PUT /products/:idcon{"product": {"stocks": [...]}}y ningún campo del producto responde 200 conbody_of. Conexpecthabría sido 400.
El chequeo que evitaba el 500 (que el envoltorio sea un objeto) es el mismo en los dos caminos, así que el comportamiento que pide la card se cumple igual.
✅ Lo que dice el cuerpo del PR, verificado contra la rama
POST /productscon{"product": "Teclado"}→ 400{"error": "param is missing or the value is empty or invalid: product"}, con la forma de ADR-015. Con{"product": {}}, también 400.expectdescarta en silencio uncompany_idque no lista (lo probé directo sobreParameters), y los specs de TESIS-97 pasan.- No queda ningún
rubocop:disabledeRails/StrongParametersExpectenapp/. El únicorequireque sobrevive es el decredentials, que la card deja afuera. - Las dos iteraciones por línea (
itemsen órdenes,stocksen productos) ya chequeabanrespond_to?(:permit)antes depermit, así que el mismo 500 no reaparece un nivel más abajo.
✅ El rescate único de ParameterMissing arregla de paso un endpoint que el PR no nombra
ShipmentQuotesController levanta ParameterMissing cuando falta el depósito de origen, pero nunca lo había rescatado. En master, ese 400 salía con el cuerpo propio de Rails. Con el rescate en ApplicationController, POST /orders/:id/quotes sin origen ahora responde {"error": "... origin_warehouse_id"}, como el resto de la API (lo comprobé en la rama).
✅ El cambio de contrato del envoltorio order no afecta al front
El envoltorio order que no es un objeto pasa de 422 a 400. Busqué en proyecto-web y ningún código distingue un 422 en órdenes: los status con tratamiento propio son 404, 409 y 412. Además, el front nunca manda un order que no sea un objeto.
✅ Los specs tienen dientes
Rompí dos cosas a la vez: saqué el chequeo is_a?(ActionController::Parameters) de body_of y devolví transferencias a require + permit. Fallaron 10 ejemplos, exactamente los nuevos de productos (alta y modificación), órdenes (alta y modificación) y transferencias, y ningún spec anterior. Los de mapeos no los rompí, porque es el mismo expect que transferencias.
🟡 El ejemplo de CLAUDE.md contradice al controller que nombra
El PR actualiza el ejemplo de la sección «Controller» a params.expect(product: %i[sku name stock]), con el comentario de TESIS-133. Pero el archivo del ejemplo es app/controllers/api/v1/products_controller.rb, justo el controller que este PR no pasa a expect, porque romper el PUT que sólo manda stocks es lo que se quiso evitar.
CLAUDE.md es lo que leen primero tanto las personas como el agente. Así como está, invita a «terminar la migración» de productos y deshacer la decisión del PR. Alcanza con una línea en el comentario del ejemplo: cuando el body tiene partes que se recorren a mano (como stocks o items), el envoltorio se valida con body_of y se hace permit sobre eso. No bloquea el merge.
⚪ Menor
El cuerpo del PR dice «14 specs de request nuevos». En el diff hay 13 it nuevos y uno reemplazado (el rejects when order is not an object que esperaba 422). No cambia nada; lo anoto para que la cuenta coincida.
Los criterios de la card
- Para cada endpoint de alta y modificación de los cuatro recursos, un String o un Array responde 400 y no 500, con su spec de request. Mapeos y transferencias sólo tienen alta (
only: %i[index create destroy]e%i[index create]). - Un
company_iden el body se sigue ignorando. - No queda ningún
rubocop:disabledeRails/StrongParametersExpectenapp/. - RuboCop y RSpec en verde.
Veredicto
APPROVE.
La desviación del alcance está justificada y probada, el rescate único mejora un endpoint más de los que declara, y cada caso nuevo tiene un test que falla si se lo saca. Lo de CLAUDE.md conviene corregirlo, pero puede ir en este PR o después.
…ntroller The sample controller in CLAUDE.md is named after products_controller.rb, which is exactly the one this card does not move to `expect`. As written, the example invites the next reader to "finish the migration" and undo the reason it was left out. Reported by Santiago in the review of #93. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp
|
Corregido lo de Lo de los «14 specs» es cierto: son 13 nuevos y uno reemplazado. Queda acá y no lo edito en el cuerpo para no pisar lo que ya revisaste. Gracias por verificar la semántica del filtrado vacío por tu cuenta en vez de creerme, y por encontrar el 400 de |
Ticket de Jira
https://proyectofinalfrlp.atlassian.net/browse/TESIS-133
Descripción
params.require(:x)devuelve lo que haya bajo la clave. Si eso es un String, elpermitde la línea siguiente levantaNoMethodErrory la API responde 500 a un body como{"product": "x"}. En TESIS-82 se arregló sólo enWarehousesControllerpasando aparams.expect; el mismo patrón seguía vivo en productos, transferencias de stock, mapeos y órdenes.Dos formas de resolverlo, según lo que tenga el body:
params.expect. No tienen partes anidadas que se armen a mano, así que alcanza.permit, detrás de un guard nuevo,body_of, enApplicationController.expectconsidera faltante un filtrado que queda vacío, y un PUT que sólo mandastocksoitemses un request válido: esas dos partes se recorren línea por línea, cada una con sus propios 422 y su mensaje, así que nunca entran en la lista depermity el filtrado vuelve vacío.body_ofhace el mismo chequeo de forma sin filtrar. (Esto es lo que se desvía del alcance escrito en la card, que pedíaexpecten los cuatro; el comportamiento que pide es el mismo.)El envoltorio
orderrespondía 422 con unRecordNotSavedescrito a mano. Su forma es contrato y no negocio, así que ahora es 400 como el resto.ActionController::ParameterMissingse rescata una sola vez enApplicationController, para que el 400 viaje como{"error": "..."}(ADR-015) y no con el cuerpo propio de Rails. Los tres controllers que lo declaraban por su cuenta —órdenes, envíos y cotizaciones— sacan su copia.Un
company_iden el body se sigue descartando en silencio:expectfiltra las claves que no permite igual quepermit. Los specs de TESIS-97 lo siguen probando y pasan.products,stock_transfers,product_mappingsyordersdejan de responder 500 a un envoltorio que no es un objeto.body_ofenApplicationController+ rescate único deParameterMissing.rubocop:disabledeRails/StrongParametersExpectenapp/.CLAUDE.mdse actualiza al patrón nuevo.Evidencia visual
N/A
Cómo probar
bundle exec rspec— 1558 ejemplos, 0 fallas.POST /api/v1/productscon{"product": "Teclado"}→ 400{"error": "param is missing or the value is empty or invalid: product"}. Enmasteres 500.{"product": ["..."]}, y constock_transfer,product_mappingyorder(alta yPUT /orders/:id).PUT /api/v1/products/:idcon{"product": {"stocks": [...]}}—sin ningún campo del producto— sigue respondiendo 200: es el caso que descarta usarexpectacá.POST /api/v1/productsconcompany_iden el body sigue creando el producto en la empresa del token.Impacto y consideraciones
¿Introduce breaking changes?
Sí, en los códigos de error, no en el camino feliz. Un body mal formado pasa de 500 a 400, que es lo que la card pide. El envoltorio
orderque no es un objeto pasa de 422 a 400: si el frontend distinguía por status, ahí cambia. Los 422 de las líneas (items,stocks) y del negocio quedan igual.¿Requiere nuevas variables de entorno?
No
¿Afecta la arquitectura o genera un nuevo patrón?
Sí, menor:
body_ofes el lugar donde se valida la forma de un envoltorio que después se recorre a mano. No amerita ADR nuevo; el contrato del cuerpo de error ya está en ADR-015.🤖 Generated with Claude Code
https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp