-
Notifications
You must be signed in to change notification settings - Fork 21
Expand file tree
/
Copy pathjsonapi.py
More file actions
110 lines (88 loc) · 3.85 KB
/
jsonapi.py
File metadata and controls
110 lines (88 loc) · 3.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
import dataclasses
import re
from typing import (
ClassVar,
Iterable,
Pattern,
Self,
)
@dataclasses.dataclass(frozen=True)
class JSONAPIQueryParam:
"""Dataclass for describing the contents of a JSON:API-compliant Query Parameter."""
family: str
args: tuple[str, ...] = ()
value: str = ""
# Matches any alphanumeric string followed by an open bracket or end of input
# (can include "_" or "-" if outside of the first or last position)
# Note: [^\W_] is equivalent to [a-zA-Z0-9]
FAMILY_REGEX: ClassVar[Pattern] = re.compile(r"^[^\W_]([\w-]*[^\W^])?(?=\[|$)")
# Captures any text located within square brackets
ARG_REGEX: ClassVar[Pattern] = re.compile(r"\[(?P<name>[^[\]]*)\]")
@classmethod
def from_key_value_pair(cls, query_param_name: str, query_param_value: str) -> Self:
family, args = cls.parse_param_name(query_param_name)
return cls(family, args, query_param_value)
@classmethod
def parse_param_name(cls, query_param_name: str) -> tuple[str, tuple[str, ...]]:
"""Parses a query parameter name into its family and bracketed args.
>>> JSONAPIQueryParam.parse_param_name('filter')
('filter', ())
>>> JSONAPIQueryParam.parse_param_name('filter[field]')
('filter', ('field',))
>>> JSONAPIQueryParam.parse_param_name('filter[nested][field]')
('filter', ('nested', 'field'))
"""
if not cls._param_name_is_valid(query_param_name):
raise ValueError(f"Invalid query param name: {query_param_name}")
family_match = cls.FAMILY_REGEX.match(query_param_name)
assert family_match is not None
family = family_match.group()
args = cls.ARG_REGEX.findall(query_param_name)
return (family, tuple(args))
@classmethod
def _param_name_is_valid(cls, query_param_name: str) -> bool:
"""Validates that a given query parameter has a valid name in JSON:API.
>>> JSONAPIQueryParam._param_name_is_valid('filter')
True
>>> JSONAPIQueryParam._param_name_is_valid('filter[so][many][nested][fields]')
True
>>> JSONAPIQueryParam._param_name_is_valid('_filter')
False
>>> JSONAPIQueryParam._param_name_is_valid('fi<er')
False
>>> JSONAPIQueryParam._param_name_is_valid('filter[field')
False
>>> JSONAPIQueryParam._param_name_is_valid('filter[field]extra')
False
"""
# Full match is FAMILY followed by 0 or more ARGS followed by end of input
full_match_regex = re.compile(
f"{cls.FAMILY_REGEX.pattern}({cls.ARG_REGEX.pattern})*$"
)
if not full_match_regex.match(query_param_name):
return False
return True
def __str__(self):
args = "".join([f"[{arg}]" for arg in self.args])
return f"{self.family}{args}={self.value}"
QueryParamFamilies = dict[str, list[JSONAPIQueryParam]]
def group_query_params_by_family(
query_items: Iterable[tuple[str, Iterable[str] | str]]
) -> QueryParamFamilies:
"""Extracts JSON:API query familes from a list of (ParameterName, ParameterValues) tuples.
Data should be pre-normalized before calling, such as by using the results of
`urllib.parse.parse_qs(...).items()` or `django.utils.QueryDict.lists()`
"""
grouped_query_params: QueryParamFamilies = {}
for _unparsed_name, _param_values in query_items:
# Handle wsgiref.headers.Headers-style multi-dicts
if isinstance(_param_values, str):
_param_values = (_param_values,)
for value in _param_values:
parsed_query_param = JSONAPIQueryParam.from_key_value_pair(
_unparsed_name, value
)
grouped_query_params.setdefault(parsed_query_param.family, []).append(
parsed_query_param
)
return grouped_query_params