Files
FTL/test/api/libs/responseVerifyer.py
T

205 lines
8.0 KiB
Python

#!/bin/python3
# Pi-hole: A black hole for Internet advertisements
# (c) 2023 Pi-hole, LLC (https://pi-hole.net)
# Network-wide ad blocking via your own hardware.
#
# FTL Engine - auxiliary files
# API test script
#
# This file is copyright under the latest version of the EUPL.
# Please see LICENSE file for your rights under this license.
from libs.openAPI import openApi
import urllib.request, urllib.parse
from libs.FTLAPI import FTLAPI
from collections.abc import MutableMapping
class ResponseVerifyer():
# Translate between OpenAPI and Python types
YAML_TYPES = { "string": [str], "integer": [int], "number": [int, float], "boolean": [bool], "array": [list] }
def __init__(self, ftl: FTLAPI, openapi: openApi):
self.ftl = ftl
self.openapi = openapi
self.errors = []
def flatten_dict(self, d: MutableMapping, parent_key: str = '', sep: str ='.') -> MutableMapping:
items = []
# Iterate over all items in the dictionary
for k, v in d.items():
# Create a new key by appending the current key to the parent key
new_key = parent_key + sep + k if parent_key else k
# If the value is a dictionary, recursively flatten it, otherwise
# simply add it to the list of items
if isinstance(v, MutableMapping):
items.extend(self.flatten_dict(v, new_key, sep=sep).items())
else:
items.append((new_key, v))
return dict(items)
def verify_endpoint(self, endpoint: str):
# If the endpoint starts with /api, remove this part (it is not
# part of the YAML specs)
if endpoint.startswith("/api"):
endpoint = endpoint[4:]
method = 'get'
rcode = '200'
# Check if the endpoint is defined in the API specs
if endpoint not in self.openapi.paths:
self.errors.append("Endpoint " + endpoint + " not found in the API specs")
return self.errors
# Check if this endpoint + method are defined in the API specs
if method not in self.openapi.paths[endpoint]:
self.errors.append("Method " + method + " not found in the API specs")
return self.errors
# Get YAML response schema and examples (if applicable)
jsonData = self.openapi.paths[endpoint][method]['responses'][str(rcode)]['content']['application/json']
YAMLresponseSchema = jsonData['schema']
YAMLresponseExamples = jsonData['examples'] if 'examples' in jsonData else None
# Prepare required parameters (if any)
FTLparameters = []
if 'parameters' in self.openapi.paths[endpoint][method]:
YAMLparameters = self.openapi.paths[endpoint][method]['parameters']
for param in YAMLparameters:
# We are only handling QUERY parameters here as we're doing GET
if param['in'] != 'query':
continue
# We are only adding required parameters here
if param['required'] == False:
continue
FTLparameters.append(param['name'] + "=" + urllib.parse.quote_plus(str(param['example'])))
# Get FTL response
FTLresponse = self.ftl.GET("/api" + endpoint, FTLparameters)
if FTLresponse is None:
return self.ftl.errors
self.YAMLresponse = {}
# Check if the response is an object. If so, we have to check it
# recursively
if 'type' in YAMLresponseSchema and YAMLresponseSchema['type'] == 'object':
# Loop over all properties of the object
for prop in YAMLresponseSchema['properties']:
self.verify_property(YAMLresponseSchema['properties'], YAMLresponseExamples, FTLresponse, [prop])
# Check if the response is a gather-all object. If so, we have
# to check all objects in the array individually
elif 'allOf' in YAMLresponseSchema and len(YAMLresponseSchema['allOf']) > 0:
for i in range(len(YAMLresponseSchema['allOf'])):
for prop in YAMLresponseSchema['allOf'][i]['properties']:
self.verify_property(YAMLresponseSchema['allOf'][i]['properties'], YAMLresponseExamples, FTLresponse, [prop])
# If neither of the above is true, thie definition is invalid
else:
self.errors.append("Top-level response should be either an object or a non-empty allOf/anyOf/oneOf")
# Finally, we check if there are extra properties in the FTL response
# that are not defined in the API specs
# Flatten the FTL response
FTLflat = self.flatten_dict(FTLresponse)
YAMLflat = self.YAMLresponse
# Check for properties in FTL that are not in the API specs
for property in FTLflat.keys():
if property not in YAMLflat.keys():
self.errors.append("Property '" + property + "' missing in the API specs")
# Return all errors
return self.errors
# Verify a single property's type
def verify_type(self, prop_type: any, yaml_type: str, yaml_nullable: bool):
# None is an acceptable reply when this is specified in the API specs
if prop_type is type(None) and yaml_nullable:
return True
# Check if the type is correct using the YAML_TYPES translation table
if yaml_type not in self.YAML_TYPES:
self.errors.append("Property type \"" + yaml_type + "\" is not valid in OpenAPI specs")
return False
return prop_type in self.YAML_TYPES[yaml_type]
# Verify a single property
def verify_property(self, YAMLprops: dict, YAMLexamples: dict, FTLprops: dict, props: list):
all_okay = True
# Build flat path of this property
flat_path = ".".join(props)
# Check if the property is defined in the API specs
if props[-1] not in YAMLprops:
self.errors.append("Property '" + flat_path + "' missing in the API specs")
return False
YAMLprop = YAMLprops[props[-1]]
# Check if the property is defined in the FTL response
if props[-1] not in FTLprops:
self.errors.append("Property '" + flat_path + "' missing in FTL's response")
return False
FTLprop = FTLprops[props[-1]]
# If this is another object, we have to dive deeper
if YAMLprop['type'] == 'object':
# Loop over all properties of the object ...
for prop in YAMLprop['properties']:
# ... and check them recursively
if not self.verify_property(YAMLprop['properties'], YAMLexamples, FTLprop, props + [prop]):
all_okay = False
else:
# Check this property
# Get type of this property using the YAML_TYPES translation table
yaml_type = YAMLprop['type']
# Check if this property is nullable (can be None even
# if not defined as string, integer, etc.)
yaml_nullable = 'nullable' in YAMLprop and YAMLprop['nullable'] == True
# Add this property to the YAML response
self.YAMLresponse[flat_path] = []
# Check type of YAML example (if defined)
if 'example' in YAMLprop:
example_type = type(YAMLprop['example'])
# Check if the type of the example matches the
# type we defined in the API specs
self.YAMLresponse[flat_path].append(YAMLprop['example'])
if not self.verify_type(example_type, yaml_type, yaml_nullable):
self.errors.append(f"API example ({str(example_type)}) does not match defined type ({yaml_type}) in {flat_path} (nullable: " + ("True" if yaml_nullable else "False") + ")")
return False
# Check type of externally defined YAML examples (next to schema)
elif YAMLexamples is not None:
for t in YAMLexamples:
if 'value' not in YAMLexamples[t]:
self.errors.append(f"Example {flat_path} does not have a 'value' property")
return False
example = YAMLexamples[t]['value']
# Dive into the example to get to the property we want
for p in props:
if p not in example:
self.errors.append(f"Example {flat_path} does not have an '{p}' item")
return False
example = example[p]
# Check if the type of the example matches the type we defined in the API specs
example_type = type(example)
self.YAMLresponse[flat_path].append(example)
if not self.verify_type(example_type, yaml_type, yaml_nullable):
self.errors.append(f"API example ({str(example_type)}) does not match defined type ({yaml_type}) in {flat_path} (nullable: " + ("True" if yaml_nullable else "False") + ")")
return False
# Compare type of FTL's reply against what we defined in the API specs
ftl_type = type(FTLprop)
if not self.verify_type(ftl_type, yaml_type, yaml_nullable):
self.errors.append(f"FTL's reply ({str(ftl_type)}) does not match defined type ({yaml_type}) in {flat_path}")
return False
return all_okay