Skip to content

Commit b63a55f

Browse files
committed
httpserver: add URI matching
Extend URI matching functionality which now accepts: * a URIPattern object, whose match() method will be called * a compiled regexp returned by re.compile(). In this case the URI will be matched against the regexp. In both cases the URI will be an absolute path starting with a slash. Fixes #34 and #36.
1 parent 2088e10 commit b63a55f

5 files changed

Lines changed: 116 additions & 7 deletions

File tree

doc/guide.rst

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,19 @@ Requests
4040
~~~~~~~~
4141
When registering a :py:class:`pytest_httpserver.server.RequestMatcher`, it can use various parts
4242
of the HTTP request to be matched: URI, method, data, headers, and query string can be specified.
43-
All of these are based on simple equality checking, with the exception of method and URI where a special
44-
value specifying `any` can be given (variables `URI_DEFAULT` and `METHOD_ALL`, respectively).
43+
44+
The following can be matched:
45+
46+
* uri: a string, a regexp or an :py:class:`pytest_httpserver.server.URIPattern` object
47+
48+
* method: GET/POST/..., specified as string
49+
50+
* data: a string or bytes. It is possible to match with arbitrary byte data.
51+
52+
* headers: a str-str dictionary or a :py:class:`pytest_httpserver.server.HeaderValueMatcher` object
53+
which matches each header with its own provided callable
54+
55+
* query_string: a string, bytes or a dict specifying the key-value pairs of the query string
4556

4657
:py:class:`pytest_httpserver.server.HTTPServer` also determines how these matchers are looked up and
4758
what their lifetime is. You can register handlers which handle any amount of requests, but you can also

pytest_httpserver/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@
88
from .httpserver import HTTPServer
99
from .httpserver import HTTPServerError, Error, NoHandlerError
1010
from .httpserver import WaitingSettings, HeaderValueMatcher, RequestHandler
11-
from .httpserver import URI_DEFAULT, METHOD_ALL
11+
from .httpserver import URIPattern, URI_DEFAULT, METHOD_ALL

pytest_httpserver/httpserver.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
import threading
44
import json
55
import time
6+
import re
67
from collections import defaultdict
78
from enum import Enum
89
from contextlib import suppress, contextmanager
910
from copy import copy
10-
from typing import Callable, Mapping, Optional, Union
11+
from typing import Callable, Mapping, Optional, Union, Pattern
1112
from ssl import SSLContext
1213
import abc
1314

