Skip to content

Commit 4b34e1b

Browse files
authored
extra/cookies - add a Message Plugin to work cookies requirements for authentication credentials
1 parent 78e654f commit 4b34e1b

File tree

10 files changed

+260
-7
lines changed

10 files changed

+260
-7
lines changed

aiopenapi3/extra/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .reduce import Cull, Reduce
2+
from .cookies import Cookies
3+
4+
__all__ = ["Cull", "Reduce", "Cookies"]

aiopenapi3/extra/cookies.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from typing import Literal
2+
import email.message
3+
import http.cookiejar
4+
import urllib.request
5+
6+
import aiopenapi3.plugin
7+
8+
9+
class Cookies(aiopenapi3.plugin.Message, aiopenapi3.plugin.Init):
10+
class _Request(urllib.request.Request):
11+
"""
12+
c.f. httpx _CookieCompatRequest
13+
"""
14+
15+
def __init__(self, ctx: aiopenapi3.plugin.Message.Context) -> None:
16+
super().__init__(
17+
url=str(ctx.request.api.url),
18+
headers=dict(ctx.headers),
19+
method=ctx.request.method,
20+
)
21+
self.ctx = ctx
22+
23+
def add_unredirected_header(self, key: str, value: str) -> None:
24+
assert key.lower() == "cookie"
25+
name, _, value = value.partition("=")
26+
self.ctx.cookies[name] = value
27+
28+
class _Response:
29+
"""
30+
c.f. c.f. httpx _CookieCompatResponse
31+
"""
32+
33+
def __init__(self, ctx: aiopenapi3.plugin.Message.Context) -> None:
34+
self.ctx = ctx
35+
36+
def info(self) -> email.message.Message:
37+
info = email.message.Message()
38+
key = "set-cookie"
39+
for value in self.ctx.headers.get_list(key):
40+
info[key] = value
41+
return info
42+
43+
def __init__(
44+
self, cookiejar: http.cookiejar.CookieJar = None, policy: Literal["jar", "securitySchemes"] = "jar"
45+
) -> None:
46+
self.cookiejar: http.cookiejar.CookieJar = cookiejar or http.cookiejar.CookieJar()
47+
self.policy: Literal["jar", "securitySchemes"] = policy
48+
self.schemes: dict[str, str] = dict()
49+
50+
if policy not in ["jar", "securitySchemes"]:
51+
raise ValueError(f"policy {self.policy} is not a valid policy")
52+
53+
super().__init__()
54+
55+
def initialized(self, ctx: "aiopenapi3.plugin.Init.Context") -> "aiopenapi3.plugin.Init.Context":
56+
self.schemes = {
57+
v.root.name: k
58+
for k, v in filter(
59+
lambda x: (x[1].root.type.lower(), x[1].root.in_) == ("apikey", "cookie"),
60+
self.api.components.securitySchemes.items(),
61+
)
62+
}
63+
return ctx
64+
65+
def received(self, ctx: "aiopenapi3.plugin.Message.Context") -> "aiopenapi3.plugin.Message.Context":
66+
response = Cookies._Response(ctx)
67+
request = Cookies._Request(ctx)
68+
69+
cookies = self.cookiejar.make_cookies(response, request)
70+
71+
for cookie in cookies:
72+
if not self.cookiejar._policy.set_ok(cookie, request):
73+
continue # pragma: no cover
74+
75+
if (ss := self.schemes.get(cookie.name)) is not None:
76+
self.api.authenticate(**{ss: cookie.value})
77+
elif self.policy == "jar":
78+
self.cookiejar.set_cookie(cookie)
79+
80+
return ctx
81+
82+
def sending(self, ctx: "aiopenapi3.plugin.Message.Context") -> "aiopenapi3.plugin.Message.Context":
83+
if self.policy == "jar":
84+
self.cookiejar.add_cookie_header(Cookies._Request(ctx))
85+
elif self.policy == "securitySchemes":
86+
# authentication will take care
87+
pass
88+
return ctx
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import logging
44
import re
55

6-
from .plugin import Document, Init
6+
from ..plugin import Document, Init
77

88
if typing.TYPE_CHECKING:
9-
from ._types import HTTPMethodMatchType, PathItemType
9+
from .._types import HTTPMethodMatchType, PathItemType
1010

1111
PathMatchType = Union[re.Pattern, str]
1212
OperationIdMatchType = Union[re.Pattern, str]

aiopenapi3/plugin.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
from aiopenapi3 import OpenAPI
1818

1919
import httpx
20-
from .base import PathItemBase, SchemaBase, RequestBase
20+
from .base import PathItemBase, SchemaBase
21+
from .request import RequestBase
2122

