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]
DEFAULT_ACCEPT = 'text/lino, application/json;q=0.5'
DEFAULT_ALLOWED_HEADERS = ['Content-Type', 'Accept', 'Authorization', 'If-Match', 'If-None-Match']
DEFAULT_EXPOSED_HEADERS = ['ETag', 'Link', 'Location', 'Allow']
DEFAULT_LIMIT = 20
DEFAULT_MAX_BODY_BYTES = 1048576
DESCRIPTION_PATH = '/.well-known/lino-api'
EMPTY = Ellipsis
HTTP_METHODS = ('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS')
IMPLICIT_METHODS = ('HEAD', 'OPTIONS')
JSON_CONTENT_TYPE = 'application/json'
LINO_API_DESCRIPTION_VERSION = '1.0'
LINO_COMPACT_CONTENT_TYPE = 'text/lino-compact'
LINO_CONTENT_TYPE = 'text/lino'
LINO_LINE_CONTENT_TYPE = 'text/lino-line'
LINO_PROBLEM_CONTENT_TYPE = 'application/problem+lino'
MAX_LIMIT = 100
OPENAPI_PATH = '/.well-known/openapi.json'
PROBLEM_TYPE_BASE = 'https://link-foundation.github.io/lino-rest-api/errors/'
RESERVED_QUERY_PARAMETERS = ('limit', 'offset', 'sort', 'fields')
RESOURCE_OPERATIONS = ('list', 'create', 'get', 'update', 'patch', 'remove')
SUPPORTED_MEDIA_TYPES = ['text/lino', 'text/lino-line', 'text/lino-compact', 'application/json']
class AsyncLinoClient(lino_rest_api.client._RequestBuilder):
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.

AsyncLinoClient( base_url: str, *, accept: str = 'text/lino, application/json;q=0.5', content_type: str = 'text/lino', headers: dict[str, str] | None = None, client: httpx.AsyncClient | None = None, **client_options: Any)
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

async def aclose(self) -> None:
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.

async def request( self, method: str, path: str, **options: Any) -> LinoResponse:
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

async def get(self, path: str, **options: Any) -> LinoResponse:
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

async def post( self, path: str, body: Any = None, **options: Any) -> LinoResponse:
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

async def put( self, path: str, body: Any = None, **options: Any) -> LinoResponse:
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

async def patch( self, path: str, body: Any = None, **options: Any) -> LinoResponse:
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

async def delete(self, path: str, **options: Any) -> LinoResponse:
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

async def head(self, path: str, **options: Any) -> LinoResponse:
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

async def options(self, path: str, **options: Any) -> list[str]:
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

async def list( self, path: str, query: dict[str, typing.Any] | None = None, **options: Any) -> Any:
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

async def describe(self) -> Any:
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

class LinoAPI:
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.

LinoAPI( title: str = 'LINO REST API', description: str = 'REST API using Links Notation', version: str = '0.1.0', **kwargs)
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

app
def get(self, path: str, **kwargs):
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

def post(self, path: str, **kwargs):
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

def put(self, path: str, **kwargs):
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

def delete(self, path: str, **kwargs):
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

def patch(self, path: str, **kwargs):
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

def get_fastapi_app(self) -> fastapi.applications.FastAPI:
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

Get the underlying FastAPI app.

Returns: FastAPI application instance

class LinoAPIRoute(fastapi.routing.APIRoute):
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.

def get_route_handler(self) -> Callable:
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
class LinoApp:
 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.

LinoApp( *, title: str = 'LINO REST API', version: str = '1.0.0', description: str | None = None, cors: bool | dict[str, typing.Any] | None = None, describe: bool = True, supported: list[str] | None = None, max_body_bytes: int = 1048576, expose_traceback: bool = False, default_limit: int | None = None, max_limit: int | None = None)
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

info: dict[str, str]
cors
supported
max_body_bytes
expose_traceback
routes
handlers: dict[tuple[str, str], Callable[[LinoHttpRequest], typing.Any | Awaitable[typing.Any]]]
query_defaults: dict[str, int]
def route( self, method: str, path: str, handler: Callable[[LinoHttpRequest], typing.Any | Awaitable[typing.Any]] | None = None, meta: dict[str, typing.Any] | None = None) -> Union[LinoApp, Callable[[Callable[[LinoHttpRequest], Any | Awaitable[Any]]], Callable[[LinoHttpRequest], Any | Awaitable[Any]]]]:
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