@@ -230,13 +231,27 @@ def _create_query_matcher(query_string: Union[None, QueryMatcher, str, bytes, Ma
230231
raise TypeError("Unable to cast this type to QueryMatcher: {!r}".format(type(query_string)))
231232

232233

234+
class URIPattern(abc.ABC):
235+
@abc.abstractmethod
236+
def match(self, uri: str) -> bool:
237+
"""
238+
Matches the provided URI.
239+
240+
:param uri: URI of the request. This is an absolute path startting
241+
with "/" and does not contain the query part.
242+
:return: True if there's a match, False otherwise
243+
"""
244+
pass
245+
246+
233247
class RequestMatcher:
234248
"""
235249
Matcher object for the incoming request.
236250
237251
It defines various parameters to match the incoming request.
238252
239-
:param uri: URI of the request. This must be an absolute path starting with ``/``.
253+
:param uri: URI of the request. This must be an absolute path starting with ``/``, a
254+
:py:class:`URIPattern` object, or a regular expression compiled by re.compile.
240255
:param method: HTTP method of the request. If not specified (or `METHOD_ALL`
241256
specified), all HTTP requests will match.
242257
:param data: payload of the HTTP request. This could be a string (utf-8 encoded
@@ -253,7 +268,7 @@ class RequestMatcher:
253268

254269
def __init__(
255270
self,
256-
uri: str,
271+
uri: Union[str, URIPattern, Pattern[str]],
257272
method: str = METHOD_ALL,
258273
data: Union[str, bytes, None] = None,
259274
data_encoding: str = "utf-8",
@@ -300,6 +315,27 @@ def match_data(self, request: Request) -> bool:
300315
return True
301316
return request.data == self.data
302317

318+
def match_uri(self, request: Request) -> bool:
319+
path = request.path
320+
321+
if isinstance(self.uri, URIPattern):
322+
return self.uri.match(path)
323+
324+
# this is python version depending
325+
# in python 3.7 and above: it is re.Pattern
326+
# below python 3.7 it is _sre.SRE_Pattern which cannot be accessed directly
327+
elif isinstance(self.uri, re.compile("").__class__):
328+
return bool(self.uri.match(path))
329+
330+
else:
331+
# there could be a guard isinstance(self.uri, str) been here
332+
# but we want to allow any object which provides the __eq__ parameter
333+
# (note: in this case it will be not typeing correct)
334+
#
335+
# also, python will raise TypeError when self.uri is a conflicting type
336+
337+
return self.uri == URI_DEFAULT or path == self.uri
338+
303339
def difference(self, request: Request) -> list:
304340
"""
305341
Calculates the difference between the matcher and the request.
@@ -313,7 +349,8 @@ def difference(self, request: Request) -> list:
313349
"""
314350

315351
retval = []
316-
if self.uri != URI_DEFAULT and request.path != self.uri:
352+
353+
if not self.match_uri(request):
317354
retval.append(("uri", request.path, self.uri))
318355

319356
if self.method != METHOD_ALL and self.method != request.method:
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
features:
3+
- |
4+
Extend URI matching by allowing to specify URIPattern object or a compiled
5+
regular expression, which will be matched against the URI. URIPattern class
6+
is defined as abstract in the library so the user need to implement a new
7+
class based on it.

tests/test_urimatch.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
2+
3+
import re
4+
5+
from pytest_httpserver import HTTPServer, URIPattern
6+
import requests
7+
8+
9+
class PrefixMatch(URIPattern):
10+
def __init__(self, prefix: str):
11+
self.prefix = prefix
12+
13+
def match(self, uri):
14+
return uri.startswith(self.prefix)
15+
16+
17+
class PrefixMatchEq:
18+
def __init__(self, prefix: str):
19+
self.prefix = prefix
20+
21+
def __eq__(self, uri):
22+
return uri.startswith(self.prefix)
23+
24+
25+
def test_uripattern_object(httpserver: HTTPServer):
26+
httpserver.expect_request(PrefixMatch("/foo")).respond_with_json({"foo": "bar"})
27+
assert requests.get(httpserver.url_for("/foo")).json() == {"foo": "bar"}
28+
assert requests.get(httpserver.url_for("/foobar")).json() == {"foo": "bar"}
29+
assert requests.get(httpserver.url_for("/foobaz")).json() == {"foo": "bar"}
30+
31+
assert requests.get(httpserver.url_for("/barfoo")).status_code == 500
32+
33+
assert len(httpserver.assertions) == 1
34+
35+
36+
def test_regexp(httpserver: HTTPServer):
37+
httpserver.expect_request(re.compile(r"/foo/\d+/bar/")).respond_with_json({"foo": "bar"})
38+
assert requests.get(httpserver.url_for("/foo/123/bar/")).json() == {"foo": "bar"}
39+
assert requests.get(httpserver.url_for("/foo/9999/bar/")).json() == {"foo": "bar"}
40+
41+
assert requests.get(httpserver.url_for("/foo/bar/")).status_code == 500
42+
43+
assert len(httpserver.assertions) == 1
44+
45+
46+
def test_object_with_eq(httpserver: HTTPServer):
47+
httpserver.expect_request(PrefixMatchEq("/foo")).respond_with_json({"foo": "bar"})
48+
assert requests.get(httpserver.url_for("/foo")).json() == {"foo": "bar"}
49+
assert requests.get(httpserver.url_for("/foobar")).json() == {"foo": "bar"}
50+
assert requests.get(httpserver.url_for("/foobaz")).json() == {"foo": "bar"}
51+
52+
assert requests.get(httpserver.url_for("/barfoo")).status_code == 500
53+
54+
assert len(httpserver.assertions) == 1

0 commit comments

Comments
 (0)