Skip to content

Commit f8c766e

Browse files
committed
Migrate apirules and oapiformat to a dataclass implementation
1 parent bd91ea0 commit f8c766e

3 files changed

Lines changed: 414 additions & 57 deletions

File tree

pygeoapi/models/config.py

Lines changed: 92 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# Francesco Bartoli <xbartolone@gmail.com>
55
#
66
# Copyright (c) 2023 Sander Schaminee
7-
# Copyright (c) 2025 Francesco Bartoli
7+
# Copyright (c) 2026 Francesco Bartoli
88
#
99
# Permission is hereby granted, free of charge, to any person
1010
# obtaining a copy of this software and associated documentation
@@ -29,59 +29,96 @@
2929
#
3030
# =================================================================
3131

32-
from pydantic import BaseModel, Field
33-
import pydantic
32+
import re
33+
from dataclasses import dataclass, fields, asdict
34+
from typing import Any, Dict
3435

35-
# Handle Pydantic v1/v2 compatibility
36-
if pydantic.VERSION.startswith('1'):
37-
model_validator = 'parse_obj'
38-
model_fields = '__fields__'
39-
regex_param = {'regex': r'^\d+\.\d+\..+$'}
40-
else:
41-
model_validator = 'model_validate'
42-
model_fields = 'model_fields'
43-
regex_param = {'pattern': r'^\d+\.\d+\..+$'}
4436

37+
SEMVER_PATTERN = re.compile(r'^\d+\.\d+\..+$')
4538

