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
5 changes: 3 additions & 2 deletions docs/dev/case-v1p1-conformance-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
- **未定義サブパスの 404 / 未捕捉の 500 を imsx_StatusInfo 形式で返す**(旧 C14 / C15)。`main.py` に `StarletteHTTPException` ハンドラ(CASE API パスの 404 → `unknownobject`)とグローバル `Exception` ハンドラ(CASE API パスの 500 → `internal_server_error`)を追加。CASE API 以外は既定挙動を維持。

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

Expand All @@ -34,8 +35,8 @@ compeito の現在のゴールは **OpenCASE / OpenSALT との実用的な相互
| 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 に変換 |
| C15 | **500 が imsx 形式でない** | 未捕捉例外は Starlette 既定のプレーン 500 を返し、`internal_server_error` の imsx_StatusInfo 形式になっていない | P2 | グローバル `Exception` ハンドラを `main.py` に追加し、CASE API パスの 500 を imsx 形式に変換 |

> C14(未定義サブパスの 404 imsx 化)と C15(500 imsx 化)は対応済み。上記「すでに対応済み」を参照。

## デプロイ上の制約(参考)

Expand Down
5 changes: 2 additions & 3 deletions docs/spec/api-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -747,9 +747,8 @@ The response includes an `Allow: GET` header.

### 500 Internal Server Error

> ⚠️ **Target shape, not yet implemented.** Uncaught errors currently return
> Starlette's default plain 500, not the imsx shape below. Tracked as
> [conformance backlog](../dev/case-v1p1-conformance-backlog.md) C15.
Uncaught errors on the CASE API return this imsx shape (a global exception
handler). Off the CASE API the default plain 500 is kept.

```json
{
Expand Down
14 changes: 6 additions & 8 deletions docs/spec/api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,9 @@ The CASE v1.1 `imsx_StatusInfo` shape. Fields are at the root (no wrapper):
- 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.
- We do not use FastAPI's default 422 Validation Error; a custom exception handler converts `RequestValidationError` into a **400** `invalid_selection_field` imsx_StatusInfo response.
- ⚠️ **Not yet implemented** (see [conformance backlog](../dev/case-v1p1-conformance-backlog.md) C14 / C15) — two cases in the status mapping above are not yet wired:
- **404 for undefined sub-paths:** requests to undefined sub-paths under `/{tenant}/ims/case/v1p1/...` currently fall through to FastAPI/Starlette's default 404 (`{"detail": "Not Found"}`), **not** the imsx_StatusInfo shape. A catch-all route or a `StarletteHTTPException` handler for the CASE API path is needed to translate it (C14).
- **500 (internal_server_error):** uncaught server errors currently return Starlette's default plain 500, **not** the `internal_server_error` imsx shape. A global exception handler is needed (C15).
- Error shapes that **are** implemented: 400 (`invalid_uuid` / `invalid_selection_field`), 404 `unknownobject` for a known resource type whose ID does not exist, and 405.
- **404 for undefined sub-paths:** requests to undefined sub-paths under `/{tenant}/ims/case/v1p1/...` return a **404** `unknownobject` in the imsx_StatusInfo shape (a `StarletteHTTPException` handler translates Starlette's default 404 for CASE API paths). Off the CASE API the default 404 (`{"detail": "Not Found"}`) is kept.
- **500 (internal_server_error):** uncaught errors on the CASE API return a **500** `internal_server_error` in the imsx_StatusInfo shape (a global `Exception` handler). Off the CASE API the default plain 500 is kept. In both cases the original exception is still re-raised by Starlette's `ServerErrorMiddleware` for logging.
- All other documented error shapes are implemented: 400 (`invalid_uuid` / `invalid_selection_field`), 404 `unknownobject` for a known resource type whose ID does not exist, and 405.

## Unsupported HTTP methods

Expand Down Expand Up @@ -595,10 +594,9 @@ CASE v1.1 の imsx_StatusInfo 形式。ルートレベルに直接フィール
- 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` を返す
- FastAPI のデフォルト 422 Validation Error レスポンスは使用せず、imsx_StatusInfo 形式の **400**(`invalid_selection_field`)に変換する。カスタム例外ハンドラで RequestValidationError をキャッチし、imsx_StatusInfo 形式で返す
- ⚠️ **未実装**([conformance backlog](../dev/case-v1p1-conformance-backlog.md) C14 / C15 参照)— 上記マッピングのうち以下2件は未配線:
- **未定義サブパスの 404:** `/{tenant}/ims/case/v1p1/...` 配下の未定義サブパスは現状 FastAPI/Starlette のデフォルト 404(`{"detail": "Not Found"}`)にフォールスルーし、imsx_StatusInfo 形式**ではない**。CASE API パス配下の catch-all ルートまたは `StarletteHTTPException` ハンドラで変換する必要がある(C14)。
- **500(internal_server_error):** 未捕捉のサーバーエラーは現状 Starlette のデフォルトのプレーン 500 を返し、`internal_server_error` の imsx 形式**ではない**。グローバル例外ハンドラが必要(C15)。
- **実装済み**のエラー形式: 400(`invalid_uuid` / `invalid_selection_field`)、既知リソース種別で ID 不在時の 404 `unknownobject`、405。
- **未定義サブパスの 404:** `/{tenant}/ims/case/v1p1/...` 配下の未定義サブパスには **404** `unknownobject` を imsx_StatusInfo 形式で返す(CASE API パスの 404 を `StarletteHTTPException` ハンドラで変換)。CASE API 以外では既定の 404(`{"detail": "Not Found"}`)を維持。
- **500(internal_server_error):** CASE API での未捕捉エラーには **500** `internal_server_error` を imsx_StatusInfo 形式で返す(グローバル `Exception` ハンドラ)。CASE API 以外では既定のプレーン 500 を維持。いずれの場合も Starlette の `ServerErrorMiddleware` が元例外を再 raise するためログには残る。
- 上記以外の文書化済みエラー形式はすべて実装済み: 400(`invalid_uuid` / `invalid_selection_field`)、既知リソース種別で ID 不在時の 404 `unknownobject`、405。

