Skip to content

Commit de6b94a

Browse files
Merge pull request #321 from minvws/cleanup-unused-oin-pattern
Refactor to use OinType
2 parents bf1c3d0 + 3687f45 commit de6b94a

28 files changed

Lines changed: 602 additions & 295 deletions

app/db/entities/hsm_key_versions.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from app.db.entities.base import Base
1010
from app.db.entities.organization import Organization
11+
from app.models.oin import Oin
1112

1213

1314
class HsmKeyVersion(Base):
@@ -37,13 +38,13 @@ class HsmKeyVersion(Base):
3738
organization: Mapped[Organization] = relationship("Organization")
3839

3940
@property
40-
def oin(self) -> str:
41+
def oin(self) -> Oin:
4142
return self.organization.oin
4243

4344
def to_dict(self) -> dict[str, Any]:
4445
return {
4546
"id": str(self.id),
46-
"oin": self.oin,
47+
"oin": self.oin.value,
4748
"version": self.version,
4849
"from_dt": self.from_dt,
4950
"until_dt": self.until_dt,

app/db/entities/organization.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import uuid
22
from typing import Any, List, TYPE_CHECKING, Optional
33

4+
from app.db.types.oin import OinType
5+
from app.models.oin import Oin
46
from sqlalchemy import String
57
from sqlalchemy.dialects.postgresql import UUID
68
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -21,7 +23,7 @@ class Organization(Base):
2123
id: Mapped[uuid.UUID] = mapped_column(
2224
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
2325
)
24-
oin: Mapped[str] = mapped_column("oin", String, unique=True)
26+
oin: Mapped[Oin] = mapped_column("oin", OinType(), unique=True)
2527
name: Mapped[str] = mapped_column("name", String)
2628
max_rid_usage: Mapped[str] = mapped_column("max_rid_usage", String)
2729

@@ -33,7 +35,7 @@ class Organization(Base):
3335

3436
def to_dict(self) -> dict[str, Any]:
3537
return {
36-
"oin": self.oin,
38+
"oin": self.oin.value,
3739
"name": self.name,
3840
"max_rid_usage": self.max_rid_usage,
3941
}

app/db/repositories/hsm_key_version_repository.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def get_active_versions(
3636
)
3737
)
3838
if oin is not None:
39-
query = query.join(Organization).where(Organization.oin == oin.value)
39+
query = query.join(Organization).where(Organization.oin == oin)
4040
return self.db_session.execute(query).scalars().all()
4141

4242
def get_by_oin(self, oin: Oin) -> Sequence[HsmKeyVersion]:
@@ -48,7 +48,7 @@ def get_by_oin(self, oin: Oin) -> Sequence[HsmKeyVersion]:
4848
select(HsmKeyVersion)
4949
.options(joinedload(HsmKeyVersion.organization))
5050
.join(Organization)
51-
.where(Organization.oin == oin.value)
51+
.where(Organization.oin == oin)
5252
.order_by(HsmKeyVersion.version)
5353
)
5454
return self.db_session.execute(query).scalars().all()

app/db/repositories/org_repository.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,21 @@
77
from app.db.repositories.repository_base import RepositoryBase
88
from sqlalchemy import select
99

10+
from app.models.oin import Oin
11+
1012
logger = logging.getLogger(__name__)
1113

1214

1315
@repository(Organization)
1416
class OrgRepository(RepositoryBase):
15-
def get_by_oin(self, oin: str) -> Optional[Organization]:
17+
def get_by_oin(self, oin: Oin) -> Optional[Organization]:
1618
"""
1719
Fetches the organization by its unique ID.
1820
"""
1921
query = select(Organization).where(Organization.oin == oin)
2022
return self.db_session.execute(query).scalars().first()
2123

22-
def create(self, oin: str, name: str, max_usage_level: str) -> Organization:
24+
def create(self, oin: Oin, name: str, max_usage_level: str) -> Organization:
2325
"""
2426
Creates a new org entry.
2527
"""
@@ -31,7 +33,7 @@ def create(self, oin: str, name: str, max_usage_level: str) -> Organization:
3133
self.db_session.add(entry)
3234
self.db_session.flush()
3335

34-
logger.info("created organization with OIN %s and name %r", oin, name)
36+
logger.info("created organization with OIN %s and name %r", oin.value, name)
3537
return entry
3638