46-
class APIRules(BaseModel):
39+
40+
class APIRulesValidationError(ValueError):
41+
"""Raised when APIRules validation fails."""
42+
pass
43+
44+
45+
@dataclass
46+
class APIRules:
4747
"""
48-
Pydantic model for API design rules that must be adhered to.
48+
API design rules that must be adhered to.
49+
50+
Concrete dataclass implementation that can be mimicked
51+
downstream.
52+
53+
:param api_version: Semantic API version number (e.g. '1.0.0')
54+
:param url_prefix: URL path prefix for routes (e.g. '/v1')
55+
If set, pygeoapi routes will be prepended
56+
with the given URL path prefix (e.g. '/v1').
57+
Defaults to an empty string (no prefix).
58+
:param version_header: Response header name for API version
59+
If set, pygeoapi will set a response
60+
header with this name and its value will
61+
hold the API version.
62+
Defaults to an empty string (i.e. no header).
63+
Often 'API-Version' or 'X-API-Version' are
64+
used here.
65+
:param strict_slashes: Whether trailing slashes return 404
66+
If False (default), URL trailing slashes
67+
are allowed.
68+
If True, pygeoapi will return a 404.
4969
"""
50-
api_version: str = Field(**regex_param,
51-
description='Semantic API version number.')
52-
url_prefix: str = Field(
53-
'',
54-
description="If set, pygeoapi routes will be prepended with the "
55-
"given URL path prefix (e.g. '/v1'). "
56-
"Defaults to an empty string (no prefix)."
57-
)
58-
version_header: str = Field(
59-
'',
60-
description="If set, pygeoapi will set a response header with this "
61-
"name and its value will hold the API version. "
62-
"Defaults to an empty string (i.e. no header). "
63-
"Often 'API-Version' or 'X-API-Version' are used here."
64-
)
65-
strict_slashes: bool = Field(
66-
False,
67-
description="If False (default), URL trailing slashes are allowed. "
68-
"If True, pygeoapi will return a 404."
69-
)
70-
71-
@staticmethod
72-
def create(**rules_config) -> 'APIRules':
70+
71+
api_version: str = ''
72+
url_prefix: str = ''
73+
version_header: str = ''
74+
strict_slashes: bool = False
75+
76+
def __post_init__(self):
77+
if not isinstance(self.api_version, str):
78+
raise APIRulesValidationError(
79+
"api_version must be a string, "
80+
f"got {type(self.api_version).__name__}"
81+
)
82+
if not SEMVER_PATTERN.match(self.api_version):
83+
raise APIRulesValidationError(
84+
f"Invalid semantic version: '{self.api_version}'. "
85+
f"Expected format: MAJOR.MINOR.PATCH"
86+
)
87+
if not isinstance(self.url_prefix, str):
88+
raise APIRulesValidationError(
89+
"url_prefix must be a string, "
90+
f"got {type(self.url_prefix).__name__}"
91+
)
92+
if not isinstance(self.version_header, str):
93+
raise APIRulesValidationError(
94+
"version_header must be a string, "
95+
f"got {type(self.version_header).__name__}"
96+
)
97+
if not isinstance(self.strict_slashes, bool):
98+
raise APIRulesValidationError(
99+
"strict_slashes must be a bool, "
100+
f"got {type(self.strict_slashes).__name__}"
101+
)
102+
103+
@classmethod
104+
def create(cls, **rules_config) -> 'APIRules':
73105
"""
74106
Returns a new APIRules instance for the current API version
75107
and configured rules.
108+
109+
Filters only valid fields from the config dict and
110+
creates a validated instance.
111+
112+
:param rules_config: Configuration dict
113+
114+
:returns: Validated APIRules instance
76115
"""
77-
obj = {
116+
valid = {f.name for f in fields(cls)}
117+
filtered = {
78118
k: v for k, v in rules_config.items()
79-
if k in getattr(APIRules, model_fields)
119+
if k in valid
80120
}
81-
# Validation will fail if required `api_version` is missing
82-
# or if `api_version` is not a semantic version number
83-
model_validator_ = getattr(APIRules, model_validator)
84-
return model_validator_(obj)
121+
return cls(**filtered)
85122

86123
@property
87124
def response_headers(self) -> dict:
@@ -122,3 +159,15 @@ def get_url_prefix(self, style: str = '') -> str:
122159
else:
123160
# If no format is specified, return only the bare prefix
124161
return prefix
162+
163+
def model_dump(
164+
self, exclude_none: bool = False
165+
) -> Dict[str, Any]:
166+
"""Serialize to dict."""
167+
result = asdict(self)
168+
if exclude_none:
169+
result = {
170+
k: v for k, v in result.items()
171+
if v is not None
172+
}
173+
return result

pygeoapi/models/openapi.py

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
# ****************************** -*-
2-
# flake8: noqa
31
# =================================================================
42
#
53
# Authors: Francesco Bartoli <xbartolone@gmail.com>
64
#
7-
# Copyright (c) 2025 Francesco Bartoli
5+
# Copyright (c) 2026 Francesco Bartoli
86
#
97
# Permission is hereby granted, free of charge, to any person
108
# obtaining a copy of this software and associated documentation
@@ -29,20 +27,58 @@
2927
#
3028
# =================================================================
3129

30+
from dataclasses import dataclass
3231
from enum import Enum
33-
34-
from pydantic import BaseModel
35-
import pydantic
32+
from typing import Any, Dict
3633

3734

3835
class SupportedFormats(Enum):
3936
JSON = 'json'
4037
YAML = 'yaml'
4138

42-
# Handle Pydantic v1/v2 compatibility
43-
if pydantic.VERSION.startswith('1'):
44-
class OAPIFormat(BaseModel):
45-
__root__: SupportedFormats = SupportedFormats.YAML
46-
else:
47-
class OAPIFormat(BaseModel):
48-
root: SupportedFormats = SupportedFormats.YAML
39+
40+
@dataclass
41+
class OAPIFormat:
42+
"""
43+
OpenAPI output format.
44+
45+
Concrete dataclass implementation that can be mimicked
46+
downstream.
47+
48+
:param root: output format, defaults to ``yaml``
49+
"""
50+
51+
root: SupportedFormats = SupportedFormats.YAML
52+
53+
def __post_init__(self):
54+
if isinstance(self.root, SupportedFormats):
55+
return
56+
if isinstance(self.root, str):
57+
try:
58+
self.root = SupportedFormats(self.root)
59+
except ValueError:
60+
raise ValueError(
61+
f"Unsupported format: '{self.root}'. "
62+
f"Must be one of: "
63+
f"{[f.value for f in SupportedFormats]}"
64+
)
65+
else:
66+
raise ValueError(
67+
f"root must be a string or SupportedFormats, "
68+
f"got {type(self.root).__name__}"
69+
)
70+
71+
def __eq__(self, other):
72+
if isinstance(other, str):
73+
return self.root.value == other
74+
if isinstance(other, SupportedFormats):
75+
return self.root == other
76+
if isinstance(other, OAPIFormat):
77+
return self.root == other.root
78+
return NotImplemented
79+
80+
def model_dump(
81+
self, exclude_none: bool = False
82+
) -> Dict[str, Any]:
83+
"""Serialize to dict."""
84+
return {'root': self.root.value}

0 commit comments

Comments
 (0)