## 非対応HTTPメソッド

Expand Down
29 changes: 27 additions & 2 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
from pathlib import Path

from fastapi import FastAPI, Request
from fastapi.exception_handlers import http_exception_handler as default_http_exception_handler
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException

from src.errors import InvalidUUIDError, ResourceNotFoundError, imsx_error_response
from src.routers.case_api import router as case_api_router
from src.routers.web import router as web_router

# Marker identifying CASE API requests (kept in sync with the middleware below).
_CASE_API_MARKER = "/ims/case/v1p1/"

app = FastAPI(
title="COMPEITO",
description="1EdTech CASE v1.1 compatible web service",
Expand Down Expand Up @@ -46,7 +51,7 @@ async def redirect_v1p0(request: Request, call_next):

@app.middleware("http")
async def method_not_allowed(request: Request, call_next):
if "/ims/case/v1p1/" in request.url.path and request.method != "GET":
if _CASE_API_MARKER in request.url.path and request.method != "GET":
response = imsx_error_response(405, "Method not allowed", "invalid_selection_field")
response.headers["Allow"] = "GET"
return response
Expand All @@ -73,6 +78,26 @@ async def resource_not_found_handler(request: Request, exc: ResourceNotFoundErro
return imsx_error_response(404, exc.message, "unknownobject")


@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
# Undefined sub-paths under the CASE API get an imsx 404 (unknownobject);
# everything else keeps FastAPI's default handling (e.g. the Web UI).
if exc.status_code == 404 and _CASE_API_MARKER in request.url.path:
return imsx_error_response(404, exc.detail or "Not found", "unknownobject")
return await default_http_exception_handler(request, exc)


@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse | PlainTextResponse:
# Uncaught errors on the CASE API return an imsx 500; off the CASE API we
# mirror Starlette's default plain 500. In both cases Starlette's
# ServerErrorMiddleware re-raises the original exception afterwards, so the
# traceback is still logged by the server.
if _CASE_API_MARKER in request.url.path:
return imsx_error_response(500, "Internal server error", "internal_server_error")
return PlainTextResponse("Internal Server Error", status_code=500)


# ---------------------------------------------------------------------------
# Health endpoint
# ---------------------------------------------------------------------------
Expand Down
76 changes: 76 additions & 0 deletions tests/integration/test_error_envelope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Error-envelope completeness for the CASE API (conformance backlog C14 / C15).

- Undefined sub-paths under /{tenant}/ims/case/v1p1/ return an imsx 404.
- Uncaught errors on the CASE API return an imsx 500.
- Non-CASE paths keep FastAPI/Starlette's default error handling.
"""

import uuid

import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession

from src.database import get_session
from src.main import app
from src.models.tenant import Tenant

pytestmark = pytest.mark.asyncio


class TestCaseApi404Envelope:
async def test_undefined_case_subpath_returns_imsx_404(self, client: AsyncClient) -> None:
# No route matches "Bogus" → Starlette 404 → converted to imsx (C14).
resp = await client.get(f"/{uuid.uuid4()}/ims/case/v1p1/Bogus")
assert resp.status_code == 404
body = resp.json()
assert body["imsx_codeMajor"] == "failure"
assert body["imsx_severity"] == "error"
assert "unknownobject" in str(body["imsx_codeMinor"])

async def test_non_case_404_keeps_default(self, client: AsyncClient) -> None:
# A missing static file 404s off the CASE API → default shape, not imsx.
resp = await client.get("/static/does-not-exist.xyz")
assert resp.status_code == 404
assert "imsx_codeMajor" not in resp.json()

async def test_non_case_405_keeps_default(self, client: AsyncClient) -> None:
# 405 off the CASE API is delegated to the default handler unchanged.
resp = await client.post("/health")
assert resp.status_code == 405
assert "imsx_codeMajor" not in resp.json()


class TestCaseApi500Envelope:
async def test_500_on_case_api_returns_imsx(
self,
db_session: AsyncSession,
tenant: Tenant,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.services import case_query_service

def boom(*args, **kwargs):
raise RuntimeError("boom")

# Force an uncaught error after tenant resolution succeeds.
monkeypatch.setattr(case_query_service, "count_cf_documents", boom)

async def _override_get_session():
yield db_session

app.dependency_overrides[get_session] = _override_get_session
# raise_app_exceptions=False so the handled 500 response is observable
# (ServerErrorMiddleware re-raises the original for logging otherwise).
transport = ASGITransport(app=app, raise_app_exceptions=False)
try:
async with AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.get(f"/{tenant.id}/ims/case/v1p1/CFDocuments")
finally:
app.dependency_overrides.clear()

assert resp.status_code == 500
body = resp.json()
assert body["imsx_codeMajor"] == "failure"
assert body["imsx_severity"] == "error"
assert "internal_server_error" in str(body["imsx_codeMinor"])
Loading