diff --git a/docs/dev/case-v1p1-conformance-backlog.md b/docs/dev/case-v1p1-conformance-backlog.md index 986d0d3..aa0648e 100644 --- a/docs/dev/case-v1p1-conformance-backlog.md +++ b/docs/dev/case-v1p1-conformance-backlog.md @@ -19,6 +19,7 @@ compeito の現在のゴールは **OpenCASE / OpenSALT との実用的な相互 - **未定義サブパスの 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`)。 - **`ext:` associationType の文字種検証**(旧 C12)。import 受理を公式パターン `^ext:[a-zA-Z0-9.\-_]+$` で検証し、不一致(`ext:日本語` / `ext:` / 空白入り等)は invalid associationType として skip + warning。 +- **`caseVersion` の import 検証**(旧 C8)。想定外の値(`1.0` / `1.1` 以外)は警告を出す。値そのものは保持する(round-trip 忠実度を保つため emit 固定はしない)。 ## certification 着手項目(未対応 / 意図的差異) @@ -31,7 +32,6 @@ compeito の現在のゴールは **OpenCASE / OpenSALT との実用的な相互 | C5 | **`Link` ページネーションヘッダ** | 未実装(next/prev/first/last)。`X-Total-Count` は実装済み | P2 | `GET /CFDocuments` に RFC 8288 形式の `Link` を追加。既存クエリ(sort/filter/fields)を保持してリンク生成 | | C6 | **filter の網羅性** | scalar + `subject`(JSONB) 対応。ネストのドット記法(`licenseURI.identifier` 等)未対応。ordering は大小区別のまま(等価は大小無視に対応済) | P2 | dot-notation のリンクフィールド filter、必要なら collation 指定の case-insensitive ordering | | C7 | **不明な `sort` / `fields` → 400** | binding 散文は「不明 sort は既定順」「不明 field は全件返す(空 field のみ invalid_selection_field)」。compeito は 400 で明示エラー(typo 可視で親切) | P2 | strict/compat モードでのみ binding 散文どおりの寛容挙動に切替(既定は現状維持を推奨) | -| 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 を展開 | | C13 | **スキーマ層の出力時検証なし** | Pydantic スキーマで identifier の UUID パターン・associationType / targetType の enum を検証していない(import 側で防いでいるため実害は低い) | P3 | strict 出力モード導入時に field_validator で同梱 | diff --git a/docs/spec/db-schema.md b/docs/spec/db-schema.md index 0c88506..4bbf751 100644 --- a/docs/spec/db-schema.md +++ b/docs/spec/db-schema.md @@ -52,7 +52,7 @@ creator: VARCHAR -- Required in CASE v1.1 but nullable here to a publisher: VARCHAR description: TEXT framework_type: VARCHAR -- v1.1 new. Standard value "CourseCodes" (free-form string per OpenAPI). -case_version: VARCHAR -- v1.1 new. OpenAPI enum: ["1.1"]. Only "1.1" is valid. +case_version: VARCHAR -- v1.1 new. OpenAPI enum: ["1.1"]. Import also accepts "1.0" (v1.0 sources); other values are kept with a warning. language: VARCHAR(10) version: VARCHAR adoption_status: VARCHAR @@ -301,7 +301,7 @@ creator: VARCHAR -- CASE v1.1 では required だが、CSV イ publisher: VARCHAR description: TEXT framework_type: VARCHAR -- v1.1 new. 標準値は "CourseCodes"(OpenAPI 上は自由文字列) -case_version: VARCHAR -- v1.1 new. OpenAPI では enum: ["1.1"]。値は "1.1" のみ有効 +case_version: VARCHAR -- v1.1 new. OpenAPI では enum: ["1.1"]。import は "1.0"(v1.0 ソース)も受理し、その他の値は警告のうえ保持 language: VARCHAR(10) version: VARCHAR adoption_status: VARCHAR diff --git a/docs/spec/import-logic.md b/docs/spec/import-logic.md index ae6f291..97f61c1 100644 --- a/docs/spec/import-logic.md +++ b/docs/spec/import-logic.md @@ -292,7 +292,7 @@ Mapping from the external CFPackage's CFDocument object to DB columns: - `publisher` → `publisher`. - `description` → `description`. - `frameworkType` → `framework_type` (v1.1 new). -- `caseVersion` → `case_version` (v1.1 new; only `"1.1"` is valid). +- `caseVersion` → `case_version` (v1.1 new). `"1.0"` and `"1.1"` are accepted without a warning (`"1.0"` because compeito ingests v1.0 sources); any other value emits a warning ("CFDocument '{id}': unexpected caseVersion '{val}' …") and the value is **kept as-is** (not rewritten, to preserve round-trip fidelity). - `language` → `language` (validate length ≤ 10; too long → NULL with a warning — same rule as CSV import). - `version` → `version`. - `adoptionStatus` → `adoption_status`. @@ -863,7 +863,7 @@ CSVインポートと同様に、既存ドキュメント更新時は Step 3 で - `publisher` → `publisher` - `description` → `description` - `frameworkType` → `framework_type`(v1.1 new) -- `caseVersion` → `case_version`(v1.1 new。値は `"1.1"` のみ有効) +- `caseVersion` → `case_version`(v1.1 new。`"1.0"` と `"1.1"` は警告なしで受理する(`"1.0"` は v1.0 ソース取り込みのため)。それ以外の値は警告を出力し(「CFDocument '{id}': unexpected caseVersion '{val}' …」)、値はそのまま保持する(round-trip 忠実度を保つため書き換えない)。) - `language` → `language`(10文字以下であることを検証する。超過の場合は NULL として保存し警告出力。CSV インポートと同一ルール) - `version` → `version` - `adoptionStatus` → `adoption_status` diff --git a/docs/spec/web-ui.md b/docs/spec/web-ui.md index 3d9de01..8f9b9b4 100644 --- a/docs/spec/web-ui.md +++ b/docs/spec/web-ui.md @@ -534,7 +534,7 @@ OpenSALT の `/uri/{uuid}` ページを参考にしつつ、デザインは Tail | licenseURI | 任意 | ネスト表示(title, identifier, uri)。CFItem と同一形式 | | officialSourceURL | 任意 | URL(リンク) | | frameworkType | 任意 | テキスト(v1.1 new。外部インポート由来で設定される場合がある) | -| caseVersion | 任意 | テキスト(v1.1 new。値は "1.1" のみ) | +| caseVersion | 任意 | テキスト(v1.1 new。OpenAPI enum は "1.1"。import は "1.0" も受理し、その他の値は警告のうえ保持) | | subject | 任意 | 配列をカンマ区切りで表示 | | subjectURI | 任意 | 各要素のネスト表示(title, identifier, uri)。配列 | | CFPackageURI | 必須 | ネスト表示(title, identifier, uri) | diff --git a/src/services/case_import_service.py b/src/services/case_import_service.py index 7c4b734..66460a8 100644 --- a/src/services/case_import_service.py +++ b/src/services/case_import_service.py @@ -260,6 +260,28 @@ def _validate_language( return val +# Accepted caseVersion values. The CASE v1.1 OpenAPI enum is just "1.1"; we also +# accept "1.0" because compeito ingests v1.0 sources (normalized to v1.1 output). +_VALID_CASE_VERSIONS = {"1.0", "1.1"} + + +def _validate_case_version( + val: str | None, + context: str, + warnings: list[str], +) -> str | None: + """Warn on an unexpected caseVersion; the stored value is left unchanged. + + A warning (not a rewrite) keeps round-trip fidelity intact while still + surfacing malformed values such as "2.0" / "v1.1" / typos. + """ + if val is not None and val not in _VALID_CASE_VERSIONS: + warnings.append( + f"{context}: unexpected caseVersion '{val}' (expected one of {sorted(_VALID_CASE_VERSIONS)}); kept as-is" + ) + return val + + # --------------------------------------------------------------------------- # HTTP fetch # --------------------------------------------------------------------------- @@ -907,7 +929,7 @@ def _create_document( publisher=data.get("publisher"), description=data.get("description"), framework_type=data.get("frameworkType"), - case_version=data.get("caseVersion"), + case_version=_validate_case_version(data.get("caseVersion"), f"CFDocument '{ident}'", warnings), language=lang, version=data.get("version"), adoption_status=data.get("adoptionStatus"), @@ -960,7 +982,7 @@ def _update_document( if data.get("frameworkType") is not None: doc.framework_type = data["frameworkType"] if data.get("caseVersion") is not None: - doc.case_version = data["caseVersion"] + doc.case_version = _validate_case_version(data["caseVersion"], f"CFDocument '{doc.identifier}'", warnings) if data.get("notes") is not None: doc.notes = data["notes"] if data.get("extensions") is not None: diff --git a/tests/unit/test_case_import.py b/tests/unit/test_case_import.py index 4f79256..f64cd73 100644 --- a/tests/unit/test_case_import.py +++ b/tests/unit/test_case_import.py @@ -28,6 +28,7 @@ _normalize_v1p0_package, _parse_sequence_number, _validate_association, + _validate_case_version, _validate_cf_package, fetch_cf_package, import_case_from_dict, @@ -378,6 +379,30 @@ def test_empty_title(self): _validate_cf_package(pkg) +class TestCaseVersionValidation: + def test_v11_no_warning(self): + warnings: list[str] = [] + assert _validate_case_version("1.1", "CFDocument 'x'", warnings) == "1.1" + assert warnings == [] + + def test_v10_no_warning(self): + warnings: list[str] = [] + assert _validate_case_version("1.0", "CFDocument 'x'", warnings) == "1.0" + assert warnings == [] + + def test_none_no_warning(self): + warnings: list[str] = [] + assert _validate_case_version(None, "CFDocument 'x'", warnings) is None + assert warnings == [] + + def test_unexpected_value_warns_but_keeps_value(self): + warnings: list[str] = [] + # Value is preserved (round-trip fidelity); only a warning is emitted. + assert _validate_case_version("2.0", "CFDocument 'x'", warnings) == "2.0" + assert len(warnings) == 1 + assert "caseVersion" in warnings[0] + + class TestAssociationValidation: def test_valid_association(self): assoc = _make_association()