- 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 -
-From 66d8735c33e8f35b2d17a6d0e2dc3367680225db Mon Sep 17 00:00:00 2001 From: AJ Rice <53190766+ajrice6713@users.noreply.github.com> Date: Wed, 2 Mar 2022 10:59:22 -0500 Subject: [PATCH 1/9] Refactoring `action` to `validate` --- pypony.py | 2 +- src/__main__.py | 2 +- src/{action.py => validate.py} | 2 +- test/README.md | 2 +- test/{action_test.py => validate_test.py} | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename src/{action.py => validate.py} (99%) rename test/{action_test.py => validate_test.py} (99%) diff --git a/pypony.py b/pypony.py index d027f8e..db81950 100644 --- a/pypony.py +++ b/pypony.py @@ -4,7 +4,7 @@ import click from actions_toolkit import core -from src.action import verify_api +from src.validate import verify_api @click.group() diff --git a/src/__main__.py b/src/__main__.py index e88f090..55a09b5 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -4,7 +4,7 @@ import click from actions_toolkit import core -from .action import verify_api +from .validate import verify_api @click.command() diff --git a/src/action.py b/src/validate.py similarity index 99% rename from src/action.py rename to src/validate.py index d349a0b..f25f36d 100644 --- a/src/action.py +++ b/src/validate.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""action.py +"""validate.py This module contains the primary code for the GitHub Action. Using the parsed OpenAPI specs and step files, it will create API requests and diff --git a/test/README.md b/test/README.md index 96aab01..ea85c86 100644 --- a/test/README.md +++ b/test/README.md @@ -5,7 +5,7 @@ ## Folder Structure - `/test` - - `action_test.py` + - `validate_test.py` - `...` - `/fixtures` (mock data) - `/specs` diff --git a/test/action_test.py b/test/validate_test.py similarity index 99% rename from test/action_test.py rename to test/validate_test.py index 5902b7e..d8a5d72 100644 --- a/test/action_test.py +++ b/test/validate_test.py @@ -5,7 +5,7 @@ import prance import pytest -from src.action import verify_api +from src.validate import verify_api from src.errors import ( UndocumentedEndpointError, InsufficientCoverageError, From 9b49e906b8038fa3814c7f1b544a5ec442c91f19 Mon Sep 17 00:00:00 2001 From: AJ Rice <53190766+ajrice6713@users.noreply.github.com> Date: Wed, 2 Mar 2022 11:00:13 -0500 Subject: [PATCH 2/9] Delete `__main__.py` Replaced with `./pypony.py` --- src/__main__.py | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 src/__main__.py diff --git a/src/__main__.py b/src/__main__.py deleted file mode 100644 index 55a09b5..0000000 --- a/src/__main__.py +++ /dev/null @@ -1,34 +0,0 @@ -import sys -import traceback - -import click -from actions_toolkit import core - -from .validate import verify_api - - -@click.command() -@click.option( - "--spec_file", required=True, type=click.STRING, envvar="INPUT_SPEC_FILE" -) -@click.option( - "--step_file", required=True, type=click.STRING, envvar="INPUT_STEP_FILE" -) -@click.option( - "-ff", "--fail-fast", default=False, type=click.BOOL, envvar="INPUT_FAIL_FAST" -) -@click.option( - "-v", "--verbose", default=False, type=click.BOOL, envvar="INPUT_VERBOSE" -) -def main(spec_file, step_file, fail_fast, verbose): - try: - verify_api(spec_file, step_file, fail_fast, verbose) - except BaseException as e: - if verbose: - core.error(traceback.format_exc(), title=e.__class__.__name__) - else: - core.error(str(e), title=e.__class__.__name__) - - sys.exit(1) - -main() From 459d39e6099250ee66b0dca1bea72461fb922d49 Mon Sep 17 00:00:00 2001 From: AJ Rice <53190766+ajrice6713@users.noreply.github.com> Date: Wed, 2 Mar 2022 11:06:12 -0500 Subject: [PATCH 3/9] PEP8 Spacing in `pypony.py` --- pypony.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pypony.py b/pypony.py index db81950..60650ad 100644 --- a/pypony.py +++ b/pypony.py @@ -11,6 +11,7 @@ def cli(): pass + @cli.command() @click.option( "--spec_file", required=True, type=click.STRING, envvar="INPUT_SPEC_FILE" @@ -35,4 +36,5 @@ def main(spec_file, step_file, fail_fast, verbose): sys.exit(1) + main() From d0ea2608b775714eac584ffb988e42521f7e546a Mon Sep 17 00:00:00 2001 From: AJ Rice <53190766+ajrice6713@users.noreply.github.com> Date: Wed, 2 Mar 2022 11:36:20 -0500 Subject: [PATCH 4/9] Add long description to `setup.py` Add rich dependency to requirements.txt --- requirements.txt | 1 + setup.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 6c3e144..a64ec26 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ py-openapi-schema-to-json-schema==0.0.3 python-dotenv==0.19.1 PyYAML==6.0 requests==2.26.0 +rich==11.2.0 actions-toolkit==0.0.9 diff --git a/setup.py b/setup.py index 2d62322..85d6350 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,8 @@ from setuptools import setup, find_packages +from pathlib import Path + +this_directory = Path(__file__).parent +long_description = (this_directory / "README.md").read_text() with open("requirements.txt", "r") as fp: requirements = fp.readlines() @@ -6,14 +10,16 @@ setup( name='PyPony', description='A python utility for contract testing APIs', + long_description=long_description, + long_description_content_type='text/markdown', author='Bandwidth', author_email='letstalk@bandwidth.com', url='https://github.com/Bandwidth/pypony/', version='0.1.0', py_modules=['pypony', 'src'], install_requires=requirements, - packages = find_packages(exclude=["website", "test"]), - entry_points = ''' + packages=find_packages(exclude=["website", "test"]), + entry_points=''' [console_scripts] pypony=pypony:cli ''' From f7c4a3f3d62cc87c67c88c9b611ceae46dcdc178 Mon Sep 17 00:00:00 2001 From: AJ Rice <53190766+ajrice6713@users.noreply.github.com> Date: Wed, 2 Mar 2022 11:46:13 -0500 Subject: [PATCH 5/9] Convert `-ff` and `-v` inputs to be true flags --- pypony.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypony.py b/pypony.py index 60650ad..fb5b62e 100644 --- a/pypony.py +++ b/pypony.py @@ -20,10 +20,10 @@ def cli(): "--step_file", required=True, type=click.STRING, envvar="INPUT_STEP_FILE" ) @click.option( - "-ff", "--fail-fast", default=False, type=click.BOOL, envvar="INPUT_FAIL_FAST" + "-ff", "--fail-fast", is_flag=True, envvar="INPUT_FAIL_FAST" ) @click.option( - "-v", "--verbose", default=False, type=click.BOOL, envvar="INPUT_VERBOSE" + "-v", "--verbose", is_flag=True, envvar="INPUT_VERBOSE" ) def main(spec_file, step_file, fail_fast, verbose): try: From ddce65e3242c8d4a7e80476b69e9d70ccdd6671d Mon Sep 17 00:00:00 2001 From: AJ Rice <53190766+ajrice6713@users.noreply.github.com> Date: Wed, 2 Mar 2022 11:51:58 -0500 Subject: [PATCH 6/9] remove env var for `-v` and `-ff` flags --- pypony.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pypony.py b/pypony.py index fb5b62e..3ae4105 100644 --- a/pypony.py +++ b/pypony.py @@ -20,10 +20,10 @@ def cli(): "--step_file", required=True, type=click.STRING, envvar="INPUT_STEP_FILE" ) @click.option( - "-ff", "--fail-fast", is_flag=True, envvar="INPUT_FAIL_FAST" + "-ff", "--fail-fast", is_flag=True ) @click.option( - "-v", "--verbose", is_flag=True, envvar="INPUT_VERBOSE" + "-v", "--verbose", is_flag=True ) def main(spec_file, step_file, fail_fast, verbose): try: From 3639b54b08983bbbca95e52387e1b292560b108d Mon Sep 17 00:00:00 2001 From: AJ Rice <53190766+ajrice6713@users.noreply.github.com> Date: Wed, 2 Mar 2022 13:57:11 -0500 Subject: [PATCH 7/9] Convert some `core.info` statements to use rich `print` --- src/validate.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/validate.py b/src/validate.py index f25f36d..0012cda 100644 --- a/src/validate.py +++ b/src/validate.py @@ -10,6 +10,7 @@ import requests from actions_toolkit import core +from rich import print, print_json from dotenv import load_dotenv from jschon import create_catalog from jschon.jsonschema import OutputFormat @@ -104,7 +105,7 @@ def make_requests(spec_data: dict, steps_data: dict, fail_fast: bool, verbose: b ResponseValidationError: If the response does not match the expected response according to schema """ - core.info('Validating APIs') + print('Validating APIs') # Create requests base_url: str = steps_data["base_url"] @@ -130,7 +131,7 @@ def make_requests(spec_data: dict, steps_data: dict, fail_fast: bool, verbose: b try: # Get step name step_name = step_data.pop("name") - core.info(step_name) + print(step_name) # Create Request object path_url = step_data.pop("url") @@ -139,12 +140,13 @@ def make_requests(spec_data: dict, steps_data: dict, fail_fast: bool, verbose: b # Evaluate expressions request.evaluate_all() - core.info(' Request:') - core.info(f' {request.method} {request.url}') - core.info(f' Authentication: {request.auth}') - core.info(f' Body: {request.body}') - core.info(f' Headers: {request.headers}') - core.info(f' Parameters: {request.params}') + print(' Request:') + print(f' {request.method} {request.url}') + print(f' Authentication: {request.auth}') + print(f' Body:') + print_json(data=request.body, indent=4) + print(f' Headers: {request.headers}') + print(f' Parameters: {request.params}') # Create Step object step = Step(step_name, request) @@ -163,10 +165,10 @@ def make_requests(spec_data: dict, steps_data: dict, fail_fast: bool, verbose: b response = step.response - core.info(' Response:') - core.info(f' HTTP {response.status_code} {response.reason}') - core.info(f' Body: {response.body}') - core.info('') + print(' Response:') + print(f' HTTP {response.status_code} {response.reason}') + print_json(data=response.body, indent=4) + print('') status_code = step.response.status_code From f13f148301682d9d9ef482aca2392ace917b996b Mon Sep 17 00:00:00 2001 From: AJ Rice <53190766+ajrice6713@users.noreply.github.com> Date: Thu, 3 Mar 2022 16:30:08 -0500 Subject: [PATCH 8/9] Remove GH Actions and Coverage library usage Took 19 minutes --- .coveragerc | 7 - coverage/badge.svg | 1 - coverage/coverage_html.js | 575 ------------------ coverage/d_8e5d7c2a8ace5693_context_py.html | 173 ------ coverage/d_8e5d7c2a8ace5693_errors_py.html | 112 ---- coverage/d_8e5d7c2a8ace5693_request_py.html | 153 ----- coverage/d_8e5d7c2a8ace5693_response_py.html | 98 --- coverage/d_8e5d7c2a8ace5693_schema_py.html | 105 ---- coverage/d_8e5d7c2a8ace5693_singleton_py.html | 88 --- coverage/d_8e5d7c2a8ace5693_step_py.html | 107 ---- coverage/d_b4251882f9eb8989_action_py.html | 315 ---------- coverage/d_b4251882f9eb8989_errors_py.html | 162 ----- coverage/d_b4251882f9eb8989_parsing_py.html | 177 ------ .../d_b4251882f9eb8989_preprocessing_py.html | 127 ---- coverage/favicon_32.png | Bin 1732 -> 0 bytes coverage/index.html | 180 ------ coverage/index.js | 3 - coverage/keybd_closed.png | Bin 9004 -> 0 bytes coverage/keybd_open.png | Bin 9003 -> 0 bytes coverage/status.json | 1 - coverage/style.css | 307 ---------- requirements.dev.txt | 3 - requirements.txt | 1 - src/validate.py | 33 +- 24 files changed, 13 insertions(+), 2715 deletions(-) delete mode 100644 .coveragerc delete mode 100644 coverage/badge.svg delete mode 100644 coverage/coverage_html.js delete mode 100644 coverage/d_8e5d7c2a8ace5693_context_py.html delete mode 100644 coverage/d_8e5d7c2a8ace5693_errors_py.html delete mode 100644 coverage/d_8e5d7c2a8ace5693_request_py.html delete mode 100644 coverage/d_8e5d7c2a8ace5693_response_py.html delete mode 100644 coverage/d_8e5d7c2a8ace5693_schema_py.html delete mode 100644 coverage/d_8e5d7c2a8ace5693_singleton_py.html delete mode 100644 coverage/d_8e5d7c2a8ace5693_step_py.html delete mode 100644 coverage/d_b4251882f9eb8989_action_py.html delete mode 100644 coverage/d_b4251882f9eb8989_errors_py.html delete mode 100644 coverage/d_b4251882f9eb8989_parsing_py.html delete mode 100644 coverage/d_b4251882f9eb8989_preprocessing_py.html delete mode 100644 coverage/favicon_32.png delete mode 100644 coverage/index.html delete mode 100644 coverage/index.js delete mode 100644 coverage/keybd_closed.png delete mode 100644 coverage/keybd_open.png delete mode 100644 coverage/status.json delete mode 100644 coverage/style.css 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/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. -
-