diff --git a/.gitignore b/.gitignore index 352f896..001568e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ texttest/html2ascii venv .project .pydevproject +**/build/* +.vscode/* +**/.pytest_cache/* +**/.specmatic/* diff --git a/README.md b/README.md index 1f0fc8e..742715a 100644 --- a/README.md +++ b/README.md @@ -70,3 +70,12 @@ For each service, (users, newsletter and greeting), here referred to as $SERVICE When they are all running, you should be able to open a browser on the [Newsletter swagger api](http://localhost:5010/docs) to access the functionality. +## Contract Testing with Specmatic +To run the contract tests for newsletter service: + + virtualenv -p /usr/bin/python3 venv + source venv/bin/activate + cd newsletter + pytest test -v -s + +In specmatic.json under newsletter folder, you will notice that we've specified the path to newsletter service's OpenAPI spec under the test section. Similarlly we've specified, user and greeting service's OpenAPI spec path under stub section. This ensures that specmatic creates a stub for both those service dependencies using their OpenAPI specifications. \ No newline at end of file diff --git a/contracts/greeting-openapi.yaml b/contracts/greeting-openapi.yaml new file mode 100644 index 0000000..40a21e3 --- /dev/null +++ b/contracts/greeting-openapi.yaml @@ -0,0 +1,85 @@ +components: + schemas: + ValidationError: + properties: + detail: + properties: + : + properties: + : + items: + type: string + type: array + type: object + type: object + message: + type: string + type: object +info: + title: Greeting Service + version: 0.1.0 +openapi: 3.0.3 +paths: + /formatGreeting: + get: + parameters: + - in: query + name: description + required: false + schema: + type: string + examples: + SUCCESS: + value: Independent Technical Coach, YouTuber, creator of Samman Coaching, Author + UNKNOWN: + value: "" + - in: query + name: name + required: true + schema: + type: string + examples: + SUCCESS: + value: Emily Bache + UNKNOWN: + value: Unknown + - in: query + name: title + required: false + schema: + type: string + examples: + SUCCESS: + value: Miss. + UNKNOWN: + value: "" + - in: query + name: first_time_user + required: false + schema: + type: boolean + examples: + SUCCESS: + value: false + UNKNOWN: + value: true + responses: + '200': + content: + text/html: + schema: + type: string + examples: + SUCCESS: + value: Hello, Miss. Emily Bache. Independent Technical Coach, YouTuber, creator of Samman Coaching, Author is my favourite person. Welcome back! + UNKNOWN: + value: Hello, Unknown. Nice to meet you! + description: Successful response + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: Validation error + summary: Format Greeting +tags: [] diff --git a/contracts/newsletter-openapi.yaml b/contracts/newsletter-openapi.yaml new file mode 100644 index 0000000..17ddfde --- /dev/null +++ b/contracts/newsletter-openapi.yaml @@ -0,0 +1,67 @@ +components: + schemas: + HTTPError: + properties: + detail: + type: object + message: + type: string + type: object +info: + title: Newsletter Service + version: 0.1.0 +openapi: 3.0.3 +paths: + /sayHello: + post: + requestBody: + content: + application/json: + schema: + type: object + required: + - "name" + properties: + name: + type: string + examples: + SUCCESS: + value: + name: "Emily Bache" + UNKNOWN: + value: + name: "Unknown" + BLACKLISTED: + value: + name: "DDT" + responses: + '200': + content: + text/html: + schema: + type: string + examples: + SUCCESS: + value: "Hello, Miss. Emily Bache. Independent Technical Coach, YouTuber, creator of Samman Coaching, Author is my favourite person. Welcome back!" + UNKNOWN: + value: "Hello, Unknown. Nice to meet you!" + description: Successful response + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + examples: + BLACKLISTED: + value: + detail: { "error": "Blacklisted name" } + message: "Bad request" + description: Bad request + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + description: Unprocessable entity + summary: Say Hello +tags: [] diff --git a/contracts/users-openapi.yaml b/contracts/users-openapi.yaml new file mode 100644 index 0000000..0519ef9 --- /dev/null +++ b/contracts/users-openapi.yaml @@ -0,0 +1,97 @@ +components: + schemas: + HTTPError: + properties: + detail: + type: object + message: + type: string + type: object + example: + detail: { "error": "Invalid input" } + message: "Bad request" + PersonOut: + properties: + description: + nullable: true + type: string + name: + type: string + title: + nullable: true + type: string + first_time_user: + type: boolean + type: object +info: + title: Users Service + version: 0.1.0 +openapi: 3.0.3 +paths: + /persons: + post: + requestBody: + content: + application/json: + schema: + type: object + required: + - "name" + properties: + name: + type: string + title: + nullable: true + type: string + description: + nullable: true + type: string + examples: + SUCCESS: + value: + name: "Emily Bache" + UNKNOWN: + value: + name: "Unknown" + BLACKLISTED: + value: + name: "DDT" + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PersonOut' + examples: + SUCCESS: + value: + description: "Independent Technical Coach, YouTuber, creator of Samman Coaching, Author" + name: "Emily Bache" + title: "Miss." + first_time_user: false + UNKNOWN: + value: + name: "Unknown" + first_time_user: true + description: "" + title: "" + description: Successful response + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + examples: + BLACKLISTED: + value: + detail: { "error": "Invalid input" } + message: "DDTs are not kind to bees." + description: Bad Input + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + description: Not found + summary: Get Person Http +tags: [] diff --git a/greeting/src/greeting.py b/greeting/src/greeting.py index b616d49..85e1591 100644 --- a/greeting/src/greeting.py +++ b/greeting/src/greeting.py @@ -3,7 +3,7 @@ from apiflask import APIFlask, Schema from apiflask.fields import String -#import yaml +import yaml app = APIFlask('greeting', title='Greeting Service') @@ -11,15 +11,15 @@ class PersonData(Schema): name = String(required=True) title = String() description = String() + first_time_user = bool() - @app.get("/formatGreeting") -#@app.output(StringSchema, content_type="text/html", status_code=200) @app.input(PersonData, "query") def format_greeting(query_data): name = query_data.get('name') title = query_data.get('title') description = query_data.get('description') + first_time_user = query_data.get('first_time_user') greeting = 'Hello' greeting += ', ' @@ -28,10 +28,16 @@ def format_greeting(query_data): greeting += name + '!' if description: greeting += ' ' + description + " is my favourite!" + if first_time_user: + greeting += ' Nice to meet you!' + else: + greeting += ' Welcome back!' return greeting if __name__ == "__main__": port = 0 if "DYNAMIC_PORTS" in os.environ else 5002 - # with open(os.path.join(os.path.dirname(__file__), "openapi.yaml"), "w") as f: - # yaml.dump(app.spec, f) + if "DUMP_SCHEMA" in os.environ: + print("Writing schema file") + with open(os.path.join(os.path.dirname(__file__), "greeting-openapi.yaml"), "w") as f: + yaml.dump(app.spec, f) app.run(port=port) diff --git a/newsletter/specmatic.json b/newsletter/specmatic.json new file mode 100644 index 0000000..45be4ed --- /dev/null +++ b/newsletter/specmatic.json @@ -0,0 +1,36 @@ +{ + "sources": [ + { + "provider": "git", + "test": [ + "../contracts/newsletter-openapi.yaml" + ], + "stub": [ + "../contracts/greeting-openapi.yaml", + "../contracts/users-openapi.yaml" + ] + } + ], + "report": { + "formatters": [ + { + "type": "text", + "layout": "table" + } + ], + "types": { + "APICoverage": { + "OpenAPI": { + "successCriteria": { + "minThresholdPercentage": 100, + "maxMissedEndpointsInSpec": 0, + "enforce": true + }, + "excludedEndpoints": [ + "/docs", "/docs/oauth2-redirect", "/openapi.json" + ] + } + } + } + } +} \ No newline at end of file diff --git a/newsletter/src/__init__.py b/newsletter/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/newsletter/src/newsletter.py b/newsletter/src/newsletter.py index a9a0601..99fe5bc 100644 --- a/newsletter/src/newsletter.py +++ b/newsletter/src/newsletter.py @@ -3,7 +3,9 @@ import requests from flask_cors import CORS, cross_origin -from apiflask import APIFlask, abort +from apiflask import APIFlask, abort, Schema +from apiflask.fields import String +import yaml app = APIFlask('newsletter', title='Newsletter Service') CORS(app) @@ -14,21 +16,24 @@ 'requestInterceptor': interceptor } -@app.get("/sayHello/") -@app.output({ "type": "string" }, content_type="text/html", status_code=200) + +class PersonIn(Schema): + name = String() + + +@app.post("/sayHello") +@app.input(PersonIn) @cross_origin() -def say_hello(name): - person = get_person(name) - resp = format_greeting(person) - return resp +def say_hello(json_data): + name = json_data.get('name') + person = fetch_or_create_person(name) + return format_greeting(person) -def get_person(name): +def fetch_or_create_person(name): users_url = os.getenv("USERS_URL", 'http://localhost:5001') - url = f'{users_url}/getPerson/{name}' - res = _get(url) - person = json.loads(res) - return person + url = f'{users_url}/persons' + return _post(url, person={'name': name}) def format_greeting(person): @@ -36,6 +41,15 @@ def format_greeting(person): url = greeting_url + '/formatGreeting' return _get(url, params=person) + +def _post(url, person): + r = requests.post(url, json=person) + data = json.loads(r.text) + if r.status_code != 200: + abort(r.status_code, data['message']) + return data + + def _get(url, params=None): r = requests.get(url, params=params) if r.status_code != 200: @@ -46,4 +60,8 @@ def _get(url, params=None): if __name__ == "__main__": port = 0 if "DYNAMIC_PORTS" in os.environ else 5010 + if "DUMP_SCHEMA" in os.environ: + print("Writing schema file") + with open(os.path.join(os.path.dirname(__file__), "newsletter-openapi.yaml"), "w") as f: + yaml.dump(app.spec, f) app.run(port=port) diff --git a/newsletter/test/__init__.py b/newsletter/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/newsletter/test/test_contract.py b/newsletter/test/test_contract.py new file mode 100644 index 0000000..710219f --- /dev/null +++ b/newsletter/test/test_contract.py @@ -0,0 +1,31 @@ +import os +import pytest +from specmatic.core.specmatic import Specmatic +from src.newsletter import app + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +app_host = "127.0.0.1" +app_port = 5010 +stub_host = "127.0.0.1" +stub_port = 9000 +stub_url = 'http://' + stub_host + ':' + str(stub_port) + +os.environ['USERS_URL'] = stub_url +os.environ['GREETING_URL'] = stub_url +os.environ['SPECMATIC_GENERATIVE_TESTS'] = "true" + +folder_with_stub_expectation_jsons = ROOT_DIR + '/test/data' + +class TestContract: + pass + +Specmatic() \ + .with_project_root(ROOT_DIR) \ + .with_stub(stub_host, stub_port) \ + .with_wsgi_app(app, app_host, app_port) \ + .test_with_api_coverage_for_flask_app(TestContract, app) \ + .run() + +if __name__ == '__main__': + pytest.main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index a3c3921..9b6afd5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ flask==2.2.5 -Werkzeug==2.3.7 +Werkzeug==2.3.8 apiflask Flask-Cors sqlalchemy @@ -9,3 +9,5 @@ dbtext capturemock pyodbc PyYAML +pytest==7.3.1 +specmatic==0.26.3 \ No newline at end of file diff --git a/users/src/users.py b/users/src/users.py index c4ea78a..8c773ae 100644 --- a/users/src/users.py +++ b/users/src/users.py @@ -11,28 +11,41 @@ class PersonOut(Schema): name = String() title = String(allow_none=True) description = String(allow_none=True) + first_time_user = bool() -@app.get("/getPerson/") +class PersonIn(Schema): + name = String() + title = String(allow_none=True) + description = String(allow_none=True) + +@app.post("/persons") @app.output(PersonOut) -def get_person_http(name): +@app.input(PersonIn) +def get_person_http(json_data): + name = json_data.get('name') + first_time_user = False person = Person.get(name) if person is None: if name in ["Neonicotinoid", "Insecticide", "DDT"]: abort(400, f"{name}s are not kind to bees.") person = Person() person.name = name - person.save() - else: - person.description + person.title = json_data.get('title') + person.description = json_data.get('description') + person.save() + first_time_user = True response = { 'name': person.name, 'title': person.title, 'description': person.description, + 'first_time_user': first_time_user } return response if __name__ == "__main__": port = 0 if "DYNAMIC_PORTS" in os.environ else 5001 - # with open(os.path.join(os.path.dirname(__file__), "openapi.yaml"), "w") as f: - # yaml.dump(app.spec, f) + if "DUMP_SCHEMA" in os.environ: + print("Writing schema file") + with open(os.path.join(os.path.dirname(__file__), "users-openapi.yaml"), "w") as f: + yaml.dump(app.spec, f) app.run(port=port)