msgspec-fastapi 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msgspec_fastapi/__init__.py +4 -0
- msgspec_fastapi/responses.py +10 -0
- msgspec_fastapi/router.py +406 -0
- msgspec_fastapi/validation.py +239 -0
- msgspec_fastapi-0.0.1.dist-info/METADATA +98 -0
- msgspec_fastapi-0.0.1.dist-info/RECORD +8 -0
- msgspec_fastapi-0.0.1.dist-info/WHEEL +4 -0
- msgspec_fastapi-0.0.1.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
import msgspec
|
|
3
|
+
from starlette.responses import JSONResponse
|
|
4
|
+
|
|
5
|
+
_JSON_ENCODER = msgspec.json.Encoder()
|
|
6
|
+
|
|
7
|
+
class MsgspecJSONResponse(JSONResponse):
|
|
8
|
+
"""Encode JSON with msgspec; use MsgspecRouter for output validation."""
|
|
9
|
+
def render(self, content: Any) -> bytes:
|
|
10
|
+
return _JSON_ENCODER.encode(content)
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from functools import partial, wraps
|
|
6
|
+
from hashlib import sha256
|
|
7
|
+
from typing import Annotated, get_args, get_origin, get_type_hints
|
|
8
|
+
|
|
9
|
+
import msgspec
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
|
|
12
|
+
from fastapi.datastructures import DefaultPlaceholder
|
|
13
|
+
from fastapi.exceptions import RequestValidationError, ResponseValidationError
|
|
14
|
+
from fastapi.openapi.utils import validation_error_definition, validation_error_response_definition
|
|
15
|
+
from fastapi.params import Body, Depends, Param
|
|
16
|
+
from fastapi.routing import APIRoute, _effective_route_context_var, iter_route_contexts
|
|
17
|
+
from starlette.concurrency import run_in_threadpool
|
|
18
|
+
|
|
19
|
+
from .responses import MsgspecJSONResponse
|
|
20
|
+
from .validation import base_type, constrained_type, prepare_parameters, structured_type, uses_pydantic
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _untyped_encode_hook(value):
|
|
24
|
+
if isinstance(value, BaseModel):
|
|
25
|
+
return value.model_dump(mode='json', by_alias=True)
|
|
26
|
+
raise TypeError(f'Encoding objects of type {type(value).__name__} is unsupported')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _contains_struct(annotation):
|
|
30
|
+
value = base_type(annotation)
|
|
31
|
+
return (isinstance(value, type) and issubclass(value, msgspec.Struct)) or any(
|
|
32
|
+
_contains_struct(arg) for arg in get_args(value)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _body_type(annotation):
|
|
37
|
+
value = base_type(annotation)
|
|
38
|
+
if structured_type(value):
|
|
39
|
+
return True
|
|
40
|
+
if get_origin(value) in (list, dict, tuple, set, frozenset):
|
|
41
|
+
return True
|
|
42
|
+
return any(_body_type(arg) for arg in get_args(value))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _metadata(parameter, annotation):
|
|
46
|
+
items = list(get_args(annotation)[1:]) if get_origin(annotation) is Annotated else []
|
|
47
|
+
return [*items, parameter.default]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _merge_components(target, incoming):
|
|
51
|
+
for name, schema in incoming.items():
|
|
52
|
+
if name in target and target[name] != schema:
|
|
53
|
+
raise ValueError(f'Conflicting OpenAPI component: {name}')
|
|
54
|
+
target[name] = deepcopy(schema)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _schema(annotation, components):
|
|
58
|
+
(schema,), definitions = msgspec.json.schema_components(
|
|
59
|
+
[annotation], ref_template='#/components/schemas/{name}'
|
|
60
|
+
)
|
|
61
|
+
_merge_components(components, definitions)
|
|
62
|
+
return schema
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _json_type(annotation):
|
|
66
|
+
if annotation in (inspect.Signature.empty, None) or uses_pydantic(annotation):
|
|
67
|
+
return False
|
|
68
|
+
value = base_type(annotation)
|
|
69
|
+
if isinstance(value, type) and issubclass(value, Response):
|
|
70
|
+
return False
|
|
71
|
+
try:
|
|
72
|
+
msgspec.json.Decoder(type=annotation)
|
|
73
|
+
msgspec.json.schema(annotation)
|
|
74
|
+
except (TypeError, ValueError):
|
|
75
|
+
return False
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _hints(endpoint):
|
|
80
|
+
target = endpoint
|
|
81
|
+
while isinstance(target, partial):
|
|
82
|
+
target = target.func
|
|
83
|
+
if not inspect.isfunction(target) and not inspect.ismethod(target):
|
|
84
|
+
target = target.__call__
|
|
85
|
+
return get_type_hints(target, include_extras=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_body(request, raw, config):
|
|
89
|
+
decoder, default, embedded = config
|
|
90
|
+
if not raw:
|
|
91
|
+
if default is inspect.Signature.empty:
|
|
92
|
+
raise RequestValidationError([{'loc': ('body',), 'msg': 'Field required', 'type': 'missing'}])
|
|
93
|
+
return deepcopy(default)
|
|
94
|
+
media_type = request.headers.get('content-type', '').split(';')[0].strip().lower()
|
|
95
|
+
if media_type and media_type != 'application/json' and not (
|
|
96
|
+
media_type.startswith('application/') and media_type.endswith('+json')
|
|
97
|
+
):
|
|
98
|
+
raise HTTPException(415, 'Expected a JSON request body')
|
|
99
|
+
try:
|
|
100
|
+
value = decoder.decode(raw)
|
|
101
|
+
except msgspec.ValidationError as exc:
|
|
102
|
+
# Typed decoding can stop on a field error before discovering invalid syntax.
|
|
103
|
+
try:
|
|
104
|
+
msgspec.json.decode(raw)
|
|
105
|
+
except (msgspec.DecodeError, UnicodeError) as syntax:
|
|
106
|
+
raise HTTPException(400, f'Malformed JSON body: {syntax}') from syntax
|
|
107
|
+
raise RequestValidationError([{'loc': ('body',), 'msg': str(exc), 'type': 'value_error.msgspec'}]) from exc
|
|
108
|
+
except (msgspec.DecodeError, UnicodeError) as exc:
|
|
109
|
+
raise HTTPException(400, f'Malformed JSON body: {exc}') from exc
|
|
110
|
+
return value.value if embedded else value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class MsgspecRoute(APIRoute):
|
|
114
|
+
def get_route_handler(self):
|
|
115
|
+
context = _effective_route_context_var.get()
|
|
116
|
+
route = context if context is not None and context.original_route is self else self
|
|
117
|
+
prepare_parameters(route.dependant)
|
|
118
|
+
handler = super().get_route_handler()
|
|
119
|
+
config = getattr(self.endpoint, '_msgspec_body_config', None)
|
|
120
|
+
if config is None:
|
|
121
|
+
return handler
|
|
122
|
+
if route.body_field is not None:
|
|
123
|
+
raise TypeError('Other FastAPI body fields cannot be combined with a msgspec body')
|
|
124
|
+
|
|
125
|
+
async def handle(request):
|
|
126
|
+
# Invalid JSON must be rejected before dependency side effects.
|
|
127
|
+
request.scope['_msgspec_body_value'] = _parse_body(request, await request.body(), config)
|
|
128
|
+
return await handler(request)
|
|
129
|
+
return handle
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def install_openapi(app: FastAPI, *routers: 'MsgspecRouter') -> None:
|
|
133
|
+
"""Install once after including routers, before serving the application."""
|
|
134
|
+
if not routers:
|
|
135
|
+
raise TypeError('Pass the MsgspecRouter instances included in the app')
|
|
136
|
+
if getattr(app, '_msgspec_openapi_installed', False):
|
|
137
|
+
raise RuntimeError('msgspec OpenAPI is already installed')
|
|
138
|
+
# FastAPI builds included contexts lazily; validate them before serving.
|
|
139
|
+
list(iter_route_contexts(app.routes))
|
|
140
|
+
original = app.openapi
|
|
141
|
+
app.openapi_schema = None
|
|
142
|
+
|
|
143
|
+
def openapi():
|
|
144
|
+
document = original()
|
|
145
|
+
components = document.setdefault('components', {}).setdefault('schemas', {})
|
|
146
|
+
for router in routers:
|
|
147
|
+
_merge_components(components, router.msgspec_components)
|
|
148
|
+
for route in iter_route_contexts(app.routes):
|
|
149
|
+
original_route = getattr(route, 'original_route', route)
|
|
150
|
+
if not isinstance(original_route, MsgspecRoute):
|
|
151
|
+
continue
|
|
152
|
+
fields = []
|
|
153
|
+
def collect(dependant):
|
|
154
|
+
for group in ('path_params', 'query_params', 'header_params', 'cookie_params'):
|
|
155
|
+
fields.extend(getattr(dependant, group))
|
|
156
|
+
for dependency in dependant.dependencies:
|
|
157
|
+
collect(dependency)
|
|
158
|
+
collect(route.dependant)
|
|
159
|
+
for method in route.methods:
|
|
160
|
+
operation = document.get('paths', {}).get(route.path_format, {}).get(method.lower(), {})
|
|
161
|
+
for parameter in operation.get('parameters', []):
|
|
162
|
+
for field in fields:
|
|
163
|
+
if (getattr(field, '_msgspec_parameter', False)
|
|
164
|
+
and parameter['in'] == field.field_info.in_.value
|
|
165
|
+
and parameter['name'] == field.alias):
|
|
166
|
+
schema = deepcopy(field._msgspec_schema)
|
|
167
|
+
for key in ('title', 'description', 'default'):
|
|
168
|
+
if key in parameter.get('schema', {}):
|
|
169
|
+
schema[key] = parameter['schema'][key]
|
|
170
|
+
parameter['schema'] = schema
|
|
171
|
+
return document
|
|
172
|
+
|
|
173
|
+
app.openapi = openapi
|
|
174
|
+
app._msgspec_openapi_installed = True
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class MsgspecRouter(APIRouter):
|
|
178
|
+
"""Typed msgspec JSON bodies and validated responses for ordinary CRUD routes."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, *args, **kwargs):
|
|
181
|
+
kwargs.setdefault('route_class', MsgspecRoute)
|
|
182
|
+
kwargs.setdefault('default_response_class', MsgspecJSONResponse)
|
|
183
|
+
if not issubclass(kwargs['route_class'], MsgspecRoute):
|
|
184
|
+
raise TypeError('route_class must extend MsgspecRoute')
|
|
185
|
+
super().__init__(*args, **kwargs)
|
|
186
|
+
self.msgspec_components = {}
|
|
187
|
+
|
|
188
|
+
def include_router(self, router, *args, **kwargs):
|
|
189
|
+
if isinstance(router, MsgspecRouter):
|
|
190
|
+
_merge_components(self.msgspec_components, router.msgspec_components)
|
|
191
|
+
return super().include_router(router, *args, **kwargs)
|
|
192
|
+
|
|
193
|
+
def add_api_route(self, path, endpoint, **kwargs):
|
|
194
|
+
override = kwargs.get('route_class_override')
|
|
195
|
+
if override is not None and not issubclass(override, MsgspecRoute):
|
|
196
|
+
raise TypeError('route_class_override must extend MsgspecRoute')
|
|
197
|
+
# Included routes already have a prepared signature and response metadata.
|
|
198
|
+
if getattr(endpoint, '_msgspec_prepared', False):
|
|
199
|
+
return super().add_api_route(path, endpoint, **kwargs)
|
|
200
|
+
signature = inspect.signature(endpoint)
|
|
201
|
+
hints = _hints(endpoint)
|
|
202
|
+
parameters = [p.replace(annotation=hints.get(p.name, p.annotation)) for p in signature.parameters.values()]
|
|
203
|
+
return_type = hints.get('return', signature.return_annotation)
|
|
204
|
+
explicit = kwargs.get('response_model', inspect.Signature.empty)
|
|
205
|
+
inferred = explicit is inspect.Signature.empty or isinstance(explicit, DefaultPlaceholder)
|
|
206
|
+
output_type = return_type if inferred else explicit
|
|
207
|
+
validates_output = _json_type(output_type)
|
|
208
|
+
if validates_output:
|
|
209
|
+
output_type, output_strict = constrained_type(output_type)
|
|
210
|
+
if output_strict is False:
|
|
211
|
+
raise TypeError('JSON msgspec responses use strict validation')
|
|
212
|
+
output_decoder = msgspec.json.Decoder(type=output_type) if validates_output else None
|
|
213
|
+
# Like FastAPI with a Pydantic response_model at the top level: an instance of exactly
|
|
214
|
+
# the declared Struct class is trusted and encoded once; other top-level values,
|
|
215
|
+
# including subclass instances, are validated against the schema. Unlike Pydantic,
|
|
216
|
+
# values nested in a trusted instance are encoded as they are (a nested subclass keeps
|
|
217
|
+
# its extra fields, and the type parameters of a generic Struct are not checked).
|
|
218
|
+
output_class = (get_origin(base_type(output_type)) or base_type(output_type)) if validates_output else None
|
|
219
|
+
trusts_instances = isinstance(output_class, type) and issubclass(output_class, msgspec.Struct)
|
|
220
|
+
output_encoder = msgspec.json.Encoder() if validates_output else None
|
|
221
|
+
response_class = kwargs.get('response_class')
|
|
222
|
+
if response_class is None or isinstance(response_class, DefaultPlaceholder):
|
|
223
|
+
response_class = self.default_response_class
|
|
224
|
+
if isinstance(response_class, DefaultPlaceholder):
|
|
225
|
+
response_class = response_class.value
|
|
226
|
+
serializes_untyped_output = (
|
|
227
|
+
output_type in (inspect.Signature.empty, None)
|
|
228
|
+
and not uses_pydantic(return_type)
|
|
229
|
+
and response_class is MsgspecJSONResponse
|
|
230
|
+
)
|
|
231
|
+
handles_output = validates_output or serializes_untyped_output or (
|
|
232
|
+
explicit is None and _contains_struct(return_type)
|
|
233
|
+
)
|
|
234
|
+
untyped_encoder = (
|
|
235
|
+
msgspec.json.Encoder(enc_hook=_untyped_encode_hook)
|
|
236
|
+
if serializes_untyped_output else None
|
|
237
|
+
)
|
|
238
|
+
body = None
|
|
239
|
+
body_meta = None
|
|
240
|
+
for parameter in parameters:
|
|
241
|
+
metadata = _metadata(parameter, parameter.annotation)
|
|
242
|
+
if any(isinstance(x, Depends) for x in metadata):
|
|
243
|
+
continue
|
|
244
|
+
is_body = _contains_struct(parameter.annotation) or any(isinstance(x, Body) for x in metadata)
|
|
245
|
+
if _body_type(parameter.annotation) and not any(isinstance(x, Param) for x in metadata):
|
|
246
|
+
is_body = True
|
|
247
|
+
if is_body and _json_type(parameter.annotation):
|
|
248
|
+
if any(isinstance(x, Param) for x in metadata):
|
|
249
|
+
raise TypeError('msgspec Struct query/path/header/cookie parameters are not supported')
|
|
250
|
+
if body is not None:
|
|
251
|
+
raise TypeError('Only one msgspec body parameter is supported; other body fields cannot be combined')
|
|
252
|
+
body = parameter
|
|
253
|
+
body_meta = next((x for x in metadata if isinstance(x, Body)), None)
|
|
254
|
+
|
|
255
|
+
if body is None and not handles_output:
|
|
256
|
+
return super().add_api_route(path, endpoint, **kwargs)
|
|
257
|
+
if any(p.kind in (p.VAR_KEYWORD, p.VAR_POSITIONAL) for p in parameters):
|
|
258
|
+
raise TypeError('Variadic msgspec endpoints are not supported')
|
|
259
|
+
if handles_output:
|
|
260
|
+
if validates_output:
|
|
261
|
+
for option in ('response_model_include', 'response_model_exclude',
|
|
262
|
+
'response_model_exclude_unset', 'response_model_exclude_defaults',
|
|
263
|
+
'response_model_exclude_none'):
|
|
264
|
+
if kwargs.get(option):
|
|
265
|
+
raise TypeError(f'{option} is not supported for msgspec responses')
|
|
266
|
+
if kwargs.get('response_model_by_alias', True) is False:
|
|
267
|
+
raise TypeError('msgspec responses use encoded field names')
|
|
268
|
+
if validates_output and kwargs.get('status_code') in (204, 304):
|
|
269
|
+
raise TypeError('A typed msgspec response requires a response body')
|
|
270
|
+
response_class = kwargs.get('response_class')
|
|
271
|
+
if response_class is not None and not isinstance(response_class, DefaultPlaceholder) and response_class is not MsgspecJSONResponse:
|
|
272
|
+
raise TypeError('Custom response_class is not supported for typed msgspec responses')
|
|
273
|
+
kwargs['response_model'] = None
|
|
274
|
+
kwargs['response_class'] = MsgspecJSONResponse
|
|
275
|
+
|
|
276
|
+
components = {}
|
|
277
|
+
responses = deepcopy(kwargs.get('responses') or {})
|
|
278
|
+
openapi_extra = deepcopy(kwargs.get('openapi_extra') or {})
|
|
279
|
+
default = inspect.Signature.empty
|
|
280
|
+
decoder = None
|
|
281
|
+
embedded = False
|
|
282
|
+
if body is not None:
|
|
283
|
+
metadata = [x for x in get_args(body.annotation)[1:] if not isinstance(x, Body)] if get_origin(body.annotation) is Annotated else []
|
|
284
|
+
body_type, body_strict = constrained_type(base_type(body.annotation), metadata)
|
|
285
|
+
if body_strict is False:
|
|
286
|
+
raise TypeError('JSON msgspec bodies use strict validation')
|
|
287
|
+
if body_meta is not None:
|
|
288
|
+
if body_meta.media_type != 'application/json':
|
|
289
|
+
raise TypeError('Only application/json msgspec bodies are supported')
|
|
290
|
+
body_type, body_strict = constrained_type(body_type, body_meta.metadata)
|
|
291
|
+
if body_strict is False:
|
|
292
|
+
raise TypeError('JSON msgspec bodies use strict validation')
|
|
293
|
+
embedded = body_meta.embed is True
|
|
294
|
+
default = body_meta.default
|
|
295
|
+
from pydantic_core import PydanticUndefined
|
|
296
|
+
if default is PydanticUndefined or default is Ellipsis:
|
|
297
|
+
default = inspect.Signature.empty
|
|
298
|
+
if not isinstance(body.default, Body) and body.default is not inspect.Signature.empty:
|
|
299
|
+
default = body.default
|
|
300
|
+
if body_meta.default_factory is not None:
|
|
301
|
+
raise TypeError('Body default_factory is not supported')
|
|
302
|
+
else:
|
|
303
|
+
default = body.default
|
|
304
|
+
decode_type = body_type
|
|
305
|
+
if embedded:
|
|
306
|
+
# One explicit JSON envelope, preserving a possible Body alias.
|
|
307
|
+
field = ('value', body_type) if default is inspect.Signature.empty else (
|
|
308
|
+
'value', body_type, msgspec.field(default_factory=lambda: deepcopy(default))
|
|
309
|
+
)
|
|
310
|
+
identity = {'schema': msgspec.json.schema(body_type), 'alias': body_meta.alias or body.name,
|
|
311
|
+
'required': default is inspect.Signature.empty}
|
|
312
|
+
if default is not inspect.Signature.empty:
|
|
313
|
+
identity['default'] = default
|
|
314
|
+
name = 'MsgspecBody_' + sha256(msgspec.json.encode(identity)).hexdigest()[:16]
|
|
315
|
+
decode_type = msgspec.defstruct(name, [field],
|
|
316
|
+
rename={'value': body_meta.alias or body.name})
|
|
317
|
+
decoder = msgspec.json.Decoder(type=decode_type)
|
|
318
|
+
body_schema = _schema(decode_type, components)
|
|
319
|
+
openapi_extra['requestBody'] = {'required': default is inspect.Signature.empty,
|
|
320
|
+
'content': {'application/json': {'schema': body_schema}}}
|
|
321
|
+
responses.setdefault(400, {'description': 'Malformed JSON'})
|
|
322
|
+
responses.setdefault(422, {'description': 'Validation Error', 'content': {
|
|
323
|
+
'application/json': {'schema': {'$ref': '#/components/schemas/HTTPValidationError'}}}})
|
|
324
|
+
_merge_components(components, {'HTTPValidationError': validation_error_response_definition,
|
|
325
|
+
'ValidationError': validation_error_definition})
|
|
326
|
+
if validates_output:
|
|
327
|
+
code = kwargs.get('status_code') or 200
|
|
328
|
+
key = str(code) if str(code) in responses else code
|
|
329
|
+
result_meta = responses.setdefault(key, {})
|
|
330
|
+
result_meta.setdefault('description', kwargs.get('response_description', 'Successful Response'))
|
|
331
|
+
result_meta.setdefault('content', {})['application/json'] = {'schema': _schema(output_type, components)}
|
|
332
|
+
previous_components = deepcopy(self.msgspec_components)
|
|
333
|
+
_merge_components(self.msgspec_components, components)
|
|
334
|
+
kwargs['responses'] = responses
|
|
335
|
+
kwargs['openapi_extra'] = openapi_extra
|
|
336
|
+
|
|
337
|
+
remaining = [p for p in parameters if p is not body]
|
|
338
|
+
def injected_name(type_, fallback):
|
|
339
|
+
for p in remaining:
|
|
340
|
+
annotation = base_type(p.annotation)
|
|
341
|
+
if isinstance(annotation, type) and issubclass(annotation, type_):
|
|
342
|
+
return p.name, False
|
|
343
|
+
names = {p.name for p in parameters}
|
|
344
|
+
while fallback in names:
|
|
345
|
+
fallback += '_'
|
|
346
|
+
remaining.append(inspect.Parameter(fallback, kind=inspect.Parameter.KEYWORD_ONLY, annotation=type_))
|
|
347
|
+
return fallback, True
|
|
348
|
+
request_name, remove_request = injected_name(Request, '__msgspec_request') if body else (None, False)
|
|
349
|
+
response_name, remove_response = injected_name(Response, '__msgspec_response') if handles_output else (None, False)
|
|
350
|
+
|
|
351
|
+
is_async = inspect.iscoroutinefunction(endpoint) or inspect.iscoroutinefunction(getattr(endpoint, '__call__', None))
|
|
352
|
+
@wraps(endpoint)
|
|
353
|
+
async def wrapper(**values):
|
|
354
|
+
sub_response = values.get(response_name)
|
|
355
|
+
if remove_response:
|
|
356
|
+
values.pop(response_name)
|
|
357
|
+
if body is not None:
|
|
358
|
+
request = values[request_name]
|
|
359
|
+
if remove_request:
|
|
360
|
+
values.pop(request_name)
|
|
361
|
+
value = request.scope['_msgspec_body_value']
|
|
362
|
+
values[body.name] = value
|
|
363
|
+
if is_async:
|
|
364
|
+
result = await endpoint(**values)
|
|
365
|
+
else:
|
|
366
|
+
result = await run_in_threadpool(endpoint, **values)
|
|
367
|
+
if inspect.isawaitable(result):
|
|
368
|
+
result = await result
|
|
369
|
+
if isinstance(result, Response):
|
|
370
|
+
return result
|
|
371
|
+
if not handles_output:
|
|
372
|
+
return msgspec.to_builtins(result) if _contains_struct(return_type) else result
|
|
373
|
+
if validates_output and not (trusts_instances and type(result) is output_class):
|
|
374
|
+
try:
|
|
375
|
+
# Typed decoding validates fields and Meta, even for existing Structs.
|
|
376
|
+
result = output_decoder.decode(output_encoder.encode(result))
|
|
377
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
378
|
+
raise ResponseValidationError([{'loc': ('response',), 'msg': str(exc), 'type': 'value_error.msgspec'}]) from exc
|
|
379
|
+
status_code = sub_response.status_code or kwargs.get('status_code') or 200
|
|
380
|
+
if untyped_encoder is not None:
|
|
381
|
+
response = Response(untyped_encoder.encode(result), status_code=status_code,
|
|
382
|
+
media_type='application/json')
|
|
383
|
+
else:
|
|
384
|
+
response = MsgspecJSONResponse(result, status_code=status_code)
|
|
385
|
+
if response.status_code < 200 or response.status_code in (204, 205, 304):
|
|
386
|
+
response.body = b''
|
|
387
|
+
if 'content-length' in response.headers:
|
|
388
|
+
del response.headers['content-length']
|
|
389
|
+
response.raw_headers.extend(sub_response.raw_headers)
|
|
390
|
+
return response
|
|
391
|
+
|
|
392
|
+
wrapper.__signature__ = signature.replace(parameters=remaining, return_annotation=inspect.Signature.empty if handles_output else return_type)
|
|
393
|
+
wrapper._msgspec_prepared = True
|
|
394
|
+
if body is not None:
|
|
395
|
+
wrapper._msgspec_body_config = (decoder, default, embedded)
|
|
396
|
+
route_count = len(self.routes)
|
|
397
|
+
try:
|
|
398
|
+
result = super().add_api_route(path, wrapper, **kwargs)
|
|
399
|
+
if body is not None and self.routes[-1].body_field is not None:
|
|
400
|
+
raise TypeError('Other FastAPI body fields cannot be combined with a msgspec body')
|
|
401
|
+
return result
|
|
402
|
+
except Exception:
|
|
403
|
+
del self.routes[route_count:]
|
|
404
|
+
self.msgspec_components = previous_components
|
|
405
|
+
raise
|
|
406
|
+
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Route-local validation for HTTP parameters; no global FastAPI changes."""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from typing import Annotated, Any, Literal, Union, get_args, get_origin, get_type_hints
|
|
5
|
+
from dataclasses import is_dataclass
|
|
6
|
+
from typing_extensions import is_typeddict
|
|
7
|
+
from types import UnionType
|
|
8
|
+
|
|
9
|
+
import annotated_types
|
|
10
|
+
import msgspec
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
from pydantic.fields import FieldInfo
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def base_type(annotation):
|
|
16
|
+
return get_args(annotation)[0] if get_origin(annotation) is Annotated else annotation
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def structured_type(annotation):
|
|
20
|
+
value = get_origin(base_type(annotation)) or base_type(annotation)
|
|
21
|
+
return isinstance(value, type) and (
|
|
22
|
+
issubclass(value, msgspec.Struct) or is_typeddict(value) or is_dataclass(value)
|
|
23
|
+
or (issubclass(value, tuple) and hasattr(value, '_fields'))
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def uses_pydantic(annotation):
|
|
28
|
+
value = base_type(annotation)
|
|
29
|
+
return (isinstance(value, type) and issubclass(value, BaseModel)) or any(
|
|
30
|
+
uses_pydantic(item) for item in get_args(value)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _check_structured_metadata(annotation, seen=None):
|
|
35
|
+
if seen is None:
|
|
36
|
+
seen = set()
|
|
37
|
+
if get_origin(annotation) is Annotated:
|
|
38
|
+
annotation, *metadata = get_args(annotation)
|
|
39
|
+
if any(not isinstance(item, msgspec.Meta) for item in metadata):
|
|
40
|
+
raise TypeError('Use msgspec.Meta inside structured model fields')
|
|
41
|
+
origin = get_origin(annotation)
|
|
42
|
+
value = origin or annotation
|
|
43
|
+
structured = structured_type(annotation)
|
|
44
|
+
if structured and value not in seen:
|
|
45
|
+
seen.add(value)
|
|
46
|
+
for field_type in get_type_hints(value, include_extras=True).values():
|
|
47
|
+
_check_structured_metadata(field_type, seen)
|
|
48
|
+
for arg in get_args(annotation):
|
|
49
|
+
_check_structured_metadata(arg, seen)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def constrained_type(annotation, metadata=()):
|
|
53
|
+
"""Translate supported FastAPI constraints, rejecting unhandled validation."""
|
|
54
|
+
if get_origin(annotation) is Annotated:
|
|
55
|
+
annotation, *extras = get_args(annotation)
|
|
56
|
+
metadata = (*extras, *metadata)
|
|
57
|
+
origin, args = get_origin(annotation), get_args(annotation)
|
|
58
|
+
if structured_type(annotation):
|
|
59
|
+
_check_structured_metadata(annotation)
|
|
60
|
+
if args and origin is not Literal:
|
|
61
|
+
converted = []
|
|
62
|
+
for arg in args:
|
|
63
|
+
if arg is Ellipsis:
|
|
64
|
+
converted.append(arg)
|
|
65
|
+
continue
|
|
66
|
+
target, nested_strict = constrained_type(arg)
|
|
67
|
+
if nested_strict is not None:
|
|
68
|
+
raise TypeError('Nested strict constraints are not supported; use a strict top-level parameter')
|
|
69
|
+
converted.append(target)
|
|
70
|
+
converted = tuple(converted)
|
|
71
|
+
if hasattr(annotation, 'copy_with'):
|
|
72
|
+
annotation = annotation.copy_with(converted)
|
|
73
|
+
elif origin in (Union, UnionType):
|
|
74
|
+
annotation = Union[converted]
|
|
75
|
+
else:
|
|
76
|
+
annotation = origin[converted[0] if len(converted) == 1 else converted]
|
|
77
|
+
expanded = []
|
|
78
|
+
for item in metadata:
|
|
79
|
+
if isinstance(item, FieldInfo):
|
|
80
|
+
expanded.extend(item.metadata)
|
|
81
|
+
else:
|
|
82
|
+
expanded.append(item)
|
|
83
|
+
metadata = expanded
|
|
84
|
+
constraints = {}
|
|
85
|
+
metas = []
|
|
86
|
+
strict = None
|
|
87
|
+
for item in metadata:
|
|
88
|
+
if isinstance(item, msgspec.Meta):
|
|
89
|
+
metas.append(item)
|
|
90
|
+
elif isinstance(item, (annotated_types.Gt, annotated_types.Ge, annotated_types.Lt,
|
|
91
|
+
annotated_types.Le, annotated_types.MultipleOf,
|
|
92
|
+
annotated_types.MinLen, annotated_types.MaxLen)):
|
|
93
|
+
name = {'MinLen': 'min_length', 'MaxLen': 'max_length'}.get(type(item).__name__,
|
|
94
|
+
{'MultipleOf': 'multiple_of'}.get(type(item).__name__, type(item).__name__.lower()))
|
|
95
|
+
constraints[name] = getattr(item, name)
|
|
96
|
+
elif type(item).__module__.startswith('pydantic') and type(item).__name__ == 'Strict':
|
|
97
|
+
strict = item.strict
|
|
98
|
+
elif type(item).__module__.startswith('pydantic') and type(item).__name__ == '_PydanticGeneralMetadata':
|
|
99
|
+
for name, value in vars(item).items():
|
|
100
|
+
if name not in ('pattern',):
|
|
101
|
+
raise TypeError(f'Unsupported msgspec parameter constraint: {name}')
|
|
102
|
+
constraints[name] = value
|
|
103
|
+
else:
|
|
104
|
+
raise TypeError(f'Unsupported msgspec parameter metadata: {type(item).__name__}')
|
|
105
|
+
if constraints:
|
|
106
|
+
metas.append(msgspec.Meta(**constraints))
|
|
107
|
+
if metas:
|
|
108
|
+
# FastAPI constraints on nullable scalars apply to the non-null branch.
|
|
109
|
+
args = get_args(annotation)
|
|
110
|
+
if get_origin(annotation) in (Union, UnionType) and type(None) in args and len(args) == 2:
|
|
111
|
+
inner = next(arg for arg in args if arg is not type(None))
|
|
112
|
+
target = Union[Annotated[inner, *metas], type(None)]
|
|
113
|
+
else:
|
|
114
|
+
target = Annotated[annotation, *metas]
|
|
115
|
+
else:
|
|
116
|
+
target = annotation
|
|
117
|
+
return target, strict
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Unicode White_Space used by Rust str::trim, excluding Python's extra C0 separators.
|
|
122
|
+
_HTTP_WHITESPACE = '\t\n\v\f\r \x85\xa0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000'
|
|
123
|
+
_HTTP_INTEGER = re.compile(r'-?(?:0|[1-9][0-9]*)\Z')
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _http_int(value):
|
|
127
|
+
"""Match pydantic-core 2.46.5 str_as_int/clean_int_str on HTTP strings."""
|
|
128
|
+
if not isinstance(value, str):
|
|
129
|
+
return value
|
|
130
|
+
# Preserve the direct parser's size/error behavior before its cleanup fallback.
|
|
131
|
+
if _HTTP_INTEGER.fullmatch(value):
|
|
132
|
+
return int(value)
|
|
133
|
+
number = value.strip(_HTTP_WHITESPACE)
|
|
134
|
+
if number.startswith('+'):
|
|
135
|
+
number = number[1:]
|
|
136
|
+
if number.startswith('-'):
|
|
137
|
+
raise ValueError('Invalid integer parameter')
|
|
138
|
+
negative = number.startswith('-')
|
|
139
|
+
if negative:
|
|
140
|
+
number = number[1:]
|
|
141
|
+
if number.startswith(('-', '+')):
|
|
142
|
+
raise ValueError('Invalid integer parameter')
|
|
143
|
+
if not number or number[0] not in '0123456789-':
|
|
144
|
+
raise ValueError('Invalid integer parameter')
|
|
145
|
+
if number[0] == '0':
|
|
146
|
+
for offset in range(1, len(number)):
|
|
147
|
+
char = number[offset]
|
|
148
|
+
if char in '0_':
|
|
149
|
+
continue
|
|
150
|
+
if char in '123456789-':
|
|
151
|
+
number = number[offset:]
|
|
152
|
+
break
|
|
153
|
+
if char == '.':
|
|
154
|
+
number = number[offset - 1:]
|
|
155
|
+
break
|
|
156
|
+
raise ValueError('Invalid integer parameter')
|
|
157
|
+
else:
|
|
158
|
+
number = number[-1:]
|
|
159
|
+
if '.' in number:
|
|
160
|
+
whole, decimal = number.split('.', 1)
|
|
161
|
+
if decimal and all(char == '0' for char in decimal):
|
|
162
|
+
number = whole
|
|
163
|
+
if ('_' in number and not number.startswith('_') and not number.endswith('_')
|
|
164
|
+
and '__' not in number):
|
|
165
|
+
number = number.replace('_', '')
|
|
166
|
+
if negative:
|
|
167
|
+
number = '-' + number
|
|
168
|
+
if not _HTTP_INTEGER.fullmatch(number):
|
|
169
|
+
raise ValueError('Invalid integer parameter')
|
|
170
|
+
return int(number)
|
|
171
|
+
|
|
172
|
+
def _http_normalizer(annotation):
|
|
173
|
+
"""Compile supported transport coercions once at route registration."""
|
|
174
|
+
annotation = base_type(annotation)
|
|
175
|
+
origin, args = get_origin(annotation), get_args(annotation)
|
|
176
|
+
if origin in (Union, UnionType) and type(None) in args and len(args) == 2:
|
|
177
|
+
return _http_normalizer(next(arg for arg in args if arg is not type(None)))
|
|
178
|
+
if origin in (list, set, frozenset, tuple):
|
|
179
|
+
if origin is tuple and args and args[-1] is not Ellipsis:
|
|
180
|
+
children = tuple(_http_normalizer(arg) for arg in args)
|
|
181
|
+
if not any(children):
|
|
182
|
+
return None
|
|
183
|
+
def normalize_tuple(value):
|
|
184
|
+
return [children[i](item) if i < len(children) and children[i] else item
|
|
185
|
+
for i, item in enumerate(value)] if isinstance(value, (list, tuple)) else value
|
|
186
|
+
return normalize_tuple
|
|
187
|
+
child = _http_normalizer(args[0]) if args else None
|
|
188
|
+
if child is None:
|
|
189
|
+
return None
|
|
190
|
+
def normalize_sequence(value):
|
|
191
|
+
return [child(item) for item in value] if isinstance(value, (list, tuple)) else value
|
|
192
|
+
return normalize_sequence
|
|
193
|
+
if annotation is int:
|
|
194
|
+
return _http_int
|
|
195
|
+
if annotation is bool:
|
|
196
|
+
def normalize_bool(value):
|
|
197
|
+
if isinstance(value, str):
|
|
198
|
+
lowered = value.lower()
|
|
199
|
+
if lowered in ('yes', 'on', 'y', 't'):
|
|
200
|
+
return True
|
|
201
|
+
if lowered in ('no', 'off', 'n', 'f'):
|
|
202
|
+
return False
|
|
203
|
+
return value
|
|
204
|
+
return normalize_bool
|
|
205
|
+
if annotation is bytes:
|
|
206
|
+
return lambda value: value.encode('utf-8') if isinstance(value, str) else value
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _parameter_validator(target, strict):
|
|
211
|
+
normalizer = None if strict else _http_normalizer(target)
|
|
212
|
+
convert = msgspec.convert
|
|
213
|
+
def validate(value: Any, values=None, *, loc=()):
|
|
214
|
+
try:
|
|
215
|
+
if normalizer is not None:
|
|
216
|
+
value = normalizer(value)
|
|
217
|
+
return convert(value, type=target, strict=strict), []
|
|
218
|
+
except (ValueError, UnicodeError, OverflowError) as exc:
|
|
219
|
+
return None, [{'loc': loc, 'msg': str(exc), 'type': 'value_error.msgspec'}]
|
|
220
|
+
return validate
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def prepare_parameters(dependant):
|
|
224
|
+
"""Keep FastAPI extraction/aliases/defaults; replace supported validators."""
|
|
225
|
+
for group in ('path_params', 'query_params', 'header_params', 'cookie_params'):
|
|
226
|
+
for field in getattr(dependant, group):
|
|
227
|
+
if uses_pydantic(field.field_info.annotation):
|
|
228
|
+
continue
|
|
229
|
+
if getattr(field, '_msgspec_parameter', False):
|
|
230
|
+
continue
|
|
231
|
+
target, strict = constrained_type(field.field_info.annotation, field.field_info.metadata)
|
|
232
|
+
strict = bool(strict)
|
|
233
|
+
schema = msgspec.json.schema(target)
|
|
234
|
+
|
|
235
|
+
field.validate = _parameter_validator(target, strict)
|
|
236
|
+
field._msgspec_parameter = True
|
|
237
|
+
field._msgspec_schema = schema
|
|
238
|
+
for dependency in dependant.dependencies:
|
|
239
|
+
prepare_parameters(dependency)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: msgspec-fastapi
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Experimental FastAPI router that decodes request bodies and encodes responses with msgspec
|
|
5
|
+
Project-URL: Source, https://github.com/Siyet/msgspec-fastapi
|
|
6
|
+
Project-URL: Issues, https://github.com/Siyet/msgspec-fastapi/issues
|
|
7
|
+
Author: Aleksandr Tseluiko
|
|
8
|
+
License-Expression: BSD-3-Clause
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: fastapi<0.144,>=0.142.2
|
|
15
|
+
Requires-Dist: msgspec>=0.22.0
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# msgspec-fastapi
|
|
19
|
+
|
|
20
|
+
An experimental FastAPI router that uses msgspec for JSON request bodies and responses.
|
|
21
|
+
|
|
22
|
+
This is a prototype. Its API may change, and it relies on private FastAPI internals, so
|
|
23
|
+
it is pinned to FastAPI 0.142.2 to 0.143.x. It is a personal project, not part of
|
|
24
|
+
msgspec; feedback is welcome in the issues.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install msgspec-fastapi
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import msgspec
|
|
36
|
+
from fastapi import FastAPI
|
|
37
|
+
from msgspec_fastapi import MsgspecRouter, install_openapi
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class City(msgspec.Struct):
|
|
41
|
+
name: str
|
|
42
|
+
population: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
router = MsgspecRouter()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.post('/cities')
|
|
49
|
+
async def create_city(city: City) -> City:
|
|
50
|
+
return city
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
app = FastAPI()
|
|
54
|
+
app.include_router(router)
|
|
55
|
+
install_openapi(app, router)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## What it does
|
|
59
|
+
|
|
60
|
+
- A msgspec body parameter (a Struct or another type msgspec can decode) is decoded from
|
|
61
|
+
the raw request bytes by a `msgspec.json.Decoder` built once at route registration.
|
|
62
|
+
A non-JSON content type returns 415, malformed JSON returns 400, and a missing or
|
|
63
|
+
invalid body returns 422. Bodies are decoded in msgspec strict mode: for example, the
|
|
64
|
+
JSON string `"1"` is not accepted for an `int` field, unlike FastAPI with Pydantic.
|
|
65
|
+
- A msgspec response type, from the return annotation or `response_model`, is encoded
|
|
66
|
+
with msgspec. An instance of exactly the declared Struct class is encoded without
|
|
67
|
+
revalidation, as FastAPI does for a Pydantic `response_model`; other values are
|
|
68
|
+
validated against the declared type. Nested values and type parameters of a trusted
|
|
69
|
+
instance are not checked.
|
|
70
|
+
- Responses of routes without a return annotation or `response_model` are also encoded
|
|
71
|
+
with msgspec, so the output can differ from FastAPI's `jsonable_encoder` (for example,
|
|
72
|
+
`bytes` are base64-encoded and `Decimal` is encoded as a string).
|
|
73
|
+
- Path, query, header and cookie parameters keep FastAPI extraction, aliases and
|
|
74
|
+
defaults, but are converted with `msgspec.convert(strict=False)` instead of Pydantic,
|
|
75
|
+
so coercion differs for some inputs (for example, `" 1.5"` is rejected for `float`
|
|
76
|
+
and `"nan"` is accepted for `Decimal`). Parameter types must be supported by msgspec;
|
|
77
|
+
Pydantic-specific types such as `AnyUrl` raise `TypeError` at route registration.
|
|
78
|
+
- `install_openapi` adds the msgspec JSON schemas to the OpenAPI document. It is required
|
|
79
|
+
for a valid document: call it once after including the routers, passing every
|
|
80
|
+
`MsgspecRouter` that is not included directly in another `MsgspecRouter`.
|
|
81
|
+
- A Pydantic body or response model is left to FastAPI; the route parameters are still
|
|
82
|
+
validated with msgspec.
|
|
83
|
+
|
|
84
|
+
## Limitations
|
|
85
|
+
|
|
86
|
+
- Only one msgspec body parameter per route, and it cannot be combined with other
|
|
87
|
+
FastAPI body fields. Only JSON bodies are supported: `application/json`,
|
|
88
|
+
`application/*+json`, or a request without a content type.
|
|
89
|
+
- Structs, alone or inside containers, are not supported as path, query, header or
|
|
90
|
+
cookie parameters.
|
|
91
|
+
- `response_model_include`, `response_model_exclude`, `response_model_exclude_*` and
|
|
92
|
+
`response_model_by_alias=False` are rejected for typed msgspec responses.
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
uv run --group test pytest
|
|
98
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
msgspec_fastapi/__init__.py,sha256=qza4iZ78zIdyMQjhSyfu3kExhINEBjVp30ctKDIj6nw,165
|
|
2
|
+
msgspec_fastapi/responses.py,sha256=Ocx51DUVWUU1zCqegwIO-NVqyFJK8Gf-SL4k1GABiEw,332
|
|
3
|
+
msgspec_fastapi/router.py,sha256=jsNl6PF1ZkEmQTkT8Ny0_ySlg7fwv-D5B9PtKFW9y30,21106
|
|
4
|
+
msgspec_fastapi/validation.py,sha256=zomhWlcqZzUWTnq7mNp6sVCPU2lh0kMsK2f5eazmAko,10049
|
|
5
|
+
msgspec_fastapi-0.0.1.dist-info/METADATA,sha256=lOU-mSz8OkfYBuCA7HHq1Or1nO0qfkp_oxxMHSdHqsU,3791
|
|
6
|
+
msgspec_fastapi-0.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
msgspec_fastapi-0.0.1.dist-info/licenses/LICENSE,sha256=-tcdcjgHx0RS0YgoKnr_WJOaYz5gCXajKEnmMHRvwkc,1526
|
|
8
|
+
msgspec_fastapi-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Aleksandr Tseluiko
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|