Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/dev/case-v1p1-conformance-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ compeito の現在のゴールは **OpenCASE / OpenSALT との実用的な相互
- エラー封筒 `imsx_StatusInfo`(codeMajor / severity / codeMinor.codeMinorField[].{Name,Value})は適合
- `GET /CFItemAssociations/{id}` の既定を全件返却に(公式契約にページネーション定義なし。既定 limit=100 のサイレント切り詰めを廃止、`limit`/`offset` は明示指定時のみの拡張に。2026-06 適合性監査 N1、PR #220)
- **未定義サブパスの 404 / 未捕捉の 500 を imsx_StatusInfo 形式で返す**(旧 C14 / C15)。`main.py` に `StarletteHTTPException` ハンドラ(CASE API パスの 404 → `unknownobject`)とグローバル `Exception` ハンドラ(CASE API パスの 500 → `internal_server_error`)を追加。CASE API 以外は既定挙動を維持。
- **エラー封筒の `imsx_codeMinorFieldName` を実フィールド名に**(旧 C11)。`imsx_error_response` に `field_name` 引数を追加し、sort / orderBy / filter / fields / limit / offset と request-validation 由来のフィールド名を渡す(既定は `sourcedId`)。

## certification 着手項目(未対応 / 意図的差異)

Expand All @@ -32,7 +33,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 で同梱 |

Expand Down
7 changes: 4 additions & 3 deletions docs/spec/api-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/spec/api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -589,7 +589,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` を返す
Expand Down
14 changes: 13 additions & 1 deletion src/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 9 additions & 1 deletion src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,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)
Expand Down
8 changes: 6 additions & 2 deletions src/routers/cf_association_groupings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions src/routers/cf_concepts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions src/routers/cf_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,21 @@ 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:
order_by = case_query_params.parse_sort(sort, orderBy)
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)
Expand Down
8 changes: 6 additions & 2 deletions src/routers/cf_item_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions src/routers/cf_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
8 changes: 6 additions & 2 deletions src/routers/cf_licenses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions src/routers/cf_rubrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions src/routers/cf_subjects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 23 additions & 13 deletions src/services/case_query_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand All @@ -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


Expand Down
Loading
Loading