- Coverage for api_validator/models/context.py: - 94% -
-Shortcuts on this page
-- r - m - x - p - toggle line displays -
-- j - k next/prev highlighted chunk -
-- 0 (zero) top of page -
-- 1 (one) first highlighted chunk -
-diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index dce6827..0000000 --- a/.coveragerc +++ /dev/null @@ -1,7 +0,0 @@ -[run] -source= - src -omit= - venv/* - */__init__.py - */__main__.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21d1109..5f7fb98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,5 @@ jobs: pip install --upgrade pip pip install -r requirements.txt pip install -r requirements.dev.txt - - name: Tests with coverage - run: | - coverage run --branch -m pytest -vv - coverage report -m + - name: Test + run: pytest diff --git a/README.md b/README.md index 210bc55..d655e60 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # PyPony -[![Unit Tests](https://github.com/Bandwidth/pypony/actions/workflows/ci.yml/badge.svg)](https://github.com/Bandwidth/pypony/actions/workflows/ci.yml) -[![coverage badge](coverage/badge.svg)](https://github.com/Bandwidth/pypony/coverage/) +[![Unit Tests](https://github.com/Bandwidth/pypony/actions/workflows/ci.yml/badge.svg)](https://github.com/Bandwidth/pypony/actions/workflows/ci.yml) PyPony is a Python CLI tool for contract testing OpenAPI specifications against the live APIs that they define. diff --git a/coverage/badge.svg b/coverage/badge.svg deleted file mode 100644 index a43f6c6..0000000 --- a/coverage/badge.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/coverage/coverage_html.js b/coverage/coverage_html.js deleted file mode 100644 index 00e1848..0000000 --- a/coverage/coverage_html.js +++ /dev/null @@ -1,575 +0,0 @@ -// Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -// For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt - -// Coverage.py HTML report browser code. -/*jslint browser: true, sloppy: true, vars: true, plusplus: true, maxerr: 50, indent: 4 */ -/*global coverage: true, document, window, $ */ - -coverage = {}; - -// General helpers -function debounce(callback, wait) { - let timeoutId = null; - return function(...args) { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - callback.apply(this, args); - }, wait); - }; -}; - -function checkVisible(element) { - const rect = element.getBoundingClientRect(); - const viewBottom = Math.max(document.documentElement.clientHeight, window.innerHeight); - const viewTop = 30; - return !(rect.bottom < viewTop || rect.top >= viewBottom); -} - -// Helpers for table sorting -function getCellValue(row, column = 0) { - const cell = row.cells[column] - if (cell.childElementCount == 1) { - const child = cell.firstElementChild - if (child instanceof HTMLTimeElement && child.dateTime) { - return child.dateTime - } else if (child instanceof HTMLDataElement && child.value) { - return child.value - } - } - return cell.innerText || cell.textContent; -} - -function rowComparator(rowA, rowB, column = 0) { - let valueA = getCellValue(rowA, column); - let valueB = getCellValue(rowB, column); - if (!isNaN(valueA) && !isNaN(valueB)) { - return valueA - valueB - } - return valueA.localeCompare(valueB, undefined, {numeric: true}); -} - -function sortColumn(th) { - // Get the current sorting direction of the selected header, - // clear state on other headers and then set the new sorting direction - const currentSortOrder = th.getAttribute("aria-sort"); - [...th.parentElement.cells].forEach(header => header.setAttribute("aria-sort", "none")); - if (currentSortOrder === "none") { - th.setAttribute("aria-sort", th.dataset.defaultSortOrder || "ascending"); - } else { - th.setAttribute("aria-sort", currentSortOrder === "ascending" ? "descending" : "ascending"); - } - - const column = [...th.parentElement.cells].indexOf(th) - - // Sort all rows and afterwards append them in order to move them in the DOM - Array.from(th.closest("table").querySelectorAll("tbody tr")) - .sort((rowA, rowB) => rowComparator(rowA, rowB, column) * (th.getAttribute("aria-sort") === "ascending" ? 1 : -1)) - .forEach(tr => tr.parentElement.appendChild(tr) ); -} - -// Find all the elements with data-shortcut attribute, and use them to assign a shortcut key. -coverage.assign_shortkeys = function () { - document.querySelectorAll("[data-shortcut]").forEach(element => { - document.addEventListener("keypress", event => { - if (event.target.tagName.toLowerCase() === "input") { - return; // ignore keypress from search filter - } - if (event.key === element.dataset.shortcut) { - element.click(); - } - }); - }); -}; - -// Create the events for the filter box. -coverage.wire_up_filter = function () { - // Cache elements. - const table = document.querySelector("table.index"); - const table_body_rows = table.querySelectorAll("tbody tr"); - const no_rows = document.getElementById("no_rows"); - - // Observe filter keyevents. - document.getElementById("filter").addEventListener("input", debounce(event => { - // Keep running total of each metric, first index contains number of shown rows - const totals = new Array(table.rows[0].cells.length).fill(0); - // Accumulate the percentage as fraction - totals[totals.length - 1] = { "numer": 0, "denom": 0 }; - - // Hide / show elements. - table_body_rows.forEach(row => { - if (!row.cells[0].textContent.includes(event.target.value)) { - // hide - row.classList.add("hidden"); - return; - } - - // show - row.classList.remove("hidden"); - totals[0]++; - - for (let column = 1; column < totals.length; column++) { - // Accumulate dynamic totals - cell = row.cells[column] - if (column === totals.length - 1) { - // Last column contains percentage - const [numer, denom] = cell.dataset.ratio.split(" "); - totals[column]["numer"] += parseInt(numer, 10); - totals[column]["denom"] += parseInt(denom, 10); - } else { - totals[column] += parseInt(cell.textContent, 10); - } - } - }); - - // Show placeholder if no rows will be displayed. - if (!totals[0]) { - // Show placeholder, hide table. - no_rows.style.display = "block"; - table.style.display = "none"; - return; - } - - // Hide placeholder, show table. - no_rows.style.display = null; - table.style.display = null; - - const footer = table.tFoot.rows[0]; - // Calculate new dynamic sum values based on visible rows. - for (let column = 1; column < totals.length; column++) { - // Get footer cell element. - const cell = footer.cells[column]; - - // Set value into dynamic footer cell element. - if (column === totals.length - 1) { - // Percentage column uses the numerator and denominator, - // and adapts to the number of decimal places. - const match = /\.([0-9]+)/.exec(cell.textContent); - const places = match ? match[1].length : 0; - const { numer, denom } = totals[column]; - cell.dataset.ratio = `${numer} ${denom}`; - // Check denom to prevent NaN if filtered files contain no statements - cell.textContent = denom - ? `${(numer * 100 / denom).toFixed(places)}%` - : `${(100).toFixed(places)}%`; - } else { - cell.textContent = totals[column]; - } - } - })); - - // Trigger change event on setup, to force filter on page refresh - // (filter value may still be present). - document.getElementById("filter").dispatchEvent(new Event("change")); -}; - -coverage.INDEX_SORT_STORAGE = "COVERAGE_INDEX_SORT_2"; - -// Loaded on index.html -coverage.index_ready = function () { - coverage.assign_shortkeys(); - coverage.wire_up_filter(); - document.querySelectorAll("[data-sortable] th[aria-sort]").forEach( - th => th.addEventListener("click", e => sortColumn(e.target)) - ); - - // Look for a localStorage item containing previous sort settings: - const stored_list = localStorage.getItem(coverage.INDEX_SORT_STORAGE); - - if (stored_list) { - const {column, direction} = JSON.parse(stored_list); - const th = document.querySelector("[data-sortable]").tHead.rows[0].cells[column]; - th.setAttribute("aria-sort", direction === "ascending" ? "descending" : "ascending"); - th.click() - } - - // Watch for page unload events so we can save the final sort settings: - window.addEventListener("unload", function () { - const th = document.querySelector('[data-sortable] th[aria-sort="ascending"], [data-sortable] [aria-sort="descending"]'); - if (!th) { - return; - } - localStorage.setItem(coverage.INDEX_SORT_STORAGE, JSON.stringify({ - column: [...th.parentElement.cells].indexOf(th), - direction: th.getAttribute("aria-sort"), - })); - }); -}; - -// -- pyfile stuff -- - -coverage.LINE_FILTERS_STORAGE = "COVERAGE_LINE_FILTERS"; - -coverage.pyfile_ready = function () { - // If we're directed to a particular line number, highlight the line. - var frag = location.hash; - if (frag.length > 2 && frag[1] === 't') { - document.querySelector(frag).closest(".n").classList.add("highlight"); - coverage.set_sel(parseInt(frag.substr(2), 10)); - } else { - coverage.set_sel(0); - } - - const on_click = function(sel, fn) { - const elt = document.querySelector(sel); - if (elt) { - elt.addEventListener("click", fn); - } - } - on_click(".button_toggle_run", coverage.toggle_lines); - on_click(".button_toggle_mis", coverage.toggle_lines); - on_click(".button_toggle_exc", coverage.toggle_lines); - on_click(".button_toggle_par", coverage.toggle_lines); - - on_click(".button_next_chunk", coverage.to_next_chunk_nicely); - on_click(".button_prev_chunk", coverage.to_prev_chunk_nicely); - on_click(".button_top_of_page", coverage.to_top); - on_click(".button_first_chunk", coverage.to_first_chunk); - - coverage.filters = undefined; - try { - coverage.filters = localStorage.getItem(coverage.LINE_FILTERS_STORAGE); - } catch(err) {} - - if (coverage.filters) { - coverage.filters = JSON.parse(coverage.filters); - } - else { - coverage.filters = {run: false, exc: true, mis: true, par: true}; - } - - for (cls in coverage.filters) { - coverage.set_line_visibilty(cls, coverage.filters[cls]); - } - - coverage.assign_shortkeys(); - coverage.init_scroll_markers(); - coverage.wire_up_sticky_header(); - - // Rebuild scroll markers when the window height changes. - window.addEventListener("resize", coverage.build_scroll_markers); -}; - -coverage.toggle_lines = function (event) { - const btn = event.target.closest("button"); - const category = btn.value - const show = !btn.classList.contains("show_" + category); - coverage.set_line_visibilty(category, show); - coverage.build_scroll_markers(); - coverage.filters[category] = show; - try { - localStorage.setItem(coverage.LINE_FILTERS_STORAGE, JSON.stringify(coverage.filters)); - } catch(err) {} -}; - -coverage.set_line_visibilty = function (category, should_show) { - const cls = "show_" + category; - const btn = document.querySelector(".button_toggle_" + category); - if (btn) { - if (should_show) { - document.querySelectorAll("#source ." + category).forEach(e => e.classList.add(cls)); - btn.classList.add(cls); - } - else { - document.querySelectorAll("#source ." + category).forEach(e => e.classList.remove(cls)); - btn.classList.remove(cls); - } - } -}; - -// Return the nth line div. -coverage.line_elt = function (n) { - return document.getElementById("t" + n)?.closest("p"); -}; - -// Set the selection. b and e are line numbers. -coverage.set_sel = function (b, e) { - // The first line selected. - coverage.sel_begin = b; - // The next line not selected. - coverage.sel_end = (e === undefined) ? b+1 : e; -}; - -coverage.to_top = function () { - coverage.set_sel(0, 1); - coverage.scroll_window(0); -}; - -coverage.to_first_chunk = function () { - coverage.set_sel(0, 1); - coverage.to_next_chunk(); -}; - -// Return a string indicating what kind of chunk this line belongs to, -// or null if not a chunk. -coverage.chunk_indicator = function (line_elt) { - const classes = line_elt?.className; - if (!classes) { - return null; - } - const match = classes.match(/\bshow_\w+\b/); - if (!match) { - return null; - } - return match[0]; -}; - -coverage.to_next_chunk = function () { - const c = coverage; - - // Find the start of the next colored chunk. - var probe = c.sel_end; - var chunk_indicator, probe_line; - while (true) { - probe_line = c.line_elt(probe); - if (!probe_line) { - return; - } - chunk_indicator = c.chunk_indicator(probe_line); - if (chunk_indicator) { - break; - } - probe++; - } - - // There's a next chunk, `probe` points to it. - var begin = probe; - - // Find the end of this chunk. - var next_indicator = chunk_indicator; - while (next_indicator === chunk_indicator) { - probe++; - probe_line = c.line_elt(probe); - next_indicator = c.chunk_indicator(probe_line); - } - c.set_sel(begin, probe); - c.show_selection(); -}; - -coverage.to_prev_chunk = function () { - const c = coverage; - - // Find the end of the prev colored chunk. - var probe = c.sel_begin-1; - var probe_line = c.line_elt(probe); - if (!probe_line) { - return; - } - var chunk_indicator = c.chunk_indicator(probe_line); - while (probe > 1 && !chunk_indicator) { - probe--; - probe_line = c.line_elt(probe); - if (!probe_line) { - return; - } - chunk_indicator = c.chunk_indicator(probe_line); - } - - // There's a prev chunk, `probe` points to its last line. - var end = probe+1; - - // Find the beginning of this chunk. - var prev_indicator = chunk_indicator; - while (prev_indicator === chunk_indicator) { - probe--; - if (probe <= 0) { - return; - } - probe_line = c.line_elt(probe); - prev_indicator = c.chunk_indicator(probe_line); - } - c.set_sel(probe+1, end); - c.show_selection(); -}; - -// Returns 0, 1, or 2: how many of the two ends of the selection are on -// the screen right now? -coverage.selection_ends_on_screen = function () { - if (coverage.sel_begin === 0) { - return 0; - } - - const begin = coverage.line_elt(coverage.sel_begin); - const end = coverage.line_elt(coverage.sel_end-1); - - return ( - (checkVisible(begin) ? 1 : 0) - + (checkVisible(end) ? 1 : 0) - ); -}; - -coverage.to_next_chunk_nicely = function () { - if (coverage.selection_ends_on_screen() === 0) { - // The selection is entirely off the screen: - // Set the top line on the screen as selection. - - // This will select the top-left of the viewport - // As this is most likely the span with the line number we take the parent - const line = document.elementFromPoint(0, 0).parentElement; - if (line.parentElement !== document.getElementById("source")) { - // The element is not a source line but the header or similar - coverage.select_line_or_chunk(1); - } else { - // We extract the line number from the id - coverage.select_line_or_chunk(parseInt(line.id.substring(1), 10)); - } - } - coverage.to_next_chunk(); -}; - -coverage.to_prev_chunk_nicely = function () { - if (coverage.selection_ends_on_screen() === 0) { - // The selection is entirely off the screen: - // Set the lowest line on the screen as selection. - - // This will select the bottom-left of the viewport - // As this is most likely the span with the line number we take the parent - const line = document.elementFromPoint(document.documentElement.clientHeight-1, 0).parentElement; - if (line.parentElement !== document.getElementById("source")) { - // The element is not a source line but the header or similar - coverage.select_line_or_chunk(coverage.lines_len); - } else { - // We extract the line number from the id - coverage.select_line_or_chunk(parseInt(line.id.substring(1), 10)); - } - } - coverage.to_prev_chunk(); -}; - -// Select line number lineno, or if it is in a colored chunk, select the -// entire chunk -coverage.select_line_or_chunk = function (lineno) { - var c = coverage; - var probe_line = c.line_elt(lineno); - if (!probe_line) { - return; - } - var the_indicator = c.chunk_indicator(probe_line); - if (the_indicator) { - // The line is in a highlighted chunk. - // Search backward for the first line. - var probe = lineno; - var indicator = the_indicator; - while (probe > 0 && indicator === the_indicator) { - probe--; - probe_line = c.line_elt(probe); - if (!probe_line) { - break; - } - indicator = c.chunk_indicator(probe_line); - } - var begin = probe + 1; - - // Search forward for the last line. - probe = lineno; - indicator = the_indicator; - while (indicator === the_indicator) { - probe++; - probe_line = c.line_elt(probe); - indicator = c.chunk_indicator(probe_line); - } - - coverage.set_sel(begin, probe); - } - else { - coverage.set_sel(lineno); - } -}; - -coverage.show_selection = function () { - // Highlight the lines in the chunk - document.querySelectorAll("#source .highlight").forEach(e => e.classList.remove("highlight")); - for (let probe = coverage.sel_begin; probe < coverage.sel_end; probe++) { - coverage.line_elt(probe).querySelector(".n").classList.add("highlight"); - } - - coverage.scroll_to_selection(); -}; - -coverage.scroll_to_selection = function () { - // Scroll the page if the chunk isn't fully visible. - if (coverage.selection_ends_on_screen() < 2) { - const element = coverage.line_elt(coverage.sel_begin); - coverage.scroll_window(element.offsetTop - 60); - } -}; - -coverage.scroll_window = function (to_pos) { - window.scroll({top: to_pos, behavior: "smooth"}); -}; - -coverage.init_scroll_markers = function () { - // Init some variables - coverage.lines_len = document.querySelectorAll('#source > p').length; - - // Build html - coverage.build_scroll_markers(); -}; - -coverage.build_scroll_markers = function () { - const temp_scroll_marker = document.getElementById('scroll_marker') - if (temp_scroll_marker) temp_scroll_marker.remove(); - // Don't build markers if the window has no scroll bar. - if (document.body.scrollHeight <= window.innerHeight) { - return; - } - - const marker_scale = window.innerHeight / document.body.scrollHeight; - const line_height = Math.min(Math.max(3, window.innerHeight / coverage.lines_len), 10); - - let previous_line = -99, last_mark, last_top; - - const scroll_marker = document.createElement("div"); - scroll_marker.id = "scroll_marker"; - document.getElementById('source').querySelectorAll( - 'p.show_run, p.show_mis, p.show_exc, p.show_exc, p.show_par' - ).forEach(element => { - const line_top = Math.floor(element.offsetTop * marker_scale); - const line_number = parseInt(element.id.substr(1)); - - if (line_number === previous_line + 1) { - // If this solid missed block just make previous mark higher. - last_mark.style.height = `${line_top + line_height - last_top}px`; - } else { - // Add colored line in scroll_marker block. - last_mark = document.createElement("div"); - last_mark.id = `m${line_number}`; - last_mark.classList.add("marker"); - last_mark.style.height = `${line_height}px`; - last_mark.style.top = `${line_top}px`; - scroll_marker.append(last_mark); - last_top = line_top; - } - - previous_line = line_number; - }); - - // Append last to prevent layout calculation - document.body.append(scroll_marker); -}; - -coverage.wire_up_sticky_header = function () { - const header = document.querySelector('header'); - const header_bottom = ( - header.querySelector('.content h2').getBoundingClientRect().top - - header.getBoundingClientRect().top - ); - - function updateHeader() { - if (window.scrollY > header_bottom) { - header.classList.add('sticky'); - } else { - header.classList.remove('sticky'); - } - } - - window.addEventListener('scroll', updateHeader); - updateHeader(); -}; - -document.addEventListener("DOMContentLoaded", () => { - if (document.body.classList.contains("indexfile")) { - coverage.index_ready(); - } else { - coverage.pyfile_ready(); - } -}); diff --git a/coverage/d_8e5d7c2a8ace5693_context_py.html b/coverage/d_8e5d7c2a8ace5693_context_py.html deleted file mode 100644 index 220a602..0000000 --- a/coverage/d_8e5d7c2a8ace5693_context_py.html +++ /dev/null @@ -1,173 +0,0 @@ - - -
- - -Shortcuts on this page
-- r - m - x - p - toggle line displays -
-- j - k next/prev highlighted chunk -
-- 0 (zero) top of page -
-- 1 (one) first highlighted chunk -
-1# -*- coding: utf-8 -*-
-2"""context.py
- -4Context manages all variables used during validation,
-5like system environment variables and responses of previous steps.
-6"""
- -8import os
-9import re
-10from types import SimpleNamespace
- -12from .singleton import Singleton
-13from .errors import BaseContextError, EvaluationError, EnvironmentVariableError
- - -16class Context(metaclass=Singleton):
-17 """
-18 The global context object will be used as a singleton across the entire system.
-19 """
- -21 _steps = SimpleNamespace()
- -23 @property
-24 def steps(self):
-25 return self._steps
- -27 def add_steps(self, step):
-28 """
-29 Adds a Step object as an attribute of `self.steps`
- -31 Args:
-32 step (Step): the Step object to add
-33 """
- -35 setattr(self.steps, step.name, step)
- -37 def clear_steps(self):
-38 """
-39 Clears all Steps objects from attributes of `self.steps`
-40 """
- -42 self._steps = SimpleNamespace()
- -44 # noinspection PyMethodMayBeStatic
-45 def evaluate(self, expression: any) -> any:
-46 """
-47 Recursively evaluate nested expressions using depth-first search.
-48 Eventually the evaluation result as a string is returned.
- -50 The only allowed base contexts are "env" and "steps".
- -52 Args:
-53 expression (str): Object of any type that may contain expression(s)
- -55 Raises:
-56 EnvironmentVariableError:
-57 if the expression represents an environment variable but it cannot be found
- -59 Returns:
-60 The evaluated result as a string if there is any expression, original value otherwise.
-61 """
- -63 if expression is None: 63 ↛ 64line 63 didn't jump to line 64, because the condition on line 63 was never true
-64 return
- -66 # Evaluate each value in a dictionary
-67 if isinstance(expression, dict):
-68 return dict(map(lambda x: (x[0], self.evaluate(x[1])), expression.items()))
- -70 # Evaluate each element in a list
-71 if isinstance(expression, list):
-72 return list(map(lambda x: self.evaluate(x), expression))
- -74 if not isinstance(expression, str):
-75 return expression
- -77 matches: list[str] = re.findall(r"(\${{[^/}]*}})", expression)
-78 if not matches:
-79 return expression
- -81 for match in matches:
-82 value = match.removeprefix("${{").removesuffix("}}").strip()
-83 base = value.split(".").pop(0)
- -85 if base == "env":
-86 # Only split at the first dot
-87 result = os.environ.get(value.split(".", 1)[1])
-88 if result is None:
-89 raise EnvironmentVariableError(value)
-90 elif base == "steps":
-91 try:
-92 result = eval("self." + value)
-93 except AttributeError as e:
-94 raise EvaluationError(e)
-95 else:
-96 raise BaseContextError(base)
- -98 # Only replace the first occurrence
-99 expression = expression.replace(match, str(result), 1)
- -101 return expression
-Shortcuts on this page
-- r - m - x - p - toggle line displays -
-- j - k next/prev highlighted chunk -
-- 0 (zero) top of page -
-- 1 (one) first highlighted chunk -
-1# -*- coding: utf-8 -*-
-2"""errors.py:
- -4This module contains custom errors related to the models subpackage.
-5These errors mostly include exceptions related to resolving previous step and environment variable references in step files.
-6"""
- - -9class BaseContextError(Exception):
-10 """
-11 Raises when the base context of an expression is not either "env" or "steps".
-12 """
-13 def __init__(self, value):
-14 super().__init__(
-15 f"the base context must be either 'env' or 'steps', but found '{value}'"
-16 )
- - -19class EvaluationError(Exception):
-20 """
-21 Raises when the errors occur during expression evaluation.
-22 """
-23 def __init__(self, message):
-24 super().__init__(message)
- - -27class EnvironmentVariableError(Exception):
-28 """
-29 Raises when an environment variable is not found.
-30 """
-31 def __init__(self, value):
-32 super().__init__(f"environment variable {value} not found")
- - -35class InvalidExpressionError(Exception):
-36 """
-37 Raises when an expression is invalid.
-38 """
-39 def __init__(self, value):
-40 super().__init__(f"invalid expression: {value}")
-Shortcuts on this page
-- r - m - x - p - toggle line displays -
-- j - k next/prev highlighted chunk -
-- 0 (zero) top of page -
-- 1 (one) first highlighted chunk -
-1# -*- coding: utf-8 -*-
-2"""request.py
- -4Request will be sent off to a server to request or query some resource.
-5"""
- -7from addict import Dict
- -9from api_validator.models import Context
- -11context = Context()
- - -14class Request:
-15 """
-16 Request object includes method, url, params, body, headers, and auth data.
-17 """
-18 def __init__(
-19 self,
-20 method: str,
-21 url: str,
-22 params: dict = None,
-23 body: dict = None,
-24 headers: dict = None,
-25 auth: dict = None,
-26 ):
-27 self.method = method
-28 self.url = url
-29 self.params = params
-30 self.body = body
-31 self.headers = headers
-32 self.auth = auth
- -34 @property
-35 def params(self):
-36 return self._params
- -38 @params.setter
-39 def params(self, value):
-40 self._params = Dict(value)
- -42 @property
-43 def body(self):
-44 return self._body
- -46 @body.setter
-47 def body(self, value):
-48 self._body = Dict(value)
- -50 @property
-51 def headers(self):
-52 return self._headers
- -54 @headers.setter
-55 def headers(self, value):
-56 self._headers = Dict(value)
- -58 @property
-59 def auth(self):
-60 return self._auth
- -62 @auth.setter
-63 def auth(self, value):
-64 self._auth = Dict(value if value else {"username": "", "password": ""})
- -66 def evaluate_all(self) -> None:
-67 """
-68 Evaluates url, params, body, and header in-place.
- -70 Expressions can be either simple or nested:
- -72 /users/${{ steps.createUser.response.body.id }}
-73 /users/${{ steps.createUser.response.body.id }}/orders/${{ steps.getOrders.response.body[0].id }}
-74 """
- -76 self.url = context.evaluate(self.url)
- -78 # Evaluate all Dict object
-79 for name in ("params", "body", "headers", "auth"):
-80 attr = self.__getattribute__(name)
-81 self.__setattr__(name, context.evaluate(attr))
-Shortcuts on this page
-- r - m - x - p - toggle line displays -
-- j - k next/prev highlighted chunk -
-- 0 (zero) top of page -
-- 1 (one) first highlighted chunk -
-1# -*- coding: utf-8 -*-
-2"""response.py
- -4Response is generated once a request gets a response back from the server.
-5"""
- -7from dataclasses import dataclass
- -9import requests
-10from addict import Dict
- - -13@dataclass
-14class Response:
-15 """
-16 Stores the response sent back to the server.
-17 """
- -19 __response: requests.Response
- -21 def __getattr__(self, item):
-22 if item == "body":
-23 body = self.__response.json()
-24 return Dict(body) if isinstance(body, dict) else body
- -26 return getattr(self.__response, item)
-Shortcuts on this page
-- r - m - x - p - toggle line displays -
-- j - k next/prev highlighted chunk -
-- 0 (zero) top of page -
-- 1 (one) first highlighted chunk -
-1# -*- coding: utf-8 -*-
-2"""schema.py
- -4JSON Schema is a vocabulary that allows you to annotate and validate JSON documents.
-5"""
- -7from jschon import JSONSchema, JSON
-8from jschon.jsonschema import Scope
- -10META_SCHEMA = "https://json-schema.org/draft/2020-12/schema"
- - -13class Schema:
-14 """
-15 Predefines meta schema and stores JSON schema document model.
-16 """
- -18 def __init__(self, schema: dict, meta_schema: str = META_SCHEMA):
-19 schema["$schema"] = meta_schema
-20 self.schema = JSONSchema(schema)
- -22 def evaluate(self, json: dict) -> Scope:
-23 """
-24 Verify the JSON against the schema.
- -26 Args:
-27 json (dict): the JSON document to evaluate
- -29 Returns:
-30 Evaluate a JSON document and return the complete evaluation result tree.
-31 """
- -33 return self.schema.evaluate(JSON(json))
-Shortcuts on this page
-- r - m - x - p - toggle line displays -
-- j - k next/prev highlighted chunk -
-- 0 (zero) top of page -
-- 1 (one) first highlighted chunk -
-1# -*- coding: utf-8 -*-
-2"""singleton.py
- -4This module is only used by Context to ensure the uniqueness of the global context state.
-5"""
- -7class Singleton(type):
-8 """
-9 Restricts the instantiation of a class to one single instance.
-10 """
-11 _instances = {}
- -13 def __call__(cls, *args, **kwargs):
-14 if cls not in cls._instances:
-15 cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
-16 return cls._instances[cls]
-Shortcuts on this page
-- r - m - x - p - toggle line displays -
-- j - k next/prev highlighted chunk -
-- 0 (zero) top of page -
-- 1 (one) first highlighted chunk -
-1# -*- coding: utf-8 -*-
-2"""step.py:
- -4Encapsulates step data from the step file, including name, request, response, and schema for easy access.
-5"""
- -7from dataclasses import dataclass
- -9from jschon.jsonschema import Scope
- -11from .request import Request
-12from .response import Response
-13from .schema import Schema
- - -16@dataclass
-17class Step:
-18 """
-19 Steps object manages request, response, and schema.
-20 """
- -22 name: str
-23 request: Request = None
-24 response: Response = None
-25 schema: Schema = None
- -27 def verify(self) -> Scope:
-28 """
-29 Verify the response against the schema.
- -31 Returns:
-32 Evaluate the response body and return the complete evaluation result tree.
-33 """
- -35 return self.schema.evaluate(self.response.json())
-Shortcuts on this page
-- r - m - x - p - toggle line displays -
-- j - k next/prev highlighted chunk -
-- 0 (zero) top of page -
-- 1 (one) first highlighted chunk -
-1# -*- coding: utf-8 -*-
-2"""action.py
- -4This module contains the primary code for the GitHub Action.
-5Using the parsed OpenAPI specs and step files, it will create API requests and
-6validate the responses.
-7"""
-8import os
-9import traceback
- -11import requests
-12from actions_toolkit import core
-13from dotenv import load_dotenv
-14from jschon import create_catalog
-15from jschon.jsonschema import OutputFormat
-16from openapi_schema_to_json_schema import to_json_schema
-17from requests.auth import HTTPBasicAuth
- -19from .errors import (
-20 InsufficientCoverageError,
-21 ResponseMatchError,
-22 ResponseValidationError,
-23 UndocumentedEndpointError,
-24)
-25from .models import Context, Request, Response, Schema, Step
-26from .parsing import parse_spec, parse_steps
-27from .preprocessing import get_endpoint_coverage
- -29# Global variable storing all runtime contexts (initialize once)
-30context = Context()
- -32# Load dotenv
-33load_dotenv()
- - -36def parse_spec_steps(spec_file_path: str, step_file_path: str) -> tuple[dict, dict]:
-37 """
-38 Parses the OpenAPI spec and step files and return each of them
- -40 Args:
-41 spec_file_path (str): The path to the OpenAPI spec file
-42 step_file_path (str): The path to the step file
-43 Returns:
-44 A 2-tuple of dictionaries, containing the parsed OpenAPI spec and step files
-45 Raises:
-46 FileNotFoundError: If the spec or step file does not exist
-47 ValidationError: If the spec file is not valid according to the OpenAPI standard
-48 ScannerError: If the step file is not valid YAML
-49 JSONValidatorError: If the step file instance is not valid according to the JSON schema
-50 """
- -52 # Parse OpenAPI specification file
-53 core.start_group("Parsing spec file")
-54 spec_data = parse_spec(spec_file_path)
-55 core.end_group()
- -57 # Parse step file
-58 core.start_group("Parsing step file")
-59 steps_data = parse_steps(step_file_path)
-60 core.end_group()
- -62 return spec_data, steps_data
- - -65def check_endpoint_coverage(spec_data: dict, steps_data: dict):
-66 """
-67 Checks the endpoint coverage of the step file against the OpenAPI spec.
- -69 Args:
-70 spec_data (dict): The parsed OpenAPI spec
-71 steps_data (dict): The parsed step file
-72 Raises:
-73 UndocumentedEndpointError:
-74 """
- -76 endpoint_coverage = get_endpoint_coverage(spec_data, steps_data)
- -78 # If any undocumented endpoints, immediately halt
-79 if endpoint_coverage.has_undocumented_endpoints():
-80 raise UndocumentedEndpointError(endpoint_coverage.undocumented)
- -82 # Check if endpoint coverage meets threshold
-83 if "coverage_threshold" in steps_data:
-84 target_coverage: float = steps_data["coverage_threshold"]
-85 achieved_coverage = endpoint_coverage.proportion_covered()
- -87 if achieved_coverage < target_coverage:
-88 raise InsufficientCoverageError(
-89 achieved_coverage, target_coverage, endpoint_coverage.uncovered
-90 )
- - -93def make_requests(spec_data: dict, steps_data: dict, fail_fast: bool, verbose: bool):
-94 """
-95 Given a parsed OpenAPI spec and step file, make requests and validate responses.
- -97 Args:
-98 spec_data (dict): The parsed OpenAPI spec
-99 steps_data (dict): The parsed step file
-100 fail_fast (bool): Whether to exit immediately if exception raised
-101 verbose (bool): Whether to output stacktrace
-102 Raises:
-103 ResponseMatchError: If the response does not match the expected response according to status code
-104 ResponseValidationError: If the response does not match the expected response according to schema
-105 """
- -107 core.info('Validating APIs')
- -109 # Create requests
-110 base_url: str = steps_data["base_url"]
-111 paths: dict = steps_data["paths"]
- -113 # Go through each path in the steps
-114 path_value: dict
-115 for path_key, path_value in paths.items():
-116 core.start_group(path_key)
- -118 # Go through each method in each path
-119 method_value: dict
-120 for method_name, method_value in path_value.items():
-121 # Store steps of current method
-122 context.clear_steps()
- -124 # Get steps YAML from file
-125 method_steps: list = method_value["steps"]
- -127 # Go through each step in each method
-128 step_data: dict
-129 for step_data in method_steps:
-130 try:
-131 # Get step name
-132 step_name = step_data.pop("name")
-133 core.info(step_name)
- -135 # Create Request object
-136 path_url = step_data.pop("url")
-137 request = Request(url=(base_url + path_url), **step_data)
- -139 # Evaluate expressions
-140 request.evaluate_all()
- -142 core.info(' Request:')
-143 core.info(f' {request.method} {request.url}')
-144 core.info(f' Authentication: {request.auth}')
-145 core.info(f' Body: {request.body}')
-146 core.info(f' Headers: {request.headers}')
-147 core.info(f' Parameters: {request.params}')
- -149 # Create Step object
-150 step = Step(step_name, request)
- -152 # Send the request
-153 step.response = Response(
-154 requests.request(
-155 method=request.method,
-156 url=request.url,
-157 params=request.params.to_dict(),
-158 headers=request.headers.to_dict(),
-159 json=request.body.to_dict(),
-160 auth=HTTPBasicAuth(**request.auth),
-161 )
-162 )
- -164 response = step.response
- -166 core.info(' Response:')
-167 core.info(f' HTTP {response.status_code} {response.reason}')
-168 core.info(f' Body: {response.body}')
-169 core.info('')
- -171 status_code = step.response.status_code
- -173 # Fetch schema
-174 try:
-175 schema = to_json_schema(
-176 spec_data.get("paths")
-177 .get(path_key)
-178 .get(method_name)
-179 .get("responses")
-180 .get(str(status_code))
-181 .get("content")
-182 .get("application/json")
-183 .get("schema")
-184 )
-185 step.schema = Schema(schema)
-186 except AttributeError:
-187 raise ResponseMatchError(
-188 spec_data.get("paths")
-189 .get(path_key)
-190 .get(method_name)
-191 .get("responses")
-192 .keys(),
-193 step.response,
-194 )
- -196 # Save the step to further use
-197 context.add_steps(step)
- -199 # Verify the response
-200 verification_result = step.verify()
-201 if not verification_result.valid:
-202 raise ResponseValidationError(
-203 errors=verification_result.output(OutputFormat.BASIC)["errors"],
-204 url=path_url,
-205 method=method_name,
-206 status_code=status_code,
-207 )
- -209 except BaseException as e:
-210 if fail_fast:
-211 raise e
- -213 if verbose:
-214 core.warning(traceback.format_exc(), title=e.__class__.__name__)
-215 else:
-216 core.warning(str(e), title=e.__class__.__name__)
- -218 core.end_group()
- - -221def verify_api(spec_file_path: str, step_file_path: str, fail_fast: bool = False, verbose: bool = False):
-222 """
-223 This is the main function of the API verifier.
-224 It parses the OpenAPI spec and step files, measures coverage, makes requests, and validates responses.
-225 If this method completes without raising any exceptions, the API is considered valid.
- -227 Args:
-228 spec_file_path (str): The path to the OpenAPI spec file
-229 step_file_path (str): The path to the step file
-230 fail_fast (bool): Whether to exit immediately if exception raised
-231 verbose (bool): Whether to output stacktrace
-232 """
- -234 create_catalog("2020-12", default=True)
- -236 # Parse spec and step files
-237 spec_data, steps_data = parse_spec_steps(spec_file_path, step_file_path)
- -239 # Check endpoint coverage
-240 check_endpoint_coverage(spec_data, steps_data)
- -242 # Make requests
-243 make_requests(spec_data, steps_data, fail_fast, verbose)
-Shortcuts on this page
-- r - m - x - p - toggle line displays -
-- j - k next/prev highlighted chunk -
-- 0 (zero) top of page -
-- 1 (one) first highlighted chunk -
-1# -*- coding: utf-8 -*-
-2"""errors.py
- -4This module contains custom errors for the API validator.
-5Often, these errors are raised when a library gives an error with a difficult to read message.
-6These errors will nicely format these error messages for logging and stack traces.
-7"""
- -9# Import errors from models subpackage
-10from .models.errors import *
- -12# For type hints
-13from .models import Response
- - -16class UndocumentedEndpointError(BaseException):
-17 """
-18 Raised when the step file contains endpoints that are not documented in the OpenAPI spec.
-19 This error will halt further execution of the action.
-20 """
- -22 def __init__(self, undocumented: set[str]):
-23 super().__init__(
-24 f"""The following endpoints are undocumented:
-25 {undocumented}"""
-26 )
- - -29class InsufficientCoverageError(BaseException):
-30 """
-31 Raised the step file does not sufficiently cover the OpenAPI spec according the the coverage threshold.
-32 If a coverage threshold given in the step file, this error will halt further execution of the action.
-33 """
- -35 def __init__(
-36 self, achieved_coverage: float, target_coverage: float, uncovered: set[str]
-37 ):
-38 super().__init__(
-39 f"""The endpoint coverage is {achieved_coverage} but the target is {target_coverage}.
-40 The following endpoints are uncovered: {uncovered}"""
-41 )
- - -44class ResponseMatchError(BaseException):
-45 """
-46 Raised when a response does not match any of the expected responses in the OpenAPI spec according to status code.
-47 This error will halt further execution of the action.
-48 """
- -50 def __init__(self, statuses: list, step: Response):
-51 super().__init__(
-52 f"""Cannot find a matching response in the specification.
-53 Expected possible responses:
-54 {', '.join(list(statuses))}
-55 Actual response:
-56 {step.status_code} {step.reason}: {step.text}"""
-57 )
- - -60class ResponseValidationError(BaseException):
-61 """
-62 Raised when the structure of a response does not match the expected schema in the OpenAPI spec.
-63 This error will halt further execution of the action.
-64 """
- -66 def __init__(self, errors: list, url: str, method: str, status_code: int):
-67 super().__init__(
-68 f"""Response is not valid against the schema at:
-69 {url}:
-70 {method}:
-71 {status_code}:
-72 {list(map(lambda x: {x['keywordLocation']: x['error']}, errors))}"""
-73 )
- - -76class JSONValidatorError(BaseException):
-77 """
-78 Raised when the structure of a JSON does not match the expected JSON schema.
-79 This error will halt further execution of the action.
-80 """
- -82 def __init__(self, errors: list[dict[str, str]]):
-83 super().__init__(
-84 "\n".join(
-85 [
-86 f"""{error['instanceLocation']}\n {error['error']}"""
-87 for error in errors
-88 ]
-89 )
-90 )
-Shortcuts on this page
-- r - m - x - p - toggle line displays -
-- j - k next/prev highlighted chunk -
-- 0 (zero) top of page -
-- 1 (one) first highlighted chunk -
-1# -*- coding: utf-8 -*-
-2"""parsing.py
- -4This module contains functions for parsing the input file.
-5It will parse input files, returning a nested dictionary object.
-6If the input file is not valid, it will raise an exception.
-7"""
- -9import importlib.resources
-10import json
-11import os
- -13import yaml
-14from jschon import JSON, JSONSchema
-15from jschon.jsonschema import OutputFormat
-16from openapi_spec_validator import validate_spec
-17from prance import ResolvingParser, ValidationError
-18from prance.util.url import ResolutionError
-19from ruamel.yaml.scanner import ScannerError as RuamelScannerError
-20from yaml.constructor import SafeConstructor
-21from yaml.scanner import ScannerError as PyYAMLScannerError
- -23from .errors import JSONValidatorError
- - -26def parse_spec(spec_file_path: str) -> dict:
-27 """Parse OpenAPI spec file to a dictionary.
- -29 Args:
-30 spec_file_path (str): Path to spec file.
- -32 Returns:
-33 dict: Spec file parsed into a dictionary.
- -35 Raises:
-36 ValidationError: If the spec file is not valid according to the OpenAPI spec.
-37 ResolutionError: If the spec file has refs cannot be resolved
-38 ruamel.yaml.scanner.ScannerError:
-39 """
- -41 if not os.path.exists(spec_file_path):
-42 raise FileNotFoundError(f"Spec file '{spec_file_path}' not found")
- -44 try:
-45 parser = ResolvingParser(spec_file_path)
-46 spec_dict = parser.specification
-47 validate_spec(spec_dict)
-48 return spec_dict
-49 except ValidationError as e:
-50 raise ValidationError(
-51 f"Spec file {spec_file_path} is not valid according to the OpenAPI specification"
-52 ) from e
-53 except ResolutionError as e:
-54 raise ResolutionError(
-55 f"Spec file {spec_file_path} has refs that cannot be resolved"
-56 )
-57 except RuamelScannerError as e:
-58 raise RuamelScannerError(
-59 f"Could not parse {spec_file_path} as a YAML file"
-60 ) from e
- - -63def parse_steps(step_file_path: str) -> dict:
-64 """Parse steps file to a dictionary.
- -66 Args:
-67 step_file_path (str): Path to step file.
- -69 Returns:
-70 dict: Step file parsed into a dictionary.
- -72 Raises:
-73 FileNotFoundError: If the step file does not exist.
-74 yaml.scanner.ScannerError: If the step file is not valid YAML.
-75 JSONValidatorError: If the step file is not valid against the step file schema.
-76 """
- -78 try:
-79 with importlib.resources.path(
-80 __package__, "steps_schema.json"
-81 ) as step_schema_file:
-82 steps_schema = JSONSchema(json.loads(step_schema_file.read_text()))
-83 except FileNotFoundError as e:
-84 raise FileNotFoundError(f"Steps schema file not found") from e
- -86 try:
-87 with open(step_file_path, "r") as step_file:
-88 # Drop support for datetime
-89 SafeConstructor.yaml_constructors[
-90 "tag:yaml.org,2002:timestamp"
-91 ] = SafeConstructor.yaml_constructors["tag:yaml.org,2002:str"]
- -93 # Load the step file and validate it against the step file schema
-94 yaml_dict = yaml.safe_load(step_file)
-95 result = steps_schema.evaluate(JSON(yaml_dict))
-96 result_output = result.output(OutputFormat.BASIC)
- -98 if not result_output["valid"]:
-99 raise JSONValidatorError(result_output["errors"])
-100 except FileNotFoundError as e:
-101 raise FileNotFoundError(f"Steps file {step_file_path} not found") from e
-102 except PyYAMLScannerError as e:
-103 raise PyYAMLScannerError("Steps file is not valid YAML") from e
- -105 return yaml_dict
-Shortcuts on this page
-- r - m - x - p - toggle line displays -
-- j - k next/prev highlighted chunk -
-- 0 (zero) top of page -
-- 1 (one) first highlighted chunk -
-1# -*- coding: utf-8 -*-
-2"""preprocessing.py
- -4This module contains functions to ensure compatibility for a step file and corresponding spec file.
-5It will check that every method referenced in the step file exists in the spec file.
-6"""
- -8from dataclasses import dataclass
- - -11@dataclass
-12class EndpointCoverage:
-13 """
-14 Dictionary classifying endpoints into three categories:
-15 - covered: endpoints that are called in the step file and documented in the spec file
-16 - uncovered: endpoints that are in the spec file but not called in the step file
-17 - undocumented: endpoints that are called in the step file but not documented in the spec file
-18 """
- -20 covered: set[str]
-21 uncovered: set[str]
-22 undocumented: set[str]
- -24 def has_undocumented_endpoints(self) -> bool:
-25 return len(self.undocumented) > 0
- -27 def proportion_covered(self) -> float:
-28 return len(self.covered) / (
-29 len(self.covered) + len(self.uncovered) + len(self.undocumented)
-30 )
- - -33def get_endpoint_coverage(spec: dict, step: dict) -> EndpointCoverage:
-34 """
-35 Given a parsed spec and step file, determine the endpoints in the spec that are achieved by the step file.
- -37 Args:
-38 spec (dict): specification file parsed as dict
-39 step (dict): step file parsed as dict
- -41 Returns:
-42 EndpointCoverage: A dataclass containing the endpoints covered, uncovered, and undocumented
-43 """
- -45 # Get all endpoints in the spec file
-46 spec_endpoints = set(spec["paths"].keys())
- -48 # Get all endpoints in the step file
-49 step_endpoints = set(step["paths"].keys())
- -51 return EndpointCoverage(
-52 covered=spec_endpoints & step_endpoints,
-53 uncovered=spec_endpoints - step_endpoints,
-54 undocumented=step_endpoints - spec_endpoints,
-55 )
-Shortcuts on this page
-- n - s - m - x - b - p - c change column sorting -
-Module | -statements | -missing | -excluded | -branches | -partial | -coverage | -
---|---|---|---|---|---|---|
api_validator/action.py | -84 | -0 | -0 | -18 | -0 | -100% | -
api_validator/errors.py | -18 | -0 | -0 | -4 | -0 | -100% | -
api_validator/models/context.py | -42 | -3 | -0 | -22 | -1 | -94% | -
api_validator/models/errors.py | -13 | -2 | -0 | -0 | -0 | -85% | -
api_validator/models/request.py | -41 | -0 | -0 | -2 | -0 | -100% | -
api_validator/models/response.py | -12 | -0 | -0 | -4 | -0 | -100% | -
api_validator/models/schema.py | -10 | -0 | -0 | -0 | -0 | -100% | -
api_validator/models/singleton.py | -7 | -0 | -0 | -2 | -0 | -100% | -
api_validator/models/step.py | -14 | -0 | -0 | -2 | -0 | -100% | -
api_validator/parsing.py | -47 | -2 | -0 | -10 | -0 | -96% | -
api_validator/preprocessing.py | -15 | -0 | -0 | -2 | -0 | -100% | -
Total | -303 | -7 | -0 | -66 | -1 | -98% | -
- No items found using the specified filter. -
-