3739
def update(

app/db/types/__init__.py

Whitespace-only changes.

app/db/types/oin.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from typing import Any
2+
3+
from sqlalchemy import String
4+
from sqlalchemy.types import TypeDecorator
5+
6+
from app.models.oin import Oin
7+
8+
9+
class OinType(TypeDecorator[Oin]):
10+
"""Map ``Organization.oin`` to the OIN value object and store it as text."""
11+
12+
impl = String
13+
cache_ok = True
14+
python_type = Oin
15+
16+
def process_bind_param(self, value: Any, _dialect: Any) -> str | None:
17+
if value is None:
18+
return None
19+
20+
if isinstance(value, Oin):
21+
return value.value
22+
23+
return str(value)
24+
25+
def process_result_value(self, value: Any, _dialect: Any) -> Oin | None:
26+
if value is None:
27+
return None
28+
29+
return Oin(value)

app/models/oin.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@
33

44
from pydantic import GetCoreSchemaHandler
55
from pydantic.json_schema import JsonSchemaValue
6-
from pydantic_core import core_schema
6+
from pydantic_core import PydanticCustomError, core_schema
77

88
# An OIN is always 20 characters:
99
# prefix (8 digits) + mainnumber (8 or 9 alphanumeric) + suffix (4 or 3 zeros)
1010
OIN_PATTERN = re.compile(r"^\d{8}(?:[A-Za-z0-9]{8}0{4}|[A-Za-z0-9]{9}0{3})$")
11+
RECIPIENT_ORGANIZATION_PREFIX = "oin:"
12+
_RECIPIENT_ORGANIZATION_SUFFIX_PATTERN = (
13+
r"\d{8}(?:[A-Za-z0-9]{8}0{4}|[A-Za-z0-9]{9}0{3})"
14+
)
15+
RECIPIENT_ORGANIZATION_PATTERN = re.compile(
16+
rf"^{RECIPIENT_ORGANIZATION_PREFIX}{_RECIPIENT_ORGANIZATION_SUFFIX_PATTERN}$"
17+
)
1118

1219

1320
class Oin:
@@ -92,6 +99,8 @@ def __repr__(self) -> str:
9299
def __eq__(self, other: Any) -> bool:
93100
if isinstance(other, Oin):
94101
return self.value == other.value
102+
if isinstance(other, str):
103+
return self.value == other
95104
return False
96105

97106
def __hash__(self) -> int:
@@ -128,3 +137,75 @@ def __get_pydantic_json_schema__(
128137
"8-digit prefix + 8/9-character alphanumeric mainnumber + 4/3 trailing zeros (suffix)."
129138
),
130139
}
140+
141+
142+
class RecipientOrganizationOin(Oin):
143+
"""An OIN supplied with recipient organization prefix, e.g. ``oin:<oin>``.
144+
145+
Internally the stored value remains a plain OIN without the ``oin:`` prefix.
146+
"""
147+
148+
PREFIX = RECIPIENT_ORGANIZATION_PREFIX
149+
_INVALID_ERROR = "Invalid recipient organization. Format: oin:<oin_number>"
150+
151+
def __init__(self, value: Any) -> None:
152+
if isinstance(value, str) and value.startswith(self.PREFIX):
153+
value = value[len(self.PREFIX) :]
154+
super().__init__(value)
155+
156+
@classmethod
157+
def _pydantic_validate(cls, value: Any) -> "RecipientOrganizationOin":
158+
if isinstance(value, cls):
159+
return value
160+
161+
if isinstance(value, Oin):
162+
return cls(value.value)
163+
164+
if not isinstance(value, str):
165+
raise PydanticCustomError(
166+
"invalid_recipient_organization", cls._INVALID_ERROR
167+
)
168+
169+
if not value.startswith(cls.PREFIX):
170+
raise PydanticCustomError(
171+
"invalid_recipient_organization", cls._INVALID_ERROR
172+
)
173+
174+
try:
175+
return cls(value[len(cls.PREFIX) :])
176+
except ValueError as e:
177+
raise PydanticCustomError(
178+
"invalid_recipient_organization", "{error}", {"error": str(e)}
179+
) from e
180+
181+
@classmethod
182+
def __get_pydantic_core_schema__(
183+
cls, _source_type: Any, _handler: GetCoreSchemaHandler
184+
) -> core_schema.CoreSchema:
185+
return core_schema.no_info_plain_validator_function(
186+
cls._pydantic_validate,
187+
serialization=core_schema.to_string_ser_schema(),
188+
)
189+
190+
@classmethod
191+
def __get_pydantic_json_schema__(
192+
cls, _schema: core_schema.CoreSchema, _handler: Any
193+
) -> JsonSchemaValue:
194+
return {
195+
"type": "string",
196+
"pattern": RECIPIENT_ORGANIZATION_PATTERN.pattern,
197+
"examples": [f"{RECIPIENT_ORGANIZATION_PREFIX}00000099000000001000"],
198+
"description": (
199+
"Recipient organization string: prefix followed by an OIN (20-char "
200+
"identifier)."
201+
),
202+
}
203+
204+
def __str__(self) -> str:
205+
"""Return the recipient organization OIN in external format, including ``oin:``."""
206+
return f"{self.PREFIX}{self.value}"
207+
208+
@property
209+
def value(self) -> str:
210+
"""Return the normalized OIN value without the ``oin:`` prefix."""
211+
return super().value

