|
4 | 4 | # Francesco Bartoli <xbartolone@gmail.com> |
5 | 5 | # |
6 | 6 | # Copyright (c) 2023 Sander Schaminee |
7 | | -# Copyright (c) 2025 Francesco Bartoli |
| 7 | +# Copyright (c) 2026 Francesco Bartoli |
8 | 8 | # |
9 | 9 | # Permission is hereby granted, free of charge, to any person |
10 | 10 | # obtaining a copy of this software and associated documentation |
|
29 | 29 | # |
30 | 30 | # ================================================================= |
31 | 31 |
|
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 |
34 | 35 |
|
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+\..+$'} |
44 | 36 |
|
| 37 | +SEMVER_PATTERN = re.compile(r'^\d+\.\d+\..+$') |
45 | 38 |
|
46 | | -class APIRules(BaseModel): |
| 39 | + |
| 40 | +class APIRulesValidationError(ValueError): |
| 41 | + """Raised when APIRules validation fails.""" |
| 42 | + pass |
| 43 | + |
| 44 | + |
| 45 | +@dataclass |
| 46 | +class APIRules: |
47 | 47 | """ |
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. |
49 | 69 | """ |
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': |
73 | 105 | """ |
74 | 106 | Returns a new APIRules instance for the current API version |
75 | 107 | 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 |
76 | 115 | """ |
77 | | - obj = { |
| 116 | + valid = {f.name for f in fields(cls)} |
| 117 | + filtered = { |
78 | 118 | k: v for k, v in rules_config.items() |
79 | | - if k in getattr(APIRules, model_fields) |
| 119 | + if k in valid |
80 | 120 | } |
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) |
85 | 122 |
|
86 | 123 | @property |
87 | 124 | def response_headers(self) -> dict: |
@@ -122,3 +159,15 @@ def get_url_prefix(self, style: str = '') -> str: |
122 | 159 | else: |
123 | 160 | # If no format is specified, return only the bare prefix |
124 | 161 | 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 |
0 commit comments