2223
"""
2324
the plugin interface replicates the suds way of dealing with broken data/schema information
@@ -117,6 +118,8 @@ class Context:
117118
"""available :func:`~aiopenapi3.plugin.Message.received` """
118119
headers: "httpx.Headers" = None
119120
"""available :func:`~aiopenapi3.plugin.Message.sending` :func:`~aiopenapi3.plugin.Message.received` """
121+
cookies: dict[str, str] = None
122+
"""available :func:`~aiopenapi3.plugin.Message.sending` """
120123
status_code: Optional[str] = None
121124
"""available :func:`~aiopenapi3.plugin.Message.received` """
122125
content_type: Optional[str] = None

aiopenapi3/v30/glue.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ def _prepare_secschemes_default(self, scheme: str, value: Union[str, Sequence[st
172172
self.req.headers[ss.name] = value
173173

174174
if ss.in_ == "cookie":
175-
self.req.cookies = {ss.name: value}
175+
self.req.cookies[ss.name] = value
176176

177177
def _prepare_secschemes_extra(self, scheme: str, value: Union[str, Sequence[str]]) -> None:
178178
assert (
@@ -251,7 +251,7 @@ def _prepare_secschemes_extra(self, scheme: str, value: Union[str, Sequence[str]
251251
auths.append(auth(value, ss.name))
252252

253253
if ss.in_ == "cookie":
254-
self.req.cookies = {ss.name: value}
254+
self.req.cookies[ss.name] = value
255255

256256
for auth in auths:
257257
if self.req.auth and isinstance(self.req.auth, SupportMultiAuth):
@@ -341,10 +341,15 @@ def _prepare_body(self, data_: Optional["RequestData"], rbq: dict[str, str]) ->
341341

342342
if not self.operation.requestBody:
343343
ctx = self.api.plugins.message.sending(
344-
request=self, operationId=self.operation.operationId, sending=None, headers=self.req.headers
344+
request=self,
345+
operationId=self.operation.operationId,
346+
sending=None,
347+
headers=self.req.headers,
348+
cookies=self.req.cookies,
345349
)
346350
self.req.content = ctx.sending
347351
self.req.headers = ctx.headers
352+
self.req.cookies = ctx.cookies
348353
return
349354

350355
if data_ is None and self.operation.requestBody.required:
@@ -364,10 +369,15 @@ def _prepare_body(self, data_: Optional["RequestData"], rbq: dict[str, str]) ->
364369
data: bytes = data.encode() # type: ignore[union-attr]
365370
self.req.headers["Content-Type"] = "application/json"
366371
ctx = self.api.plugins.message.sending(
367-
request=self, operationId=self.operation.operationId, sending=data, headers=self.req.headers
372+
request=self,
373+
operationId=self.operation.operationId,
374+
sending=data,
375+
headers=self.req.headers,
376+
cookies=self.req.cookies,
368377
)
369378
self.req.content = ctx.sending
370379
self.req.headers = ctx.headers
380+
self.req.cookies = ctx.cookies
371381
elif (ct := "multipart/form-data") in self.operation.requestBody.content:
372382
"""
373383
https://swagger.io/docs/specification/describing-request-body/multipart-requests/
@@ -413,6 +423,18 @@ def _prepare_body(self, data_: Optional["RequestData"], rbq: dict[str, str]) ->
413423
else:
414424
assert media.schema_
415425
raise TypeError((type(data_), media.schema_.get_type()))
426+
427+
# sending is unset here
428+
ctx = self.api.plugins.message.sending(
429+
request=self,
430+
operationId=self.operation.operationId,
431+
sending=None,
432+
headers=self.req.headers,
433+
cookies=self.req.cookies,
434+
)
435+
self.req.headers = ctx.headers
436+
self.req.cookies = ctx.cookies
437+
416438
elif (ct := "application/x-www-form-urlencoded") in self.operation.requestBody.content:
417439
self.req.headers["Content-Type"] = ct
418440
media: aiopenapi3.v30.media.MediaType = self.operation.requestBody.content[ct]
@@ -424,6 +446,18 @@ def _prepare_body(self, data_: Optional["RequestData"], rbq: dict[str, str]) ->
424446
params = parameters_from_urlencoded(data_, media)
425447
content = urllib.parse.urlencode(params, doseq=True)
426448
self.req.content = content
449+
450+
ctx = self.api.plugins.message.sending(
451+
request=self,
452+
operationId=self.operation.operationId,
453+
sending=self.req.content,
454+
headers=self.req.headers,
455+
cookies=self.req.cookies,
456+
)
457+
self.req.content = ctx.sending
458+
self.req.headers = ctx.headers
459+
self.req.cookies = ctx.cookies
460+
427461
elif (ct := "application/octet-stream") in self.operation.requestBody.content:
428462
self.req.headers["Content-Type"] = ct
429463
value: "RequestFileParameter"
@@ -434,6 +468,18 @@ def _prepare_body(self, data_: Optional["RequestData"], rbq: dict[str, str]) ->
434468
self.req.content = data_
435469
else:
436470
raise TypeError(data_)
471+
472+
ctx = self.api.plugins.message.sending(
473+
request=self,
474+
operationId=self.operation.operationId,
475+
sending=self.req.content,
476+
headers=self.req.headers,
477+
cookies=self.req.cookies,
478+
)
479+
self.req.content = ctx.sending
480+
self.req.headers = ctx.headers
481+
self.req.cookies = ctx.cookies
482+
437483
else:
438484
raise NotImplementedError(self.operation.requestBody.content)
439485