app/models/requests.py

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,34 @@
11
import base64
22
import logging
33
from datetime import datetime
4-
from typing import Any, Literal, List, Optional
4+
from typing import Any, Literal, List
55

66
from pydantic import BaseModel, ConfigDict, model_validator, Field, field_validator
77

8-
from app.models.oin import Oin
8+
from app.models.oin import Oin, RecipientOrganizationOin
99
from app.personal_id import PersonalId
1010
from app.services.pseudonym_service import PseudonymType
1111
from app.rid import RidUsage
1212

1313
logger = logging.getLogger(__name__)
14-
OIN_PATTERN = r"^\d{8}(?:[A-Za-z0-9]{8}0{4}|[A-Za-z0-9]{9}0{3})$"
1514

1615

1716
class RegisterRequest(BaseModel):
1817
scope: List[str]
19-
key_id: Optional[str]
18+
key_id: str | None
2019

2120

2221
class OrgRequest(BaseModel):
23-
oin: str
22+
oin: Oin
2423
name: str = Field(..., min_length=5, max_length=50)
2524
max_key_usage: RidUsage
2625

27-
@field_validator("oin")
28-
def validate_oin(cls, v: Any) -> str:
29-
return str(Oin(v))
30-
3126

3227
class HsmKeyVersionRequest(BaseModel):
33-
oin: str
28+
oin: Oin
3429
from_dt: datetime | None = None
3530
until_dt: datetime | None = None
3631

37-
@field_validator("oin")
38-
def validate_oin(cls, v: Any) -> str:
39-
return str(Oin(v))
40-
4132

4233
class HsmKeyVersionUpdateRequest(BaseModel):
4334
until_dt: datetime | None = None
@@ -46,14 +37,14 @@ class HsmKeyVersionUpdateRequest(BaseModel):
4637

4738
class RidReceiveRequest(BaseModel):
4839
rid: str
49-
recipientOrganization: str
40+
recipientOrganization: RecipientOrganizationOin
5041
recipientScope: str
5142
pseudonymType: Literal["rp", "irp", "bsn"]
5243

5344

5445
class BlindRequest(BaseModel):
5546
encryptedPersonalId: str = Field(..., min_length=2)
56-
recipientOrganization: str = Field(..., min_length=2)
47+
recipientOrganization: RecipientOrganizationOin
5748
recipientScope: str = Field(..., min_length=2)
5849

5950
@field_validator("encryptedPersonalId")
@@ -70,7 +61,7 @@ def validate_base64(cls, v: str) -> str:
7061

7162
class RidExchangeRequest(BaseModel):
7263
personalId: Any
73-
recipientOrganization: str
64+
recipientOrganization: RecipientOrganizationOin
7465
recipientScope: str
7566
ridUsage: Any
7667

@@ -99,7 +90,7 @@ class ExchangeRequest(BaseModel):
9990
model_config = ConfigDict(arbitrary_types_allowed=True)
10091

10192
personalId: Any
102-
recipientOrganization: str
93+
recipientOrganization: RecipientOrganizationOin
10394
recipientScope: str = Field(..., min_length=1, max_length=100)
10495
pseudonymType: PseudonymType
10596

0 commit comments

Comments
 (0)