def get( self, path: str, handler: Callable[[LinoHttpRequest], typing.Any | Awaitable[typing.Any]] | None = None, meta: dict[str, typing.Any] | None = None) -> Union[LinoApp, Callable[[Callable[[LinoHttpRequest], Any | Awaitable[Any]]], Callable[[LinoHttpRequest], Any | Awaitable[Any]]]]:
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

def post( self, path: str, handler: Callable[[LinoHttpRequest], typing.Any | Awaitable[typing.Any]] | None = None, meta: dict[str, typing.Any] | None = None) -> Union[LinoApp, Callable[[Callable[[LinoHttpRequest], Any | Awaitable[Any]]], Callable[[LinoHttpRequest], Any | Awaitable[Any]]]]:
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

def put( self, path: str, handler: Callable[[LinoHttpRequest], typing.Any | Awaitable[typing.Any]] | None = None, meta: dict[str, typing.Any] | None = None) -> Union[LinoApp, Callable[[Callable[[LinoHttpRequest], Any | Awaitable[Any]]], Callable[[LinoHttpRequest], Any | Awaitable[Any]]]]:
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

def patch( self, path: str, handler: Callable[[LinoHttpRequest], typing.Any | Awaitable[typing.Any]] | None = None, meta: dict[str, typing.Any] | None = None) -> Union[LinoApp, Callable[[Callable[[LinoHttpRequest], Any | Awaitable[Any]]], Callable[[LinoHttpRequest], Any | Awaitable[Any]]]]:
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

def delete( self, path: str, handler: Callable[[LinoHttpRequest], typing.Any | Awaitable[typing.Any]] | None = None, meta: dict[str, typing.Any] | None = None) -> Union[LinoApp, Callable[[Callable[[LinoHttpRequest], Any | Awaitable[Any]]], Callable[[LinoHttpRequest], Any | Awaitable[Any]]]]:
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

def resource(self, path: str, store: Any, **options: Any) -> LinoApp:
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

def describe(self) -> dict[str, typing.Any]:
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

def openapi(self) -> dict[str, typing.Any]:
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

async def handle( self, request: LinoHttpRequest) -> lino_rest_api.middleware.ResponseParts:
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

class LinoClient(lino_rest_api.client._RequestBuilder):
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.

LinoClient( base_url: str, *, accept: str = 'text/lino, application/json;q=0.5', content_type: str = 'text/lino', headers: dict[str, str] | None = None, client: httpx.Client | None = None, **client_options: Any)
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

def close(self) -> None:
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.

def request( self, method: str, path: str, **options: Any) -> LinoResponse:
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

def get(self, path: str, **options: Any) -> LinoResponse:
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

def post( self, path: str, body: Any = None, **options: Any) -> LinoResponse:
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

def put( self, path: str, body: Any = None, **options: Any) -> LinoResponse:
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

def patch( self, path: str, body: Any = None, **options: Any) -> LinoResponse:
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

def delete(self, path: str, **options: Any) -> LinoResponse:
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

def head(self, path: str, **options: Any) -> LinoResponse:
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

def options(self, path: str, **options: Any) -> list[str]:
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

def list( self, path: str, query: dict[str, typing.Any] | None = None, **options: Any) -> Any:
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

def describe(self) -> Any:
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

class LinoClientError(builtins.Exception):
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.

LinoClientError( status: int, problem: Any = None, response: httpx.Response | None = None)
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

status
problem
response
class LinoHttpError(builtins.Exception):
 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.

LinoHttpError( status: int, detail: str | None = None, *, title: str | None = None, type_uri: str | None = None, instance: str | None = None, headers: dict[str, str] | None = None, extensions: dict[str, typing.Any] | None = None)
 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

status
title
detail
type
instance
headers
extensions
message: str
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.

def to_problem(self, instance: str | None = None) -> dict[str, typing.Any]:
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

@dataclass
class LinoHttpRequest:
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.

LinoHttpRequest( method: str, path: str, headers: dict[str, str] = <factory>, params: dict[str, str] = <factory>, query_string: str = '', body: Any = None, media_type: str = '', request_media_type: str | None = None, scope: dict[str, typing.Any] = <factory>, app: Any = None)
method: str
path: str
headers: dict[str, str]
params: dict[str, str]
query_string: str = ''
body: Any = None
media_type: str = ''
request_media_type: str | None = None
scope: dict[str, typing.Any]
app: Any = None
query: dict[str, typing.Any]
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

def header(self, name: str) -> str | None:
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

def param(self, name: str) -> str | None:
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

def collection_query(self, **options: Any) -> lino_rest_api.query.CollectionQuery:
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

class LinoRequest:
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.

LinoRequest(request: starlette.requests.Request)
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

request
async def body(self) -> Any:
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

class LinoResponse(starlette.responses.PlainTextResponse):
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.

LinoResponse( content: Any = None, status_code: int = 200, headers: dict | None = None, **kwargs)
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

media_type = 'text/lino'
@dataclass
class LinoResult:
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.

LinoResult( value: Any = Ellipsis, status: int = 200, headers: dict[str, str] = <factory>, etag: bool = True, preconditions: bool | None = None, require_precondition: bool = False, media_type: str | None = None, raw: bool = False)
value: Any = Ellipsis
status: int = 200
headers: dict[str, str]
etag: bool = True
preconditions: bool | None = None
require_precondition: bool = False
media_type: str | None = None
raw: bool = False
class MemoryStore:
 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.

MemoryStore( items: list[dict[str, typing.Any]] | None = None, *, id_field: str = 'id')
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

id_field
items: dict[typing.Any, dict[str, typing.Any]]
next_id
def normalize_id(self, identifier: Any) -> Any:
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

def list( self, query: lino_rest_api.query.CollectionQuery) -> dict[str, typing.Any]:
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

def get(self, identifier: Any) -> dict[str, typing.Any] | None:
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

def create(self, body: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
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

def update( self, identifier: Any, body: dict[str, typing.Any] | None) -> dict[str, typing.Any] | None:
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

def patch( self, identifier: Any, body: dict[str, typing.Any] | None) -> dict[str, typing.Any] | None:
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

def remove(self, identifier: Any) -> bool:
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

def clear(self) -> None:
149    def clear(self) -> None:
150        """Remove every item."""
151        self.items.clear()
152        self.next_id = 1

Remove every item.

class RouteTable:
 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.

routes: dict[str, lino_rest_api.router.RouteEntry]
def register( self, method: str, pattern: str, meta: dict[str, typing.Any] | None = None) -> None:
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

def find(self, pathname: str) -> lino_rest_api.router.RouteEntry | None:
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

def match( self, pathname: str) -> tuple[lino_rest_api.router.RouteEntry, dict[str, str]] | None:
 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

def allowed_methods(self, pathname: str) -> list[str] | None:
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

def describe(self) -> list[dict[str, typing.Any]]:
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

def accepted( value: Any, headers: dict[str, str] | None = None) -> LinoResult:
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

def append_vary(headers: dict[str, str], field_name: str) -> None:
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

def apply_collection_query( items: list[typing.Any], query: lino_rest_api.query.CollectionQuery) -> dict[str, typing.Any]:
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

def build_problem_response( error: BaseException, *, media_type: str = 'text/lino', instance: str | None = None, expose_traceback: bool = False) -> lino_rest_api.middleware.ResponseParts:
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

def build_query_string(query: dict[str, typing.Any] | None) -> str:
 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

def query_value(value: Any) -> str:
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

def build_response( value: Any, *, status: int = 200, media_type: str = 'text/lino', headers: dict[str, str] | None = None, request_headers: Mapping[str, str] | None = None, method: str = 'GET', etag: bool = True, preconditions: bool | None = None, require_precondition: bool = False, raw: bool = False) -> lino_rest_api.middleware.ResponseParts:
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

def collection_envelope( items: list[typing.Any], *, limit: int, offset: int, total: int) -> dict[str, typing.Any]:
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

def compile_path_pattern(pattern: str) -> re.Pattern[str]:
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

def compute_etag(body: str) -> str:
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

def cors_headers( options: dict[str, typing.Any] | bool | None = None, request_origin: str | None = None) -> dict[str, str]:
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)

def create_async_lino_client(base_url: str, **options: Any) -> AsyncLinoClient:
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

def create_lino_app(**options: Any) -> LinoApp:
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

def create_lino_client(base_url: str, **options: Any) -> LinoClient:
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

def created( value: Any, location: str, headers: dict[str, str] | None = None) -> LinoResult:
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

def decode(notation: str) -> Any:
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

def decode_from(body: str, media_type: str) -> Any:
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

def decode_request_body( raw: bytes, content_type: str | None, *, max_bytes: int = 1048576) -> tuple[typing.Any, str | None]:
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

def decode_single_line(notation: str) -> Any:
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

def encode(value: Any) -> str:
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

def encode_compact_notation(value: Any) -> str:
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

def encode_for(value: Any, media_type: str) -> str:
 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

def encode_single_line(value: Any) -> str:
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

def etag_matches(header_value: str | None, current_etag: str) -> bool:
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

def evaluate_preconditions( headers: Mapping[str, str], method: str, current_etag: str, *, require_precondition: bool = False) -> lino_rest_api.etag.PreconditionResult:
 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

def is_decodable_media_type(media_type: str) -> bool:
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

async def lino_request_handler(request: starlette.requests.Request) -> Any:
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

def matches_filters(item: Any, filters: dict[str, typing.Any]) -> bool:
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

def negotiate_media_type( accept_header: str | None, supported: list[str] | None = None) -> str | None:
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

def negotiate_request(headers: Mapping[str, str], supported: list[str] | None = None) -> str:
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

def no_content( headers: dict[str, str] | None = None) -> LinoResult:
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

def ok( value: Any, headers: dict[str, str] | None = None) -> LinoResult:
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

def info_object(info: dict[str, str]) -> dict[str, str]:
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

def openapi_document( info: dict[str, str], routes: list[dict[str, typing.Any]], media_types: list[str] | None = None) -> dict[str, typing.Any]:
 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

def parse_accept(header_value: str | None) -> list[lino_rest_api.media_type.AcceptRange]:
 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

def parse_collection_query( query: dict[str, typing.Any] | None = None, *, default_limit: int = 20, max_limit: int = 100) -> lino_rest_api.query.CollectionQuery:
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

def parse_content_type(header_value: str | None) -> str:
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)

def parse_etag_list(header_value: str | None) -> list[str]:
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

def parse_fields(raw: str | None) -> list[str] | None:
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

def parse_scalar(raw: str) -> Any:
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

def parse_sort(raw: str | None) -> list[lino_rest_api.query.SortKey]:
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

def path_parameters(path: str) -> list[str]:
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

def problem_details( status: int, detail: str | None = None, **options: Any) -> dict[str, typing.Any]:
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

def problem_slug(title: str) -> str:
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

def project_fields(item: Any, fields: list[str] | None) -> Any:
 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

def raw_response( body: str, media_type: str, status_code: int = 200, headers: dict[str, str] | None = None) -> LinoResult:
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

def reason_phrase(status: int) -> str:
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

def register_resource( app: Any, base_path: str, store: Any, *, id_param: str = 'id', id_field: str | None = None, name: str | None = None, operations: list[str] | tuple[str, ...] = ('list', 'create', 'get', 'update', 'patch', 'remove'), require_precondition: bool = False, upsert: bool = False, **query_options: Any) -> Any:
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

def representation_etag(request: LinoHttpRequest, value: Any) -> str:
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

def service_description( info: dict[str, str], routes: list[dict[str, typing.Any]], media_types: list[str] | None = None) -> dict[str, typing.Any]:
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

def sort_items( items: list[typing.Any], sort: list[lino_rest_api.query.SortKey] | None) -> list[typing.Any]:
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

def status( status_code: int, value: Any = Ellipsis, headers: dict[str, str] | None = None) -> LinoResult:
 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

def to_http_error(error: BaseException) -> LinoHttpError:
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

def to_openapi_path(path: str) -> str:
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

def validation_error( errors: list[dict[str, str]], detail: str = 'Request body failed validation') -> LinoHttpError:
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

def with_charset(media_type: str) -> str:
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