diff --git a/.github/workflows/execute-python-tests.yml b/.github/workflows/execute-python-tests.yml index 8c022d5..374aecb 100644 --- a/.github/workflows/execute-python-tests.yml +++ b/.github/workflows/execute-python-tests.yml @@ -15,16 +15,28 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} # See docs: https://github.com/actions/checkout#checkout-pull-request-head-commit-instead-of-merge-commit + - name: Set up Python 3.10 uses: actions/setup-python@v2 with: python-version: "3.10" + + - name: Creates .staging.env file + uses: SpicyPizza/create-envfile@v1.3 + with: + envkey_DATABASE_CONNECTION_STRING: ${{ secrets.DB_CONNECTION_STRING }} + file_name: .staging.env + fail_on_empty: true + - name: Install dependencies run: | python -m pip install --upgrade pip pip install pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Execute tests with pytest run: | python -m pytest diff --git a/.gitignore b/.gitignore index 1bf116c..97ca030 100644 --- a/.gitignore +++ b/.gitignore @@ -157,3 +157,6 @@ cython_debug/ #.idea/ # End of https://www.toptal.com/developers/gitignore/api/python + +# .env files +*.env \ No newline at end of file diff --git a/Makefile b/Makefile index 569cc94..afb0f8f 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ install: # install project dependencies from requirements.txt file test: # execute all tests @echo "$(COLOUR_GREEN)Executing tests ...$(COLOUR_END)" - python -m pytest + python -m pytest --verbose lint: # use linter @echo "$(COLOUR_GREEN)Running lint process ...$(COLOUR_END)" @@ -25,12 +25,29 @@ lint: # use linter python -m isort --profile black $(APP_SOURCE_CODE_DIR) ; \ python -m autopep8 --in-place --recursive --verbose $(APP_SOURCE_CODE_DIR) -help: # list all Makefile commands - @echo "$(COLOUR_BLUE)These are all the avalaible commands ...$(COLOUR_END)" - grep ':' Makefile +run: # starts uvicorn server with auto reload @ port 8880 + @echo "$(COLOUR_GREEN)Starting server ...$(COLOUR_END)" + uvicorn testglossary.main:app --reload --port=8880 + +go-prod: # Run in Production environment. Starts uvicorn server with auto reload + @echo "$(COLOUR_GREEN)Starting server ...$(COLOUR_END)" + PRODUCTION_READY=true uvicorn testglossary.main:app --reload --log-config=log_conf.yaml + +gh-deploy: # builds and deploy MkDocs documentation style to GitHub Pages + mkdocs gh-deploy --verbose --strict --remote-branch="support/gh-pages" docker-build: GET_NOW := $(shell date +%s) docker-build: # builds a new container image @echo "$(COLOUR_RED)Building a Docker image ...$(COLOUR_END)" TAG_NAME="$(DOCKER_HUB_USERNAME)/$(DOCKER_HUB_REPOSITORY):$(GET_NOW)" ; \ - docker build -t $${TAG_NAME} . \ No newline at end of file + docker build -t $${TAG_NAME} . + +#: ######################################### +#: #### Help - Makefile for TestGlossary API +#: ######################################### + +help: # list all Makefile commands + @echo "$(COLOUR_BLUE)These are all the avalaible commands ...$(COLOUR_END)" + @echo "" + @grep ': #' Makefile + \ No newline at end of file diff --git a/README.md b/README.md index 12e199b..cfba83a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # test-glossary-project Building a glossary of testing terms based on ISTQB's glossary + +Documentation is available at https://thiagojacinto.github.io/test-glossary-project/ \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index e4a7237..3578842 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,18 +1,29 @@ -# Welcome to TestGlossary projects +# Welcome to TestGlossary -This project uses mkdocs. For full documentation how-to visit [mkdocs.org](https://www.mkdocs.org). +This is the official documentation for the TestGlossary project. + +If you have ideas, feel free to get in touch or open an Issue to the project [repository](https://github.com/thiagojacinto/test-glossary-project/issues). ## Commands +* `make run` - start uvicorn server at port `8880` * `make install` - install project dependencies from requirements.txt file -* `make test` - execute all tests +* `make test` - execute all tests +* `make lint` - use linter * `make lint` - use linter * `make help` - lists all Makefile commands +### Auxiliary commands +* `make go-prod` - start uvicorn server with production environment configuraiton +* `make gh-deploy` - uses the built-in tool of MkDocs (docs [here](https://www.mkdocs.org/user-guide/deploying-your-docs/)) to deploy documenation to the specific branch that is responsible to handle the GitHub page: [thiagojacinto.github.io/test-glossary-project/](https://thiagojacinto.github.io/test-glossary-project/) +* `make docker-build` - build a new Docker image and tag it with timestamp. + ## Project layout - Makefile # Makefile with simplifications for project maintenance - mkdocs.yml # MkDocs configuration file. + Makefile # Makefile with simplifications for project maintenance + mkdocs.yml # MkDocs configuration file. + log_conf.yaml # Logging configuraiton file. + .env # Important environment variables like DATABASE_CONNECTION_STRING docs/ index.md # The documentation homepage. ... # Other markdown pages, images and other documentation related files. @@ -21,3 +32,23 @@ This project uses mkdocs. For full documentation how-to visit [mkdocs.org](https testglossary/ ... # Main application files must be placed under this directory, following futher structure. +### .env files + +The development was done considering two environments: production and staging. So, at root directory must exist at least **two files** named: +``` +- .env +- .staging.env +``` + +The content of these files must contain the following variables and its values: +``` +- DATABASE_CONNECTION_STRING # desired database connection string. + # An example could be a DB-as-a-Service implementation like: + # Heroku Postgres DBaaS, ElephantSQL +``` + +## Why documentation? + +Documentation matters. + +To help with this project goal, TestGlossary uses MkDocs. For full documentation how-to visit [mkdocs.org](https://www.mkdocs.org). We `highly` recommend you to. \ No newline at end of file diff --git a/docs/service/index.md b/docs/service/index.md new file mode 100644 index 0000000..86ca1f7 --- /dev/null +++ b/docs/service/index.md @@ -0,0 +1,9 @@ +# Service + +TestGlossary offers an API Service to be used, and documentation for that is also available in well stablished formats - once the server is up and running: + +- `OpenAPI v3`: accessing `/api/openapi.json` +- `swagger`: accessing `/api/docs` +- `redocs`: accessing `/api/redocs` + +While you may find some discussion here about the funcionalities, feel free to visit the the links above. \ No newline at end of file diff --git a/docs/service/v1.md b/docs/service/v1.md new file mode 100644 index 0000000..1a699a0 --- /dev/null +++ b/docs/service/v1.md @@ -0,0 +1,28 @@ +# API: Version 1 + +This page lists the features implemented in this version of the Test-Glossary service API. + +## `api/v1/healthcheck` + +Endpoint that returns an OK status response just to give an overall status feedback. + +### Allowed Methods + +``` +GET api/v1/healthcheck +``` + +## `api/v1/terms` + +Service that concentrate interactions between user and test Terms database. + + - returns a paginated list of all registered glossary test Terms; + - allow filtering of specific test Terms, returning a paginated list as well, if any match. + +### Allowed Methods +``` +GET api/v1/terms +GET api/v1/terms?page=2&terms_per_page=5 +GET api/v1/terms/search/{specific-term} +GET api/v1/terms/search/{specific-term}?page=1&terms_per_page=5 +``` \ No newline at end of file diff --git a/log_conf.yaml b/log_conf.yaml new file mode 100644 index 0000000..a611496 --- /dev/null +++ b/log_conf.yaml @@ -0,0 +1,29 @@ +version: 1 +disable_existing_loggers: False +formatters: + default: + "()": uvicorn.logging.DefaultFormatter + format: '%(asctime)s %(levelname)s %(name)s - %(message)s' + access: + "()": uvicorn.logging.AccessFormatter + format: '%(asctime)s %(levelname)s %(name)s - %(message)s' +handlers: + default: + formatter: default + class: logging.StreamHandler + stream: ext://sys.stderr + access: + formatter: access + class: logging.StreamHandler + stream: ext://sys.stdout +loggers: + uvicorn.error: + level: INFO + handlers: + - default + propagate: no + uvicorn.access: + level: INFO + handlers: + - access + propagate: yes \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 646c9fd..5e31cfc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,2 +1,2 @@ site_name: TestGlossary -theme: readthedocs +theme: readthedocs \ No newline at end of file diff --git a/prod-requirements.txt b/prod-requirements.txt index 8f1296d..d1b25c6 100644 --- a/prod-requirements.txt +++ b/prod-requirements.txt @@ -21,3 +21,7 @@ uvloop==0.16.0 watchgod==0.7 websockets==10.2 zipp==3.7.0 + +SQLAlchemy==1.4.34 +psycopg2==2.9.3 +greenlet==1.1.2 diff --git a/requirements.txt b/requirements.txt index 39c9568..0a5c5f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,9 +3,12 @@ asgiref==3.5.0 attrs==21.4.0 autopep8==1.6.0 black==22.1.0 +certifi==2021.10.8 +charset-normalizer==2.0.12 click==8.0.4 fastapi==0.75.0 ghp-import==2.0.2 +greenlet==1.1.2 h11==0.13.0 httptools==0.3.0 idna==3.3 @@ -22,6 +25,7 @@ packaging==21.3 pathspec==0.9.0 platformdirs==2.5.1 pluggy==1.0.0 +psycopg2==2.9.3 py==1.11.0 pycodestyle==2.8.0 pydantic==1.9.0 @@ -31,12 +35,15 @@ python-dateutil==2.8.2 python-dotenv==0.19.2 PyYAML==6.0 pyyaml_env_tag==0.1 +requests==2.27.1 six==1.16.0 sniffio==1.2.0 +SQLAlchemy==1.4.34 starlette==0.17.1 toml==0.10.2 tomli==2.0.1 typing_extensions==4.1.1 +urllib3==1.26.9 uvicorn==0.17.5 uvloop==0.16.0 watchdog==2.1.6 diff --git a/testglossary/database/__init__.py b/testglossary/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/testglossary/database/connection.py b/testglossary/database/connection.py new file mode 100644 index 0000000..f7047d6 --- /dev/null +++ b/testglossary/database/connection.py @@ -0,0 +1,15 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from testglossary.internal.config import configuration + +# from FastAPI docs' https://fastapi.tiangolo.com/tutorial/sql-databases/#create-the-sqlalchemy-parts + +engine = create_engine( + url=configuration.DATABASE_CONNECTION_STRING, pool_pre_ping=True, echo=True +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() diff --git a/testglossary/database/entities.py b/testglossary/database/entities.py new file mode 100644 index 0000000..9c083bf --- /dev/null +++ b/testglossary/database/entities.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, ForeignKey, Integer, String + +from testglossary.database.connection import Base + + +class Term(Base): + """Test term to be explained""" + + __tablename__ = "terms" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String) + acronym = Column(String) + definition = Column(String) + version = Column(Integer) + language_id = Column(Integer, ForeignKey("languages.id"), index=True) + + +class Language(Base): + """Choose language of test term translation""" + + __tablename__ = "languages" + + id = Column(Integer, primary_key=True, index=True) + language = Column(String, unique=True) diff --git a/testglossary/database/interface.py b/testglossary/database/interface.py new file mode 100644 index 0000000..0e97911 --- /dev/null +++ b/testglossary/database/interface.py @@ -0,0 +1,25 @@ +from sqlalchemy.orm import Session + +from testglossary.database.entities import Term + + +def get_terms(db: Session, page: int = 0, results_per_page: int = 30): + """ + Return all test terms, paginated + """ + return db.query(Term).offset(page).limit(results_per_page).all() + + +def get_term_by_name( + db: Session, term_name: str, page: int = 0, results_per_page: int = 5 +): + """ + Search for a test term by its name + """ + return ( + db.query(Term) + .where(Term.name.ilike("%{}%".format(term_name))) + .offset(page) + .limit(results_per_page) + .all() + ) diff --git a/testglossary/internal/config.py b/testglossary/internal/config.py new file mode 100644 index 0000000..d38ee9d --- /dev/null +++ b/testglossary/internal/config.py @@ -0,0 +1,26 @@ +from os import getenv + +from pydantic import BaseSettings + + +class Configuration(BaseSettings): + """ + Configuration & settings wrapper using Pydantic's BaseSettings + """ + + APP_NAME: str = "TestGlossary API" + DATABASE_CONNECTION_STRING: str = "PROVIDE A VALID DB CONNECTION STRING" + + class Config: + """ + Sub class for handling .env file reading + """ + + env_file = ".staging.env" + env_file_encoding = "utf-8" + + +if getenv("PRODUCTION_READY") == "true": + configuration = Configuration(_env_file=".env") +else: + configuration = Configuration() diff --git a/testglossary/internal/exceptions.py b/testglossary/internal/exceptions.py new file mode 100644 index 0000000..77380f6 --- /dev/null +++ b/testglossary/internal/exceptions.py @@ -0,0 +1,4 @@ +terms: dict = { + "NOT_FOUND": "It was not possible to find any test term with that filter.", + "EMPTY_LIST": "The test term list is currently empty.", +} diff --git a/testglossary/internal/serializers.py b/testglossary/internal/serializers.py new file mode 100644 index 0000000..f6400bc --- /dev/null +++ b/testglossary/internal/serializers.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel + +from testglossary.models.schemas import Term + + +class Paginated_ouput(BaseModel): + """ + Paginated response output serializer. + """ + + result: list | dict | None + page: int = 0 + offset: int = 0 + + +class Paginated_terms_list(Paginated_ouput): + """ + Specialized paginated response for a Test Terms list + """ + + result: list[Term] diff --git a/testglossary/main.py b/testglossary/main.py index 9061aab..b2c7c10 100644 --- a/testglossary/main.py +++ b/testglossary/main.py @@ -1,11 +1,9 @@ -from sys import prefix - from fastapi import APIRouter, FastAPI, status from fastapi.responses import RedirectResponse -from .internal import health_check -from .internal.api_versions import API_versions -from .routers import terms +from testglossary.internal import health_check +from testglossary.internal.api_versions import API_versions +from testglossary.routers import terms app = FastAPI( title="TestGlossary API", @@ -16,6 +14,7 @@ }, docs_url="/api/docs", redoc_url="/api/redocs", + openapi_url="/api/openapi.json", ) active_API_version = API_versions.v1.value active_API_version_router = APIRouter(prefix="/{}".format(active_API_version)) diff --git a/testglossary/models/__init__.py b/testglossary/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/testglossary/models/schemas.py b/testglossary/models/schemas.py new file mode 100644 index 0000000..90faf96 --- /dev/null +++ b/testglossary/models/schemas.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel + + +class TermBase(BaseModel): + """Basic usage of Test Term model""" + + name: str + definition: str + + +class Term(TermBase): + """Test Term that shows the language info""" + + id: int + language_id: int + acronym: str | None + + class Config: + orm_mode = True + + +class CreateTerm(Term): + version: int + language_id: int + + +class Language(BaseModel): + """Basic usage of Language model""" + + id: int + language: str | None + + class Config: + orm_mode = True diff --git a/testglossary/routers/terms.py b/testglossary/routers/terms.py index b07ae10..2fe3218 100644 --- a/testglossary/routers/terms.py +++ b/testglossary/routers/terms.py @@ -1,17 +1,82 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends, HTTPException, Path, Query, status +from sqlalchemy.orm import Session + +from testglossary.database import connection, entities, interface +from testglossary.internal import exceptions, serializers router = APIRouter( prefix="/terms", tags=["terms"], responses={404: {"description": "Not found"}} ) +# Create tables +entities.Base.metadata.create_all(bind=connection.engine) + +# Database Dependency + + +def use_db(): + db = connection.SessionLocal() + try: + yield db + finally: + db.close() + @router.get( path="/", summary="Terms lists", description="Returns a list of terms from the glossary", + response_model=serializers.Paginated_terms_list, ) -async def list_terms(): +async def list_terms( + page: int | None = Query(default=0, title="Page number", example=0, ge=0), + terms_per_page: int + | None = Query( + default=10, title="Number of terms listed by page", example=10, ge=1 + ), + db: Session = Depends(use_db), +): """ returns a list of terms from the glossary """ - return [{}] + if terms_list := interface.get_terms( + db, page=page, results_per_page=terms_per_page + ): + paginated_terms_list = serializers.Paginated_ouput( + result=terms_list, page=page, offset=terms_per_page + ) + return paginated_terms_list + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=exceptions.terms.get( + "EMPTY_LIST") + ) + + +@router.get( + path="/search/{name}", + summary="Search by term name", + description="Search by specific test term name", + response_model=serializers.Paginated_terms_list, +) +async def get_term_by_name( + name: str = Path(None, title="Test term query string", + max_length=30, min_length=3), + page: int | None = Query(default=0, title="Page number", example=0, ge=0), + terms_per_page: int + | None = Query(default=5, title="Number of terms listed by page", example=10, ge=1), + db: Session = Depends(use_db), +): + """ + Search for a term by its name + """ + if db_terms := interface.get_term_by_name( + db, term_name=name, page=page, results_per_page=terms_per_page + ): + paginated_terms_list = serializers.Paginated_ouput( + result=db_terms, page=page, offset=terms_per_page + ) + return paginated_terms_list + + raise HTTPException( + status_code=404, detail=exceptions.terms.get("NOT_FOUND")) diff --git a/tests/example_test.py b/tests/example_test.py deleted file mode 100644 index 87bf96a..0000000 --- a/tests/example_test.py +++ /dev/null @@ -1,7 +0,0 @@ -import pytest - -def test_project_name_should_be_test_glossary(): - - project_name = 'test_glossary' - assert project_name == 'test_glossary' - diff --git a/tests/internal/health_check_test.py b/tests/internal/health_check_test.py new file mode 100644 index 0000000..8a17595 --- /dev/null +++ b/tests/internal/health_check_test.py @@ -0,0 +1,24 @@ +import pytest +from fastapi.testclient import TestClient + +from testglossary.main import app, active_API_version + +client = TestClient(app) +active_target_url = '/api/{}/healthcheck'.format(active_API_version) + +def test_active_API_version_should_be_a_string(): + "validate if active_API_version is a string" + assert isinstance(active_API_version, str) + +def test_should_return_status_as_OK(): + response = client.get(active_target_url) + assert response.status_code == 200 + assert response.json() == { "status": "OK" } + +def test_should_return_status_as_not_None(): + response = client.get(active_target_url) + assert response.json() is not None + +def test_should_return_status_code_200(): + response = client.get(active_target_url) + assert response.status_code == 200 \ No newline at end of file diff --git a/tests/terms_test.py b/tests/terms_test.py new file mode 100644 index 0000000..bf01322 --- /dev/null +++ b/tests/terms_test.py @@ -0,0 +1,107 @@ +import pytest +from fastapi.testclient import TestClient +from fastapi import Response + +from testglossary.main import app, active_API_version + +client = TestClient(app) +active_target_url = '/api/{}/terms'.format(active_API_version) + +def test_should_return_an_array(): + response = client.get(active_target_url) + assert response.status_code == 200 + assert isinstance(response.json().get('result'), list) == True + + +def test_should_return_status_code_200(): + response = client.get(active_target_url) + assert response.status_code == 200 + + +def test_should_return_404_on_not_found_resource(): + response = client.get('{}/2'.format(active_target_url)) + assert response.status_code == 404 + + +def test_page_path_parameter_should_not_be_required(): + response_with_page = client.get('{}?page=0'.format(active_target_url)) + response_without_page = client.get('{}'.format(active_target_url)) + assert response_with_page.status_code == response_without_page.status_code + assert response_with_page.json().get('result') == response_without_page.json().get('result') + + +def test_page_path_should_be_positive(): + response_with_page_negative = client.get('{}?page=-1'.format(active_target_url)) + assert response_with_page_negative.status_code == 422 + + +def test_tests_terms_per_page_path_parameter_should_be_greater_than_one(): + response_with_invalid_terms_per_page_parameter = client.get( + '{}?terms_per_page=0'.format(active_target_url)) + assert response_with_invalid_terms_per_page_parameter.status_code == 422 + + +def test_terms_list_path_parameters_should_have_default_values(): + response_with_default_values = client.get(active_target_url) + assert response_with_default_values.status_code == 200 + assert response_with_default_values.json().get('page') == 0 + assert response_with_default_values.json().get('offset') == 10 + + +def test_terms_per_page_path_parameter_should_not_be_required(): + response_with_terms_per_page = client.get( + '{}?terms_per_page=10'.format(active_target_url)) + response_without_terms_per_page = client.get('{}'.format(active_target_url)) + assert response_with_terms_per_page.status_code == response_without_terms_per_page.status_code + assert response_with_terms_per_page.json().get('result') == response_without_terms_per_page.json().get('result') + +def test_terms_list_response_should_have_paginated_result_structure(): + response = client.get(active_target_url) + response_attributes = response.json() + expected_attributes = ['result', 'page', 'offset'] + + assert response.status_code == 200 + assert expected_attributes == [attribute for attribute in expected_attributes if attribute in response_attributes] + + +@pytest.fixture() +def search_term_by(): + def _search_term_by_name(search_term='test') -> Response: + return client.get('{}/search/{}'.format(active_target_url, search_term)) + + return _search_term_by_name + + +def test_search_term_by_name(search_term_by): + assert search_term_by('test').status_code == 200 + + +def test_search_term_attempt_with_more_than_30_characters(search_term_by): + longer_sentence = 'this-is-a-longer-sentence-more-than-30-caracters' + + assert search_term_by(longer_sentence).status_code == 422 + + +def test_search_term_attempt_with_less_than_3_characters(search_term_by): + small_sentence = 'no' + + assert search_term_by(small_sentence).status_code == 422 + + +def test_search_term_with_page_path_parameter_should_be_positive(search_term_by): + page_parameter = 'aplha?page=-1' + + assert search_term_by(page_parameter).status_code == 422 + + +def test_search_term_with_test_term_by_page_path_parameter_should_be_greater_than_one(search_term_by): + page_parameter = 'aplha?terms_per_page=0' + + assert search_term_by(page_parameter).status_code == 422 + +def test_search_term_non_existent(search_term_by): + term_that_not_exists = 'not-a-test-term' + response = search_term_by(term_that_not_exists) + + assert response.status_code == 404 + assert 'not' in response.json().get('detail').lower() \ No newline at end of file