docs/source/api.rst

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,3 +396,24 @@ Code below will eleminate all schemas not required to serve the operations ident
396396
.. autoclass:: Reduce
397397
:members: __init__
398398
.. autoclass:: Cull
399+
400+
401+
Cookies
402+
-------
403+
404+
This plugin deals with cookies from responses and adds in requests based on a policy.
405+
406+
There are two policies:
407+
* securitySchemes - only cookies whose name is in the securitySchemes are used. To match authentication requirements credentials are set via OpenAPI.authenticate(name=value)
408+
* jar - all cookies are used, basically like a browser would do. Cookies not mentioned in securitySchemes are set besides OpenAPI.authenticate() to allow using them without adjusting the description document.
409+
410+
.. code:: python
411+
412+
api = OpenAPI.load_sync(
413+
"http://127.0.0.1/api.yaml",
414+
plugins=[
415+
Cookies(policy="jar")
416+
],
417+
)
418+
419+
.. autoclass:: Cookies

docs/source/extra.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@ Large description documents which are autogenerated by converting other service
1818
may benefit from additional changes to the description document to eliminate conversion artifacts depending on the converter used.
1919

2020
As an example for additional steps based on the `Microsoft Graph API <https://github.com/microsoftgraph/msgraph-metadata>`_ refer to :aioai3:ref:`tests.extra_test.MSGraph`.
21+
22+
Cookies
23+
=======
24+
To assist in dealing with cookie requirements of some APIs, :class:`aiopenapi3.extra.Cookies` can be used.

tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,11 @@ def with_extra_reduced():
380380
yield "extra-reduced.yaml"
381381

382382

383+
@pytest.fixture
384+
def with_extra_cookie(openapi_version):
385+
yield _get_parsed_yaml("extra-cookie.yaml", openapi_version)
386+
387+
383388
@pytest.fixture
384389
def with_schema_self_recursion(openapi_version):
385390
yield _get_parsed_yaml("schema-self-recursion.yaml", openapi_version)

tests/extra_test.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,44 @@ def test_reduced(with_extra_reduced, httpx_mock, compressor):
199199
assert "A1" in api.components.schemas
200200
assert "A" in api.components.responses
201201
assert "A" in api.components.requestBodies
202+
203+
204+
from aiopenapi3.extra import Cookies
205+
206+
207+
@pytest.mark.parametrize("cookie", [dict(policy="jar"), dict(policy="securitySchemes")], ids=["jar", "securityScheme"])
208+
def test_cookies(httpx_mock, with_extra_cookie, cookie):
209+
210+
api = OpenAPI(
211+
"http://127.0.0.1/api.yaml",
212+
with_extra_cookie,
213+
session_factory=httpx.Client,
214+
plugins=[Cookies(**cookie)],
215+
)
216+
217+
httpx_mock.add_response(
218+
url="http://127.0.0.1/api/set-cookie",
219+
headers=[("Set-Cookie", "Session=value"), ("Set-Cookie", "a=b")],
220+
json='"ok"',
221+
)
222+
api._.set_cookie()
223+
224+
if cookie["policy"] == "jar":
225+
httpx_mock.add_response(
226+
url="http://127.0.0.1/api/require-cookie", match_headers={"Cookie": "Session=value; a=b"}, json='"ok"'
227+
)
228+
else:
229+
httpx_mock.add_response(
230+
url="http://127.0.0.1/api/require-cookie", match_headers={"Cookie": "Session=value"}, json='"ok"'
231+
)
232+
api._.require_cookie()
233+
234+
req = httpx_mock.get_requests()[-1]
235+
236+
if cookie["policy"] == "securitySchemes":
237+
assert req.headers.get_list("cookie") == ["Session=value"]
238+
239+
240+
def test_cookies_policy(with_extra_cookie):
241+
with pytest.raises(ValueError, match="policy … is not a valid policy"):
242+
Cookies(policy="…")

tests/fixtures/extra-cookie.yaml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
openapi: 3.0.3
2+
info:
3+
title: ''
4+
version: 0.0.0
5+
servers:
6+
- url: http://127.0.0.1/api
7+
8+
security:
9+
- cookieAuth: []
10+
11+
paths:
12+
/set-cookie:
13+
get:
14+
operationId: set_cookie
15+
responses:
16+
'200':
17+
content:
18+
application/json:
19+
schema:
20+
type: string
21+
description: ''
22+
security: []
23+
24+
/require-cookie:
25+
get:
26+
operationId: require_cookie
27+
description: ''
28+
responses:
29+
'200':
30+
content:
31+
application/json:
32+
schema:
33+
type: string
34+
description: ''
35+
36+
components:
37+
securitySchemes:
38+
cookieAuth:
39+
type: apiKey
40+
in: cookie
41+
name: Session

0 commit comments

Comments
 (0)