Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automated testing of REST API #550

Merged
merged 27 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9e6b42f
Add examples to URI parameters
krestenlaust Jan 8, 2025
1e2aa14
Rename 'category_*' to 'product_category'
krestenlaust Jan 8, 2025
59549f9
Rename schema to be consistent
krestenlaust Jan 8, 2025
9e7f38f
Inline response examples
krestenlaust Jan 8, 2025
0eef126
Correct content-types in responses
krestenlaust Jan 8, 2025
ca85c3b
Add Fixture fitted with spec examples
krestenlaust Jan 9, 2025
b83c76c
Add Dredd configuration and Test hook
krestenlaust Jan 9, 2025
ebea407
Add Github workflow to run dredd
krestenlaust Jan 9, 2025
488026b
fixup! Add Github workflow to run dredd
krestenlaust Jan 9, 2025
6da1169
Update comments in hook file
krestenlaust Jan 9, 2025
ccc44ad
Check that API parameter instances exists
krestenlaust Jan 10, 2025
0e242fe
Update api-sale response with wrong member
krestenlaust Jan 10, 2025
5308c04
Add option to skip tests in hooks file
krestenlaust Jan 10, 2025
251638e
Add log in hook file
krestenlaust Jan 10, 2025
ac7d5c5
Update sale-api result to be more accurate
krestenlaust Jan 10, 2025
a294567
Replace body parameter in Post requests
krestenlaust Jan 10, 2025
c9b54c0
Refactor code
krestenlaust Jan 10, 2025
cd98404
Move util to different file
krestenlaust Jan 10, 2025
5db0b71
Move dredd.yml into openapi folder
krestenlaust Jan 13, 2025
1b3f7f6
Reformat with 4 spaces instead of 2
krestenlaust Jan 13, 2025
d6718ad
Reduce wildcard import
krestenlaust Jan 13, 2025
a421ca7
Update util function with docstring (and new name)
krestenlaust Jan 13, 2025
1f301c7
fixup! Update util function with docstring (and new name)
krestenlaust Jan 13, 2025
43bd849
Add docstring, add types of util-function
krestenlaust Jan 13, 2025
96ee92b
Move local imports and add typing
krestenlaust Jan 13, 2025
1215834
Merge branch 'next' into feat/openapi-dredd-test
krestenlaust Jan 13, 2025
0acc55a
Merge branch 'next' into feat/openapi-dredd-test
krestenlaust Jan 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/openapi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: OpenAPI Validation & HTTP Test

on:
push:
branches:
- master
- next
pull_request:

jobs:
build:

runs-on: ubuntu-20.04
strategy:
max-parallel: 4
matrix:
python-version: ["3.11.9"]

steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r openapi/requirements.txt
- uses: actions/setup-node@v4
with:
node-version: 18
- run: npm i -g dredd
- run: dredd --config openapi/dredd.yml
32 changes: 32 additions & 0 deletions openapi/dredd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
color: true
dry-run: null
hookfiles: ["openapi/dredd_hook.py"]
language: python
require: null
server: "python3 manage.py testserver stregsystem/fixtures/openapi-fixture.json"
server-wait: 5
init: false
custom: {}
names: false
only: []
reporter: apiary
output: []
header: []
sorted: false
user: null
inline-errors: false
details: false
method: []
loglevel: warn
path: []
hooks-worker-timeout: 5000
hooks-worker-connect-timeout: 1500
hooks-worker-connect-retry: 500
hooks-worker-after-connect-wait: 100
hooks-worker-term-timeout: 5000
hooks-worker-term-retry: 500
hooks-worker-handler-host: 127.0.0.1
hooks-worker-handler-port: 61321
config: ./dredd.yml
blueprint: openapi/stregsystem.yaml
endpoint: 'http://127.0.0.1:8000'
42 changes: 42 additions & 0 deletions openapi/dredd_hook.py
krestenlaust marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import dredd_hooks as hooks
import json
from utils import update_query_parameter_values, update_dictionary_values

not_found_parameter_values = {
'room_id': 1,
'member_id': 1,
'username': "404_user",
}

skipped_endpoints = [
"GET (400) /api/member/payment/qr?username=kresten" # Skipped: test can't be implemented properly in OpenAPI
]

@hooks.before_each
def skip_endpoint(transaction):
if transaction['id'] in skipped_endpoints:
print(f"Skipping endpoint: {transaction['id']}")
transaction['skip'] = True


# https://dredd.org/en/latest/data-structures.html#transaction-object
@hooks.before_each
def replace_4xx_parameter_values(transaction):
"""
It isn't possible to specify individual parameter example values for each response type in OpenAPI.
To properly test the return value of not-found parameters, replace all parameters.
"""
if transaction['expected']['statusCode'][0] == '4':
new_path = update_query_parameter_values(transaction['fullPath'], not_found_parameter_values)
print(f"Update endpoint path, from '{transaction['fullPath']}' to '{new_path}'")
transaction['fullPath'] = new_path
transaction['request']['uri'] = new_path


@hooks.before_each
def replace_body_in_post_requests(transaction):
if transaction['expected']['statusCode'][0] == '4' and transaction['id'].startswith("POST"):
body = json.loads(transaction['request']['body'])
update_dictionary_values(body, not_found_parameter_values)

transaction['request']['body'] = json.dumps(body)
1 change: 1 addition & 0 deletions openapi/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dredd_hooks
85 changes: 48 additions & 37 deletions openapi/stregsystem.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -190,27 +190,33 @@ components:
required: true
schema:
$ref: '#/components/schemas/member_id'
example: 321
room_id_param:
name: room_id
in: query
description: ID of the room to retrieve.
required: true
schema:
$ref: '#/components/schemas/room_id'
example: 10
username_param:
name: username
in: query
description: Username of the member.
required: true
schema:
$ref: '#/components/schemas/username'
example: kresten
amount_param:
name: amount
in: query
description: Amount of money in streg-oere.
required: false
schema:
$ref: '#/components/schemas/stregoere_balance'
examples:
normalBalance:
value: 20000
schemas:
memberNotFoundMessage:
type: string
Expand All @@ -230,7 +236,7 @@ components:
missingRoomIdMessage:
type: string
example: "Parameter missing: room_id"
missingMemberUsername:
missingMemberUsernameMessage:
type: string
example: "Parameter missing: username"
balance:
Expand Down Expand Up @@ -264,14 +270,17 @@ components:
stregoere_price:
type: integer
example: 600
stregoere_price_three_beers:
type: integer
example: 1800
stregoere_balance:
type: integer
example: 20000
named_products_example:
type: object
properties:
beer:
$ref: '#/components/schemas/product_id'
stregkroner_balance:
description: Stregbalance in kroner, only used in API-Sale
type: number
format: float
example: 182.00
sale_input:
type: object
properties:
Expand All @@ -288,36 +297,26 @@ components:
$ref: '#/components/schemas/product_name'
price:
$ref: '#/components/schemas/stregoere_price'
active_products_example:
type: object
properties:
123:
$ref: '#/components/schemas/active_product'
buystring:
type: string
example: "kresten beer:3"
category_name:
product_category_name:
type: string
example: "Alcohol"
category_id:
product_category_id:
type: integer
example: 11
category:
product_category:
type: object
properties:
category_id:
$ref: '#/components/schemas/category_id'
$ref: '#/components/schemas/product_category_id'
category_name:
$ref: '#/components/schemas/category_name'
category_mapping:
$ref: '#/components/schemas/product_category_name'
product_category_mapping:
type: array
items:
$ref: '#/components/schemas/category'
category_mappings_example:
type: object
properties:
123:
$ref: '#/components/schemas/category_mapping'
$ref: '#/components/schemas/product_category'
created_on:
type: string
format: date
Expand All @@ -330,13 +329,15 @@ components:
type: boolean
example: false
bp_minutes:
description: Ballmer Peak minutes
type: integer
nullable: true
example: 2
example: null
bp_seconds:
description: Ballmer Peak seconds
type: integer
nullable: true
example: 30
example: null
caffeine:
type: integer
example: 2
Expand All @@ -354,7 +355,7 @@ components:
example: true
sale_hints:
type: string
example: "<span class=\"username\">kresten</span> beer:3"
example: "<span class=\"username\">kresten</span> 123:3"
member_has_low_balance:
type: boolean
example: false
Expand All @@ -374,6 +375,10 @@ components:
type: array
items:
$ref: '#/components/schemas/product_id'
example:
- 123
- 123
- 123
promille:
$ref: '#/components/schemas/promille'
is_ballmer_peaking:
Expand All @@ -391,15 +396,15 @@ components:
is_coffee_master:
$ref: '#/components/schemas/is_coffee_master'
cost:
$ref: '#/components/schemas/stregoere_price'
$ref: '#/components/schemas/stregoere_price_three_beers'
give_multibuy_hint:
$ref: '#/components/schemas/give_multibuy_hint'
sale_hints:
$ref: '#/components/schemas/sale_hints'
member_has_low_balance:
$ref: '#/components/schemas/member_has_low_balance'
member_balance:
$ref: '#/components/schemas/stregoere_balance'
$ref: '#/components/schemas/stregkroner_balance'
sale:
type: object
properties:
Expand Down Expand Up @@ -472,19 +477,25 @@ components:
content:
application/json:
example:
$ref: '#/components/schemas/named_products_example'
beer: 123
ActiveProducts:
description: Dictionary of all activated products, with their name and price (in stregører).
content:
application/json:
example:
$ref: '#/components/schemas/active_products_example'
123:
name: Beer
price: 600
CategoryMappings:
description: Dictionary of all activated products, with their mapped categories (both category name and ID).
content:
application/json:
example:
$ref: '#/components/schemas/category_mappings_example'
123:
- category_id:
11
category_name:
"Alcohol"
SaleSuccess:
description: An object containing various statistics and info regarding the purchase.
content:
Expand All @@ -509,18 +520,18 @@ components:
InvalidQRInputResponse:
description: Invalid input has been provided.
content:
text/html:
text/html; charset=utf-8:
schema:
type: string
example: Invalid input for MobilePay QR code generation
MemberUsernameParameter_BadResponse:
description: Member does not exist, or missing parameter.
content:
text/html:
text/html; charset=utf-8:
schema:
oneOf:
- $ref: '#/components/schemas/memberNotFoundMessage'
- $ref: '#/components/schemas/missingMemberUsername'
- $ref: '#/components/schemas/missingMemberUsernameMessage'
examples:
memberNotFound:
$ref: '#/components/examples/MemberNotFoundExample'
Expand All @@ -529,7 +540,7 @@ components:
MemberIdParameter_BadResponse:
description: Member does not exist, invalid member ID, or missing parameter.
content:
text/html:
text/html; charset=utf-8:
schema:
oneOf:
- $ref: '#/components/schemas/memberNotFoundMessage'
Expand All @@ -545,7 +556,7 @@ components:
RoomIdParameter_BadResponse:
description: Room does not exist, invalid room ID, or missing parameter.
content:
text/html:
text/html; charset=utf-8:
schema:
oneOf:
- $ref: '#/components/schemas/roomNotFoundMessage'
Expand All @@ -561,7 +572,7 @@ components:
Member_RoomIdParameter_BadResponse:
description: Room or member does not exist, invalid room or member ID, or missing parameter.
content:
text/html:
text/html; charset=utf-8:
schema:
oneOf:
- $ref: '#/components/schemas/memberNotFoundMessage'
Expand Down
31 changes: 31 additions & 0 deletions openapi/utils.py
krestenlaust marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse


def update_dictionary_values(original: dict[Any, Any], replacement: dict[Any, Any]) -> None:
"""
Same as dict.update(...) but doesn't add new keys.
Mutates 'original'
:param original: The dictionary to mutate.
:param replacement: The dictionary with values to update the original with.
"""
original.update({k: v for k, v in replacement.items() if k in original})


def update_query_parameter_values(url_string: str, new_parameter_values: dict[str, Any]) -> str:
"""
Updates query parameters with new parameter values from new_parameter_values.
:param url_string: The URL path of which to modify query parameters.
:param new_parameter_values: The dictionary with new query parameter values.
:return: The URL with updated query parameter.
"""
parsed_url = urlparse(url_string)

qs_dict = parse_qs(parsed_url.query, keep_blank_values=True)
qs_dict_flattened = {key: value[0] for key, value in qs_dict.items()}
update_dictionary_values(qs_dict_flattened, new_parameter_values)
updated_qs = urlencode(qs_dict_flattened, doseq=False)

parsed_url = parsed_url._replace(query=updated_qs)

return str(urlunparse(parsed_url))
Loading
Loading