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 @@ -18,6 +18,7 @@ compeito の現在のゴールは **OpenCASE / OpenSALT との実用的な相互
- `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`)。
- **`ext:` associationType の文字種検証**(旧 C12)。import 受理を公式パターン `^ext:[a-zA-Z0-9.\-_]+$` で検証し、不一致(`ext:日本語` / `ext:` / 空白入り等)は invalid associationType として skip + warning。

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

Expand All @@ -33,7 +34,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 を展開 |
| 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 化)と C15(500 imsx 化)は対応済み。上記「すでに対応済み」を参照。
Expand Down
8 changes: 7 additions & 1 deletion src/services/case_import_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

import math
import re
import uuid

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -122,6 +123,11 @@ class CaseImportReport:
"isTranslationOf",
}

# CASE v1.1 allows extension association types of the form `ext:<token>`, where
# <token> 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

Expand Down Expand Up @@ -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")
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/test_case_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading