Skip to content

Commit 5785385

Browse files
📝 Update documentation.
1 parent 923e259 commit 5785385

10 files changed

Lines changed: 131 additions & 46 deletions

File tree

.config/mkdocs.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ nav:
2424
- usage/operation.md
2525
- usage/auth.md
2626
- usage/middleware.md
27+
- usage/paging.md
2728
- API reference: reference/api.md
2829
- Lapidary Render: https://lapidary.dev/lapidary-render
2930

docs/index.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,11 @@ poetry add lapidary
2424

2525
## Usage
2626

27-
With Lapidary, you define an API client by creating a class that mirrors the API structure, akin to OpenAPI but through
28-
decorated and annotated Python methods. Calling these method handles making HTTP requests and transforming the responses
29-
back into Python objects.
27+
With Lapidary, user creates an API client by writing a class that mirrors the API itself, in a similar manner to OpenAPI, except
28+
decorated and annotated Python methods are used. These methods handle making HTTP requests and transforming the
29+
responses back into Python objects.
3030

3131
```python
32-
from collections.abc import Awaitable
3332
from typing import Annotated, Self
3433
from lapidary.runtime import *
3534

@@ -48,7 +47,7 @@ class CatClient:
4847
*,
4948
id: Annotated[int, Path],
5049
) -> Annotated[
51-
Awaitable[Cat],
50+
tuple[Cat, None],
5251
Responses({'2XX': Response(Body({'application/json': Cat}))}),
5352
]:
5453
pass
@@ -63,8 +62,8 @@ import httpx
6362

6463
async def main():
6564
async with httpx.AsyncClient() as http:
66-
client = lapidary.runtime.client.for_api(CatClient, http, 'http://localhost:8080/api')
67-
cat = await client.cat_get(id=7)
65+
client = lapidary.runtime.client.for_api(CatClient, http, 'https://example.com/api')
66+
cat = await client.ops.cat_get(id=7)
6867
```
6968

7069
See [this test file](https://github.com/python-lapidary/lapidary/blob/develop/tests/test_client.py) for a working

docs/usage/auth.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ from lapidary.runtime.auth import HeaderApiKey
1818
from typing import Self, Annotated
1919

2020

21+
class LoginRequest(ModelBase):
22+
username: str
23+
password: str
24+
25+
26+
class LoginResponse(ModelBase):
27+
token: str
28+
29+
2130
class MyClient:
2231
@get('/api/operation')
2332
async def my_op(self: Self) -> ...:
@@ -26,9 +35,12 @@ class MyClient:
2635
@post('/api/login')
2736
async def login(
2837
self: Self,
29-
user: Annotated[str, ...],
30-
password: Annotated[str, ...],
31-
) -> ...:
38+
*,
39+
body: Annotated[LoginRequest, Body({'application/json': LoginRequest})],
40+
) -> Annotated[
41+
tuple[LoginResponse, None],
42+
Responses({'2XX': Response(Body({'application/json': LoginResponse}))}),
43+
]:
3244
pass
3345

3446

@@ -37,8 +49,8 @@ async def main():
3749
async with httpx.AsyncClient() as http:
3850
client = lapidary.runtime.client.for_api(MyClient, http, 'https://example.com/')
3951

40-
token = (await client.ops.login('username', 'secret')).token
41-
client_w_auth = client.with_auth(client, HeaderApiKey(token))
52+
response, _ = await client.ops.login(body=LoginRequest(username='user', password='secret'))
53+
client_w_auth = client.with_auth(HeaderApiKey(response.token))
4254

4355
await client_w_auth.ops.my_op()
4456
```

docs/usage/client.md

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,14 @@
33
The core of the Lapidary API client is a single class that declares all the methods. Pass it to `for_api()`
44
along with an `httpx.AsyncClient` instance and a base URL to get an `APIClient`. Operations are then accessed via `.ops`.
55

6-
Example usage:
7-
86
```python
9-
class CatClient:
10-
# operation mothods
11-
...
12-
137
async with httpx.AsyncClient() as http:
148
client = lapidary.runtime.client.for_api(CatClient, http, 'https://example.com')
15-
# call client methods via client.ops
9+
result, _ = await client.ops.some_operation()
1610
```
1711

12+
`for_api()` also accepts `auth` (see [Authentication](auth.md)) and `middlewares` (see [Middleware](middleware.md)).
13+
1814
## Additional HTTP headers
1915

2016
HTTP request headers can be added using the standard httpx API. It's recommended to add User-Agent header.

docs/usage/operation.md

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,18 @@ await client.ops.list_cats(version='2')
116116

117117
results in the execution of a GET request that includes the header `version: 2`.
118118

119-
Note: The Cookie, Header, Param, and Query annotations all accept parameters such as name, style, and explode as defined
120-
by OpenAPI.
119+
HTTP headers conventionally use kebab-case names, which are not valid Python identifiers. Pass the actual header name as the first argument to `Header`:
120+
121+
```python
122+
@get('/cats')
123+
async def list_cats(
124+
self: Self,
125+
x_request_id: Annotated[str, Header('X-Request-Id')],
126+
):
127+
pass
128+
```
129+
130+
The same applies to `Query` and `Cookie` — pass the wire name as the first argument when it differs from the Python parameter name.
121131

122132
### Cookie headers
123133

@@ -143,6 +153,22 @@ await client.ops.list_cats(cookie_key='value')
143153

144154
will send a GET request that includes the header Cookie: key=value.
145155

156+
## Optional parameters
157+
158+
Query, header, and cookie parameters can all be made optional by using `T | None` and a default of `None`. Parameters set to `None` are omitted from the request entirely.
159+
160+
```python
161+
@get('/cats')
162+
async def list_cats(
163+
self: Self,
164+
*,
165+
color: Annotated[str | None, Query] = None,
166+
x_request_id: Annotated[str | None, Header('X-Request-Id')] = None,
167+
session: Annotated[str | None, Cookie('session_id')] = None,
168+
):
169+
pass
170+
```
171+
146172
## Request body
147173

148174
To mark a parameter for serialization into the HTTP body, annotate it with RequestBody. Each method can include only one
@@ -192,10 +218,10 @@ Example:
192218
async def list_cats(
193219
self: Self,
194220
) -> Annotated[
195-
tuple[List[Cat], None],
221+
tuple[list[Cat], None],
196222
Responses(
197223
{
198-
'2XX': Response(Body({'application/json': List[Cat]})),
224+
'2XX': Response(Body({'application/json': list[Cat]})),
199225
}
200226
),
201227
]:
@@ -240,7 +266,7 @@ class CatClient:
240266
async with httpx.AsyncClient() as http:
241267
client = lapidary.runtime.client.for_api(CatClient, http, 'https://example.com')
242268
cats_body, cats_meta = await client.ops.list_cats()
243-
assert cats_body.body == [Cat(...)]
269+
assert cats_body == [Cat(...)]
244270
assert cats_meta.count == 1
245271
assert cats_meta.status_code == 200
246272
```
@@ -259,7 +285,7 @@ class ErrorModel(ModelBase):
259285
async def list_cats(
260286
self: Self,
261287
) -> Annotated[
262-
tuple[List[Cat], None],
288+
tuple[list[Cat], None],
263289
Responses(
264290
{
265291
'2XX': Response(...),
@@ -282,6 +308,7 @@ except HttpErrorResponse as e:
282308
```
283309

284310
Any responses not declared in the response map, regardless of their status code, raise `UnexpectedResponse`.
311+
`UnexpectedResponse` is also raised if the response body cannot be decoded into the declared model type.
285312

286313
```python
287314
try:

docs/usage/paging.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Paging
2+
3+
`iter_pages` wraps an operation method and returns a function that, when called, yields successive pages as an async iterator.
4+
5+
```python
6+
from lapidary.runtime import iter_pages
7+
8+
9+
async def get_cursor(result: tuple[list[Cat], None]) -> str | None:
10+
body, _ = result
11+
return body.next_cursor if body.next_cursor else None
12+
13+
14+
paginated = iter_pages(client.ops.list_cats, 'cursor', get_cursor)
15+
16+
async for page in paginated(color='black'):
17+
body, _ = page
18+
process(body)
19+
```
20+
21+
The wrapped function is called first without the cursor parameter. After each call, `get_cursor` is invoked on the result. Iteration stops when `get_cursor` returns `None`.
22+
23+
24+
## Shortcut
25+
26+
Since an API typically uses the same paging pattern for all its operations, it's practical to define a project-level helper:
27+
28+
```python
29+
from lapidary.runtime import iter_pages as _iter_pages
30+
31+
32+
def iter_pages[P, R](fn: Callable[P, Awaitable[R]]) -> Callable[P, AsyncIterable[R]]:
33+
return _iter_pages(fn, 'cursor', lambda result: result[0].next_cursor or None)
34+
```

src/lapidary/runtime/annotations.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ class Body(WebArg):
2222
2323
Example use with parameter:
2424
25-
```python
25+
.. code-block:: python
26+
2627
body: Body({'application/json': BodyModel})
27-
```
2828
"""
2929

3030
content: Mapping[MimeType, type]
@@ -36,19 +36,20 @@ class Metadata(WebArg):
3636
Can be used to group request parameters as an alternative to passing parameters directly.
3737
3838
Example:
39-
```python
39+
40+
.. code-block:: python
41+
4042
class RequestMetadata(pydantic.BaseModel):
4143
my_header: typing.Annotated[
4244
str,
4345
Header('my-header'),
4446
]
4547
4648
class Client(ApiClient):
47-
@get(...)
48-
async def my_method(
49-
headers: Annotated[RequestMetadata, Metadata]
50-
):
51-
```
49+
@get(...)
50+
async def my_method(
51+
headers: Annotated[RequestMetadata, Metadata]
52+
):
5253
"""
5354

5455

@@ -139,8 +140,23 @@ class StatusCode(WebArg):
139140

140141
@dc.dataclass
141142
class Response:
143+
"""
144+
Declare the expected body and headers for a single HTTP response status code.
145+
146+
Used as a value inside the :class:`Responses` mapping.
147+
148+
Example:
149+
150+
.. code-block:: python
151+
152+
Response(Body({'application/json': Cat}))
153+
154+
Response(Body({'application/json': Cat}), CatListMeta)
155+
"""
156+
142157
body: Body
143158
headers: type[pydantic.BaseModel] | None = None
159+
"""Pydantic model to deserialize response headers into. Fields must be annotated with :class:`Header` or :class:`StatusCode`."""
144160

145161

146162
@dc.dataclass

src/lapidary/runtime/client.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ def for_api(
2323
middlewares: Sequence[HttpxMiddleware] = (),
2424
auth: httpx.Auth | None = None,
2525
) -> APIClient[API_T]:
26+
"""
27+
Create an :class:`APIClient` for the given API descriptor class.
28+
29+
:param api: The API descriptor class — a plain class whose methods are decorated with :func:`get`, :func:`post`, etc.
30+
:param client: The underlying :class:`httpx.AsyncClient` used to send requests.
31+
:param base_url: Base URL prepended to all operation paths.
32+
:param middlewares: Optional sequence of :class:`HttpxMiddleware` instances applied to every request.
33+
:param auth: Optional :class:`httpx.Auth` instance used to authenticate requests.
34+
"""
2635
api_model = APIModel(api)
2736
return APIClient(api_model, client, base_url, auth=auth, middlewares=middlewares)
2837

src/lapidary/runtime/model/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33

44
class ModelBase(pydantic.BaseModel):
5+
"""A simple base class for request and response models. Pydantic's BaseModel with defaults suitable for typical cases."""
6+
57
model_config = pydantic.ConfigDict(
68
extra='allow',
79
populate_by_name=True,

src/lapidary/runtime/paging.py

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,10 @@ def iter_pages(
2222
The function :param:`fn` will be called initially without the cursor parameter and then called with the cursor parameter
2323
as long as :param:`get_cursor` can extract a cursor from the result.
2424
25-
**Example:**
25+
Example::
2626
27-
```python
28-
async for page in iter_pages(client.fn, 'cursor', extractor_fn)(parameter=value):
27+
async for page in iter_pages(client.ops.fn, 'cursor', get_cursor_from_response)(parameter=value):
2928
# Process page
30-
```
31-
32-
Typically, an API will use the same paging pattern for all operations supporting it, so it's a good idea to write a shortcut function:
33-
34-
```python
35-
from lapidary.runtime import iter_pages as _iter_pages
36-
37-
def iter_pages[P, R](fn: Callable[P, Awaitable[R]]) -> Callable[P, AsyncIterable[R]]:
38-
return _iter_pages(fn, 'cursor', lambda result: ...)
39-
```
4029
4130
:param fn: An async function that retrieves a page of data.
4231
:param cursor_param_name: The name of the cursor parameter in :param:`fn`.

0 commit comments

Comments
 (0)