lino_rest_api
lino-rest-api — a REST API framework that speaks Links Notation instead of JSON.
The public surface follows the LINO REST API specification shipped in
docs/spec/README.md: content negotiation, problem details, collections,
conditional requests, CORS and a machine-readable service description.
1""" 2lino-rest-api — a REST API framework that speaks Links Notation instead of JSON. 3 4The public surface follows the LINO REST API specification shipped in 5``docs/spec/README.md``: content negotiation, problem details, collections, 6conditional requests, CORS and a machine-readable service description. 7""" 8 9from typing import TYPE_CHECKING 10 11from .app import ( 12 DESCRIPTION_PATH, 13 HTTP_METHODS, 14 OPENAPI_PATH, 15 LinoApp, 16 create_lino_app, 17) 18from .client import ( 19 DEFAULT_ACCEPT, 20 AsyncLinoClient, 21 LinoClient, 22 LinoClientError, 23 build_query_string, 24 create_async_lino_client, 25 create_lino_client, 26 query_value, 27) 28from .codec import ( 29 decode, 30 decode_from, 31 decode_single_line, 32 encode, 33 encode_compact_notation, 34 encode_for, 35 encode_single_line, 36) 37from .collection import ( 38 apply_collection_query, 39 collection_envelope, 40 matches_filters, 41 pagination_link_header, 42 project_fields, 43 sort_items, 44) 45from .cors import ( 46 DEFAULT_ALLOWED_HEADERS, 47 DEFAULT_EXPOSED_HEADERS, 48 cors_headers, 49) 50from .description import ( 51 LINO_API_DESCRIPTION_VERSION, 52 info_object, 53 openapi_document, 54 path_parameters, 55 service_description, 56 to_openapi_path, 57) 58from .etag import ( 59 compute_etag, 60 etag_matches, 61 evaluate_preconditions, 62 parse_etag_list, 63) 64from .media_type import ( 65 JSON_CONTENT_TYPE, 66 LINO_COMPACT_CONTENT_TYPE, 67 LINO_CONTENT_TYPE, 68 LINO_LINE_CONTENT_TYPE, 69 LINO_PROBLEM_CONTENT_TYPE, 70 SUPPORTED_MEDIA_TYPES, 71 is_decodable_media_type, 72 negotiate_media_type, 73 parse_accept, 74 parse_content_type, 75 with_charset, 76) 77from .middleware import ( 78 DEFAULT_MAX_BODY_BYTES, 79 append_vary, 80 build_problem_response, 81 build_response, 82 decode_request_body, 83 negotiate_request, 84) 85from .problem import ( 86 PROBLEM_TYPE_BASE, 87 LinoHttpError, 88 problem_details, 89 problem_slug, 90 reason_phrase, 91 to_http_error, 92 validation_error, 93) 94from .query import ( 95 DEFAULT_LIMIT, 96 MAX_LIMIT, 97 RESERVED_QUERY_PARAMETERS, 98 parse_collection_query, 99 parse_fields, 100 parse_scalar, 101 parse_sort, 102) 103from .request import LinoHttpRequest 104from .resource import ( 105 RESOURCE_OPERATIONS, 106 register_resource, 107 representation_etag, 108) 109from .response import ( 110 EMPTY, 111 LinoResult, 112 accepted, 113 created, 114 no_content, 115 ok, 116 raw_response, 117 status, 118) 119from .router import IMPLICIT_METHODS, RouteTable, compile_path_pattern 120from .store import MemoryStore 121 122__version__ = "0.2.0" 123 124if TYPE_CHECKING: # Resolved at runtime by __getattr__ below. 125 from .fastapi_adapter import ( 126 LinoAPI, 127 LinoAPIRoute, 128 LinoRequest, 129 LinoResponse, 130 lino_request_handler, 131 ) 132 133#: Names served from :mod:`lino_rest_api.fastapi_adapter` on first use, so that 134#: the package imports without FastAPI installed (``pip install 135#: lino-rest-api[fastapi]`` enables them). 136_FASTAPI_ADAPTER_NAMES = frozenset( 137 {"LinoAPI", "LinoAPIRoute", "LinoRequest", "LinoResponse", "lino_request_handler"} 138) 139 140 141def __getattr__(name: str): 142 """ 143 Resolve the FastAPI adapter names on first use. 144 145 Args: 146 name: Attribute name 147 148 Returns: 149 The requested attribute 150 151 Raises: 152 AttributeError: When the name is not exported by this package 153 """ 154 if name in _FASTAPI_ADAPTER_NAMES: 155 from . import fastapi_adapter 156 157 value = getattr(fastapi_adapter, name) 158 # Bind it in the package, so that later lookups skip this function and 159 # tools that read the module dictionary — pdoc, for one — find it. 160 globals()[name] = value 161 return value 162 raise AttributeError(f"module {__name__!r} has no attribute {name!r}") 163 164__all__ = [ 165 "DEFAULT_ACCEPT", 166 "DEFAULT_ALLOWED_HEADERS", 167 "DEFAULT_EXPOSED_HEADERS", 168 "DEFAULT_LIMIT", 169 "DEFAULT_MAX_BODY_BYTES", 170 "DESCRIPTION_PATH", 171 "EMPTY", 172 "HTTP_METHODS", 173 "IMPLICIT_METHODS", 174 "JSON_CONTENT_TYPE", 175 "LINO_API_DESCRIPTION_VERSION", 176 "LINO_COMPACT_CONTENT_TYPE", 177 "LINO_CONTENT_TYPE", 178 "LINO_LINE_CONTENT_TYPE", 179 "LINO_PROBLEM_CONTENT_TYPE", 180 "MAX_LIMIT", 181 "OPENAPI_PATH", 182 "PROBLEM_TYPE_BASE", 183 "RESERVED_QUERY_PARAMETERS", 184 "RESOURCE_OPERATIONS", 185 "SUPPORTED_MEDIA_TYPES", 186 "AsyncLinoClient", 187 "LinoAPI", 188 "LinoAPIRoute", 189 "LinoApp", 190 "LinoClient", 191 "LinoClientError", 192 "LinoHttpError", 193 "LinoHttpRequest", 194 "LinoRequest", 195 "LinoResponse", 196 "LinoResult", 197 "MemoryStore", 198 "RouteTable", 199 "accepted", 200 "append_vary", 201 "apply_collection_query", 202 "build_problem_response", 203 "build_query_string", 204 "query_value", 205 "build_response", 206 "collection_envelope", 207 "compile_path_pattern", 208 "compute_etag", 209 "cors_headers", 210 "create_async_lino_client", 211 "create_lino_app", 212 "create_lino_client", 213 "created", 214 "decode", 215 "decode_from", 216 "decode_request_body", 217 "decode_single_line", 218 "encode", 219 "encode_compact_notation", 220 "encode_for", 221 "encode_single_line", 222 "etag_matches", 223 "evaluate_preconditions", 224 "is_decodable_media_type", 225 "lino_request_handler", 226 "matches_filters", 227 "negotiate_media_type", 228 "negotiate_request", 229 "no_content", 230 "ok", 231 "info_object", 232 "openapi_document", 233 "pagination_link_header", 234 "parse_accept", 235 "parse_collection_query", 236 "parse_content_type", 237 "parse_etag_list", 238 "parse_fields", 239 "parse_scalar", 240 "parse_sort", 241 "path_parameters", 242 "problem_details", 243 "problem_slug", 244 "project_fields", 245 "raw_response", 246 "reason_phrase", 247 "register_resource", 248 "representation_etag", 249 "service_description", 250 "sort_items", 251 "status", 252 "to_http_error", 253 "to_openapi_path", 254 "validation_error", 255 "with_charset", 256]
427class AsyncLinoClient(_RequestBuilder): 428 """An asynchronous client for a service that speaks Links Notation.""" 429 430 def __init__( 431 self, 432 base_url: str, 433 *, 434 accept: str = DEFAULT_ACCEPT, 435 content_type: str = LINO_CONTENT_TYPE, 436 headers: dict[str, str] | None = None, 437 client: httpx.AsyncClient | None = None, 438 **client_options: Any, 439 ) -> None: 440 """ 441 Args: 442 base_url: Base URL of the service 443 accept: ``Accept`` header sent with every request 444 content_type: Representation used for request bodies 445 headers: Headers sent with every request 446 client: ``httpx`` client to use, created when omitted 447 **client_options: Options forwarded to :class:`httpx.AsyncClient` 448 """ 449 super().__init__( 450 base_url, accept=accept, content_type=content_type, headers=headers 451 ) 452 self._client = client or httpx.AsyncClient(**client_options) 453 self._owns_client = client is None 454 455 async def __aenter__(self) -> "AsyncLinoClient": 456 """Enter a context manager that closes the underlying client.""" 457 return self 458 459 async def __aexit__(self, *exception: Any) -> None: 460 """Close the underlying client when it was created here.""" 461 await self.aclose() 462 463 async def aclose(self) -> None: 464 """Close the underlying ``httpx`` client when this client owns it.""" 465 if self._owns_client: 466 await self._client.aclose() 467 468 async def request(self, method: str, path: str, **options: Any) -> LinoResponse: 469 """ 470 Perform a request and decode the response. 471 472 Args: 473 method: HTTP method 474 path: Path, appended to the base URL 475 **options: Request options, see :meth:`_RequestBuilder.prepare` 476 477 Returns: 478 Decoded response 479 480 Raises: 481 LinoClientError: For any 4xx or 5xx response 482 """ 483 url, headers, content = self.prepare(path, **options) 484 response = await self._client.request( 485 method, url, headers=headers, content=content 486 ) 487 return self.finish(response, method) 488 489 async def get(self, path: str, **options: Any) -> LinoResponse: 490 """ 491 ``GET`` a resource. 492 493 Args: 494 path: Path 495 **options: Request options 496 497 Returns: 498 Decoded response 499 """ 500 return await self.request("GET", path, **options) 501 502 async def post( 503 self, path: str, body: Any = None, **options: Any 504 ) -> LinoResponse: 505 """ 506 ``POST`` to a collection. 507 508 Args: 509 path: Path 510 body: Value to send 511 **options: Request options 512 513 Returns: 514 Decoded response 515 """ 516 return await self.request("POST", path, body=body, send_body=True, **options) 517 518 async def put(self, path: str, body: Any = None, **options: Any) -> LinoResponse: 519 """ 520 ``PUT`` a representation. 521 522 Args: 523 path: Path 524 body: Value to send 525 **options: Request options 526 527 Returns: 528 Decoded response 529 """ 530 return await self.request("PUT", path, body=body, send_body=True, **options) 531 532 async def patch( 533 self, path: str, body: Any = None, **options: Any 534 ) -> LinoResponse: 535 """ 536 ``PATCH`` a representation. 537 538 Args: 539 path: Path 540 body: Value to send 541 **options: Request options 542 543 Returns: 544 Decoded response 545 """ 546 return await self.request("PATCH", path, body=body, send_body=True, **options) 547 548 async def delete(self, path: str, **options: Any) -> LinoResponse: 549 """ 550 ``DELETE`` a resource. 551 552 Args: 553 path: Path 554 **options: Request options 555 556 Returns: 557 Decoded response 558 """ 559 return await self.request("DELETE", path, **options) 560 561 async def head(self, path: str, **options: Any) -> LinoResponse: 562 """ 563 ``HEAD`` a resource. 564 565 Args: 566 path: Path 567 **options: Request options 568 569 Returns: 570 Decoded response 571 """ 572 return await self.request("HEAD", path, **options) 573 574 async def options(self, path: str, **options: Any) -> list[str]: 575 """ 576 ``OPTIONS`` a path, returning the advertised methods. 577 578 Args: 579 path: Path 580 **options: Request options 581 582 Returns: 583 Methods listed in ``Allow`` 584 """ 585 return allowed_methods(await self.request("OPTIONS", path, **options)) 586 587 async def list( 588 self, path: str, query: dict[str, Any] | None = None, **options: Any 589 ) -> Any: 590 """ 591 List a collection, returning the decoded envelope. 592 593 Args: 594 path: Collection path 595 query: Collection query parameters 596 **options: Request options 597 598 Returns: 599 Collection envelope 600 """ 601 return (await self.request("GET", path, query=query, **options)).data 602 603 async def describe(self) -> Any: 604 """ 605 Fetch the service description of specification section 9. 606 607 Returns: 608 Description document 609 """ 610 return (await self.get(DESCRIPTION_PATH)).data
An asynchronous client for a service that speaks Links Notation.
430 def __init__( 431 self, 432 base_url: str, 433 *, 434 accept: str = DEFAULT_ACCEPT, 435 content_type: str = LINO_CONTENT_TYPE, 436 headers: dict[str, str] | None = None, 437 client: httpx.AsyncClient | None = None, 438 **client_options: Any, 439 ) -> None: 440 """ 441 Args: 442 base_url: Base URL of the service 443 accept: ``Accept`` header sent with every request 444 content_type: Representation used for request bodies 445 headers: Headers sent with every request 446 client: ``httpx`` client to use, created when omitted 447 **client_options: Options forwarded to :class:`httpx.AsyncClient` 448 """ 449 super().__init__( 450 base_url, accept=accept, content_type=content_type, headers=headers 451 ) 452 self._client = client or httpx.AsyncClient(**client_options) 453 self._owns_client = client is None
Args:
base_url: Base URL of the service
accept: Accept header sent with every request
content_type: Representation used for request bodies
headers: Headers sent with every request
client: httpx client to use, created when omitted
**client_options: Options forwarded to httpx.AsyncClient
463 async def aclose(self) -> None: 464 """Close the underlying ``httpx`` client when this client owns it.""" 465 if self._owns_client: 466 await self._client.aclose()
Close the underlying httpx client when this client owns it.
468 async def request(self, method: str, path: str, **options: Any) -> LinoResponse: 469 """ 470 Perform a request and decode the response. 471 472 Args: 473 method: HTTP method 474 path: Path, appended to the base URL 475 **options: Request options, see :meth:`_RequestBuilder.prepare` 476 477 Returns: 478 Decoded response 479 480 Raises: 481 LinoClientError: For any 4xx or 5xx response 482 """ 483 url, headers, content = self.prepare(path, **options) 484 response = await self._client.request( 485 method, url, headers=headers, content=content 486 ) 487 return self.finish(response, method)
Perform a request and decode the response.
Args:
method: HTTP method
path: Path, appended to the base URL
**options: Request options, see _RequestBuilder.prepare()
Returns: Decoded response
Raises: LinoClientError: For any 4xx or 5xx response
489 async def get(self, path: str, **options: Any) -> LinoResponse: 490 """ 491 ``GET`` a resource. 492 493 Args: 494 path: Path 495 **options: Request options 496 497 Returns: 498 Decoded response 499 """ 500 return await self.request("GET", path, **options)
GET a resource.
Args: path: Path **options: Request options
Returns: Decoded response
502 async def post( 503 self, path: str, body: Any = None, **options: Any 504 ) -> LinoResponse: 505 """ 506 ``POST`` to a collection. 507 508 Args: 509 path: Path 510 body: Value to send 511 **options: Request options 512 513 Returns: 514 Decoded response 515 """ 516 return await self.request("POST", path, body=body, send_body=True, **options)
POST to a collection.
Args: path: Path body: Value to send **options: Request options
Returns: Decoded response
518 async def put(self, path: str, body: Any = None, **options: Any) -> LinoResponse: 519 """ 520 ``PUT`` a representation. 521 522 Args: 523 path: Path 524 body: Value to send 525 **options: Request options 526 527 Returns: 528 Decoded response 529 """ 530 return await self.request("PUT", path, body=body, send_body=True, **options)
PUT a representation.
Args: path: Path body: Value to send **options: Request options
Returns: Decoded response
532 async def patch( 533 self, path: str, body: Any = None, **options: Any 534 ) -> LinoResponse: 535 """ 536 ``PATCH`` a representation. 537 538 Args: 539 path: Path 540 body: Value to send 541 **options: Request options 542 543 Returns: 544 Decoded response 545 """ 546 return await self.request("PATCH", path, body=body, send_body=True, **options)
PATCH a representation.
Args: path: Path body: Value to send **options: Request options
Returns: Decoded response
548 async def delete(self, path: str, **options: Any) -> LinoResponse: 549 """ 550 ``DELETE`` a resource. 551 552 Args: 553 path: Path 554 **options: Request options 555 556 Returns: 557 Decoded response 558 """ 559 return await self.request("DELETE", path, **options)
DELETE a resource.
Args: path: Path **options: Request options
Returns: Decoded response
561 async def head(self, path: str, **options: Any) -> LinoResponse: 562 """ 563 ``HEAD`` a resource. 564 565 Args: 566 path: Path 567 **options: Request options 568 569 Returns: 570 Decoded response 571 """ 572 return await self.request("HEAD", path, **options)
HEAD a resource.
Args: path: Path **options: Request options
Returns: Decoded response
574 async def options(self, path: str, **options: Any) -> list[str]: 575 """ 576 ``OPTIONS`` a path, returning the advertised methods. 577 578 Args: 579 path: Path 580 **options: Request options 581 582 Returns: 583 Methods listed in ``Allow`` 584 """ 585 return allowed_methods(await self.request("OPTIONS", path, **options))
OPTIONS a path, returning the advertised methods.
Args: path: Path **options: Request options
Returns:
Methods listed in Allow
587 async def list( 588 self, path: str, query: dict[str, Any] | None = None, **options: Any 589 ) -> Any: 590 """ 591 List a collection, returning the decoded envelope. 592 593 Args: 594 path: Collection path 595 query: Collection query parameters 596 **options: Request options 597 598 Returns: 599 Collection envelope 600 """ 601 return (await self.request("GET", path, query=query, **options)).data
List a collection, returning the decoded envelope.
Args: path: Collection path query: Collection query parameters **options: Request options
Returns: Collection envelope
603 async def describe(self) -> Any: 604 """ 605 Fetch the service description of specification section 9. 606 607 Returns: 608 Description document 609 """ 610 return (await self.get(DESCRIPTION_PATH)).data
Fetch the service description of specification section 9.
Returns: Description document
144class LinoAPI: 145 """ 146 LinoAPI class - wraps FastAPI with LINO support. 147 148 Automatically encodes responses as LINO and provides 149 helpers for parsing LINO request bodies. 150 """ 151 152 def __init__( 153 self, 154 title: str = "LINO REST API", 155 description: str = "REST API using Links Notation", 156 version: str = "0.1.0", 157 **kwargs, 158 ): 159 """ 160 Create a new LinoAPI instance. 161 162 Args: 163 title: API title 164 description: API description 165 version: API version 166 **kwargs: Additional FastAPI arguments 167 """ 168 self.app = FastAPI( 169 title=title, 170 description=description, 171 version=version, 172 **kwargs, 173 ) 174 175 def get(self, path: str, **kwargs): 176 """ 177 Decorator for GET endpoints. 178 179 Args: 180 path: Route path 181 **kwargs: Additional route arguments 182 """ 183 184 def decorator(func: Callable) -> Callable: 185 @wraps(func) 186 async def wrapper(request: Request) -> LinoResponse: 187 result = await self._call_handler(func, request) 188 return LinoResponse(content=result) 189 190 self.app.get(path, **kwargs)(wrapper) 191 return func 192 193 return decorator 194 195 def post(self, path: str, **kwargs): 196 """ 197 Decorator for POST endpoints. 198 199 Args: 200 path: Route path 201 **kwargs: Additional route arguments 202 """ 203 204 def decorator(func: Callable) -> Callable: 205 @wraps(func) 206 async def wrapper(request: Request) -> LinoResponse: 207 result = await self._call_handler(func, request) 208 return LinoResponse(content=result) 209 210 self.app.post(path, **kwargs)(wrapper) 211 return func 212 213 return decorator 214 215 def put(self, path: str, **kwargs): 216 """ 217 Decorator for PUT endpoints. 218 219 Args: 220 path: Route path 221 **kwargs: Additional route arguments 222 """ 223 224 def decorator(func: Callable) -> Callable: 225 @wraps(func) 226 async def wrapper(request: Request) -> LinoResponse: 227 result = await self._call_handler(func, request) 228 return LinoResponse(content=result) 229 230 self.app.put(path, **kwargs)(wrapper) 231 return func 232 233 return decorator 234 235 def delete(self, path: str, **kwargs): 236 """ 237 Decorator for DELETE endpoints. 238 239 Args: 240 path: Route path 241 **kwargs: Additional route arguments 242 """ 243 244 def decorator(func: Callable) -> Callable: 245 @wraps(func) 246 async def wrapper(request: Request) -> LinoResponse: 247 result = await self._call_handler(func, request) 248 return LinoResponse(content=result) 249 250 self.app.delete(path, **kwargs)(wrapper) 251 return func 252 253 return decorator 254 255 def patch(self, path: str, **kwargs): 256 """ 257 Decorator for PATCH endpoints. 258 259 Args: 260 path: Route path 261 **kwargs: Additional route arguments 262 """ 263 264 def decorator(func: Callable) -> Callable: 265 @wraps(func) 266 async def wrapper(request: Request) -> LinoResponse: 267 result = await self._call_handler(func, request) 268 return LinoResponse(content=result) 269 270 self.app.patch(path, **kwargs)(wrapper) 271 return func 272 273 return decorator 274 275 async def _call_handler(self, func: Callable, request: Request) -> Any: 276 """ 277 Call a handler function with appropriate arguments. 278 279 Args: 280 func: The handler function 281 request: The FastAPI request 282 283 Returns: 284 Handler result 285 """ 286 import inspect 287 288 sig = inspect.signature(func) 289 params = sig.parameters 290 291 kwargs = {} 292 293 for name, _param in params.items(): 294 if name == "request": 295 kwargs["request"] = request 296 elif name == "body": 297 kwargs["body"] = await lino_request_handler(request) 298 299 # Check if the function is a coroutine 300 if inspect.iscoroutinefunction(func): 301 return await func(**kwargs) 302 else: 303 return func(**kwargs) 304 305 def get_fastapi_app(self) -> FastAPI: 306 """ 307 Get the underlying FastAPI app. 308 309 Returns: 310 FastAPI application instance 311 """ 312 return self.app
LinoAPI class - wraps FastAPI with LINO support.
Automatically encodes responses as LINO and provides helpers for parsing LINO request bodies.
152 def __init__( 153 self, 154 title: str = "LINO REST API", 155 description: str = "REST API using Links Notation", 156 version: str = "0.1.0", 157 **kwargs, 158 ): 159 """ 160 Create a new LinoAPI instance. 161 162 Args: 163 title: API title 164 description: API description 165 version: API version 166 **kwargs: Additional FastAPI arguments 167 """ 168 self.app = FastAPI( 169 title=title, 170 description=description, 171 version=version, 172 **kwargs, 173 )
Create a new LinoAPI instance.
Args: title: API title description: API description version: API version **kwargs: Additional FastAPI arguments
175 def get(self, path: str, **kwargs): 176 """ 177 Decorator for GET endpoints. 178 179 Args: 180 path: Route path 181 **kwargs: Additional route arguments 182 """ 183 184 def decorator(func: Callable) -> Callable: 185 @wraps(func) 186 async def wrapper(request: Request) -> LinoResponse: 187 result = await self._call_handler(func, request) 188 return LinoResponse(content=result) 189 190 self.app.get(path, **kwargs)(wrapper) 191 return func 192 193 return decorator
Decorator for GET endpoints.
Args: path: Route path **kwargs: Additional route arguments
195 def post(self, path: str, **kwargs): 196 """ 197 Decorator for POST endpoints. 198 199 Args: 200 path: Route path 201 **kwargs: Additional route arguments 202 """ 203 204 def decorator(func: Callable) -> Callable: 205 @wraps(func) 206 async def wrapper(request: Request) -> LinoResponse: 207 result = await self._call_handler(func, request) 208 return LinoResponse(content=result) 209 210 self.app.post(path, **kwargs)(wrapper) 211 return func 212 213 return decorator
Decorator for POST endpoints.
Args: path: Route path **kwargs: Additional route arguments
215 def put(self, path: str, **kwargs): 216 """ 217 Decorator for PUT endpoints. 218 219 Args: 220 path: Route path 221 **kwargs: Additional route arguments 222 """ 223 224 def decorator(func: Callable) -> Callable: 225 @wraps(func) 226 async def wrapper(request: Request) -> LinoResponse: 227 result = await self._call_handler(func, request) 228 return LinoResponse(content=result) 229 230 self.app.put(path, **kwargs)(wrapper) 231 return func 232 233 return decorator
Decorator for PUT endpoints.
Args: path: Route path **kwargs: Additional route arguments
235 def delete(self, path: str, **kwargs): 236 """ 237 Decorator for DELETE endpoints. 238 239 Args: 240 path: Route path 241 **kwargs: Additional route arguments 242 """ 243 244 def decorator(func: Callable) -> Callable: 245 @wraps(func) 246 async def wrapper(request: Request) -> LinoResponse: 247 result = await self._call_handler(func, request) 248 return LinoResponse(content=result) 249 250 self.app.delete(path, **kwargs)(wrapper) 251 return func 252 253 return decorator
Decorator for DELETE endpoints.
Args: path: Route path **kwargs: Additional route arguments
255 def patch(self, path: str, **kwargs): 256 """ 257 Decorator for PATCH endpoints. 258 259 Args: 260 path: Route path 261 **kwargs: Additional route arguments 262 """ 263 264 def decorator(func: Callable) -> Callable: 265 @wraps(func) 266 async def wrapper(request: Request) -> LinoResponse: 267 result = await self._call_handler(func, request) 268 return LinoResponse(content=result) 269 270 self.app.patch(path, **kwargs)(wrapper) 271 return func 272 273 return decorator
Decorator for PATCH endpoints.
Args: path: Route path **kwargs: Additional route arguments
118class LinoAPIRoute(APIRoute): 119 """ 120 Custom APIRoute that automatically uses LINO for responses. 121 """ 122 123 def get_route_handler(self) -> Callable: 124 original_handler = super().get_route_handler() 125 126 async def lino_handler(request: Request) -> LinoResponse: 127 response = await original_handler(request) 128 129 # If it's already a LinoResponse, return as-is 130 if isinstance(response, LinoResponse): 131 return response 132 133 # If it's a regular Response, check if we should convert 134 if hasattr(response, "body"): 135 # Already has body, return as-is 136 return response 137 138 # Convert to LinoResponse 139 return LinoResponse(content=response) 140 141 return lino_handler
Custom APIRoute that automatically uses LINO for responses.
123 def get_route_handler(self) -> Callable: 124 original_handler = super().get_route_handler() 125 126 async def lino_handler(request: Request) -> LinoResponse: 127 response = await original_handler(request) 128 129 # If it's already a LinoResponse, return as-is 130 if isinstance(response, LinoResponse): 131 return response 132 133 # If it's a regular Response, check if we should convert 134 if hasattr(response, "body"): 135 # Already has body, return as-is 136 return response 137 138 # Convert to LinoResponse 139 return LinoResponse(content=response) 140 141 return lino_handler
98class LinoApp: 99 """An ASGI application that speaks Links Notation.""" 100 101 def __init__( 102 self, 103 *, 104 title: str = "LINO REST API", 105 version: str = "1.0.0", 106 description: str | None = None, 107 cors: bool | dict[str, Any] | None = None, 108 describe: bool = True, 109 supported: list[str] | None = None, 110 max_body_bytes: int = DEFAULT_MAX_BODY_BYTES, 111 expose_traceback: bool = False, 112 default_limit: int | None = None, 113 max_limit: int | None = None, 114 ) -> None: 115 """ 116 Args: 117 title: Service title used in the description 118 version: Service version used in the description 119 description: Prose description of the service 120 cors: Enable CORS, optionally with a policy 121 describe: Serve the service description 122 supported: Representations the server may produce 123 max_body_bytes: Largest accepted request body 124 expose_traceback: Attach tracebacks to 5xx problems 125 default_limit: Default collection page size 126 max_limit: Largest collection page size 127 """ 128 self.info: dict[str, str] = {"title": title, "version": version} 129 if description: 130 self.info["description"] = description 131 self.cors = cors 132 self.supported = supported 133 self.max_body_bytes = max_body_bytes 134 self.expose_traceback = expose_traceback 135 self.routes = RouteTable() 136 self.handlers: dict[tuple[str, str], Handler] = {} 137 138 self.query_defaults: dict[str, int] = {} 139 if default_limit is not None: 140 self.query_defaults["default_limit"] = default_limit 141 if max_limit is not None: 142 self.query_defaults["max_limit"] = max_limit 143 144 if describe: 145 self._register_description() 146 147 # Registration ----------------------------------------------------------- 148 149 def route( 150 self, 151 method: str, 152 path: str, 153 handler: Handler | None = None, 154 meta: dict[str, Any] | None = None, 155 ) -> Registration: 156 """ 157 Register a handler for a method and path. 158 159 Given a handler the route is registered and the application returned, so 160 that registrations chain; without one a decorator is returned, so that the 161 handler can be written where it is registered. 162 163 Args: 164 method: HTTP method 165 path: Path pattern, with ``:parameter`` segments 166 handler: Route handler taking a :class:`LinoHttpRequest` 167 meta: Description metadata, for example ``summary`` 168 169 Returns: 170 This application, or a decorator when the handler is omitted 171 172 Raises: 173 TypeError: When the method is not one of :data:`HTTP_METHODS` 174 """ 175 normalized = method.upper() 176 if normalized not in HTTP_METHODS: 177 raise TypeError(f"Unsupported HTTP method: {method}") 178 179 if handler is None: 180 181 def register(function: Handler) -> Handler: 182 self.route(normalized, path, function, meta) 183 return function 184 185 return register 186 187 self.routes.register(normalized, path, meta) 188 self.handlers[(normalized, path)] = handler 189 return self 190 191 def get( 192 self, 193 path: str, 194 handler: Handler | None = None, 195 meta: dict[str, Any] | None = None, 196 ) -> Registration: 197 """ 198 Register a ``GET`` handler. 199 200 Args: 201 path: Path pattern 202 handler: Route handler 203 meta: Description metadata 204 205 Returns: 206 This application, or a decorator when the handler is omitted 207 """ 208 return self.route("GET", path, handler, meta) 209 210 def post( 211 self, 212 path: str, 213 handler: Handler | None = None, 214 meta: dict[str, Any] | None = None, 215 ) -> Registration: 216 """ 217 Register a ``POST`` handler. 218 219 Args: 220 path: Path pattern 221 handler: Route handler 222 meta: Description metadata 223 224 Returns: 225 This application, or a decorator when the handler is omitted 226 """ 227 return self.route("POST", path, handler, meta) 228 229 def put( 230 self, 231 path: str, 232 handler: Handler | None = None, 233 meta: dict[str, Any] | None = None, 234 ) -> Registration: 235 """ 236 Register a ``PUT`` handler. 237 238 Args: 239 path: Path pattern 240 handler: Route handler 241 meta: Description metadata 242 243 Returns: 244 This application, or a decorator when the handler is omitted 245 """ 246 return self.route("PUT", path, handler, meta) 247 248 def patch( 249 self, 250 path: str, 251 handler: Handler | None = None, 252 meta: dict[str, Any] | None = None, 253 ) -> Registration: 254 """ 255 Register a ``PATCH`` handler. 256 257 Args: 258 path: Path pattern 259 handler: Route handler 260 meta: Description metadata 261 262 Returns: 263 This application, or a decorator when the handler is omitted 264 """ 265 return self.route("PATCH", path, handler, meta) 266 267 def delete( 268 self, 269 path: str, 270 handler: Handler | None = None, 271 meta: dict[str, Any] | None = None, 272 ) -> Registration: 273 """ 274 Register a ``DELETE`` handler. 275 276 Args: 277 path: Path pattern 278 handler: Route handler 279 meta: Description metadata 280 281 Returns: 282 This application, or a decorator when the handler is omitted 283 """ 284 return self.route("DELETE", path, handler, meta) 285 286 def resource(self, path: str, store: Any, **options: Any) -> "LinoApp": 287 """ 288 Register a CRUD resource, see :func:`lino_rest_api.resource.register_resource`. 289 290 Args: 291 path: Collection path 292 store: Resource store 293 **options: Resource options 294 295 Returns: 296 This application, for chaining 297 """ 298 return register_resource( 299 self, path, store, **{**self.query_defaults, **options} 300 ) 301 302 def _register_description(self) -> None: 303 """Register the service description routes of specification section 9.""" 304 self.get( 305 DESCRIPTION_PATH, 306 lambda request: self.describe(), 307 {"summary": "Service description"}, 308 ) 309 self.get( 310 OPENAPI_PATH, 311 lambda request: raw_response( 312 json.dumps(self.openapi(), indent=2) + "\n", 313 JSON_CONTENT_TYPE, 314 ), 315 {"summary": "OpenAPI 3.1 description"}, 316 ) 317 318 # Description ------------------------------------------------------------ 319 320 def describe(self) -> dict[str, Any]: 321 """ 322 The native service description of specification section 9. 323 324 Returns: 325 Description document 326 """ 327 return service_description(self.info, self.routes.describe(), self.supported) 328 329 def openapi(self) -> dict[str, Any]: 330 """ 331 The OpenAPI 3.1 rendering of the service description. 332 333 Returns: 334 OpenAPI document 335 """ 336 return openapi_document(self.info, self.routes.describe(), self.supported) 337 338 # Request handling ------------------------------------------------------- 339 340 async def handle(self, request: LinoHttpRequest) -> ResponseParts: 341 """ 342 Run one decoded request through routing and the handler. 343 344 Args: 345 request: Decoded request 346 347 Returns: 348 Response ready to be written 349 350 Raises: 351 LinoHttpError: 404, 405 and anything a handler raises 352 """ 353 matched = self.routes.match(request.path) 354 if matched is None: 355 raise LinoHttpError(404, f"No resource at {request.path}") 356 357 entry, params = matched 358 request.params = params 359 allowed = self.routes.allowed_methods(request.path) or [] 360 361 method = request.method 362 # HEAD is served by the GET handler with the body dropped, and OPTIONS is 363 # answered from the route table unless a handler claims it. 364 lookup = "GET" if method == "HEAD" and "GET" in entry.methods else method 365 366 if method == "OPTIONS" and "OPTIONS" not in entry.methods: 367 return ResponseParts(204, {"Allow": ", ".join(allowed)}, "") 368 369 if lookup not in entry.methods: 370 raise LinoHttpError( 371 405, 372 f"{method} is not allowed on {request.path}", 373 headers={"Allow": ", ".join(allowed)}, 374 ) 375 376 handler = self.handlers[(lookup, entry.pattern)] 377 result = handler(request) 378 if inspect.isawaitable(result): 379 result = await result 380 381 if isinstance(result, LinoResult): 382 return build_response( 383 result.value, 384 status=result.status, 385 media_type=result.media_type or request.media_type, 386 headers=result.headers, 387 request_headers=request.headers, 388 method=method, 389 etag=result.etag, 390 preconditions=result.preconditions, 391 require_precondition=result.require_precondition, 392 raw=result.raw, 393 ) 394 395 if result is None: 396 return build_response( 397 EMPTY, 398 status=204, 399 media_type=request.media_type, 400 request_headers=request.headers, 401 method=method, 402 ) 403 404 return build_response( 405 result, 406 media_type=request.media_type, 407 request_headers=request.headers, 408 method=method, 409 ) 410 411 async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None: 412 """ 413 ASGI entry point. 414 415 Args: 416 scope: ASGI scope 417 receive: ASGI receive callable 418 send: ASGI send callable 419 """ 420 if scope["type"] == "lifespan": 421 await self._lifespan(receive, send) 422 return 423 if scope["type"] != "http": 424 raise NotImplementedError(f"Unsupported scope type: {scope['type']}") 425 426 headers = { 427 name.decode("latin-1").lower(): value.decode("latin-1") 428 for name, value in scope.get("headers", []) 429 } 430 method = scope["method"].upper() 431 path = scope.get("path", "/") 432 query_string = scope.get("query_string", b"").decode("latin-1") 433 434 request = LinoHttpRequest( 435 method=method, 436 path=path, 437 headers=headers, 438 query_string=query_string, 439 scope=scope, 440 app=self, 441 ) 442 443 extra_headers = self._cors_headers(headers) 444 preflight = ( 445 method == "OPTIONS" and "access-control-request-method" in headers 446 ) 447 448 media_type = LINO_CONTENT_TYPE 449 try: 450 if preflight and extra_headers: 451 parts = ResponseParts(204, {}, "") 452 else: 453 media_type = negotiate_request(headers, self.supported) 454 request.media_type = media_type 455 raw = await self._read_body(receive) 456 body, request_media_type = decode_request_body( 457 raw, 458 headers.get("content-type"), 459 max_bytes=self.max_body_bytes, 460 ) 461 request.body = body 462 request.request_media_type = request_media_type 463 parts = await self.handle(request) 464 except Exception as error: # noqa: BLE001 - every error becomes a problem 465 instance = path if not query_string else f"{path}?{query_string}" 466 parts = build_problem_response( 467 error, 468 media_type=media_type, 469 instance=instance, 470 expose_traceback=self.expose_traceback, 471 ) 472 473 parts.headers.update( 474 { 475 name: value 476 for name, value in extra_headers.items() 477 if name not in parts.headers 478 } 479 ) 480 if extra_headers.get("Access-Control-Allow-Origin", "*") != "*": 481 append_vary(parts.headers, "Origin") 482 483 await self._send(send, parts, head=method == "HEAD") 484 485 async def _lifespan(self, receive: Callable, send: Callable) -> None: 486 """ 487 Answer the ASGI lifespan protocol so that servers can start and stop. 488 489 Args: 490 receive: ASGI receive callable 491 send: ASGI send callable 492 """ 493 while True: 494 message = await receive() 495 if message["type"] == "lifespan.startup": 496 await send({"type": "lifespan.startup.complete"}) 497 elif message["type"] == "lifespan.shutdown": 498 await send({"type": "lifespan.shutdown.complete"}) 499 return 500 501 def _cors_headers(self, headers: dict[str, str]) -> dict[str, str]: 502 """ 503 Build the CORS response headers for a request. 504 505 Args: 506 headers: Request headers, lower-cased names 507 508 Returns: 509 Response headers ({} when CORS is disabled or the origin is refused) 510 """ 511 if not self.cors: 512 return {} 513 return cors_headers(self.cors, headers.get("origin")) 514 515 async def _read_body(self, receive: Callable) -> bytes: 516 """ 517 Read the whole request body, refusing oversized payloads early. 518 519 Args: 520 receive: ASGI receive callable 521 522 Returns: 523 Raw request body 524 525 Raises: 526 LinoHttpError: 413 when the body exceeds the configured limit 527 """ 528 chunks: list[bytes] = [] 529 size = 0 530 while True: 531 message = await receive() 532 if message["type"] == "http.disconnect": 533 break 534 chunks.append(message.get("body", b"")) 535 size += len(chunks[-1]) 536 if size > self.max_body_bytes: 537 raise LinoHttpError( 538 413, 539 f"Request body exceeds {self.max_body_bytes} bytes", 540 headers={"Connection": "close"}, 541 ) 542 if not message.get("more_body", False): 543 break 544 return b"".join(chunks) 545 546 async def _send( 547 self, send: Callable, parts: ResponseParts, *, head: bool = False 548 ) -> None: 549 """ 550 Write a response to the wire. 551 552 Args: 553 send: ASGI send callable 554 parts: Response to write 555 head: Drop the body, as ``HEAD`` requires 556 """ 557 body = parts.body.encode("utf-8") 558 headers = [ 559 (name.encode("latin-1"), value.encode("latin-1")) 560 for name, value in parts.headers.items() 561 ] 562 if parts.status not in (204, 304): 563 headers.append((b"content-length", str(len(body)).encode("latin-1"))) 564 565 await send( 566 { 567 "type": "http.response.start", 568 "status": parts.status, 569 "headers": headers, 570 } 571 ) 572 await send( 573 {"type": "http.response.body", "body": b"" if head else body} 574 )
An ASGI application that speaks Links Notation.
101 def __init__( 102 self, 103 *, 104 title: str = "LINO REST API", 105 version: str = "1.0.0", 106 description: str | None = None, 107 cors: bool | dict[str, Any] | None = None, 108 describe: bool = True, 109 supported: list[str] | None = None, 110 max_body_bytes: int = DEFAULT_MAX_BODY_BYTES, 111 expose_traceback: bool = False, 112 default_limit: int | None = None, 113 max_limit: int | None = None, 114 ) -> None: 115 """ 116 Args: 117 title: Service title used in the description 118 version: Service version used in the description 119 description: Prose description of the service 120 cors: Enable CORS, optionally with a policy 121 describe: Serve the service description 122 supported: Representations the server may produce 123 max_body_bytes: Largest accepted request body 124 expose_traceback: Attach tracebacks to 5xx problems 125 default_limit: Default collection page size 126 max_limit: Largest collection page size 127 """ 128 self.info: dict[str, str] = {"title": title, "version": version} 129 if description: 130 self.info["description"] = description 131 self.cors = cors 132 self.supported = supported 133 self.max_body_bytes = max_body_bytes 134 self.expose_traceback = expose_traceback 135 self.routes = RouteTable() 136 self.handlers: dict[tuple[str, str], Handler] = {} 137 138 self.query_defaults: dict[str, int] = {} 139 if default_limit is not None: 140 self.query_defaults["default_limit"] = default_limit 141 if max_limit is not None: 142 self.query_defaults["max_limit"] = max_limit 143 144 if describe: 145 self._register_description()
Args: title: Service title used in the description version: Service version used in the description description: Prose description of the service cors: Enable CORS, optionally with a policy describe: Serve the service description supported: Representations the server may produce max_body_bytes: Largest accepted request body expose_traceback: Attach tracebacks to 5xx problems default_limit: Default collection page size max_limit: Largest collection page size
149 def route( 150 self, 151 method: str, 152 path: str, 153 handler: Handler | None = None, 154 meta: dict[str, Any] | None = None, 155 ) -> Registration: 156 """ 157 Register a handler for a method and path. 158 159 Given a handler the route is registered and the application returned, so 160 that registrations chain; without one a decorator is returned, so that the 161 handler can be written where it is registered. 162 163 Args: 164 method: HTTP method 165 path: Path pattern, with ``:parameter`` segments 166 handler: Route handler taking a :class:`LinoHttpRequest` 167 meta: Description metadata, for example ``summary`` 168 169 Returns: 170 This application, or a decorator when the handler is omitted 171 172 Raises: 173 TypeError: When the method is not one of :data:`HTTP_METHODS` 174 """ 175 normalized = method.upper() 176 if normalized not in HTTP_METHODS: 177 raise TypeError(f"Unsupported HTTP method: {method}") 178 179 if handler is None: 180 181 def register(function: Handler) -> Handler: 182 self.route(normalized, path, function, meta) 183 return function 184 185 return register 186 187 self.routes.register(normalized, path, meta) 188 self.handlers[(normalized, path)] = handler 189 return self
Register a handler for a method and path.
Given a handler the route is registered and the application returned, so that registrations chain; without one a decorator is returned, so that the handler can be written where it is registered.
Args:
method: HTTP method
path: Path pattern, with :parameter segments
handler: Route handler taking a LinoHttpRequest
meta: Description metadata, for example summary
Returns: This application, or a decorator when the handler is omitted
Raises:
TypeError: When the method is not one of HTTP_METHODS
191 def get( 192 self, 193 path: str, 194 handler: Handler | None = None, 195 meta: dict[str, Any] | None = None, 196 ) -> Registration: 197 """ 198 Register a ``GET`` handler. 199 200 Args: 201 path: Path pattern 202 handler: Route handler 203 meta: Description metadata 204 205 Returns: 206 This application, or a decorator when the handler is omitted 207 """ 208 return self.route("GET", path, handler, meta)
Register a GET handler.
Args: path: Path pattern handler: Route handler meta: Description metadata
Returns: This application, or a decorator when the handler is omitted
210 def post( 211 self, 212 path: str, 213 handler: Handler | None = None, 214 meta: dict[str, Any] | None = None, 215 ) -> Registration: 216 """ 217 Register a ``POST`` handler. 218 219 Args: 220 path: Path pattern 221 handler: Route handler 222 meta: Description metadata 223 224 Returns: 225 This application, or a decorator when the handler is omitted 226 """ 227 return self.route("POST", path, handler, meta)
Register a POST handler.
Args: path: Path pattern handler: Route handler meta: Description metadata
Returns: This application, or a decorator when the handler is omitted
229 def put( 230 self, 231 path: str, 232 handler: Handler | None = None, 233 meta: dict[str, Any] | None = None, 234 ) -> Registration: 235 """ 236 Register a ``PUT`` handler. 237 238 Args: 239 path: Path pattern 240 handler: Route handler 241 meta: Description metadata 242 243 Returns: 244 This application, or a decorator when the handler is omitted 245 """ 246 return self.route("PUT", path, handler, meta)
Register a PUT handler.
Args: path: Path pattern handler: Route handler meta: Description metadata
Returns: This application, or a decorator when the handler is omitted
248 def patch( 249 self, 250 path: str, 251 handler: Handler | None = None, 252 meta: dict[str, Any] | None = None, 253 ) -> Registration: 254 """ 255 Register a ``PATCH`` handler. 256 257 Args: 258 path: Path pattern 259 handler: Route handler 260 meta: Description metadata 261 262 Returns: 263 This application, or a decorator when the handler is omitted 264 """ 265 return self.route("PATCH", path, handler, meta)
Register a PATCH handler.
Args: path: Path pattern handler: Route handler meta: Description metadata
Returns: This application, or a decorator when the handler is omitted
267 def delete( 268 self, 269 path: str, 270 handler: Handler | None = None, 271 meta: dict[str, Any] | None = None, 272 ) -> Registration: 273 """ 274 Register a ``DELETE`` handler. 275 276 Args: 277 path: Path pattern 278 handler: Route handler 279 meta: Description metadata 280 281 Returns: 282 This application, or a decorator when the handler is omitted 283 """ 284 return self.route("DELETE", path, handler, meta)
Register a DELETE handler.
Args: path: Path pattern handler: Route handler meta: Description metadata
Returns: This application, or a decorator when the handler is omitted
286 def resource(self, path: str, store: Any, **options: Any) -> "LinoApp": 287 """ 288 Register a CRUD resource, see :func:`lino_rest_api.resource.register_resource`. 289 290 Args: 291 path: Collection path 292 store: Resource store 293 **options: Resource options 294 295 Returns: 296 This application, for chaining 297 """ 298 return register_resource( 299 self, path, store, **{**self.query_defaults, **options} 300 )
Register a CRUD resource, see lino_rest_api.resource.register_resource().
Args: path: Collection path store: Resource store **options: Resource options
Returns: This application, for chaining
320 def describe(self) -> dict[str, Any]: 321 """ 322 The native service description of specification section 9. 323 324 Returns: 325 Description document 326 """ 327 return service_description(self.info, self.routes.describe(), self.supported)
The native service description of specification section 9.
Returns: Description document
329 def openapi(self) -> dict[str, Any]: 330 """ 331 The OpenAPI 3.1 rendering of the service description. 332 333 Returns: 334 OpenAPI document 335 """ 336 return openapi_document(self.info, self.routes.describe(), self.supported)
The OpenAPI 3.1 rendering of the service description.
Returns: OpenAPI document
340 async def handle(self, request: LinoHttpRequest) -> ResponseParts: 341 """ 342 Run one decoded request through routing and the handler. 343 344 Args: 345 request: Decoded request 346 347 Returns: 348 Response ready to be written 349 350 Raises: 351 LinoHttpError: 404, 405 and anything a handler raises 352 """ 353 matched = self.routes.match(request.path) 354 if matched is None: 355 raise LinoHttpError(404, f"No resource at {request.path}") 356 357 entry, params = matched 358 request.params = params 359 allowed = self.routes.allowed_methods(request.path) or [] 360 361 method = request.method 362 # HEAD is served by the GET handler with the body dropped, and OPTIONS is 363 # answered from the route table unless a handler claims it. 364 lookup = "GET" if method == "HEAD" and "GET" in entry.methods else method 365 366 if method == "OPTIONS" and "OPTIONS" not in entry.methods: 367 return ResponseParts(204, {"Allow": ", ".join(allowed)}, "") 368 369 if lookup not in entry.methods: 370 raise LinoHttpError( 371 405, 372 f"{method} is not allowed on {request.path}", 373 headers={"Allow": ", ".join(allowed)}, 374 ) 375 376 handler = self.handlers[(lookup, entry.pattern)] 377 result = handler(request) 378 if inspect.isawaitable(result): 379 result = await result 380 381 if isinstance(result, LinoResult): 382 return build_response( 383 result.value, 384 status=result.status, 385 media_type=result.media_type or request.media_type, 386 headers=result.headers, 387 request_headers=request.headers, 388 method=method, 389 etag=result.etag, 390 preconditions=result.preconditions, 391 require_precondition=result.require_precondition, 392 raw=result.raw, 393 ) 394 395 if result is None: 396 return build_response( 397 EMPTY, 398 status=204, 399 media_type=request.media_type, 400 request_headers=request.headers, 401 method=method, 402 ) 403 404 return build_response( 405 result, 406 media_type=request.media_type, 407 request_headers=request.headers, 408 method=method, 409 )
Run one decoded request through routing and the handler.
Args: request: Decoded request
Returns: Response ready to be written
Raises: LinoHttpError: 404, 405 and anything a handler raises
245class LinoClient(_RequestBuilder): 246 """A synchronous client for a service that speaks Links Notation.""" 247 248 def __init__( 249 self, 250 base_url: str, 251 *, 252 accept: str = DEFAULT_ACCEPT, 253 content_type: str = LINO_CONTENT_TYPE, 254 headers: dict[str, str] | None = None, 255 client: httpx.Client | None = None, 256 **client_options: Any, 257 ) -> None: 258 """ 259 Args: 260 base_url: Base URL of the service 261 accept: ``Accept`` header sent with every request 262 content_type: Representation used for request bodies 263 headers: Headers sent with every request 264 client: ``httpx`` client to use, created when omitted 265 **client_options: Options forwarded to :class:`httpx.Client` 266 """ 267 super().__init__( 268 base_url, accept=accept, content_type=content_type, headers=headers 269 ) 270 self._client = client or httpx.Client(**client_options) 271 self._owns_client = client is None 272 273 def __enter__(self) -> "LinoClient": 274 """Enter a context manager that closes the underlying client.""" 275 return self 276 277 def __exit__(self, *exception: Any) -> None: 278 """Close the underlying client when it was created here.""" 279 self.close() 280 281 def close(self) -> None: 282 """Close the underlying ``httpx`` client when this client owns it.""" 283 if self._owns_client: 284 self._client.close() 285 286 def request(self, method: str, path: str, **options: Any) -> LinoResponse: 287 """ 288 Perform a request and decode the response. 289 290 Args: 291 method: HTTP method 292 path: Path, appended to the base URL 293 **options: Request options, see :meth:`_RequestBuilder.prepare` 294 295 Returns: 296 Decoded response 297 298 Raises: 299 LinoClientError: For any 4xx or 5xx response 300 """ 301 url, headers, content = self.prepare(path, **options) 302 response = self._client.request( 303 method, url, headers=headers, content=content 304 ) 305 return self.finish(response, method) 306 307 def get(self, path: str, **options: Any) -> LinoResponse: 308 """ 309 ``GET`` a resource. 310 311 Args: 312 path: Path 313 **options: Request options 314 315 Returns: 316 Decoded response 317 """ 318 return self.request("GET", path, **options) 319 320 def post(self, path: str, body: Any = None, **options: Any) -> LinoResponse: 321 """ 322 ``POST`` to a collection. 323 324 Args: 325 path: Path 326 body: Value to send 327 **options: Request options 328 329 Returns: 330 Decoded response 331 """ 332 return self.request("POST", path, body=body, send_body=True, **options) 333 334 def put(self, path: str, body: Any = None, **options: Any) -> LinoResponse: 335 """ 336 ``PUT`` a representation. 337 338 Args: 339 path: Path 340 body: Value to send 341 **options: Request options 342 343 Returns: 344 Decoded response 345 """ 346 return self.request("PUT", path, body=body, send_body=True, **options) 347 348 def patch(self, path: str, body: Any = None, **options: Any) -> LinoResponse: 349 """ 350 ``PATCH`` a representation. 351 352 Args: 353 path: Path 354 body: Value to send 355 **options: Request options 356 357 Returns: 358 Decoded response 359 """ 360 return self.request("PATCH", path, body=body, send_body=True, **options) 361 362 def delete(self, path: str, **options: Any) -> LinoResponse: 363 """ 364 ``DELETE`` a resource. 365 366 Args: 367 path: Path 368 **options: Request options 369 370 Returns: 371 Decoded response 372 """ 373 return self.request("DELETE", path, **options) 374 375 def head(self, path: str, **options: Any) -> LinoResponse: 376 """ 377 ``HEAD`` a resource. 378 379 Args: 380 path: Path 381 **options: Request options 382 383 Returns: 384 Decoded response 385 """ 386 return self.request("HEAD", path, **options) 387 388 def options(self, path: str, **options: Any) -> list[str]: 389 """ 390 ``OPTIONS`` a path, returning the advertised methods. 391 392 Args: 393 path: Path 394 **options: Request options 395 396 Returns: 397 Methods listed in ``Allow`` 398 """ 399 return allowed_methods(self.request("OPTIONS", path, **options)) 400 401 def list( 402 self, path: str, query: dict[str, Any] | None = None, **options: Any 403 ) -> Any: 404 """ 405 List a collection, returning the decoded envelope. 406 407 Args: 408 path: Collection path 409 query: Collection query parameters 410 **options: Request options 411 412 Returns: 413 Collection envelope 414 """ 415 return self.request("GET", path, query=query, **options).data 416 417 def describe(self) -> Any: 418 """ 419 Fetch the service description of specification section 9. 420 421 Returns: 422 Description document 423 """ 424 return self.get(DESCRIPTION_PATH).data
A synchronous client for a service that speaks Links Notation.
248 def __init__( 249 self, 250 base_url: str, 251 *, 252 accept: str = DEFAULT_ACCEPT, 253 content_type: str = LINO_CONTENT_TYPE, 254 headers: dict[str, str] | None = None, 255 client: httpx.Client | None = None, 256 **client_options: Any, 257 ) -> None: 258 """ 259 Args: 260 base_url: Base URL of the service 261 accept: ``Accept`` header sent with every request 262 content_type: Representation used for request bodies 263 headers: Headers sent with every request 264 client: ``httpx`` client to use, created when omitted 265 **client_options: Options forwarded to :class:`httpx.Client` 266 """ 267 super().__init__( 268 base_url, accept=accept, content_type=content_type, headers=headers 269 ) 270 self._client = client or httpx.Client(**client_options) 271 self._owns_client = client is None
Args:
base_url: Base URL of the service
accept: Accept header sent with every request
content_type: Representation used for request bodies
headers: Headers sent with every request
client: httpx client to use, created when omitted
**client_options: Options forwarded to httpx.Client
281 def close(self) -> None: 282 """Close the underlying ``httpx`` client when this client owns it.""" 283 if self._owns_client: 284 self._client.close()
Close the underlying httpx client when this client owns it.
286 def request(self, method: str, path: str, **options: Any) -> LinoResponse: 287 """ 288 Perform a request and decode the response. 289 290 Args: 291 method: HTTP method 292 path: Path, appended to the base URL 293 **options: Request options, see :meth:`_RequestBuilder.prepare` 294 295 Returns: 296 Decoded response 297 298 Raises: 299 LinoClientError: For any 4xx or 5xx response 300 """ 301 url, headers, content = self.prepare(path, **options) 302 response = self._client.request( 303 method, url, headers=headers, content=content 304 ) 305 return self.finish(response, method)
Perform a request and decode the response.
Args:
method: HTTP method
path: Path, appended to the base URL
**options: Request options, see _RequestBuilder.prepare()
Returns: Decoded response
Raises: LinoClientError: For any 4xx or 5xx response
307 def get(self, path: str, **options: Any) -> LinoResponse: 308 """ 309 ``GET`` a resource. 310 311 Args: 312 path: Path 313 **options: Request options 314 315 Returns: 316 Decoded response 317 """ 318 return self.request("GET", path, **options)
GET a resource.
Args: path: Path **options: Request options
Returns: Decoded response
320 def post(self, path: str, body: Any = None, **options: Any) -> LinoResponse: 321 """ 322 ``POST`` to a collection. 323 324 Args: 325 path: Path 326 body: Value to send 327 **options: Request options 328 329 Returns: 330 Decoded response 331 """ 332 return self.request("POST", path, body=body, send_body=True, **options)
POST to a collection.
Args: path: Path body: Value to send **options: Request options
Returns: Decoded response
334 def put(self, path: str, body: Any = None, **options: Any) -> LinoResponse: 335 """ 336 ``PUT`` a representation. 337 338 Args: 339 path: Path 340 body: Value to send 341 **options: Request options 342 343 Returns: 344 Decoded response 345 """ 346 return self.request("PUT", path, body=body, send_body=True, **options)
PUT a representation.
Args: path: Path body: Value to send **options: Request options
Returns: Decoded response
348 def patch(self, path: str, body: Any = None, **options: Any) -> LinoResponse: 349 """ 350 ``PATCH`` a representation. 351 352 Args: 353 path: Path 354 body: Value to send 355 **options: Request options 356 357 Returns: 358 Decoded response 359 """ 360 return self.request("PATCH", path, body=body, send_body=True, **options)
PATCH a representation.
Args: path: Path body: Value to send **options: Request options
Returns: Decoded response
362 def delete(self, path: str, **options: Any) -> LinoResponse: 363 """ 364 ``DELETE`` a resource. 365 366 Args: 367 path: Path 368 **options: Request options 369 370 Returns: 371 Decoded response 372 """ 373 return self.request("DELETE", path, **options)
DELETE a resource.
Args: path: Path **options: Request options
Returns: Decoded response
375 def head(self, path: str, **options: Any) -> LinoResponse: 376 """ 377 ``HEAD`` a resource. 378 379 Args: 380 path: Path 381 **options: Request options 382 383 Returns: 384 Decoded response 385 """ 386 return self.request("HEAD", path, **options)
HEAD a resource.
Args: path: Path **options: Request options
Returns: Decoded response
388 def options(self, path: str, **options: Any) -> list[str]: 389 """ 390 ``OPTIONS`` a path, returning the advertised methods. 391 392 Args: 393 path: Path 394 **options: Request options 395 396 Returns: 397 Methods listed in ``Allow`` 398 """ 399 return allowed_methods(self.request("OPTIONS", path, **options))
OPTIONS a path, returning the advertised methods.
Args: path: Path **options: Request options
Returns:
Methods listed in Allow
401 def list( 402 self, path: str, query: dict[str, Any] | None = None, **options: Any 403 ) -> Any: 404 """ 405 List a collection, returning the decoded envelope. 406 407 Args: 408 path: Collection path 409 query: Collection query parameters 410 **options: Request options 411 412 Returns: 413 Collection envelope 414 """ 415 return self.request("GET", path, query=query, **options).data
List a collection, returning the decoded envelope.
Args: path: Collection path query: Collection query parameters **options: Request options
Returns: Collection envelope
417 def describe(self) -> Any: 418 """ 419 Fetch the service description of specification section 9. 420 421 Returns: 422 Description document 423 """ 424 return self.get(DESCRIPTION_PATH).data
Fetch the service description of specification section 9.
Returns: Description document
31class LinoClientError(Exception): 32 """Error raised for any 4xx or 5xx, carrying the decoded problem details.""" 33 34 def __init__( 35 self, 36 status: int, 37 problem: Any = None, 38 response: httpx.Response | None = None, 39 ) -> None: 40 """ 41 Args: 42 status: HTTP status code 43 problem: Decoded problem details, when the body could be decoded 44 response: The response that produced this error 45 """ 46 detail = None 47 if isinstance(problem, dict): 48 detail = problem.get("detail") or problem.get("title") 49 super().__init__(detail or f"Request failed with status {status}") 50 self.status = status 51 self.problem = problem 52 self.response = response
Error raised for any 4xx or 5xx, carrying the decoded problem details.
34 def __init__( 35 self, 36 status: int, 37 problem: Any = None, 38 response: httpx.Response | None = None, 39 ) -> None: 40 """ 41 Args: 42 status: HTTP status code 43 problem: Decoded problem details, when the body could be decoded 44 response: The response that produced this error 45 """ 46 detail = None 47 if isinstance(problem, dict): 48 detail = problem.get("detail") or problem.get("title") 49 super().__init__(detail or f"Request failed with status {status}") 50 self.status = status 51 self.problem = problem 52 self.response = response
Args: status: HTTP status code problem: Decoded problem details, when the body could be decoded response: The response that produced this error
63class LinoHttpError(Exception): 64 """ 65 An HTTP error that carries problem details. 66 67 Raising one of these from a handler produces a conforming error response; the 68 client library raises the same shape when a server answers with 4xx or 5xx. 69 """ 70 71 def __init__( 72 self, 73 status: int, 74 detail: str | None = None, 75 *, 76 title: str | None = None, 77 type_uri: str | None = None, 78 instance: str | None = None, 79 headers: dict[str, str] | None = None, 80 extensions: dict[str, Any] | None = None, 81 ) -> None: 82 """ 83 Args: 84 status: HTTP status code 85 detail: Human readable explanation of this occurrence 86 title: Short, type-wide summary 87 type_uri: Problem type URI 88 instance: URI of this occurrence 89 headers: Response headers to send with the error 90 extensions: Extra problem members 91 """ 92 resolved_title = title if title is not None else reason_phrase(status) 93 super().__init__(detail if detail is not None else resolved_title) 94 95 self.status = status 96 self.title = resolved_title 97 self.detail = detail 98 self.type = ( 99 type_uri 100 if type_uri is not None 101 else f"{PROBLEM_TYPE_BASE}{problem_slug(resolved_title)}" 102 ) 103 self.instance = instance 104 self.headers = dict(headers or {}) 105 self.extensions = dict(extensions or {}) 106 107 @property 108 def message(self) -> str: 109 """Human readable message, the detail when present and the title otherwise.""" 110 return str(self) 111 112 def to_problem(self, instance: str | None = None) -> dict[str, Any]: 113 """ 114 Render the error as the problem details object of the specification. 115 116 Args: 117 instance: URI of this occurrence when not already set 118 119 Returns: 120 Problem details ready to be encoded 121 """ 122 problem: dict[str, Any] = { 123 "type": self.type, 124 "title": self.title, 125 "status": self.status, 126 } 127 if self.detail is not None: 128 problem["detail"] = self.detail 129 resolved_instance = self.instance if self.instance else instance 130 if resolved_instance: 131 problem["instance"] = resolved_instance 132 problem.update(self.extensions) 133 return problem
An HTTP error that carries problem details.
Raising one of these from a handler produces a conforming error response; the client library raises the same shape when a server answers with 4xx or 5xx.
71 def __init__( 72 self, 73 status: int, 74 detail: str | None = None, 75 *, 76 title: str | None = None, 77 type_uri: str | None = None, 78 instance: str | None = None, 79 headers: dict[str, str] | None = None, 80 extensions: dict[str, Any] | None = None, 81 ) -> None: 82 """ 83 Args: 84 status: HTTP status code 85 detail: Human readable explanation of this occurrence 86 title: Short, type-wide summary 87 type_uri: Problem type URI 88 instance: URI of this occurrence 89 headers: Response headers to send with the error 90 extensions: Extra problem members 91 """ 92 resolved_title = title if title is not None else reason_phrase(status) 93 super().__init__(detail if detail is not None else resolved_title) 94 95 self.status = status 96 self.title = resolved_title 97 self.detail = detail 98 self.type = ( 99 type_uri 100 if type_uri is not None 101 else f"{PROBLEM_TYPE_BASE}{problem_slug(resolved_title)}" 102 ) 103 self.instance = instance 104 self.headers = dict(headers or {}) 105 self.extensions = dict(extensions or {})
Args: status: HTTP status code detail: Human readable explanation of this occurrence title: Short, type-wide summary type_uri: Problem type URI instance: URI of this occurrence headers: Response headers to send with the error extensions: Extra problem members
107 @property 108 def message(self) -> str: 109 """Human readable message, the detail when present and the title otherwise.""" 110 return str(self)
Human readable message, the detail when present and the title otherwise.
112 def to_problem(self, instance: str | None = None) -> dict[str, Any]: 113 """ 114 Render the error as the problem details object of the specification. 115 116 Args: 117 instance: URI of this occurrence when not already set 118 119 Returns: 120 Problem details ready to be encoded 121 """ 122 problem: dict[str, Any] = { 123 "type": self.type, 124 "title": self.title, 125 "status": self.status, 126 } 127 if self.detail is not None: 128 problem["detail"] = self.detail 129 resolved_instance = self.instance if self.instance else instance 130 if resolved_instance: 131 problem["instance"] = resolved_instance 132 problem.update(self.extensions) 133 return problem
Render the error as the problem details object of the specification.
Args: instance: URI of this occurrence when not already set
Returns: Problem details ready to be encoded
17@dataclass 18class LinoHttpRequest: 19 """One HTTP request, decoded according to the specification.""" 20 21 method: str 22 path: str 23 headers: dict[str, str] = field(default_factory=dict) 24 params: dict[str, str] = field(default_factory=dict) 25 query_string: str = "" 26 body: Any = None 27 media_type: str = "" 28 request_media_type: str | None = None 29 scope: dict[str, Any] = field(default_factory=dict) 30 app: Any = None 31 32 @property 33 def query(self) -> dict[str, Any]: 34 """ 35 Query parameters, with repeated parameters collected into lists. 36 37 Returns: 38 Parsed query parameters 39 """ 40 parsed = parse_qs(self.query_string, keep_blank_values=True) 41 return { 42 name: values[0] if len(values) == 1 else values 43 for name, values in parsed.items() 44 } 45 46 def header(self, name: str) -> str | None: 47 """ 48 Read a request header. 49 50 Args: 51 name: Header name, case insensitive 52 53 Returns: 54 Header value, or None when absent 55 """ 56 return self.headers.get(name.lower()) 57 58 def param(self, name: str) -> str | None: 59 """ 60 Read a path parameter, percent-decoded. 61 62 Args: 63 name: Parameter name 64 65 Returns: 66 Parameter value, or None when the route does not declare it 67 """ 68 value = self.params.get(name) 69 return None if value is None else unquote(value) 70 71 def collection_query(self, **options: Any) -> CollectionQuery: 72 """ 73 Parse the query string as a collection query (specification section 6). 74 75 Args: 76 **options: Overrides for ``default_limit`` and ``max_limit`` 77 78 Returns: 79 Parsed collection query 80 """ 81 defaults = getattr(self.app, "query_defaults", {}) 82 return parse_collection_query(self.query, **{**defaults, **options})
One HTTP request, decoded according to the specification.
32 @property 33 def query(self) -> dict[str, Any]: 34 """ 35 Query parameters, with repeated parameters collected into lists. 36 37 Returns: 38 Parsed query parameters 39 """ 40 parsed = parse_qs(self.query_string, keep_blank_values=True) 41 return { 42 name: values[0] if len(values) == 1 else values 43 for name, values in parsed.items() 44 }
Query parameters, with repeated parameters collected into lists.
Returns: Parsed query parameters
46 def header(self, name: str) -> str | None: 47 """ 48 Read a request header. 49 50 Args: 51 name: Header name, case insensitive 52 53 Returns: 54 Header value, or None when absent 55 """ 56 return self.headers.get(name.lower())
Read a request header.
Args: name: Header name, case insensitive
Returns: Header value, or None when absent
58 def param(self, name: str) -> str | None: 59 """ 60 Read a path parameter, percent-decoded. 61 62 Args: 63 name: Parameter name 64 65 Returns: 66 Parameter value, or None when the route does not declare it 67 """ 68 value = self.params.get(name) 69 return None if value is None else unquote(value)
Read a path parameter, percent-decoded.
Args: name: Parameter name
Returns: Parameter value, or None when the route does not declare it
71 def collection_query(self, **options: Any) -> CollectionQuery: 72 """ 73 Parse the query string as a collection query (specification section 6). 74 75 Args: 76 **options: Overrides for ``default_limit`` and ``max_limit`` 77 78 Returns: 79 Parsed collection query 80 """ 81 defaults = getattr(self.app, "query_defaults", {}) 82 return parse_collection_query(self.query, **{**defaults, **options})
Parse the query string as a collection query (specification section 6).
Args:
**options: Overrides for default_limit and max_limit
Returns: Parsed collection query
23class LinoRequest: 24 """ 25 Wrapper for parsing LINO-formatted request bodies. 26 """ 27 28 def __init__(self, request: Request): 29 """ 30 Initialize LinoRequest with a FastAPI request. 31 32 Args: 33 request: The FastAPI request object 34 """ 35 self.request = request 36 self._body: Any | None = None 37 self._parsed = False 38 39 async def body(self) -> Any: 40 """ 41 Parse and return the request body as a Python object. 42 43 Returns: 44 Decoded Python object from LINO body 45 """ 46 if self._parsed: 47 return self._body 48 49 content_type = self.request.headers.get("content-type", "") 50 51 if LINO_CONTENT_TYPE in content_type: 52 raw_body = await self.request.body() 53 body_str = raw_body.decode("utf-8") 54 55 if body_str.strip(): 56 self._body = decode(body_str) 57 else: 58 self._body = None 59 else: 60 # Fall back to treating as plain text 61 raw_body = await self.request.body() 62 self._body = raw_body.decode("utf-8") if raw_body else None 63 64 self._parsed = True 65 return self._body
Wrapper for parsing LINO-formatted request bodies.
28 def __init__(self, request: Request): 29 """ 30 Initialize LinoRequest with a FastAPI request. 31 32 Args: 33 request: The FastAPI request object 34 """ 35 self.request = request 36 self._body: Any | None = None 37 self._parsed = False
Initialize LinoRequest with a FastAPI request.
Args: request: The FastAPI request object
39 async def body(self) -> Any: 40 """ 41 Parse and return the request body as a Python object. 42 43 Returns: 44 Decoded Python object from LINO body 45 """ 46 if self._parsed: 47 return self._body 48 49 content_type = self.request.headers.get("content-type", "") 50 51 if LINO_CONTENT_TYPE in content_type: 52 raw_body = await self.request.body() 53 body_str = raw_body.decode("utf-8") 54 55 if body_str.strip(): 56 self._body = decode(body_str) 57 else: 58 self._body = None 59 else: 60 # Fall back to treating as plain text 61 raw_body = await self.request.body() 62 self._body = raw_body.decode("utf-8") if raw_body else None 63 64 self._parsed = True 65 return self._body
Parse and return the request body as a Python object.
Returns: Decoded Python object from LINO body
68class LinoResponse(PlainTextResponse): 69 """ 70 Response class for LINO-formatted responses. 71 """ 72 73 media_type = LINO_CONTENT_TYPE 74 75 def __init__( 76 self, 77 content: Any = None, 78 status_code: int = 200, 79 headers: dict | None = None, 80 **kwargs, 81 ): 82 """ 83 Create a LINO-formatted response. 84 85 Args: 86 content: Python object to encode as LINO 87 status_code: HTTP status code 88 headers: Optional response headers 89 **kwargs: Additional arguments for PlainTextResponse 90 """ 91 # Encode the content as LINO 92 encoded_content = encode(content) if content is not None else encode(None) 93 94 super().__init__( 95 content=encoded_content, 96 status_code=status_code, 97 headers=headers, 98 **kwargs, 99 )
Response class for LINO-formatted responses.
75 def __init__( 76 self, 77 content: Any = None, 78 status_code: int = 200, 79 headers: dict | None = None, 80 **kwargs, 81 ): 82 """ 83 Create a LINO-formatted response. 84 85 Args: 86 content: Python object to encode as LINO 87 status_code: HTTP status code 88 headers: Optional response headers 89 **kwargs: Additional arguments for PlainTextResponse 90 """ 91 # Encode the content as LINO 92 encoded_content = encode(content) if content is not None else encode(None) 93 94 super().__init__( 95 content=encoded_content, 96 status_code=status_code, 97 headers=headers, 98 **kwargs, 99 )
Create a LINO-formatted response.
Args: content: Python object to encode as LINO status_code: HTTP status code headers: Optional response headers **kwargs: Additional arguments for PlainTextResponse
16@dataclass 17class LinoResult: 18 """A handler result carrying a status code and headers alongside the value.""" 19 20 value: Any = EMPTY 21 status: int = 200 22 headers: dict[str, str] = field(default_factory=dict) 23 etag: bool = True 24 preconditions: bool | None = None 25 require_precondition: bool = False 26 media_type: str | None = None 27 raw: bool = False
A handler result carrying a status code and headers alongside the value.
15class MemoryStore: 16 """A resource store backed by a dictionary.""" 17 18 def __init__( 19 self, 20 items: list[dict[str, Any]] | None = None, 21 *, 22 id_field: str = "id", 23 ) -> None: 24 """ 25 Args: 26 items: Items to seed the store with 27 id_field: Name of the identifier field 28 """ 29 self.id_field = id_field 30 self.items: dict[Any, dict[str, Any]] = {} 31 self.next_id = 1 32 for item in items or []: 33 self.create(item) 34 35 def normalize_id(self, identifier: Any) -> Any: 36 """ 37 Normalise an identifier so that ``"1"`` from a path matches the stored 1. 38 39 Args: 40 identifier: Raw identifier 41 42 Returns: 43 Normalised identifier 44 """ 45 if isinstance(identifier, str) and identifier: 46 try: 47 return int(identifier) 48 except ValueError: 49 return identifier 50 return identifier 51 52 def list(self, query: CollectionQuery) -> dict[str, Any]: 53 """ 54 Run a collection query against the store. 55 56 Args: 57 query: Query from :func:`lino_rest_api.query.parse_collection_query` 58 59 Returns: 60 Collection envelope 61 """ 62 return apply_collection_query(list(self.items.values()), query) 63 64 def get(self, identifier: Any) -> dict[str, Any] | None: 65 """ 66 Read one item. 67 68 Args: 69 identifier: Identifier 70 71 Returns: 72 Item, or None when absent 73 """ 74 return self.items.get(self.normalize_id(identifier)) 75 76 def create(self, body: dict[str, Any] | None) -> dict[str, Any]: 77 """ 78 Create an item, assigning an identifier when the body does not carry one. 79 80 Args: 81 body: Item body 82 83 Returns: 84 Created item 85 """ 86 body = body or {} 87 provided = body.get(self.id_field) 88 if provided is None: 89 identifier = self.next_id 90 self.next_id += 1 91 else: 92 identifier = self.normalize_id(provided) 93 numeric = isinstance(identifier, int) and not isinstance(identifier, bool) 94 if numeric and identifier >= self.next_id: 95 self.next_id = identifier + 1 96 item = {**body, self.id_field: identifier} 97 self.items[identifier] = item 98 return item 99 100 def update(self, identifier: Any, body: dict[str, Any] | None) -> dict[str, Any] | None: 101 """ 102 Replace an item. 103 104 Args: 105 identifier: Identifier 106 body: Replacement body 107 108 Returns: 109 Updated item, or None when absent 110 """ 111 key = self.normalize_id(identifier) 112 if key not in self.items: 113 return None 114 item = {**(body or {}), self.id_field: key} 115 self.items[key] = item 116 return item 117 118 def patch(self, identifier: Any, body: dict[str, Any] | None) -> dict[str, Any] | None: 119 """ 120 Merge changes into an item. 121 122 Args: 123 identifier: Identifier 124 body: Partial body 125 126 Returns: 127 Updated item, or None when absent 128 """ 129 key = self.normalize_id(identifier) 130 existing = self.items.get(key) 131 if existing is None: 132 return None 133 item = {**existing, **(body or {}), self.id_field: key} 134 self.items[key] = item 135 return item 136 137 def remove(self, identifier: Any) -> bool: 138 """ 139 Delete an item. 140 141 Args: 142 identifier: Identifier 143 144 Returns: 145 True when an item was deleted 146 """ 147 return self.items.pop(self.normalize_id(identifier), None) is not None 148 149 def clear(self) -> None: 150 """Remove every item.""" 151 self.items.clear() 152 self.next_id = 1
A resource store backed by a dictionary.
18 def __init__( 19 self, 20 items: list[dict[str, Any]] | None = None, 21 *, 22 id_field: str = "id", 23 ) -> None: 24 """ 25 Args: 26 items: Items to seed the store with 27 id_field: Name of the identifier field 28 """ 29 self.id_field = id_field 30 self.items: dict[Any, dict[str, Any]] = {} 31 self.next_id = 1 32 for item in items or []: 33 self.create(item)
Args: items: Items to seed the store with id_field: Name of the identifier field
35 def normalize_id(self, identifier: Any) -> Any: 36 """ 37 Normalise an identifier so that ``"1"`` from a path matches the stored 1. 38 39 Args: 40 identifier: Raw identifier 41 42 Returns: 43 Normalised identifier 44 """ 45 if isinstance(identifier, str) and identifier: 46 try: 47 return int(identifier) 48 except ValueError: 49 return identifier 50 return identifier
Normalise an identifier so that "1" from a path matches the stored 1.
Args: identifier: Raw identifier
Returns: Normalised identifier
52 def list(self, query: CollectionQuery) -> dict[str, Any]: 53 """ 54 Run a collection query against the store. 55 56 Args: 57 query: Query from :func:`lino_rest_api.query.parse_collection_query` 58 59 Returns: 60 Collection envelope 61 """ 62 return apply_collection_query(list(self.items.values()), query)
Run a collection query against the store.
Args:
query: Query from lino_rest_api.query.parse_collection_query()
Returns: Collection envelope
64 def get(self, identifier: Any) -> dict[str, Any] | None: 65 """ 66 Read one item. 67 68 Args: 69 identifier: Identifier 70 71 Returns: 72 Item, or None when absent 73 """ 74 return self.items.get(self.normalize_id(identifier))
Read one item.
Args: identifier: Identifier
Returns: Item, or None when absent
76 def create(self, body: dict[str, Any] | None) -> dict[str, Any]: 77 """ 78 Create an item, assigning an identifier when the body does not carry one. 79 80 Args: 81 body: Item body 82 83 Returns: 84 Created item 85 """ 86 body = body or {} 87 provided = body.get(self.id_field) 88 if provided is None: 89 identifier = self.next_id 90 self.next_id += 1 91 else: 92 identifier = self.normalize_id(provided) 93 numeric = isinstance(identifier, int) and not isinstance(identifier, bool) 94 if numeric and identifier >= self.next_id: 95 self.next_id = identifier + 1 96 item = {**body, self.id_field: identifier} 97 self.items[identifier] = item 98 return item
Create an item, assigning an identifier when the body does not carry one.
Args: body: Item body
Returns: Created item
100 def update(self, identifier: Any, body: dict[str, Any] | None) -> dict[str, Any] | None: 101 """ 102 Replace an item. 103 104 Args: 105 identifier: Identifier 106 body: Replacement body 107 108 Returns: 109 Updated item, or None when absent 110 """ 111 key = self.normalize_id(identifier) 112 if key not in self.items: 113 return None 114 item = {**(body or {}), self.id_field: key} 115 self.items[key] = item 116 return item
Replace an item.
Args: identifier: Identifier body: Replacement body
Returns: Updated item, or None when absent
118 def patch(self, identifier: Any, body: dict[str, Any] | None) -> dict[str, Any] | None: 119 """ 120 Merge changes into an item. 121 122 Args: 123 identifier: Identifier 124 body: Partial body 125 126 Returns: 127 Updated item, or None when absent 128 """ 129 key = self.normalize_id(identifier) 130 existing = self.items.get(key) 131 if existing is None: 132 return None 133 item = {**existing, **(body or {}), self.id_field: key} 134 self.items[key] = item 135 return item
Merge changes into an item.
Args: identifier: Identifier body: Partial body
Returns: Updated item, or None when absent
137 def remove(self, identifier: Any) -> bool: 138 """ 139 Delete an item. 140 141 Args: 142 identifier: Identifier 143 144 Returns: 145 True when an item was deleted 146 """ 147 return self.items.pop(self.normalize_id(identifier), None) is not None
Delete an item.
Args: identifier: Identifier
Returns: True when an item was deleted
51class RouteTable: 52 """The set of routes registered on an application.""" 53 54 def __init__(self) -> None: 55 self.routes: dict[str, RouteEntry] = {} 56 57 def register( 58 self, 59 method: str, 60 pattern: str, 61 meta: dict[str, Any] | None = None, 62 ) -> None: 63 """ 64 Register a method on a path. 65 66 Args: 67 method: HTTP method 68 pattern: Path pattern 69 meta: Description metadata for specification section 9 70 """ 71 entry = self.routes.get(pattern) 72 if entry is None: 73 entry = RouteEntry(pattern, compile_path_pattern(pattern)) 74 self.routes[pattern] = entry 75 entry.methods[method.upper()] = dict(meta or {}) 76 77 def find(self, pathname: str) -> RouteEntry | None: 78 """ 79 Find the route entry owning a concrete path. 80 81 Args: 82 pathname: Request path 83 84 Returns: 85 Route entry, or None when unowned 86 """ 87 match = self.match(pathname) 88 return match[0] if match else None 89 90 def match(self, pathname: str) -> tuple[RouteEntry, dict[str, str]] | None: 91 """ 92 Find the route entry owning a path together with its path parameters. 93 94 Args: 95 pathname: Request path 96 97 Returns: 98 Entry and captured parameters, or None when unowned 99 """ 100 for entry in self.routes.values(): 101 matched = entry.matcher.match(pathname) 102 if matched: 103 return entry, matched.groupdict() 104 return None 105 106 def allowed_methods(self, pathname: str) -> list[str] | None: 107 """ 108 List the methods allowed on a concrete path. 109 110 ``OPTIONS`` is always allowed, and ``HEAD`` is allowed wherever ``GET`` is. 111 112 Args: 113 pathname: Request path 114 115 Returns: 116 Allowed methods, or None when the path is unowned 117 """ 118 entry = self.find(pathname) 119 if entry is None: 120 return None 121 methods = set(entry.methods) 122 if "GET" in methods: 123 methods.add("HEAD") 124 methods.add("OPTIONS") 125 return sorted(methods) 126 127 def describe(self) -> list[dict[str, Any]]: 128 """ 129 Render the registry as the ``routes`` member of a service description. 130 131 Returns: 132 Route descriptions, ordered by path 133 """ 134 descriptions = [] 135 for entry in self.routes.values(): 136 summary = next( 137 ( 138 meta["summary"] 139 for meta in entry.methods.values() 140 if meta.get("summary") 141 ), 142 None, 143 ) 144 description: dict[str, Any] = { 145 "path": entry.pattern, 146 "methods": self.allowed_methods(entry.pattern) or [], 147 } 148 if summary: 149 description["summary"] = summary 150 descriptions.append(description) 151 return sorted(descriptions, key=lambda entry: entry["path"])
The set of routes registered on an application.
57 def register( 58 self, 59 method: str, 60 pattern: str, 61 meta: dict[str, Any] | None = None, 62 ) -> None: 63 """ 64 Register a method on a path. 65 66 Args: 67 method: HTTP method 68 pattern: Path pattern 69 meta: Description metadata for specification section 9 70 """ 71 entry = self.routes.get(pattern) 72 if entry is None: 73 entry = RouteEntry(pattern, compile_path_pattern(pattern)) 74 self.routes[pattern] = entry 75 entry.methods[method.upper()] = dict(meta or {})
Register a method on a path.
Args: method: HTTP method pattern: Path pattern meta: Description metadata for specification section 9
77 def find(self, pathname: str) -> RouteEntry | None: 78 """ 79 Find the route entry owning a concrete path. 80 81 Args: 82 pathname: Request path 83 84 Returns: 85 Route entry, or None when unowned 86 """ 87 match = self.match(pathname) 88 return match[0] if match else None
Find the route entry owning a concrete path.
Args: pathname: Request path
Returns: Route entry, or None when unowned
90 def match(self, pathname: str) -> tuple[RouteEntry, dict[str, str]] | None: 91 """ 92 Find the route entry owning a path together with its path parameters. 93 94 Args: 95 pathname: Request path 96 97 Returns: 98 Entry and captured parameters, or None when unowned 99 """ 100 for entry in self.routes.values(): 101 matched = entry.matcher.match(pathname) 102 if matched: 103 return entry, matched.groupdict() 104 return None
Find the route entry owning a path together with its path parameters.
Args: pathname: Request path
Returns: Entry and captured parameters, or None when unowned
106 def allowed_methods(self, pathname: str) -> list[str] | None: 107 """ 108 List the methods allowed on a concrete path. 109 110 ``OPTIONS`` is always allowed, and ``HEAD`` is allowed wherever ``GET`` is. 111 112 Args: 113 pathname: Request path 114 115 Returns: 116 Allowed methods, or None when the path is unowned 117 """ 118 entry = self.find(pathname) 119 if entry is None: 120 return None 121 methods = set(entry.methods) 122 if "GET" in methods: 123 methods.add("HEAD") 124 methods.add("OPTIONS") 125 return sorted(methods)
List the methods allowed on a concrete path.
OPTIONS is always allowed, and HEAD is allowed wherever GET is.
Args: pathname: Request path
Returns: Allowed methods, or None when the path is unowned
127 def describe(self) -> list[dict[str, Any]]: 128 """ 129 Render the registry as the ``routes`` member of a service description. 130 131 Returns: 132 Route descriptions, ordered by path 133 """ 134 descriptions = [] 135 for entry in self.routes.values(): 136 summary = next( 137 ( 138 meta["summary"] 139 for meta in entry.methods.values() 140 if meta.get("summary") 141 ), 142 None, 143 ) 144 description: dict[str, Any] = { 145 "path": entry.pattern, 146 "methods": self.allowed_methods(entry.pattern) or [], 147 } 148 if summary: 149 description["summary"] = summary 150 descriptions.append(description) 151 return sorted(descriptions, key=lambda entry: entry["path"])
Render the registry as the routes member of a service description.
Returns: Route descriptions, ordered by path
63def accepted(value: Any, headers: dict[str, str] | None = None) -> LinoResult: 64 """ 65 A 202 Accepted carrying a value. 66 67 Args: 68 value: Value to encode 69 headers: Response headers 70 71 Returns: 72 Handler result 73 """ 74 return LinoResult(value, 202, dict(headers or {}))
A 202 Accepted carrying a value.
Args: value: Value to encode headers: Response headers
Returns: Handler result
107def append_vary(headers: dict[str, str], field_name: str) -> None: 108 """ 109 Add a field name to ``Vary`` without repeating it. 110 111 Both content negotiation and CORS extend ``Vary``; a plain assignment from 112 either of them would silently drop the other one's contribution. 113 114 Args: 115 headers: Response headers, modified in place 116 field_name: Header field name to add 117 """ 118 current = headers.get("Vary", "") 119 existing = [entry.strip() for entry in current.split(",") if entry.strip()] 120 if any(entry.lower() == field_name.lower() for entry in existing): 121 return 122 headers["Vary"] = ", ".join([*existing, field_name])
Add a field name to Vary without repeating it.
Both content negotiation and CORS extend Vary; a plain assignment from
either of them would silently drop the other one's contribution.
Args: headers: Response headers, modified in place field_name: Header field name to add
133def apply_collection_query( 134 items: list[Any], 135 query: CollectionQuery, 136) -> dict[str, Any]: 137 """ 138 Run a parsed collection query against an in-memory list. 139 140 Args: 141 items: Every item of the collection 142 query: Query from :func:`lino_rest_api.query.parse_collection_query` 143 144 Returns: 145 Collection envelope 146 """ 147 filtered = [item for item in items if matches_filters(item, query.filters)] 148 ordered = sort_items(filtered, query.sort) 149 page = ordered[query.offset : query.offset + query.limit] 150 projected = [project_fields(item, query.fields) for item in page] 151 152 return collection_envelope( 153 projected, 154 limit=query.limit, 155 offset=query.offset, 156 total=len(filtered), 157 )
Run a parsed collection query against an in-memory list.
Args:
items: Every item of the collection
query: Query from lino_rest_api.query.parse_collection_query()
Returns: Collection envelope
271def build_problem_response( 272 error: BaseException, 273 *, 274 media_type: str = LINO_CONTENT_TYPE, 275 instance: str | None = None, 276 expose_traceback: bool = False, 277) -> ResponseParts: 278 """ 279 Render an exception as problem details (specification section 5). 280 281 Args: 282 error: Raised exception 283 media_type: Negotiated representation 284 instance: URI of this occurrence 285 expose_traceback: Attach the traceback to 5xx problems 286 287 Returns: 288 Response ready to be written 289 """ 290 http_error = to_http_error(error) 291 if expose_traceback and http_error.status >= 500: 292 import traceback 293 294 http_error.extensions = { 295 **http_error.extensions, 296 "traceback": "".join( 297 traceback.format_exception(type(error), error, error.__traceback__) 298 ), 299 } 300 301 problem = http_error.to_problem(instance) 302 headers = dict(http_error.headers) 303 append_vary(headers, "Accept") 304 headers["Content-Type"] = with_charset(problem_media_type(media_type)) 305 306 return ResponseParts(http_error.status, headers, encode_for(problem, media_type))
Render an exception as problem details (specification section 5).
Args: error: Raised exception media_type: Negotiated representation instance: URI of this occurrence expose_traceback: Attach the traceback to 5xx problems
Returns: Response ready to be written
85def build_query_string(query: dict[str, Any] | None) -> str: 86 """ 87 Build a query string from a mapping, expanding list values. 88 89 Args: 90 query: Query parameters 91 92 Returns: 93 Query string including ``?``, or an empty string 94 """ 95 if not query: 96 return "" 97 parameters: list[tuple[str, str]] = [] 98 for name, value in query.items(): 99 if value is None: 100 continue 101 values = value if isinstance(value, list | tuple) else [value] 102 parameters.extend((name, query_value(entry)) for entry in values) 103 encoded = urlencode(parameters) 104 return f"?{encoded}" if encoded else ""
Build a query string from a mapping, expanding list values.
Args: query: Query parameters
Returns:
Query string including ?, or an empty string
67def query_value(value: Any) -> str: 68 """ 69 Spell one query parameter value the way section 6.1 filters read it. 70 71 ``str(False)`` is ``"False"``, which no filter matches; booleans travel as 72 ``true`` and ``false``, exactly as they do from the JavaScript client. 73 74 Args: 75 value: Value of a query parameter 76 77 Returns: 78 Wire spelling of the value 79 """ 80 if isinstance(value, bool): 81 return "true" if value else "false" 82 return str(value)
Spell one query parameter value the way section 6.1 filters read it.
str(False) is "False", which no filter matches; booleans travel as
true and false, exactly as they do from the JavaScript client.
Args: value: Value of a query parameter
Returns: Wire spelling of the value
207def build_response( 208 value: Any, 209 *, 210 status: int = 200, 211 media_type: str = LINO_CONTENT_TYPE, 212 headers: dict[str, str] | None = None, 213 request_headers: Mapping[str, str] | None = None, 214 method: str = "GET", 215 etag: bool = True, 216 preconditions: bool | None = None, 217 require_precondition: bool = False, 218 raw: bool = False, 219) -> ResponseParts: 220 """ 221 Encode a value as the negotiated representation. 222 223 Args: 224 value: Value to encode, or :data:`lino_rest_api.response.EMPTY` for an 225 empty body 226 status: HTTP status code 227 media_type: Negotiated representation 228 headers: Extra response headers 229 request_headers: Request headers, lower-cased names 230 method: HTTP method of the request 231 etag: Emit an ``ETag`` 232 preconditions: Evaluate conditional headers (default: safe methods only) 233 require_precondition: Demand ``If-Match`` on unsafe methods 234 raw: Send the value as the body verbatim instead of encoding it 235 236 Returns: 237 Response ready to be written 238 239 Raises: 240 LinoHttpError: 412 or 428 when a precondition fails 241 """ 242 response_headers = dict(headers or {}) 243 append_vary(response_headers, "Accept") 244 245 if status == 204 or value is EMPTY: 246 return ResponseParts(204 if status == 200 else status, response_headers, "") 247 248 body = str(value) if raw else encode_for(value, media_type) 249 250 if etag: 251 tag = compute_etag(body) 252 response_headers["ETag"] = tag 253 254 # Preconditions on unsafe methods have to be evaluated against the 255 # *current* representation before the change is applied, which only the 256 # route handler can do; here the body is already the new representation. 257 safe = method in ("GET", "HEAD") 258 evaluate = preconditions if preconditions is not None else safe 259 if evaluate and evaluate_preconditions( 260 request_headers or {}, 261 method, 262 tag, 263 require_precondition=require_precondition, 264 ).not_modified: 265 return ResponseParts(304, response_headers, "") 266 267 response_headers["Content-Type"] = with_charset(media_type) 268 return ResponseParts(status, response_headers, body)
Encode a value as the negotiated representation.
Args:
value: Value to encode, or lino_rest_api.response.EMPTY for an
empty body
status: HTTP status code
media_type: Negotiated representation
headers: Extra response headers
request_headers: Request headers, lower-cased names
method: HTTP method of the request
etag: Emit an ETag
preconditions: Evaluate conditional headers (default: safe methods only)
require_precondition: Demand If-Match on unsafe methods
raw: Send the value as the body verbatim instead of encoding it
Returns: Response ready to be written
Raises: LinoHttpError: 412 or 428 when a precondition fails
103def collection_envelope( 104 items: list[Any], 105 *, 106 limit: int, 107 offset: int, 108 total: int, 109) -> dict[str, Any]: 110 """ 111 Wrap items in the collection envelope of the specification. 112 113 Args: 114 items: Items of this page 115 limit: Page size 116 offset: Index of the first item of this page 117 total: Number of items matching the query 118 119 Returns: 120 Collection envelope 121 """ 122 return { 123 "items": items, 124 "page": { 125 "limit": limit, 126 "offset": offset, 127 "total": total, 128 "count": len(items), 129 }, 130 }
Wrap items in the collection envelope of the specification.
Args: items: Items of this page limit: Page size offset: Index of the first item of this page total: Number of items matching the query
Returns: Collection envelope
19def compile_path_pattern(pattern: str) -> Pattern[str]: 20 """ 21 Compile a path pattern into a matcher that also captures path parameters. 22 23 Supports ``:parameter`` segments and a trailing ``*`` wildcard. 24 25 Args: 26 pattern: Path pattern 27 28 Returns: 29 Matcher anchored to the whole path 30 """ 31 segments = [] 32 for segment in pattern.split("/"): 33 if segment.startswith(":"): 34 segments.append(f"(?P<{segment[1:]}>[^/]+)") 35 elif segment.startswith("*"): 36 segments.append(".*") 37 else: 38 segments.append(re.escape(segment)) 39 return re.compile(f"^{'/'.join(segments)}/?$")
Compile a path pattern into a matcher that also captures path parameters.
Supports :parameter segments and a trailing * wildcard.
Args: pattern: Path pattern
Returns: Matcher anchored to the whole path
21def compute_etag(body: str) -> str: 22 """ 23 Compute the strong entity tag of an encoded representation. 24 25 Args: 26 body: Encoded representation 27 28 Returns: 29 Quoted hexadecimal SHA-256 30 """ 31 digest = sha256(body.encode("utf-8")).hexdigest() 32 return f'"{digest}"'
Compute the strong entity tag of an encoded representation.
Args: body: Encoded representation
Returns: Quoted hexadecimal SHA-256
23def cors_headers( 24 options: dict[str, Any] | bool | None = None, 25 request_origin: str | None = None, 26) -> dict[str, str]: 27 """ 28 Build the CORS response headers for a request. 29 30 Args: 31 options: Policy with ``origin``, ``methods``, ``allowed_headers``, 32 ``exposed_headers``, ``credentials`` and ``max_age`` members; True 33 selects the permissive default policy 34 request_origin: ``Origin`` header of the request 35 36 Returns: 37 Response headers ({} when the origin is not allowed) 38 """ 39 policy: dict[str, Any] = {} if options in (None, True, False) else dict(options) 40 41 origin = policy.get("origin", "*") 42 methods = policy.get("methods", DEFAULT_METHODS) 43 allowed_headers = policy.get("allowed_headers", DEFAULT_ALLOWED_HEADERS) 44 exposed_headers = policy.get("exposed_headers", DEFAULT_EXPOSED_HEADERS) 45 credentials = policy.get("credentials", False) 46 max_age = policy.get("max_age", 600) 47 48 allowed_origins = origin if isinstance(origin, list | tuple) else [origin] 49 allow_origin = None 50 if "*" in allowed_origins: 51 allow_origin = request_origin if credentials and request_origin else "*" 52 elif request_origin and request_origin in allowed_origins: 53 allow_origin = request_origin 54 55 if not allow_origin: 56 return {} 57 58 headers = { 59 "Access-Control-Allow-Origin": allow_origin, 60 "Access-Control-Allow-Methods": ", ".join(methods), 61 "Access-Control-Allow-Headers": ", ".join(allowed_headers), 62 "Access-Control-Expose-Headers": ", ".join(exposed_headers), 63 "Access-Control-Max-Age": str(max_age), 64 } 65 if credentials: 66 headers["Access-Control-Allow-Credentials"] = "true" 67 return headers
Build the CORS response headers for a request.
Args:
options: Policy with origin, methods, allowed_headers,
exposed_headers, credentials and max_age members; True
selects the permissive default policy
request_origin: Origin header of the request
Returns: Response headers ({} when the origin is not allowed)
627def create_async_lino_client(base_url: str, **options: Any) -> AsyncLinoClient: 628 """ 629 Create an asynchronous client. 630 631 Args: 632 base_url: Base URL of the service 633 **options: Client options, see :class:`AsyncLinoClient` 634 635 Returns: 636 New client 637 """ 638 return AsyncLinoClient(base_url, **options)
Create an asynchronous client.
Args:
base_url: Base URL of the service
**options: Client options, see AsyncLinoClient
Returns: New client
577def create_lino_app(**options: Any) -> LinoApp: 578 """ 579 Create a new application. 580 581 Args: 582 **options: Application options, see :class:`LinoApp` 583 584 Returns: 585 New application 586 """ 587 return LinoApp(**options)
Create a new application.
Args:
**options: Application options, see LinoApp
Returns: New application
613def create_lino_client(base_url: str, **options: Any) -> LinoClient: 614 """ 615 Create a synchronous client. 616 617 Args: 618 base_url: Base URL of the service 619 **options: Client options, see :class:`LinoClient` 620 621 Returns: 622 New client 623 """ 624 return LinoClient(base_url, **options)
Create a synchronous client.
Args:
base_url: Base URL of the service
**options: Client options, see LinoClient
Returns: New client
44def created( 45 value: Any, 46 location: str, 47 headers: dict[str, str] | None = None, 48) -> LinoResult: 49 """ 50 A 201 Created carrying a value and a ``Location``. 51 52 Args: 53 value: Value to encode 54 location: URI of the created resource 55 headers: Additional response headers 56 57 Returns: 58 Handler result 59 """ 60 return LinoResult(value, 201, {"Location": location, **(headers or {})})
A 201 Created carrying a value and a Location.
Args: value: Value to encode location: URI of the created resource headers: Additional response headers
Returns: Handler result
42def decode(notation: str) -> Any: 43 """ 44 Decode readable or compact Links Notation into a Python value. 45 46 Args: 47 notation: Links Notation document 48 49 Returns: 50 Decoded value 51 """ 52 return _decode_any(notation)
Decode readable or compact Links Notation into a Python value.
Args: notation: Links Notation document
Returns: Decoded value
126def decode_from(body: str, media_type: str) -> Any: 127 """ 128 Decode a representation of a concrete media type. 129 130 Args: 131 body: Raw request or response body 132 media_type: Media type the body was sent with 133 134 Returns: 135 Decoded value 136 137 Raises: 138 TypeError: When the media type is not a representation of this API 139 """ 140 resolved = normalize_media_type(media_type) 141 if resolved in (LINO_CONTENT_TYPE, LINO_COMPACT_CONTENT_TYPE): 142 return decode(body) 143 if resolved == LINO_LINE_CONTENT_TYPE: 144 return decode_single_line(body) 145 if resolved == JSON_CONTENT_TYPE: 146 return json.loads(body) 147 raise TypeError(f"Cannot decode media type: {media_type}")
Decode a representation of a concrete media type.
Args: body: Raw request or response body media_type: Media type the body was sent with
Returns: Decoded value
Raises: TypeError: When the media type is not a representation of this API
154def decode_request_body( 155 raw: bytes, 156 content_type: str | None, 157 *, 158 max_bytes: int = DEFAULT_MAX_BODY_BYTES, 159) -> tuple[Any, str | None]: 160 """ 161 Decode a request body according to its ``Content-Type``. 162 163 Args: 164 raw: Raw request body 165 content_type: Raw ``Content-Type`` header value 166 max_bytes: Largest accepted body 167 168 Returns: 169 Decoded body (None when empty) and the media type it arrived in 170 171 Raises: 172 LinoHttpError: 413 when too large, 415 when unsupported, 400 when malformed 173 """ 174 if len(raw) > max_bytes: 175 raise LinoHttpError( 176 413, 177 f"Request body exceeds {max_bytes} bytes", 178 headers={"Connection": "close"}, 179 ) 180 181 media_type = parse_content_type(content_type) 182 if not media_type: 183 return None, None 184 185 if not is_decodable_media_type(media_type): 186 raise LinoHttpError( 187 415, 188 f"Unsupported request media type: {media_type}", 189 extensions={"supported": list(SUPPORTED_MEDIA_TYPES)}, 190 ) 191 192 text = raw.decode("utf-8", errors="replace") 193 if not text.strip(): 194 return None, media_type 195 196 try: 197 return decode_from(text, media_type), media_type 198 except LinoHttpError: 199 raise 200 except Exception as error: 201 raise LinoHttpError( 202 400, 203 f"Malformed {media_type} request body: {error}", 204 ) from error
Decode a request body according to its Content-Type.
Args:
raw: Raw request body
content_type: Raw Content-Type header value
max_bytes: Largest accepted body
Returns: Decoded body (None when empty) and the media type it arrived in
Raises: LinoHttpError: 413 when too large, 415 when unsupported, 400 when malformed
68def decode_single_line(notation: str) -> Any: 69 """ 70 Decode one line of single-line readable Links Notation. 71 72 Args: 73 notation: One line of readable Links Notation 74 75 Returns: 76 Decoded value 77 """ 78 return _decode_line(notation)
Decode one line of single-line readable Links Notation.
Args: notation: One line of readable Links Notation
Returns: Decoded value
29def encode(value: Any) -> str: 30 """ 31 Encode a value as readable, indented Links Notation. 32 33 Args: 34 value: Value to encode 35 36 Returns: 37 Readable Links Notation 38 """ 39 return _encode_readable(value)
Encode a value as readable, indented Links Notation.
Args: value: Value to encode
Returns: Readable Links Notation
81def encode_compact_notation(value: Any) -> str: 82 """ 83 Encode a value as compact, type-tagged Links Notation. 84 85 This is the only representation that preserves shared object identity and 86 circular references. 87 88 Args: 89 value: Value to encode 90 91 Returns: 92 Compact Links Notation 93 """ 94 return _encode_compact(value)
Encode a value as compact, type-tagged Links Notation.
This is the only representation that preserves shared object identity and circular references.
Args: value: Value to encode
Returns: Compact Links Notation
97def encode_for(value: Any, media_type: str) -> str: 98 """ 99 Encode a value for a concrete media type. 100 101 Args: 102 value: Value to encode 103 media_type: One of the media types of the specification 104 105 Returns: 106 Encoded representation 107 108 Raises: 109 TypeError: When the media type is not a representation of this API 110 """ 111 resolved = normalize_media_type(media_type) 112 if resolved == LINO_CONTENT_TYPE: 113 return encode(value) 114 if resolved == LINO_LINE_CONTENT_TYPE: 115 return encode_single_line(value) 116 if resolved == LINO_COMPACT_CONTENT_TYPE: 117 return encode_compact_notation(value) 118 if resolved == JSON_CONTENT_TYPE: 119 # The separators and the raw Unicode match ``JSON.stringify`` byte for 120 # byte, so that the entity tag of a representation (section 7) is the 121 # same whichever implementation of this specification serves it. 122 return json.dumps(value, separators=(",", ":"), ensure_ascii=False) 123 raise TypeError(f"Cannot encode to media type: {media_type}")
Encode a value for a concrete media type.
Args: value: Value to encode media_type: One of the media types of the specification
Returns: Encoded representation
Raises: TypeError: When the media type is not a representation of this API
55def encode_single_line(value: Any) -> str: 56 """ 57 Encode a value as single-line readable Links Notation. 58 59 Args: 60 value: Value to encode 61 62 Returns: 63 One line of readable Links Notation 64 """ 65 return _encode_line(value)
Encode a value as single-line readable Links Notation.
Args: value: Value to encode
Returns: One line of readable Links Notation
55def etag_matches(header_value: str | None, current_etag: str) -> bool: 56 """ 57 Test whether an entity tag list matches the current tag. 58 59 Args: 60 header_value: Raw ``If-Match`` or ``If-None-Match`` value 61 current_etag: Entity tag of the current representation 62 63 Returns: 64 True when the list matches 65 """ 66 tags = parse_etag_list(header_value) 67 return "*" in tags or current_etag in tags
Test whether an entity tag list matches the current tag.
Args:
header_value: Raw If-Match or If-None-Match value
current_etag: Entity tag of the current representation
Returns: True when the list matches
70def evaluate_preconditions( 71 headers: Mapping[str, str], 72 method: str, 73 current_etag: str, 74 *, 75 require_precondition: bool = False, 76) -> PreconditionResult: 77 """ 78 Evaluate the conditional request headers of a request. 79 80 Args: 81 headers: Request headers, lower-cased names 82 method: HTTP method 83 current_etag: Entity tag of the current representation 84 require_precondition: Demand ``If-Match`` on unsafe methods 85 86 Returns: 87 Whether the response should be 304 88 89 Raises: 90 LinoHttpError: 412 on a failed ``If-Match``, 428 when one is required 91 """ 92 safe = method in ("GET", "HEAD") 93 if_match = headers.get("if-match") 94 if_none_match = headers.get("if-none-match") 95 96 if not safe: 97 if if_match is not None: 98 if not etag_matches(if_match, current_etag): 99 raise LinoHttpError( 100 412, 101 "The entity tag in If-Match does not match the current representation", 102 ) 103 elif require_precondition: 104 raise LinoHttpError( 105 428, 106 "This request requires an If-Match precondition", 107 ) 108 109 if if_none_match is not None and etag_matches(if_none_match, current_etag): 110 if safe: 111 return PreconditionResult(True) 112 raise LinoHttpError( 113 412, 114 "The entity tag in If-None-Match matches the current representation", 115 ) 116 117 return PreconditionResult(False)
Evaluate the conditional request headers of a request.
Args:
headers: Request headers, lower-cased names
method: HTTP method
current_etag: Entity tag of the current representation
require_precondition: Demand If-Match on unsafe methods
Returns: Whether the response should be 304
Raises:
LinoHttpError: 412 on a failed If-Match, 428 when one is required
189def is_decodable_media_type(media_type: str) -> bool: 190 """ 191 Test whether a request body media type can be decoded. 192 193 Args: 194 media_type: Bare media type of the request body 195 196 Returns: 197 True when the body can be decoded 198 """ 199 return normalize_media_type(media_type) in SUPPORTED_MEDIA_TYPES
Test whether a request body media type can be decoded.
Args: media_type: Bare media type of the request body
Returns: True when the body can be decoded
102async def lino_request_handler(request: Request) -> Any: 103 """ 104 Parse a LINO-formatted request body. 105 106 This is a dependency function for FastAPI endpoints. 107 108 Args: 109 request: The FastAPI request 110 111 Returns: 112 Decoded Python object from the request body 113 """ 114 lino_request = LinoRequest(request) 115 return await lino_request.body()
Parse a LINO-formatted request body.
This is a dependency function for FastAPI endpoints.
Args: request: The FastAPI request
Returns: Decoded Python object from the request body
13def matches_filters(item: Any, filters: dict[str, Any]) -> bool: 14 """ 15 Test whether an item satisfies every filter. 16 17 A filter whose value is a list matches when the item field equals any member, 18 which is what repeated query parameters (``?tag=a&tag=b``) mean. 19 20 Args: 21 item: Candidate item 22 filters: Field filters 23 24 Returns: 25 True when the item matches 26 """ 27 for field_name, expected in filters.items(): 28 actual = item.get(field_name) if isinstance(item, dict) else None 29 if isinstance(expected, list | tuple): 30 if not any(candidate == actual for candidate in expected): 31 return False 32 elif actual != expected: 33 return False 34 return True
Test whether an item satisfies every filter.
A filter whose value is a list matches when the item field equals any member,
which is what repeated query parameters (?tag=a&tag=b) mean.
Args: item: Candidate item filters: Field filters
Returns: True when the item matches
149def negotiate_media_type( 150 accept_header: str | None, 151 supported: list[str] | None = None, 152) -> str | None: 153 """ 154 Select the representation to produce for a request. 155 156 Args: 157 accept_header: Raw ``Accept`` header value 158 supported: Representations the server can produce 159 160 Returns: 161 Selected media type, or None when none is acceptable 162 """ 163 candidates = SUPPORTED_MEDIA_TYPES if supported is None else supported 164 for accepted in parse_accept(accept_header): 165 if accepted.quality <= 0: 166 continue 167 for media_type in candidates: 168 if _range_matches(accepted.type, media_type): 169 return media_type 170 return None
Select the representation to produce for a request.
Args:
accept_header: Raw Accept header value
supported: Representations the server can produce
Returns: Selected media type, or None when none is acceptable
125def negotiate_request( 126 headers: Mapping[str, str], 127 supported: list[str] | None = None, 128) -> str: 129 """ 130 Select the representation to produce for a request. 131 132 Args: 133 headers: Request headers, lower-cased names 134 supported: Representations the server can produce 135 136 Returns: 137 Selected media type 138 139 Raises: 140 LinoHttpError: 406 when no supported representation is acceptable 141 """ 142 candidates = SUPPORTED_MEDIA_TYPES if supported is None else supported 143 accept = headers.get("accept") 144 selected = negotiate_media_type(accept, candidates) 145 if selected is None: 146 raise LinoHttpError( 147 406, 148 f"No acceptable representation for Accept: {accept}", 149 extensions={"supported": list(candidates)}, 150 ) 151 return selected
Select the representation to produce for a request.
Args: headers: Request headers, lower-cased names supported: Representations the server can produce
Returns: Selected media type
Raises: LinoHttpError: 406 when no supported representation is acceptable
77def no_content(headers: dict[str, str] | None = None) -> LinoResult: 78 """ 79 A 204 No Content. 80 81 Args: 82 headers: Response headers 83 84 Returns: 85 Handler result 86 """ 87 return LinoResult(EMPTY, 204, dict(headers or {}))
A 204 No Content.
Args: headers: Response headers
Returns: Handler result
30def ok(value: Any, headers: dict[str, str] | None = None) -> LinoResult: 31 """ 32 A 200 OK carrying a value. 33 34 Args: 35 value: Value to encode 36 headers: Response headers 37 38 Returns: 39 Handler result 40 """ 41 return LinoResult(value, 200, dict(headers or {}))
A 200 OK carrying a value.
Args: value: Value to encode headers: Response headers
Returns: Handler result
28def info_object(info: dict[str, str]) -> dict[str, str]: 29 """ 30 Render the ``info`` member shared by both description documents. 31 32 Args: 33 info: ``title``, ``version`` and optional ``description`` of the service 34 35 Returns: 36 Info object, carrying ``description`` only when one was given 37 """ 38 document = {"title": info["title"], "version": info["version"]} 39 if info.get("description"): 40 document["description"] = info["description"] 41 return document
Render the info member shared by both description documents.
Args:
info: title, version and optional description of the service
Returns:
Info object, carrying description only when one was given
96def openapi_document( 97 info: dict[str, str], 98 routes: list[dict[str, Any]], 99 media_types: list[str] | None = None, 100) -> dict[str, Any]: 101 """ 102 Build an OpenAPI 3.1 document describing the service. 103 104 Every request and response body is declared for all negotiable media types, so 105 that a generated client knows it may ask for ``text/lino``. 106 107 Args: 108 info: ``title``, ``version`` and optional ``description`` of the service 109 routes: Route descriptions 110 media_types: Representations the service can produce 111 112 Returns: 113 OpenAPI 3.1 document 114 """ 115 types = SUPPORTED_MEDIA_TYPES if media_types is None else media_types 116 content = {media_type: {"schema": LINO_SCHEMA} for media_type in types} 117 118 paths: dict[str, Any] = {} 119 for route in routes: 120 template = to_openapi_path(route["path"]) 121 parameters = [ 122 { 123 "name": name, 124 "in": "path", 125 "required": True, 126 "schema": {"type": "string"}, 127 } 128 for name in path_parameters(route["path"]) 129 ] 130 131 paths[template] = {} 132 for method in route["methods"]: 133 operation: dict[str, Any] = { 134 "summary": route.get("summary") or f"{method} {route['path']}", 135 "responses": { 136 "200": {"description": "Success", "content": content}, 137 "default": { 138 "description": "RFC 9457 problem details in Links Notation", 139 "content": content, 140 }, 141 }, 142 } 143 if parameters: 144 operation["parameters"] = parameters 145 if method in ("POST", "PUT", "PATCH"): 146 operation["requestBody"] = {"required": True, "content": content} 147 paths[template][method.lower()] = operation 148 149 return { 150 "openapi": "3.1.0", 151 "info": info_object(info), 152 "paths": paths, 153 }
Build an OpenAPI 3.1 document describing the service.
Every request and response body is declared for all negotiable media types, so
that a generated client knows it may ask for text/lino.
Args:
info: title, version and optional description of the service
routes: Route descriptions
media_types: Representations the service can produce
Returns: OpenAPI 3.1 document
160def pagination_link_header( 161 path: str, 162 query: dict[str, Any] | None, 163 *, 164 limit: int, 165 offset: int, 166 total: int, 167) -> str: 168 """ 169 Build the RFC 8288 ``Link`` header value for a paginated collection. 170 171 Args: 172 path: Request path without a query string 173 query: Original query parameters 174 limit: Page size 175 offset: Index of the first item of this page 176 total: Number of items matching the query 177 178 Returns: 179 ``Link`` header value ("" when there is nothing to link to) 180 """ 181 if not limit: 182 return "" 183 184 def build(target_offset: int) -> str: 185 parameters: list[tuple[str, str]] = [] 186 for name, value in (query or {}).items(): 187 if name in ("limit", "offset"): 188 continue 189 values = value if isinstance(value, list | tuple) else [value] 190 parameters.extend((name, str(entry)) for entry in values) 191 parameters.append(("limit", str(limit))) 192 parameters.append(("offset", str(target_offset))) 193 return f"{path}?{urlencode(parameters)}" 194 195 last_offset = 0 if total == 0 else ((total - 1) // limit) * limit 196 links = [f'<{build(0)}>; rel="first"'] 197 if offset > 0: 198 links.append(f'<{build(max(0, offset - limit))}>; rel="prev"') 199 if offset + limit < total: 200 links.append(f'<{build(offset + limit)}>; rel="next"') 201 links.append(f'<{build(last_offset)}>; rel="last"') 202 203 return ", ".join(links)
Build the RFC 8288 Link header value for a paginated collection.
Args: path: Request path without a query string query: Original query parameters limit: Page size offset: Index of the first item of this page total: Number of items matching the query
Returns:
Link header value ("" when there is nothing to link to)
89def parse_accept(header_value: str | None) -> list[AcceptRange]: 90 """ 91 Parse an ``Accept`` header into ranges ordered by preference. 92 93 Args: 94 header_value: Raw ``Accept`` header value 95 96 Returns: 97 Ranges, most preferred first 98 """ 99 if not header_value or not header_value.strip(): 100 return [AcceptRange("*/*", 1.0, 0)] 101 102 ranges: list[tuple[int, AcceptRange]] = [] 103 for index, part in enumerate(header_value.split(",")): 104 raw_type, *parameters = part.split(";") 105 media_type = raw_type.strip().lower() 106 if not media_type: 107 continue 108 109 quality = 1.0 110 for parameter in parameters: 111 name, _, value = parameter.partition("=") 112 if name.strip().lower() == "q": 113 try: 114 quality = float(value) 115 except ValueError: 116 quality = 0.0 117 118 if media_type == "*/*": 119 specificity = 0 120 elif media_type.endswith("/*"): 121 specificity = 1 122 else: 123 specificity = 2 124 125 ranges.append((index, AcceptRange(media_type, quality, specificity))) 126 127 ranges.sort(key=lambda entry: (-entry[1].quality, -entry[1].specificity, entry[0])) 128 return [entry[1] for entry in ranges]
Parse an Accept header into ranges ordered by preference.
Args:
header_value: Raw Accept header value
Returns: Ranges, most preferred first
151def parse_collection_query( 152 query: dict[str, Any] | None = None, 153 *, 154 default_limit: int = DEFAULT_LIMIT, 155 max_limit: int = MAX_LIMIT, 156) -> CollectionQuery: 157 """ 158 Parse a full collection query from request query parameters. 159 160 Repeated parameters are expected as lists, which is how a query string such 161 as ``?tag=a&tag=b`` is read. 162 163 Args: 164 query: Request query parameters 165 default_limit: Page size when unspecified 166 max_limit: Largest accepted page size 167 168 Returns: 169 Parsed query 170 """ 171 query = query or {} 172 173 filters: dict[str, Any] = {} 174 for name, value in query.items(): 175 if name in RESERVED_QUERY_PARAMETERS: 176 continue 177 if isinstance(value, list | tuple): 178 filters[name] = [parse_scalar(entry) for entry in value] 179 else: 180 filters[name] = parse_scalar(value) 181 182 def single(name: str) -> str | None: 183 value = query.get(name) 184 if isinstance(value, list | tuple): 185 return value[-1] if value else None 186 return value 187 188 return CollectionQuery( 189 limit=parse_bounded_integer( 190 single("limit"), default_limit, "limit", max_limit 191 ), 192 offset=parse_bounded_integer(single("offset"), 0, "offset"), 193 sort=parse_sort(single("sort")), 194 fields=parse_fields(single("fields")), 195 filters=filters, 196 )
Parse a full collection query from request query parameters.
Repeated parameters are expected as lists, which is how a query string such
as ?tag=a&tag=b is read.
Args: query: Request query parameters default_limit: Page size when unspecified max_limit: Largest accepted page size
Returns: Parsed query
74def parse_content_type(header_value: str | None) -> str: 75 """ 76 Strip parameters and normalise case, turning a header value into a media type. 77 78 Args: 79 header_value: Raw ``Content-Type`` header value 80 81 Returns: 82 Bare, lower-cased media type ("" when absent) 83 """ 84 if not header_value: 85 return "" 86 return header_value.split(";")[0].strip().lower()
Strip parameters and normalise case, turning a header value into a media type.
Args:
header_value: Raw Content-Type header value
Returns: Bare, lower-cased media type ("" when absent)
35def parse_etag_list(header_value: str | None) -> list[str]: 36 """ 37 Split an ``If-Match`` or ``If-None-Match`` header into entity tags. 38 39 Args: 40 header_value: Raw header value 41 42 Returns: 43 Entity tags, with any weak prefix removed 44 """ 45 if not header_value: 46 return [] 47 tags = [] 48 for tag in header_value.split(","): 49 cleaned = re.sub(r"^W/", "", tag.strip()) 50 if cleaned: 51 tags.append(cleaned) 52 return tags
Split an If-Match or If-None-Match header into entity tags.
Args: header_value: Raw header value
Returns: Entity tags, with any weak prefix removed
135def parse_fields(raw: str | None) -> list[str] | None: 136 """ 137 Parse a ``fields`` parameter into a sparse fieldset. 138 139 Args: 140 raw: Raw ``fields`` value 141 142 Returns: 143 Field names, or None when every field is requested 144 """ 145 if not raw: 146 return None 147 fields = [part.strip() for part in raw.split(",") if part.strip()] 148 return fields or None
Parse a fields parameter into a sparse fieldset.
Args:
raw: Raw fields value
Returns: Field names, or None when every field is requested
43def parse_scalar(raw: str) -> Any: 44 """ 45 Parse a filter value with the Links Notation scalar rules. 46 47 ``?done=true`` filters on the boolean, ``?id=7`` on the integer, and anything 48 else stays a string. 49 50 Args: 51 raw: Raw query parameter value 52 53 Returns: 54 Parsed scalar 55 """ 56 if raw == "true": 57 return True 58 if raw == "false": 59 return False 60 if raw == "null": 61 return None 62 if raw == "": 63 return raw 64 try: 65 return int(raw) 66 except ValueError: 67 pass 68 try: 69 return float(raw) 70 except ValueError: 71 return raw
Parse a filter value with the Links Notation scalar rules.
?done=true filters on the boolean, ?id=7 on the integer, and anything
else stays a string.
Args: raw: Raw query parameter value
Returns: Parsed scalar
112def parse_sort(raw: str | None) -> list[SortKey]: 113 """ 114 Parse a ``sort`` parameter into ordered sort keys. 115 116 Args: 117 raw: Raw ``sort`` value 118 119 Returns: 120 Sort keys 121 """ 122 if not raw: 123 return [] 124 keys = [] 125 for part in (entry.strip() for entry in raw.split(",")): 126 if not part: 127 continue 128 if part.startswith("-"): 129 keys.append(SortKey(part[1:], True)) 130 else: 131 keys.append(SortKey(part, False)) 132 return keys
Parse a sort parameter into ordered sort keys.
Args:
raw: Raw sort value
Returns: Sort keys
83def path_parameters(path: str) -> list[str]: 84 """ 85 Extract the path parameters of a path pattern. 86 87 Args: 88 path: Path pattern 89 90 Returns: 91 Parameter names 92 """ 93 return re.findall(r":([A-Za-z0-9_]+)", path)
Extract the path parameters of a path pattern.
Args: path: Path pattern
Returns: Parameter names
136def problem_details( 137 status: int, 138 detail: str | None = None, 139 **options: Any, 140) -> dict[str, Any]: 141 """ 142 Build problem details for a status code without raising. 143 144 Args: 145 status: HTTP status code 146 detail: Human readable explanation 147 **options: Additional problem members, see :class:`LinoHttpError` 148 149 Returns: 150 Problem details ready to be encoded 151 """ 152 return LinoHttpError(status, detail, **options).to_problem( 153 options.get("instance") 154 )
Build problem details for a status code without raising.
Args:
status: HTTP status code
detail: Human readable explanation
**options: Additional problem members, see LinoHttpError
Returns: Problem details ready to be encoded
50def problem_slug(title: str) -> str: 51 """ 52 Kebab-case slug used as the last segment of a problem type URI. 53 54 Args: 55 title: Problem title 56 57 Returns: 58 Slug 59 """ 60 return re.sub(r"^-|-$", "", re.sub(r"[^a-z0-9]+", "-", title.lower()))
Kebab-case slug used as the last segment of a problem type URI.
Args: title: Problem title
Returns: Slug
85def project_fields(item: Any, fields: list[str] | None) -> Any: 86 """ 87 Reduce an item to a sparse fieldset. 88 89 Args: 90 item: Item to project 91 fields: Field names, or None for every field 92 93 Returns: 94 Projected item 95 """ 96 if not fields: 97 return item 98 if not isinstance(item, dict): 99 return item 100 return {name: item[name] for name in fields if name in item}
Reduce an item to a sparse fieldset.
Args: item: Item to project fields: Field names, or None for every field
Returns: Projected item
109def raw_response( 110 body: str, 111 media_type: str, 112 status_code: int = 200, 113 headers: dict[str, str] | None = None, 114) -> LinoResult: 115 """ 116 A response whose body is already encoded, bypassing negotiation. 117 118 Used for representations that are defined by their own media type, such as 119 the OpenAPI document of specification section 9. 120 121 Args: 122 body: Encoded body 123 media_type: Media type of the body 124 status_code: HTTP status code 125 headers: Response headers 126 127 Returns: 128 Handler result 129 """ 130 return LinoResult( 131 body, 132 status_code, 133 dict(headers or {}), 134 media_type=media_type, 135 raw=True, 136 )
A response whose body is already encoded, bypassing negotiation.
Used for representations that are defined by their own media type, such as the OpenAPI document of specification section 9.
Args: body: Encoded body media_type: Media type of the body status_code: HTTP status code headers: Response headers
Returns: Handler result
35def reason_phrase(status: int) -> str: 36 """ 37 Reason phrase of a status code, falling back to a generic class phrase. 38 39 Args: 40 status: HTTP status code 41 42 Returns: 43 Human readable reason phrase 44 """ 45 if status in REASON_PHRASES: 46 return REASON_PHRASES[status] 47 return "Server Error" if status >= 500 else "Request Error"
Reason phrase of a status code, falling back to a generic class phrase.
Args: status: HTTP status code
Returns: Human readable reason phrase
106def register_resource( 107 app: Any, 108 base_path: str, 109 store: Any, 110 *, 111 id_param: str = "id", 112 id_field: str | None = None, 113 name: str | None = None, 114 operations: list[str] | tuple[str, ...] = RESOURCE_OPERATIONS, 115 require_precondition: bool = False, 116 upsert: bool = False, 117 **query_options: Any, 118) -> Any: 119 """ 120 Register the routes of a CRUD resource on an application. 121 122 Args: 123 app: :class:`lino_rest_api.app.LinoApp` to register on 124 base_path: Collection path, for example ``/items`` 125 store: Store implementing ``list``, ``get``, ``create``, ``update``, 126 ``patch`` and ``remove`` 127 id_param: Path parameter holding the identifier 128 id_field: Field of an item holding the identifier 129 name: Human readable name used in problem details 130 operations: Subset of :data:`RESOURCE_OPERATIONS` to expose 131 require_precondition: Demand ``If-Match`` on ``PUT``, ``PATCH`` and ``DELETE`` 132 upsert: Let ``PUT`` create a missing item 133 **query_options: ``default_limit`` and ``max_limit`` for the collection 134 135 Returns: 136 The application, for chaining 137 """ 138 field = id_field or getattr(store, "id_field", "id") 139 resource_name = name or base_path.strip("/") 140 item_path = f"{base_path}/:{id_param}" 141 142 async def load_for_write(request: LinoHttpRequest) -> Any: 143 """ 144 Read the current item and evaluate ``If-Match`` against it before mutating. 145 146 Args: 147 request: Request being served 148 149 Returns: 150 The current item, or None when absent 151 """ 152 existing = await _resolve(store.get(request.param(id_param))) 153 if existing is None: 154 return None 155 evaluate_preconditions( 156 request.headers, 157 request.method, 158 representation_etag(request, existing), 159 require_precondition=require_precondition, 160 ) 161 return existing 162 163 async def list_items(request: LinoHttpRequest) -> LinoResult: 164 """ 165 Serve one page of the collection. 166 167 Args: 168 request: Request being served 169 170 Returns: 171 Collection envelope with pagination links 172 """ 173 query = request.collection_query(**query_options) 174 envelope = _to_envelope(await _resolve(store.list(query)), query) 175 page = envelope["page"] 176 link = pagination_link_header( 177 request.path, 178 request.query, 179 limit=page["limit"], 180 offset=page["offset"], 181 total=page["total"], 182 ) 183 return LinoResult(envelope, 200, {"Link": link} if link else {}) 184 185 async def create_item(request: LinoHttpRequest) -> LinoResult: 186 """ 187 Create one item. 188 189 Args: 190 request: Request being served 191 192 Returns: 193 The created item with its ``Location`` 194 """ 195 item = await _resolve(store.create(_require_body(request))) 196 identifier = item.get(field) if isinstance(item, dict) else None 197 if identifier is None: 198 return LinoResult(item, 201) 199 return created(item, f"{base_path}/{quote(str(identifier))}") 200 201 async def get_item(request: LinoHttpRequest) -> Any: 202 """ 203 Read one item. 204 205 Args: 206 request: Request being served 207 208 Returns: 209 The item 210 211 Raises: 212 LinoHttpError: 404 when the item does not exist 213 """ 214 item = await _resolve(store.get(request.param(id_param))) 215 if item is None: 216 raise _not_found(resource_name, request.param(id_param)) 217 return item 218 219 async def update_item(request: LinoHttpRequest) -> Any: 220 """ 221 Replace one item, optionally creating it. 222 223 Args: 224 request: Request being served 225 226 Returns: 227 The updated item 228 229 Raises: 230 LinoHttpError: 404 when the item does not exist and upserts are off 231 """ 232 body = _require_body(request) 233 existing = await load_for_write(request) 234 if existing is None: 235 if not upsert: 236 raise _not_found(resource_name, request.param(id_param)) 237 item = await _resolve( 238 store.create({**body, field: request.param(id_param)}) 239 ) 240 return created(item, f"{base_path}/{quote(str(item.get(field)))}") 241 return await _resolve(store.update(request.param(id_param), body)) 242 243 async def patch_item(request: LinoHttpRequest) -> Any: 244 """ 245 Merge changes into one item. 246 247 Args: 248 request: Request being served 249 250 Returns: 251 The updated item 252 253 Raises: 254 LinoHttpError: 404 when the item does not exist 255 """ 256 body = _require_body(request) 257 existing = await load_for_write(request) 258 if existing is None: 259 raise _not_found(resource_name, request.param(id_param)) 260 return await _resolve(store.patch(request.param(id_param), body)) 261 262 async def remove_item(request: LinoHttpRequest) -> LinoResult: 263 """ 264 Delete one item. 265 266 Args: 267 request: Request being served 268 269 Returns: 270 An empty 204 271 272 Raises: 273 LinoHttpError: 404 when the item does not exist 274 """ 275 existing = await load_for_write(request) 276 if existing is None: 277 raise _not_found(resource_name, request.param(id_param)) 278 await _resolve(store.remove(request.param(id_param))) 279 return no_content() 280 281 registrations = { 282 "list": lambda: app.get( 283 base_path, list_items, {"summary": f"List {resource_name}"} 284 ), 285 "create": lambda: app.post( 286 base_path, create_item, {"summary": f"Create {resource_name}"} 287 ), 288 "get": lambda: app.get( 289 item_path, get_item, {"summary": f"Read one {resource_name}"} 290 ), 291 "update": lambda: app.put( 292 item_path, update_item, {"summary": f"Replace one {resource_name}"} 293 ), 294 "patch": lambda: app.patch( 295 item_path, patch_item, {"summary": f"Merge changes into one {resource_name}"} 296 ), 297 "remove": lambda: app.delete( 298 item_path, remove_item, {"summary": f"Delete one {resource_name}"} 299 ), 300 } 301 302 for operation in RESOURCE_OPERATIONS: 303 if operation in operations: 304 registrations[operation]() 305 306 return app
Register the routes of a CRUD resource on an application.
Args:
app: lino_rest_api.app.LinoApp to register on
base_path: Collection path, for example /items
store: Store implementing list, get, create, update,
patch and remove
id_param: Path parameter holding the identifier
id_field: Field of an item holding the identifier
name: Human readable name used in problem details
operations: Subset of RESOURCE_OPERATIONS to expose
require_precondition: Demand If-Match on PUT, PATCH and DELETE
upsert: Let PUT create a missing item
**query_options: default_limit and max_limit for the collection
Returns: The application, for chaining
41def representation_etag(request: LinoHttpRequest, value: Any) -> str: 42 """ 43 Entity tag of a value in the representation the client asked for. 44 45 Entity tags are per representation, which is why the negotiated media type is 46 part of the computation and why every response carries ``Vary: Accept``. 47 48 Args: 49 request: Request being served 50 value: Value to tag 51 52 Returns: 53 Quoted entity tag 54 """ 55 return compute_etag(encode_for(value, request.media_type or LINO_CONTENT_TYPE))
Entity tag of a value in the representation the client asked for.
Entity tags are per representation, which is why the negotiated media type is
part of the computation and why every response carries Vary: Accept.
Args: request: Request being served value: Value to tag
Returns: Quoted entity tag
44def service_description( 45 info: dict[str, str], 46 routes: list[dict[str, Any]], 47 media_types: list[str] | None = None, 48) -> dict[str, Any]: 49 """ 50 Build the native service description. 51 52 Args: 53 info: ``title``, ``version`` and optional ``description`` of the service 54 routes: Route descriptions 55 media_types: Representations the service can produce 56 57 Returns: 58 Description ready to be encoded as Links Notation 59 """ 60 return { 61 "lino_api": LINO_API_DESCRIPTION_VERSION, 62 "info": info_object(info), 63 "media_types": list( 64 SUPPORTED_MEDIA_TYPES if media_types is None else media_types 65 ), 66 "routes": routes, 67 }
Build the native service description.
Args:
info: title, version and optional description of the service
routes: Route descriptions
media_types: Representations the service can produce
Returns: Description ready to be encoded as Links Notation
59def sort_items(items: list[Any], sort: list[SortKey] | None) -> list[Any]: 60 """ 61 Sort items by the sort keys of a collection query. 62 63 Args: 64 items: Items to sort (not mutated) 65 sort: Sort keys 66 67 Returns: 68 Sorted copy 69 """ 70 sorted_items = list(items) 71 if not sort: 72 return sorted_items 73 # Python's sort is stable, so applying the keys from least to most 74 # significant yields the same order as a multi-key comparison. 75 for key in reversed(sort): 76 sorted_items.sort( 77 key=lambda item, key=key: _sort_key( 78 item.get(key.field) if isinstance(item, dict) else None 79 ), 80 reverse=key.descending, 81 ) 82 return sorted_items
Sort items by the sort keys of a collection query.
Args: items: Items to sort (not mutated) sort: Sort keys
Returns: Sorted copy
90def status( 91 status_code: int, 92 value: Any = EMPTY, 93 headers: dict[str, str] | None = None, 94) -> LinoResult: 95 """ 96 A response with an explicit status code. 97 98 Args: 99 status_code: HTTP status code 100 value: Value to encode 101 headers: Response headers 102 103 Returns: 104 Handler result 105 """ 106 return LinoResult(value, status_code, dict(headers or {}))
A response with an explicit status code.
Args: status_code: HTTP status code value: Value to encode headers: Response headers
Returns: Handler result
180def to_http_error(error: BaseException) -> LinoHttpError: 181 """ 182 Turn any raised exception into a :class:`LinoHttpError`. 183 184 Errors that are already problem-shaped keep their status and details; anything 185 else becomes a 500 whose detail is the original message. 186 187 Args: 188 error: Raised exception 189 190 Returns: 191 Normalised error 192 """ 193 if isinstance(error, LinoHttpError): 194 return error 195 196 status = getattr(error, "status_code", getattr(error, "status", None)) 197 if isinstance(status, int) and status >= 400: 198 detail = getattr(error, "detail", None) or str(error) 199 return LinoHttpError( 200 status, 201 detail, 202 headers=getattr(error, "headers", None), 203 ) 204 return LinoHttpError(500, str(error) or type(error).__name__)
Turn any raised exception into a LinoHttpError.
Errors that are already problem-shaped keep their status and details; anything else becomes a 500 whose detail is the original message.
Args: error: Raised exception
Returns: Normalised error
70def to_openapi_path(path: str) -> str: 71 """ 72 Convert a ``:parameter`` path to the OpenAPI template syntax. 73 74 Args: 75 path: Path pattern with ``:parameter`` segments 76 77 Returns: 78 Path template with ``{parameter}`` segments 79 """ 80 return re.sub(r":([A-Za-z0-9_]+)", r"{\1}", path)
Convert a :parameter path to the OpenAPI template syntax.
Args:
path: Path pattern with :parameter segments
Returns:
Path template with {parameter} segments
157def validation_error( 158 errors: list[dict[str, str]], 159 detail: str = "Request body failed validation", 160) -> LinoHttpError: 161 """ 162 A 422 carrying field-level validation failures. 163 164 Args: 165 errors: Field failures, each with a ``field`` and a ``message`` 166 detail: Human readable explanation 167 168 Returns: 169 Error ready to be raised 170 """ 171 return LinoHttpError( 172 422, 173 detail, 174 title="Unprocessable Content", 175 type_uri=f"{PROBLEM_TYPE_BASE}validation-failed", 176 extensions={"errors": errors}, 177 )
A 422 carrying field-level validation failures.
Args:
errors: Field failures, each with a field and a message
detail: Human readable explanation
Returns: Error ready to be raised
61def with_charset(media_type: str) -> str: 62 """ 63 Add ``charset=utf-8`` to a media type so that bytes are unambiguous. 64 65 Args: 66 media_type: Bare media type 67 68 Returns: 69 Media type with an explicit charset 70 """ 71 return f"{media_type}; charset=utf-8"
Add charset=utf-8 to a media type so that bytes are unambiguous.
Args: media_type: Bare media type
Returns: Media type with an explicit charset