From 972d75884b974f0ea11db766da6d6910bd638040 Mon Sep 17 00:00:00 2001 From: Serghei Iakovlev Date: Tue, 7 Feb 2023 01:29:54 +0100 Subject: [PATCH 1/3] Create feature rich api server --- .env | 9 +++ .github/workflows/contracts.yaml | 3 +- README.md | 16 ++-- package-lock.json | 16 ++-- package.json | 15 ++-- src/products/products.json => products.json | 0 provider/__init__.py | 35 +++++++++ provider/api/__init__.py | 14 ++++ .../app.py => provider/api/products.py | 36 ++++----- provider/app.py | 78 +++++++++++++++++++ provider/config.py | 36 +++++++++ provider/main/__init__.py | 19 +++++ provider/main/errors.py | 33 ++++++++ provider/main/views.py | 25 ++++++ runner.py | 23 ++++++ src/products/.flaskenv | 2 - 16 files changed, 315 insertions(+), 45 deletions(-) create mode 100644 .env rename src/products/products.json => products.json (100%) create mode 100644 provider/__init__.py create mode 100644 provider/api/__init__.py rename src/products/app.py => provider/api/products.py (65%) create mode 100644 provider/app.py create mode 100644 provider/config.py create mode 100644 provider/main/__init__.py create mode 100644 provider/main/errors.py create mode 100644 provider/main/views.py create mode 100644 runner.py delete mode 100644 src/products/.flaskenv diff --git a/.env b/.env new file mode 100644 index 0000000..da939e7 --- /dev/null +++ b/.env @@ -0,0 +1,9 @@ +# This file is part of the Specmatic Testing Example. +# +# Copyright (C) 2023 Serghei Iakovlev +# +# For the full copyright and license information, please view +# the LICENSE file that was distributed with this source code. + +# Base URL for API provider. +BASE_URI=https://127.0.0.1:5000 diff --git a/.github/workflows/contracts.yaml b/.github/workflows/contracts.yaml index f5a0381..a3b6603 100644 --- a/.github/workflows/contracts.yaml +++ b/.github/workflows/contracts.yaml @@ -60,8 +60,7 @@ jobs: pip install -r requirements.txt - name: Run API server - run: flask run & - working-directory: src/products + run: flask --app runner:app run & shell: bash - name: Setup specmatic diff --git a/README.md b/README.md index e93ef1e..c479f11 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,25 @@ ## How to try it out -### Install project dependencies +### Install dependencies and tools -First, install python dependencies: +First, install Python dependencies: ```bash python3 -m pip install -r requirements.txt ``` -Next, install [specmatic](https://specmatic.in/download/latest.html). +Next, Node.js linters and tools +```bash +npm install +``` + +Finally, install [specmatic](https://specmatic.in/download/latest.html). -### Run products API server +### Run API server ```bash -cd src/products -flask run & +npm run server ``` ### Run the tests diff --git a/package-lock.json b/package-lock.json index 3f8a506..e968c34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "specmatic-testing-example", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "specmatic-testing-example", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@redocly/cli": "^1.0.0-beta.123", @@ -1195,9 +1195,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.286.tgz", - "integrity": "sha512-Vp3CVhmYpgf4iXNKAucoQUDcCrBQX3XLBtwgFqP9BUXuucgvAV9zWp1kYU7LL9j4++s9O+12cb3wMtN4SJy6UQ==", + "version": "1.4.287", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.287.tgz", + "integrity": "sha512-Dj649q67z9oYp4dj7B80hdxb1ExIvy8KvrFqjCuGqFxUHk43b+VXQVFkD5zxAFmhvA6Mu9fou6ZQjJkMSXNFFg==", "peer": true }, "node_modules/emoji-regex": { @@ -4857,9 +4857,9 @@ "integrity": "sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==" }, "electron-to-chromium": { - "version": "1.4.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.286.tgz", - "integrity": "sha512-Vp3CVhmYpgf4iXNKAucoQUDcCrBQX3XLBtwgFqP9BUXuucgvAV9zWp1kYU7LL9j4++s9O+12cb3wMtN4SJy6UQ==", + "version": "1.4.287", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.287.tgz", + "integrity": "sha512-Dj649q67z9oYp4dj7B80hdxb1ExIvy8KvrFqjCuGqFxUHk43b+VXQVFkD5zxAFmhvA6Mu9fou6ZQjJkMSXNFFg==", "peer": true }, "emoji-regex": { diff --git a/package.json b/package.json index 21ed282..c85f2c5 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,28 @@ { "name": "specmatic-testing-example", - "version": "1.2.0", + "version": "1.3.0", "private": true, "description": "Specmatic Testing Example", "main": "index.js", "scripts": { + "server": "flask --app runner:app run", "lint-enforcer": "openapi-enforcer validate contracts/documentation.yaml", "lint-redocly": "redocly lint --config redocly.yaml contracts/documentation.yaml", - "lint": "npm run lint-enforcer && npm run lint-redocly", - "all": "npm run lint" + "lint": "npm run lint-enforcer && npm run lint-redocly" }, "repository": { "type": "git", "url": "git+https://github.com/sergeyklay/specmatic-testing-example.git" }, "keywords": [ - "openapi", + "api", + "contracts", + "api-testing", + "contract-testing", + "contract-test", "specmatic", - "setup" + "openapi3", + "openapi" ], "author": "Serghei Iakovlev", "license": "MIT", diff --git a/src/products/products.json b/products.json similarity index 100% rename from src/products/products.json rename to products.json diff --git a/provider/__init__.py b/provider/__init__.py new file mode 100644 index 0000000..5b555ad --- /dev/null +++ b/provider/__init__.py @@ -0,0 +1,35 @@ +# This file is part of the Specmatic Testing Example. +# +# Copyright (C) 2023 Serghei Iakovlev +# +# For the full copyright and license information, please view +# the LICENSE file that was distributed with this source code. + +"""The top-level module for the application. + +This module tracks the version of the application as well as the +base application info used by various functions within the +package and provides a factory function to create application +instance. + +Misc variables: + + __author__ + __author_email__ + __copyright__ + __description__ + __license__ + __url__ + __version__ + +""" + +from flask import current_app + +__copyright__ = 'Copyright (C) 2023 Serghei Iakovlev' +__version__ = '1.3.0' +__license__ = 'MIT' +__author__ = 'Serghei Iakovlev' +__author_email__ = 'egrep@protonmail.ch' +__url__ = 'https://github.com/sergeyklay/specmatic-testing-example' +__description__ = 'Specmatic Testing Example' diff --git a/provider/api/__init__.py b/provider/api/__init__.py new file mode 100644 index 0000000..d6f060e --- /dev/null +++ b/provider/api/__init__.py @@ -0,0 +1,14 @@ +# This file is part of the Specmatic Testing Example. +# +# Copyright (C) 2023 Serghei Iakovlev +# +# For the full copyright and license information, please view +# the LICENSE file that was distributed with this source code. + +"""The api blueprint module for the application.""" + +from flask import Blueprint + +api = Blueprint('api', __name__) + +from . import products diff --git a/src/products/app.py b/provider/api/products.py similarity index 65% rename from src/products/app.py rename to provider/api/products.py index cbb482f..9b81e92 100644 --- a/src/products/app.py +++ b/provider/api/products.py @@ -1,29 +1,21 @@ -from flask import abort, json, Flask, request -from werkzeug.exceptions import HTTPException +# This file is part of the Specmatic Testing Example. +# +# Copyright (C) 2023 Serghei Iakovlev +# +# For the full copyright and license information, please view +# the LICENSE file that was distributed with this source code. +from flask import abort, json, request, Response -app = Flask(__name__) - +from provider import current_app +from provider.api import api def get_products_list(): with open('products.json') as f: return json.loads(f.read()) -@app.errorhandler(HTTPException) -def resource_not_found(e): - """Return JSON instead of HTML for HTTP errors.""" - response = e.get_response() - response.data = json.dumps({ - 'code': e.code, - 'name': e.name, - 'description': e.description, - }) - response.content_type = 'application/json' - return response - - -@app.route('/v1/products', methods=['GET']) +@api.route('/products', methods=['GET']) def list(): data = get_products_list() args = request.args @@ -44,7 +36,7 @@ def list(): continue products.append(p) - response = app.response_class( + response = current_app.response_class( response=json.dumps(products), status=200, mimetype='application/json' @@ -52,7 +44,7 @@ def list(): return response -@app.route('/v1/products/', methods=['GET']) +@api.route('/products/', methods=['GET']) def get(id): # This check is made on purpose to simulate 400 error try: @@ -63,8 +55,8 @@ def get(id): data = get_products_list() for product in data: if product['id'] == id: - response = app.response_class( - response=json.dumps(product), + response = current_app.response_class( + response=api.dumps(product), status=200, mimetype='application/json' ) diff --git a/provider/app.py b/provider/app.py new file mode 100644 index 0000000..f55c965 --- /dev/null +++ b/provider/app.py @@ -0,0 +1,78 @@ +# This file is part of the Specmatic Testing Example. +# +# Copyright (C) 2023 Serghei Iakovlev +# +# For the full copyright and license information, please view +# the LICENSE file that was distributed with this source code. + +"""Manage the application creation and configuration process. + +Functions: + create_app(config: str) -> flask.Flask + load_env_vars(base_path: str) -> None + configure_app(app: Flask, config_name=None) -> None + configure_blueprints(app: Flask) -> None + +""" + +import os + +from flask import Flask +from werkzeug.exceptions import HTTPException + + +app = Flask(__name__) + + +def create_app(config=None) -> Flask: + """Create and configure an instance of the Flask application.""" + app = Flask(__name__) + + configure_app(app, config) + configure_blueprints(app) + + return app + + +def load_env_vars(base_path: str): + """Load the current dotenv as system environment variable.""" + dotenv_path = os.path.join(base_path, '.env') + + from dotenv import load_dotenv + if os.path.exists(dotenv_path): + load_dotenv(dotenv_path=dotenv_path) + + +def configure_app(app: Flask, config_name=None): + """Configure application.""" + from provider.config import config, Config + + # Use the default config and override it afterwards + app.config.from_object(config['default']) + + if config is not None: + # Config name as a string + if isinstance(config_name, str) and config_name in config: + app.config.from_object(config[config_name]) + config[config_name].init_app(app) + # Config as an object + else: + app.config.from_object(config_name) + if isinstance(config_name, Config): + config_name.init_app(app) + + # Update config from environment variable (if any). + # Export this variable as follows: + # export APP_SETTINGS="/var/www/server/config.py" + app.config.from_envvar('APP_SETTINGS', silent=True) + + +def configure_blueprints(app: Flask): + """Configure blueprints for the application.""" + # main blueprint registration + from provider.main import main as main_blueprint + app.register_blueprint(main_blueprint) + + # api blueprint registration + from provider.api import api as api_blueprint + app.register_blueprint(api_blueprint, url_prefix='/v1') diff --git a/provider/config.py b/provider/config.py new file mode 100644 index 0000000..d64ee59 --- /dev/null +++ b/provider/config.py @@ -0,0 +1,36 @@ +# This file is part of the Specmatic Testing Example. +# +# Copyright (C) 2023 Serghei Iakovlev +# +# For the full copyright and license information, please view +# the LICENSE file that was distributed with this source code. + +import os + + +class Config: + BASE_PATH = os.path.abspath(os.path.dirname(__file__)) + + @staticmethod + def init_app(app): + pass + + +class DevelopmentConfig(Config): + DEBUG = True + + +class TestingConfig(Config): + TESTING = True + + +class ProductionConfig(Config): + pass + + +config = { + 'development': DevelopmentConfig, + 'testing': TestingConfig, + 'production': ProductionConfig, + 'default': DevelopmentConfig, +} diff --git a/provider/main/__init__.py b/provider/main/__init__.py new file mode 100644 index 0000000..6628864 --- /dev/null +++ b/provider/main/__init__.py @@ -0,0 +1,19 @@ +# This file is part of the Specmatic Testing Example. +# +# Copyright (C) 2023 Serghei Iakovlev +# +# For the full copyright and license information, please view +# the LICENSE file that was distributed with this source code. + +"""The main blueprint module for the application. + +Provides the routes and errors handlers definition for the +application. + +""" + +from flask import Blueprint + +main = Blueprint('main', __name__) + +from . import views, errors diff --git a/provider/main/errors.py b/provider/main/errors.py new file mode 100644 index 0000000..0a9aee3 --- /dev/null +++ b/provider/main/errors.py @@ -0,0 +1,33 @@ +# This file is part of the Specmatic Testing Example. +# +# Copyright (C) 2023 Serghei Iakovlev +# +# For the full copyright and license information, please view +# the LICENSE file that was distributed with this source code. + +"""The error handler module for the application. + +Functions: + + handle_error(e) -> Any + +""" + +from flask import abort, json +from werkzeug.exceptions import HTTPException + +from provider.main import main + + + +@main.errorhandler(HTTPException) +def handle_error(e): + """Return JSON instead of HTML for HTTP errors.""" + response = e.get_response() + response.data = json.dumps({ + 'code': e.code, + 'name': e.name, + 'description': e.description, + }) + response.content_type = 'application/json' + return response diff --git a/provider/main/views.py b/provider/main/views.py new file mode 100644 index 0000000..5f5c4bc --- /dev/null +++ b/provider/main/views.py @@ -0,0 +1,25 @@ +# This file is part of the Specmatic Testing Example. +# +# Copyright (C) 2023 Serghei Iakovlev +# +# For the full copyright and license information, please view +# the LICENSE file that was distributed with this source code. + +"""The routes module for the application.""" + +import os +from distutils.util import strtobool + +from flask import abort + +from . import main + + +@main.before_app_request +def maintained(): + try: + maintenance = strtobool(os.getenv('MAINTENANCE_MODE', 'False')) + if bool(maintenance): + abort(503) + except ValueError: + pass diff --git a/runner.py b/runner.py new file mode 100644 index 0000000..0fb835f --- /dev/null +++ b/runner.py @@ -0,0 +1,23 @@ +# This file is part of the Specmatic Testing Example. +# +# Copyright (C) 2023 Serghei Iakovlev +# +# For the full copyright and license information, please view +# the LICENSE file that was distributed with this source code. + +"""The main entry point for Specmatic Testing Example. + +To run this entrypoint use the following command: + + flask --app runner:app run + +""" + +import os + +from provider.app import create_app, load_env_vars + +load_env_vars(os.path.dirname(os.path.abspath(__file__))) + +config = os.getenv('APP_ENV', 'default').lower() +app = create_app(config) diff --git a/src/products/.flaskenv b/src/products/.flaskenv deleted file mode 100644 index f8071ee..0000000 --- a/src/products/.flaskenv +++ /dev/null @@ -1,2 +0,0 @@ -FLASK_DEBUG=1 -FLASK_RUN_PORT=5000 From ca3cd57e9fa248416faeb43bdd87fa2b6f9b3776 Mon Sep 17 00:00:00 2001 From: Serghei Iakovlev Date: Tue, 7 Feb 2023 01:44:06 +0100 Subject: [PATCH 2/3] Fix api response --- provider/api/products.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/api/products.py b/provider/api/products.py index 9b81e92..2c0ccff 100644 --- a/provider/api/products.py +++ b/provider/api/products.py @@ -56,7 +56,7 @@ def get(id): for product in data: if product['id'] == id: response = current_app.response_class( - response=api.dumps(product), + response=json.dumps(product), status=200, mimetype='application/json' ) From 4f6ddae67437f742ac58291c9dd93287ae99fe02 Mon Sep 17 00:00:00 2001 From: Serghei Iakovlev Date: Tue, 7 Feb 2023 02:01:26 +0100 Subject: [PATCH 3/3] Provide error handlers --- provider/main/errors.py | 42 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/provider/main/errors.py b/provider/main/errors.py index 0a9aee3..002515e 100644 --- a/provider/main/errors.py +++ b/provider/main/errors.py @@ -10,11 +10,20 @@ Functions: handle_error(e) -> Any + bad_request(e) -> Any + page_not_found(e) -> Any + method_not_allowed(e) -> Any + on_json_loading_failed(e: Request, error: json.decoder.JSONDecodeError) """ -from flask import abort, json -from werkzeug.exceptions import HTTPException +from flask import json, Request +from werkzeug.exceptions import ( + HTTPException, + BadRequest, + NotFound, + MethodNotAllowed +) from provider.main import main @@ -31,3 +40,32 @@ def handle_error(e): }) response.content_type = 'application/json' return response + + +@main.app_errorhandler(400) +def bad_request(e): + """Registers a function to handle 400 errors.""" + return handle_error(BadRequest(response=e.get_response())) + + +@main.app_errorhandler(404) +def page_not_found(e): + """Registers a function to handle 404 errors.""" + return handle_error(NotFound(response=e.get_response())) + + +@main.app_errorhandler(405) +def method_not_allowed(e): + """Registers a function to handle 405 errors.""" + return handle_error(MethodNotAllowed(response=e.get_response())) + + +def on_json_loading_failed(req, error): + """Abort with a custom JSON message.""" + raise BadRequest( + description=f'''Failed to decode JSON object: {error}''', + response=req + ) + + +Request.on_json_loading_failed = on_json_loading_failed