From 793d13ef4c430549bfb6a5616a763e48c5447cd7 Mon Sep 17 00:00:00 2001 From: Kentaro Saida Date: Sat, 20 Jun 2026 14:09:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E3=82=A8=E3=83=A9=E3=83=BC?= =?UTF-8?q?=E5=B0=81=E7=AD=92=E3=81=AE=20imsx=5FcodeMinorFieldName=20?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E3=83=95=E3=82=A3=E3=83=BC=E3=83=AB=E3=83=89?= =?UTF-8?q?=E5=90=8D=E3=81=AB=20(C11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sort / orderBy / filter / fields / limit / offset のクエリパラメータエラー、 および request-validation エラーで、imsx_codeMinorFieldName に問題のある パラメータ名を入れる(従来は全て既定の "sourcedId")。 - errors.imsx_error_response に field_name 引数(既定 "sourcedId")を追加 - QueryParamError に field_name を持たせ、parse_sort/parse_filter/parse_fields で sort/orderBy/filter/fields を設定 - 各 list ルーターの limit/offset エラーに field_name を付与 - RequestValidationError ハンドラがエラー loc から欠落/不正フィールド名を抽出 - tests/integration/test_error_field_names.py を追加 - api-spec / api-examples を更新、backlog C11 を「対応済み」へ Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XXFogp18twGiPFBAZcqJw2 --- docs/dev/case-v1p1-conformance-backlog.md | 2 +- docs/spec/api-examples.md | 7 +-- docs/spec/api-spec.md | 4 +- src/errors.py | 14 ++++- src/main.py | 10 +++- src/routers/cf_association_groupings.py | 8 ++- src/routers/cf_concepts.py | 8 ++- src/routers/cf_documents.py | 10 ++-- src/routers/cf_item_types.py | 8 ++- src/routers/cf_items.py | 8 ++- src/routers/cf_licenses.py | 8 ++- src/routers/cf_rubrics.py | 8 ++- src/routers/cf_subjects.py | 8 ++- src/services/case_query_params.py | 36 ++++++++----- tests/integration/test_error_field_names.py | 60 +++++++++++++++++++++ 15 files changed, 161 insertions(+), 38 deletions(-) create mode 100644 tests/integration/test_error_field_names.py diff --git a/docs/dev/case-v1p1-conformance-backlog.md b/docs/dev/case-v1p1-conformance-backlog.md index a806f4e..d283901 100644 --- a/docs/dev/case-v1p1-conformance-backlog.md +++ b/docs/dev/case-v1p1-conformance-backlog.md @@ -16,6 +16,7 @@ compeito の現在のゴールは **OpenCASE / OpenSALT との実用的な相互 - Service Discovery `GET /ims/case/v1p1/discovery/imscasev1p1_openapi3_v1p0.json`(実装済・テストあり) - エラー封筒 `imsx_StatusInfo`(codeMajor / severity / codeMinor.codeMinorField[].{Name,Value})は適合 - `GET /CFItemAssociations/{id}` の既定を全件返却に(公式契約にページネーション定義なし。既定 limit=100 のサイレント切り詰めを廃止、`limit`/`offset` は明示指定時のみの拡張に。2026-06 適合性監査 N1、PR #220) +- **エラー封筒の `imsx_codeMinorFieldName` を実フィールド名に**(旧 C11)。`imsx_error_response` に `field_name` 引数を追加し、sort / orderBy / filter / fields / limit / offset と request-validation 由来のフィールド名を渡す(既定は `sourcedId`)。 ## certification 着手項目(未対応 / 意図的差異) @@ -31,7 +32,6 @@ compeito の現在のゴールは **OpenCASE / OpenSALT との実用的な相互 | C8 | **`caseVersion` を "1.1" に強制しない** | 保存値をそのまま emit | P3 | import 時に "1.1" 検証、または emit 時に固定 | | C9 | **UUID 不正 → 400 / `limit`=0 許容 / `limit`・`offset` の上限 cap** | 実用優先の挙動(OpenAPI は invalid を unknownobject 扱い、`minimum:1` 等) | P3 | strict モードでのみ OpenAPI どおりに(既定は現状維持) | | C10 | **拡張 list エンドポイント** | `CFItemTypes` 等の list は compeito 拡張(公式 list は `CFDocuments` のみ)。`sort/filter/fields` も `CFDocuments` のみ対応 | — | 仕様超過なので certification 上は無害。必要なら他 list にも query を展開 | -| C11 | **エラー封筒の `imsx_codeMinorFieldName`** | 常に既定の `"sourcedId"`。invalid_sort_field / invalid_selection_field 系では `sort` / `fields` / `limit` 等の実フィールド名が意味的に正しい | P3 | `imsx_error_response` に fieldName 引数を追加し、各呼び出し箇所で該当フィールド名を渡す | | C12 | **`ext:` associationType の文字種** | import 受理が `startswith("ext:")` のみで、公式パターン `(ext:)[a-zA-Z0-9.\-_]+` の文字種を検証しない(`ext:日本語` 等も通る) | P3 | 正規表現で検証し、不一致は invalid associationType として skip + warning | | C13 | **スキーマ層の出力時検証なし** | Pydantic スキーマで identifier の UUID パターン・associationType / targetType の enum を検証していない(import 側で防いでいるため実害は低い) | P3 | strict 出力モード導入時に field_validator で同梱 | | C14 | **未定義サブパスの 404 が imsx 形式でない** | `/{tenant}/ims/case/v1p1/...` 配下の未定義サブパスは FastAPI/Starlette 既定の 404(`{"detail":"Not Found"}`)を返し、imsx_StatusInfo 形式になっていない(既知リソース種別で ID 不在の 404 `unknownobject` は実装済み) | P2 | CASE API パス配下の catch-all ルートまたは `StarletteHTTPException` ハンドラを `main.py` に追加し、imsx 404 に変換 | diff --git a/docs/spec/api-examples.md b/docs/spec/api-examples.md index a50b93a..dc8ec40 100644 --- a/docs/spec/api-examples.md +++ b/docs/spec/api-examples.md @@ -583,15 +583,16 @@ GET /550e8400-.../ims/case/v1p1/CFRubrics "imsx_codeMinor": { "imsx_codeMinorField": [ { - "imsx_codeMinorFieldName": "sourcedId", + "imsx_codeMinorFieldName": "doc", "imsx_codeMinorFieldValue": "invalid_selection_field" } ] } } ``` -> `imsx_codeMinorFieldName` is always `"sourcedId"` (per the imsx convention; see -> [api-spec.md](api-spec.md) error format and [conformance backlog](../dev/case-v1p1-conformance-backlog.md) C11). +> `imsx_codeMinorFieldName` carries the offending parameter name when meaningful +> (here `doc`, the missing required query param); it defaults to `"sourcedId"` +> otherwise. See [api-spec.md](api-spec.md) error format. > `imsx_description` carries the validation detail string from the framework, so the > exact text varies; `"Validation error"` above is illustrative. diff --git a/docs/spec/api-spec.md b/docs/spec/api-spec.md index c126e71..5962452 100644 --- a/docs/spec/api-spec.md +++ b/docs/spec/api-spec.md @@ -249,7 +249,7 @@ The CASE v1.1 `imsx_StatusInfo` shape. Fields are at the root (no wrapper): - `imsx_codeMajor`: `success` / `processing` / `failure` / `unsupported`. - `imsx_severity`: `status` / `warning` / `error`. - `imsx_description`: human-readable message (optional). -- `imsx_codeMinor`: optional. A nested object (not a string). `imsx_codeMinorFieldName` is always `"sourcedId"` for every error (per CASE v1.1 imsx convention). +- `imsx_codeMinor`: optional. A nested object (not a string). `imsx_codeMinorFieldName` defaults to `"sourcedId"` (the imsx convention) but carries the offending parameter name when one is meaningful — `sort` / `orderBy` / `filter` / `fields` / `limit` / `offset` for query-parameter errors, and the missing/invalid field name for request-validation errors. - `imsx_codeMinorFieldValue`: `fullsuccess` / `invalid_sort_field` / `invalid_selection_field` / `forbidden` / `unauthorised_request` / `internal_server_error` / `unknownobject` / `server_busy` / `invalid_uuid`. - HTTP status mapping: 400 → `failure/error`; 404 → `failure/error` + `unknownobject`; 405 → `failure/error` + `invalid_selection_field`; 429 → `failure/error` + `server_busy`; 500 → `failure/error` + `internal_server_error`. - **429 (Server Busy):** defined for every endpoint in the CASE v1.1 OpenAPI. We do not implement rate limiting in Phase 1 explicitly, but API Gateway / Lambda throttling may yield 429. In that case we return the `server_busy` imsx_StatusInfo shape. @@ -590,7 +590,7 @@ CASE v1.1 の imsx_StatusInfo 形式。ルートレベルに直接フィール - `imsx_codeMajor`: `success` / `processing` / `failure` / `unsupported` - `imsx_severity`: `status` / `warning` / `error` - `imsx_description`: 人間向けの説明文字列(任意) -- `imsx_codeMinor`: 任意。ネストされたオブジェクト(文字列ではない)。`imsx_codeMinorFieldName` は全エラーで `"sourcedId"` 固定(CASE v1.1 imsx 標準の慣例に従う) +- `imsx_codeMinor`: 任意。ネストされたオブジェクト(文字列ではない)。`imsx_codeMinorFieldName` は既定で `"sourcedId"`(imsx 標準の慣例)だが、意味のある場合は問題のあるパラメータ名を入れる — クエリパラメータエラーでは `sort` / `orderBy` / `filter` / `fields` / `limit` / `offset`、リクエスト検証エラーでは欠落/不正なフィールド名 - `imsx_codeMinorFieldValue`: `fullsuccess` / `invalid_sort_field` / `invalid_selection_field` / `forbidden` / `unauthorised_request` / `internal_server_error` / `unknownobject` / `server_busy` / `invalid_uuid` - HTTPステータスコード対応: 400→`failure/error`, 404→`failure/error`+`unknownobject`, 405→`failure/error`+`invalid_selection_field`, 429→`failure/error`+`server_busy`, 500→`failure/error`+`internal_server_error` - **429 (Server Busy):** CASE v1.1 OpenAPI で全エンドポイントに定義されている。Phase 1 では明示的なレート制限を実装しないが、API Gateway / Lambda のスロットリングにより 429 が返される可能性がある。その場合は imsx_StatusInfo 形式で `server_busy` を返す diff --git a/src/errors.py b/src/errors.py index 56e0116..59a46ea 100644 --- a/src/errors.py +++ b/src/errors.py @@ -9,13 +9,25 @@ def imsx_error_response( status_code: int, description: str, code_minor_value: str, + field_name: str = "sourcedId", ) -> JSONResponse: + """Build an imsx_StatusInfo error response. + + ``field_name`` sets ``imsx_codeMinorFieldName``. It defaults to the imsx + convention ``"sourcedId"``; pass the offending parameter name (e.g. ``sort`` + / ``fields`` / ``limit``) when it is more meaningful to the client. + """ body = ImsxStatusInfo( imsx_codeMajor="failure", imsx_severity="error", imsx_description=description, imsx_codeMinor=ImsxCodeMinor( - imsx_codeMinorField=[ImsxCodeMinorField(imsx_codeMinorFieldValue=code_minor_value)] + imsx_codeMinorField=[ + ImsxCodeMinorField( + imsx_codeMinorFieldName=field_name, + imsx_codeMinorFieldValue=code_minor_value, + ) + ] ), ) return JSONResponse( diff --git a/src/main.py b/src/main.py index fcae6d6..c6182ce 100644 --- a/src/main.py +++ b/src/main.py @@ -60,7 +60,15 @@ async def method_not_allowed(request: Request, call_next): @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): - return imsx_error_response(400, str(exc), "invalid_selection_field") + # Surface the offending parameter name (e.g. a missing required `doc`) as + # imsx_codeMinorFieldName when it can be derived from the validation error. + field_name = "sourcedId" + errors = exc.errors() + if errors: + loc = errors[0].get("loc") or () + if loc: + field_name = str(loc[-1]) + return imsx_error_response(400, str(exc), "invalid_selection_field", field_name=field_name) @app.exception_handler(InvalidUUIDError) diff --git a/src/routers/cf_association_groupings.py b/src/routers/cf_association_groupings.py index 5fe5747..fc3c8af 100644 --- a/src/routers/cf_association_groupings.py +++ b/src/routers/cf_association_groupings.py @@ -21,9 +21,13 @@ async def list_cf_association_groupings( session: AsyncSession = Depends(get_session), ) -> JSONResponse: if limit < 0: - return imsx_error_response(400, "Invalid limit: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid limit: must be a non-negative integer", "invalid_selection_field", field_name="limit" + ) if offset < 0: - return imsx_error_response(400, "Invalid offset: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid offset: must be a non-negative integer", "invalid_selection_field", field_name="offset" + ) limit = min(limit, 500) offset = min(offset, 100000) items = await case_query_service.list_cf_association_groupings(session, tenant_obj.id, limit, offset) diff --git a/src/routers/cf_concepts.py b/src/routers/cf_concepts.py index b329d68..6cf6761 100644 --- a/src/routers/cf_concepts.py +++ b/src/routers/cf_concepts.py @@ -21,9 +21,13 @@ async def list_cf_concepts( session: AsyncSession = Depends(get_session), ) -> JSONResponse: if limit < 0: - return imsx_error_response(400, "Invalid limit: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid limit: must be a non-negative integer", "invalid_selection_field", field_name="limit" + ) if offset < 0: - return imsx_error_response(400, "Invalid offset: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid offset: must be a non-negative integer", "invalid_selection_field", field_name="offset" + ) limit = min(limit, 500) offset = min(offset, 100000) items = await case_query_service.list_cf_concepts(session, tenant_obj.id, limit, offset) diff --git a/src/routers/cf_documents.py b/src/routers/cf_documents.py index 64495b7..4551fce 100644 --- a/src/routers/cf_documents.py +++ b/src/routers/cf_documents.py @@ -25,9 +25,13 @@ async def list_cf_documents( session: AsyncSession = Depends(get_session), ) -> JSONResponse: if limit < 0: - return imsx_error_response(400, "Invalid limit: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid limit: must be a non-negative integer", "invalid_selection_field", field_name="limit" + ) if offset < 0: - return imsx_error_response(400, "Invalid offset: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid offset: must be a non-negative integer", "invalid_selection_field", field_name="offset" + ) # CASE v1.1 sort / orderBy / filter / fields (IMS / OneRoster-style). try: @@ -35,7 +39,7 @@ async def list_cf_documents( filter_clause = case_query_params.parse_filter(filter) field_list = case_query_params.parse_fields(fields) except case_query_params.QueryParamError as e: - return imsx_error_response(400, e.message, e.code_minor) + return imsx_error_response(400, e.message, e.code_minor, field_name=e.field_name) limit = min(limit, 500) offset = min(offset, 100000) diff --git a/src/routers/cf_item_types.py b/src/routers/cf_item_types.py index 4608c3a..3e87ad2 100644 --- a/src/routers/cf_item_types.py +++ b/src/routers/cf_item_types.py @@ -21,9 +21,13 @@ async def list_cf_item_types( session: AsyncSession = Depends(get_session), ) -> JSONResponse: if limit < 0: - return imsx_error_response(400, "Invalid limit: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid limit: must be a non-negative integer", "invalid_selection_field", field_name="limit" + ) if offset < 0: - return imsx_error_response(400, "Invalid offset: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid offset: must be a non-negative integer", "invalid_selection_field", field_name="offset" + ) limit = min(limit, 500) offset = min(offset, 100000) items = await case_query_service.list_cf_item_types(session, tenant_obj.id, limit, offset) diff --git a/src/routers/cf_items.py b/src/routers/cf_items.py index 6295ec6..6cfc759 100644 --- a/src/routers/cf_items.py +++ b/src/routers/cf_items.py @@ -49,9 +49,13 @@ async def get_cf_item_associations( raise ResourceNotFoundError(f"CFItem not found: '{id}'") if limit is not None and limit < 0: - return imsx_error_response(400, "Invalid limit: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid limit: must be a non-negative integer", "invalid_selection_field", field_name="limit" + ) if offset < 0: - return imsx_error_response(400, "Invalid offset: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid offset: must be a non-negative integer", "invalid_selection_field", field_name="offset" + ) assocs = await case_query_service.list_item_associations(session, tenant_obj.id, str(item_uuid), limit, offset) content = { diff --git a/src/routers/cf_licenses.py b/src/routers/cf_licenses.py index e59a953..0ab2dc1 100644 --- a/src/routers/cf_licenses.py +++ b/src/routers/cf_licenses.py @@ -21,9 +21,13 @@ async def list_cf_licenses( session: AsyncSession = Depends(get_session), ) -> JSONResponse: if limit < 0: - return imsx_error_response(400, "Invalid limit: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid limit: must be a non-negative integer", "invalid_selection_field", field_name="limit" + ) if offset < 0: - return imsx_error_response(400, "Invalid offset: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid offset: must be a non-negative integer", "invalid_selection_field", field_name="offset" + ) limit = min(limit, 500) offset = min(offset, 100000) items = await case_query_service.list_cf_licenses(session, tenant_obj.id, limit, offset) diff --git a/src/routers/cf_rubrics.py b/src/routers/cf_rubrics.py index dd6a14b..3c9c6ef 100644 --- a/src/routers/cf_rubrics.py +++ b/src/routers/cf_rubrics.py @@ -23,9 +23,13 @@ async def list_cf_rubrics( ) -> JSONResponse: doc_uuid = validate_uuid(doc) if limit < 0: - return imsx_error_response(400, "Invalid limit: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid limit: must be a non-negative integer", "invalid_selection_field", field_name="limit" + ) if offset < 0: - return imsx_error_response(400, "Invalid offset: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid offset: must be a non-negative integer", "invalid_selection_field", field_name="offset" + ) limit = min(limit, 500) offset = min(offset, 100000) rubrics = await case_query_service.list_cf_rubrics(session, tenant_obj.id, doc_uuid, limit, offset) diff --git a/src/routers/cf_subjects.py b/src/routers/cf_subjects.py index e9221fe..7e21aa4 100644 --- a/src/routers/cf_subjects.py +++ b/src/routers/cf_subjects.py @@ -21,9 +21,13 @@ async def list_cf_subjects( session: AsyncSession = Depends(get_session), ) -> JSONResponse: if limit < 0: - return imsx_error_response(400, "Invalid limit: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid limit: must be a non-negative integer", "invalid_selection_field", field_name="limit" + ) if offset < 0: - return imsx_error_response(400, "Invalid offset: must be a non-negative integer", "invalid_selection_field") + return imsx_error_response( + 400, "Invalid offset: must be a non-negative integer", "invalid_selection_field", field_name="offset" + ) limit = min(limit, 500) offset = min(offset, 100000) items = await case_query_service.list_cf_subjects(session, tenant_obj.id, limit, offset) diff --git a/src/services/case_query_params.py b/src/services/case_query_params.py index 1a41d18..9b7c705 100644 --- a/src/services/case_query_params.py +++ b/src/services/case_query_params.py @@ -30,12 +30,15 @@ class QueryParamError(Exception): ``code_minor`` maps to an imsx_codeMinorFieldValue ("invalid_sort_field" / "invalid_selection_field"); ``message`` is the human-readable description. + ``field_name`` is the offending query parameter ("sort" / "orderBy" / + "filter" / "fields"), surfaced as imsx_codeMinorFieldName. """ - def __init__(self, code_minor: str, message: str): + def __init__(self, code_minor: str, message: str, field_name: str = "sourcedId"): super().__init__(message) self.code_minor = code_minor self.message = message + self.field_name = field_name # CASE field name (camelCase, as in CFDocumentDType) → (ORM column, kind). @@ -70,13 +73,15 @@ def __init__(self, code_minor: str, message: str): def parse_sort(sort: str | None, order_by: str | None): """Return an ORM order_by clause (or None for the default).""" if order_by is not None and order_by not in ("asc", "desc"): - raise QueryParamError("invalid_sort_field", f"Invalid orderBy: '{order_by}'. Valid values: asc, desc") + raise QueryParamError( + "invalid_sort_field", f"Invalid orderBy: '{order_by}'. Valid values: asc, desc", field_name="orderBy" + ) if not sort: return None entry = _CFDOC_FIELDS.get(sort) if entry is None or entry[1] == "array": # Array fields (subject) are not meaningfully sortable. - raise QueryParamError("invalid_sort_field", f"Invalid sort field: '{sort}'") + raise QueryParamError("invalid_sort_field", f"Invalid sort field: '{sort}'", field_name="sort") col = entry[0] return col.desc() if order_by == "desc" else col.asc() @@ -150,15 +155,20 @@ def parse_filter(filter_str: str | None): """Translate a CASE filter expression to an ORM clause (or None).""" if not filter_str or not filter_str.strip(): return None - has_and = " AND " in filter_str - has_or = " OR " in filter_str - if has_and and has_or: - raise QueryParamError("invalid_selection_field", "Mixing AND and OR in filter is not supported") - if has_or: - return or_(*[_predicate(t) for t in filter_str.split(" OR ")]) - if has_and: - return and_(*[_predicate(t) for t in filter_str.split(" AND ")]) - return _predicate(filter_str) + try: + has_and = " AND " in filter_str + has_or = " OR " in filter_str + if has_and and has_or: + raise QueryParamError("invalid_selection_field", "Mixing AND and OR in filter is not supported") + if has_or: + return or_(*[_predicate(t) for t in filter_str.split(" OR ")]) + if has_and: + return and_(*[_predicate(t) for t in filter_str.split(" AND ")]) + return _predicate(filter_str) + except QueryParamError as e: + # Surface the offending parameter as "filter" for all filter sub-errors. + e.field_name = "filter" + raise def parse_fields(fields: str | None) -> list[str] | None: @@ -168,7 +178,7 @@ def parse_fields(fields: str | None) -> list[str] | None: names = [f.strip() for f in fields.split(",") if f.strip()] invalid = [n for n in names if n not in _CFDOC_OUTPUT_FIELDS] if invalid: - raise QueryParamError("invalid_selection_field", f"Invalid field(s): {', '.join(invalid)}") + raise QueryParamError("invalid_selection_field", f"Invalid field(s): {', '.join(invalid)}", field_name="fields") return names diff --git a/tests/integration/test_error_field_names.py b/tests/integration/test_error_field_names.py new file mode 100644 index 0000000..2a5c2fb --- /dev/null +++ b/tests/integration/test_error_field_names.py @@ -0,0 +1,60 @@ +"""imsx_codeMinorFieldName carries the offending parameter name (backlog C11). + +For sort / orderBy / filter / fields / limit / offset errors the field name is +meaningful instead of the default "sourcedId". +""" + +import pytest +from httpx import AsyncClient + +from src.models.tenant import Tenant + +pytestmark = pytest.mark.asyncio + + +def _field_name(body: dict) -> str: + return body["imsx_codeMinor"]["imsx_codeMinorField"][0]["imsx_codeMinorFieldName"] + + +class TestErrorFieldNames: + async def test_negative_limit_field_name(self, db_client: AsyncClient, tenant: Tenant) -> None: + resp = await db_client.get(f"/{tenant.id}/ims/case/v1p1/CFDocuments?limit=-1") + assert resp.status_code == 400 + assert _field_name(resp.json()) == "limit" + + async def test_negative_offset_field_name(self, db_client: AsyncClient, tenant: Tenant) -> None: + resp = await db_client.get(f"/{tenant.id}/ims/case/v1p1/CFDocuments?offset=-1") + assert resp.status_code == 400 + assert _field_name(resp.json()) == "offset" + + async def test_invalid_sort_field_name(self, db_client: AsyncClient, tenant: Tenant) -> None: + resp = await db_client.get(f"/{tenant.id}/ims/case/v1p1/CFDocuments?sort=bogus") + assert resp.status_code == 400 + assert _field_name(resp.json()) == "sort" + + async def test_invalid_order_by_field_name(self, db_client: AsyncClient, tenant: Tenant) -> None: + resp = await db_client.get(f"/{tenant.id}/ims/case/v1p1/CFDocuments?sort=title&orderBy=bogus") + assert resp.status_code == 400 + assert _field_name(resp.json()) == "orderBy" + + async def test_invalid_filter_field_name(self, db_client: AsyncClient, tenant: Tenant) -> None: + resp = await db_client.get(f"/{tenant.id}/ims/case/v1p1/CFDocuments?filter=bogus%3Dx") + assert resp.status_code == 400 + assert _field_name(resp.json()) == "filter" + + async def test_invalid_fields_field_name(self, db_client: AsyncClient, tenant: Tenant) -> None: + resp = await db_client.get(f"/{tenant.id}/ims/case/v1p1/CFDocuments?fields=bogus") + assert resp.status_code == 400 + assert _field_name(resp.json()) == "fields" + + async def test_missing_required_param_field_name(self, db_client: AsyncClient, tenant: Tenant) -> None: + # CFRubrics requires `doc`; the RequestValidationError surfaces "doc". + resp = await db_client.get(f"/{tenant.id}/ims/case/v1p1/CFRubrics") + assert resp.status_code == 400 + assert _field_name(resp.json()) == "doc" + + async def test_uuid_error_keeps_default_field_name(self, db_client: AsyncClient, tenant: Tenant) -> None: + # A bad path UUID keeps the imsx default "sourcedId". + resp = await db_client.get(f"/{tenant.id}/ims/case/v1p1/CFDocuments/not-a-uuid") + assert resp.status_code == 400 + assert _field_name(resp.json()) == "sourcedId"