From 7d3ade5eab49ab74ca174bae2e3fc58f105d5033 Mon Sep 17 00:00:00 2001 From: Kentaro Saida Date: Sat, 20 Jun 2026 14:12:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(import):=20ext:=20associationType=20?= =?UTF-8?q?=E3=81=AE=E6=96=87=E5=AD=97=E7=A8=AE=E3=82=92=E5=85=AC=E5=BC=8F?= =?UTF-8?q?=E3=83=91=E3=82=BF=E3=83=BC=E3=83=B3=E3=81=A7=E6=A4=9C=E8=A8=BC?= =?UTF-8?q?=20(C12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CFPackage import の associationType 受理を、従来の startswith("ext:") から 公式パターン ^ext:[a-zA-Z0-9.\-_]+$ の正規表現検証に変更。文字種に合わない ext:(ext:日本語 / ext: / 空白入り 等)は invalid associationType として skip + warning する。標準型と正当な ext:token は従来どおり受理。 - _EXT_ASSOCIATION_RE を追加、_validate_association で使用 - tests/unit/test_case_import.py に文字種ケースを追加 - backlog C12 を「対応済み」へ 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 +- src/services/case_import_service.py | 8 +++++++- tests/unit/test_case_import.py | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/dev/case-v1p1-conformance-backlog.md b/docs/dev/case-v1p1-conformance-backlog.md index a806f4e..7a32c15 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) +- **`ext:` associationType の文字種検証**(旧 C12)。import 受理を公式パターン `^ext:[a-zA-Z0-9.\-_]+$` で検証し、不一致(`ext:日本語` / `ext:` / 空白入り等)は invalid associationType として skip + warning。 ## certification 着手項目(未対応 / 意図的差異) @@ -32,7 +33,6 @@ compeito の現在のゴールは **OpenCASE / OpenSALT との実用的な相互 | 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 に変換 | | C15 | **500 が imsx 形式でない** | 未捕捉例外は Starlette 既定のプレーン 500 を返し、`internal_server_error` の imsx_StatusInfo 形式になっていない | P2 | グローバル `Exception` ハンドラを `main.py` に追加し、CASE API パスの 500 を imsx 形式に変換 | diff --git a/src/services/case_import_service.py b/src/services/case_import_service.py index fb13790..7c4b734 100644 --- a/src/services/case_import_service.py +++ b/src/services/case_import_service.py @@ -6,6 +6,7 @@ from __future__ import annotations import math +import re import uuid # --------------------------------------------------------------------------- @@ -122,6 +123,11 @@ class CaseImportReport: "isTranslationOf", } +# CASE v1.1 allows extension association types of the form `ext:`, where +# matches the official pattern `(ext:)[a-zA-Z0-9.\-_]+`. Values that +# start with `ext:` but contain other characters (e.g. `ext:日本語`) are invalid. +_EXT_ASSOCIATION_RE = re.compile(r"^ext:[a-zA-Z0-9.\-_]+$") + HTTP_TIMEOUT = 30.0 MAX_REDIRECTS = 5 @@ -1376,7 +1382,7 @@ def _validate_association(data: dict) -> str | None: assoc_type = data.get("associationType") if not assoc_type: return "missing associationType" - if assoc_type not in VALID_ASSOCIATION_TYPES and not str(assoc_type).startswith("ext:"): + if assoc_type not in VALID_ASSOCIATION_TYPES and not _EXT_ASSOCIATION_RE.match(str(assoc_type)): return f"invalid associationType '{assoc_type}'" origin = data.get("originNodeURI") diff --git a/tests/unit/test_case_import.py b/tests/unit/test_case_import.py index 99a235e..4f79256 100644 --- a/tests/unit/test_case_import.py +++ b/tests/unit/test_case_import.py @@ -405,6 +405,25 @@ def test_ext_prefix_valid(self): assoc = _make_association(assoc_type="ext:customType") assert _validate_association(assoc) is None + def test_ext_prefix_allowed_charset_valid(self): + # Allowed token charset: a-z A-Z 0-9 . - _ + assoc = _make_association(assoc_type="ext:custom.Type-1_2") + assert _validate_association(assoc) is None + + def test_ext_prefix_non_ascii_invalid(self): + # `ext:` with characters outside the official pattern is rejected (C12). + assoc = _make_association(assoc_type="ext:日本語") + assert "invalid associationType" in _validate_association(assoc) + + def test_ext_prefix_empty_token_invalid(self): + # `ext:` with no token is invalid (pattern requires at least one char). + assoc = _make_association(assoc_type="ext:") + assert "invalid associationType" in _validate_association(assoc) + + def test_ext_prefix_with_space_invalid(self): + assoc = _make_association(assoc_type="ext:has space") + assert "invalid associationType" in _validate_association(assoc) + def test_missing_origin_uri(self): assoc = _make_association() del assoc["originNodeURI"]["uri"]