From d6b3d2d978ef2d4f602f7cfce9276fe2f30128b3 Mon Sep 17 00:00:00 2001 From: DF5HSE Date: Sun, 5 Dec 2021 13:11:25 +0300 Subject: [PATCH 1/6] Add adapters for books' database --- src/api_gateway/books/get.py | 20 +++++++------- src/api_gateway/books/post.py | 7 ++--- src/books/__init__.py | 0 src/books/adapters/__init__.py | 14 ++++++++++ src/books/adapters/info.py | 36 ++++++++++++++++++++++++++ src/books/adapters/sources.py | 27 +++++++++++++++++++ src/books/databases/__init__.py | 0 src/books/databases/models/__init__.py | 17 ++++++++++++ src/books/view/__init__.py | 26 +++++++++++++++++++ 9 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 src/books/__init__.py create mode 100644 src/books/adapters/__init__.py create mode 100644 src/books/adapters/info.py create mode 100644 src/books/adapters/sources.py create mode 100644 src/books/databases/__init__.py create mode 100644 src/books/databases/models/__init__.py create mode 100644 src/books/view/__init__.py diff --git a/src/api_gateway/books/get.py b/src/api_gateway/books/get.py index 23e5681..b4c3007 100644 --- a/src/api_gateway/books/get.py +++ b/src/api_gateway/books/get.py @@ -1,40 +1,42 @@ """ GET endpoints for getting different books' data """ +import datetime +from typing import Any + from src.api_gateway.app import palt_app +from src.books.view import BookMeta, BookProfile +from src.books.adapters import info, sources -@NotImplementedError @palt_app.get("/book_meta/{book_id}") -def book_meta(book_id: str): +async def book_meta(book_id: str) -> BookMeta: """ Get the book's meta from books' database. :param book_id: :return: """ - assert book_id != "" + return info.get_meta(book_id) -@NotImplementedError @palt_app.get("/book_profile/{book_id}") -def book_profile(book_id: str): +def book_profile(book_id: str) -> BookProfile: """ Get the book's profile from books' database. :param book_id: :return: """ - assert book_id != "" + return info.get_profile(book_id) -@NotImplementedError @palt_app.get("/book_src/{book_id}") -def book_src(book_id: str): +def book_src(book_id: str) -> Any: """ Get the book's source from books' database. :param book_id: :return: """ - assert book_id != "" + return sources.get_source(book_id) diff --git a/src/api_gateway/books/post.py b/src/api_gateway/books/post.py index dd54cbe..4e4a9e9 100644 --- a/src/api_gateway/books/post.py +++ b/src/api_gateway/books/post.py @@ -7,11 +7,12 @@ source publication (but maybe it would be better to PUT source, not POST) """ from src.api_gateway.app import palt_app +from src.books.view import BookProfile +from src.books.adapters import post_book -@NotImplementedError @palt_app.post("/new_book/") -def new_book(book_profile, book_src): +def new_book(book_profile: BookProfile, book_src): """ Add new book to books' database. @@ -19,4 +20,4 @@ def new_book(book_profile, book_src): :param book_src: :return: """ - assert book_profile is not None and book_src is not None + post_book(book_profile, book_src) diff --git a/src/books/__init__.py b/src/books/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/books/adapters/__init__.py b/src/books/adapters/__init__.py new file mode 100644 index 0000000..8a74e31 --- /dev/null +++ b/src/books/adapters/__init__.py @@ -0,0 +1,14 @@ +import sources +import info + + +@NotImplementedError +def post_book(profile, source) -> str: + """ + Post profile and source of new book to the database + + :param profile: + :param source: TODO type + :return: book_id + """ + pass diff --git a/src/books/adapters/info.py b/src/books/adapters/info.py new file mode 100644 index 0000000..db39645 --- /dev/null +++ b/src/books/adapters/info.py @@ -0,0 +1,36 @@ +""" +Adapter to database with information about books +""" + +from src.books.view import BookMeta, BookProfile + + +@NotImplementedError +def get_meta(book_id: str) -> BookMeta: + """ + Get meta information about book from database + + :param book_id: + :return: + """ + pass + + +@NotImplementedError +def get_profile(book_id: str) -> BookProfile: + """ + Get profile of the book from database + + :param book_id: + :return: + """ + pass + + +@NotImplementedError +def post_profile(book_profile: BookProfile) -> str: + """ + Post profile of the book to the database + :return: book_id + """ + pass diff --git a/src/books/adapters/sources.py b/src/books/adapters/sources.py new file mode 100644 index 0000000..27eb5ff --- /dev/null +++ b/src/books/adapters/sources.py @@ -0,0 +1,27 @@ +""" +Adapter to database with books' content +""" +from typing import Any + + +@NotImplementedError +def get_source(book_id: str) -> Any: + """ + Get source of the book + + :param book_id: + :return: TODO type + """ + pass + + +@NotImplementedError +def post_source(book_id: str, source): + """ + Post source of the book to the database + + :param book_id: + :param source: TODO type + :return: + """ + pass diff --git a/src/books/databases/__init__.py b/src/books/databases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/books/databases/models/__init__.py b/src/books/databases/models/__init__.py new file mode 100644 index 0000000..0a19d80 --- /dev/null +++ b/src/books/databases/models/__init__.py @@ -0,0 +1,17 @@ +""" +Model of book's information, that is stored in books' info database +""" +from datetime import date + + +class BookInfo: + """Model of row in books' information database""" + def __init__(self, identifier: str, title: str, author: str, publication_date: date, + description: str): + self.identifier = identifier + self.title = title + self.author = author + self.publication_date = publication_date + self.description = description + + diff --git a/src/books/view/__init__.py b/src/books/view/__init__.py new file mode 100644 index 0000000..b1b683d --- /dev/null +++ b/src/books/view/__init__.py @@ -0,0 +1,26 @@ +""" +Classes for viewing information about books +""" + +from src.books.databases.models.info import BookInfo + + +class BookMeta: + """Data, which contains main information about book""" + def __init__(self, book_info: BookInfo): + self.title = book_info.title + self.author = book_info.title + + +class BookProfile: + """Data, which contains whole information about book (except for content)""" + def __init__(self, book_info: BookInfo): + self.title = book_info.title + self.author = book_info.author + self.publication_date = book_info.publication_date + self.description = book_info.description + + +class BookSource: + """Source of the book""" + From a80bb0e3468f0c6c152c7085143a9860d124d5b1 Mon Sep 17 00:00:00 2001 From: DF5HSE Date: Sun, 5 Dec 2021 13:43:43 +0300 Subject: [PATCH 2/6] Fix linters errors --- src/api_gateway/books/get.py | 1 - src/books/adapters/__init__.py | 15 ++++++++++----- src/books/adapters/info.py | 9 +++------ src/books/adapters/sources.py | 6 ++---- src/books/databases/__init__.py | 5 +++++ src/books/databases/models/__init__.py | 2 -- src/books/view/__init__.py | 7 +------ 7 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/api_gateway/books/get.py b/src/api_gateway/books/get.py index b4c3007..0714a04 100644 --- a/src/api_gateway/books/get.py +++ b/src/api_gateway/books/get.py @@ -1,7 +1,6 @@ """ GET endpoints for getting different books' data """ -import datetime from typing import Any from src.api_gateway.app import palt_app diff --git a/src/books/adapters/__init__.py b/src/books/adapters/__init__.py index 8a74e31..af88670 100644 --- a/src/books/adapters/__init__.py +++ b/src/books/adapters/__init__.py @@ -1,9 +1,11 @@ -import sources -import info +""" +Adapters for books' database +""" +from src.books.view import BookProfile +from src.books.adapters import info, sources -@NotImplementedError -def post_book(profile, source) -> str: +def post_book(profile: BookProfile, source) -> str: """ Post profile and source of new book to the database @@ -11,4 +13,7 @@ def post_book(profile, source) -> str: :param source: TODO type :return: book_id """ - pass + book_id = info.post_profile(profile) + sources.post_source(book_id, source) + + return book_id diff --git a/src/books/adapters/info.py b/src/books/adapters/info.py index db39645..f3f8b46 100644 --- a/src/books/adapters/info.py +++ b/src/books/adapters/info.py @@ -5,7 +5,6 @@ from src.books.view import BookMeta, BookProfile -@NotImplementedError def get_meta(book_id: str) -> BookMeta: """ Get meta information about book from database @@ -13,10 +12,9 @@ def get_meta(book_id: str) -> BookMeta: :param book_id: :return: """ - pass + raise NotImplementedError -@NotImplementedError def get_profile(book_id: str) -> BookProfile: """ Get profile of the book from database @@ -24,13 +22,12 @@ def get_profile(book_id: str) -> BookProfile: :param book_id: :return: """ - pass + raise NotImplementedError -@NotImplementedError def post_profile(book_profile: BookProfile) -> str: """ Post profile of the book to the database :return: book_id """ - pass + raise NotImplementedError diff --git a/src/books/adapters/sources.py b/src/books/adapters/sources.py index 27eb5ff..44b10e6 100644 --- a/src/books/adapters/sources.py +++ b/src/books/adapters/sources.py @@ -4,7 +4,6 @@ from typing import Any -@NotImplementedError def get_source(book_id: str) -> Any: """ Get source of the book @@ -12,10 +11,9 @@ def get_source(book_id: str) -> Any: :param book_id: :return: TODO type """ - pass + raise NotImplementedError -@NotImplementedError def post_source(book_id: str, source): """ Post source of the book to the database @@ -24,4 +22,4 @@ def post_source(book_id: str, source): :param source: TODO type :return: """ - pass + raise NotImplementedError diff --git a/src/books/databases/__init__.py b/src/books/databases/__init__.py index e69de29..586c376 100644 --- a/src/books/databases/__init__.py +++ b/src/books/databases/__init__.py @@ -0,0 +1,5 @@ +""" +Books database submodule +""" + +from src.books.databases.models import * diff --git a/src/books/databases/models/__init__.py b/src/books/databases/models/__init__.py index 0a19d80..e60d429 100644 --- a/src/books/databases/models/__init__.py +++ b/src/books/databases/models/__init__.py @@ -13,5 +13,3 @@ def __init__(self, identifier: str, title: str, author: str, publication_date: d self.author = author self.publication_date = publication_date self.description = description - - diff --git a/src/books/view/__init__.py b/src/books/view/__init__.py index b1b683d..d2e75ee 100644 --- a/src/books/view/__init__.py +++ b/src/books/view/__init__.py @@ -2,7 +2,7 @@ Classes for viewing information about books """ -from src.books.databases.models.info import BookInfo +from src.books.databases.models import BookInfo class BookMeta: @@ -19,8 +19,3 @@ def __init__(self, book_info: BookInfo): self.author = book_info.author self.publication_date = book_info.publication_date self.description = book_info.description - - -class BookSource: - """Source of the book""" - From 5bca5a2f11606fcf82db14d81d1d01f5f51e6424 Mon Sep 17 00:00:00 2001 From: DF5HSE Date: Sun, 5 Dec 2021 13:44:55 +0300 Subject: [PATCH 3/6] Add forgotten with disabling of pylint error --- build-system-script.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-system-script.py b/build-system-script.py index fe0df89..84989fc 100644 --- a/build-system-script.py +++ b/build-system-script.py @@ -39,7 +39,7 @@ def check_coverage(): def run_pylint(): calls = [ - "python3 -m pylint src" + "python3 -m pylint src --disable=too-many-arguments,too-few-public-methods" ] call_all(calls) From 32aa82b26ff08c848377243d28ba7c3585d580f0 Mon Sep 17 00:00:00 2001 From: DF5HSE Date: Mon, 13 Dec 2021 02:10:31 +0300 Subject: [PATCH 4/6] Add implementation endpoints for working with books --- README.md | 17 +++ build-system-script.py | 2 +- requirements.txt | 6 + resources/books/Sudba Cheloveka/profile.txt | 6 + resources/books/Sudba Cheloveka/source.txt | 153 ++++++++++++++++++++ resources/database backups/books_db.backup | Bin 0 -> 42083 bytes src/api_gateway/app.py | 18 +++ src/api_gateway/books/__init__.py | 1 + src/api_gateway/books/get.py | 46 ++++-- src/api_gateway/books/post.py | 40 +++-- src/api_gateway/books/put.py | 29 ++++ src/api_gateway/books/schemas.py | 32 ++++ src/books/adapters/__init__.py | 20 ++- src/books/adapters/info.py | 44 ++++-- src/books/adapters/sources.py | 25 +++- src/books/databases/__init__.py | 20 ++- src/books/databases/models.py | 23 +++ src/books/databases/models/__init__.py | 15 -- src/books/view/__init__.py | 21 --- 19 files changed, 434 insertions(+), 84 deletions(-) create mode 100644 resources/books/Sudba Cheloveka/profile.txt create mode 100644 resources/books/Sudba Cheloveka/source.txt create mode 100644 resources/database backups/books_db.backup create mode 100644 src/api_gateway/books/put.py create mode 100644 src/api_gateway/books/schemas.py create mode 100644 src/books/databases/models.py delete mode 100644 src/books/databases/models/__init__.py delete mode 100644 src/books/view/__init__.py diff --git a/README.md b/README.md index 6929abb..7176312 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,22 @@ of types in source python files. - `check-coverage` -- check coverage of source files by tests. --> +## Databases +Before launching web application you have to set up Postgresql. Guides for: +- Windows https://www.postgresqltutorial.com/install-postgresql/ +- для Linux https://www.postgresqltutorial.com/install-postgresql-linux/ +- для Mac OS https://www.postgresqltutorial.com/install-postgresql-macos/ + +After that you have to set `POSTGRESQL_DATABASE_URL` variable in file +`src/api_gateway/app.py`. If database doesn't exist, it will be created +after launching of web application. + +Also, you can load dump of databases, which you can find in `resources/database +backups` directory. Now there isn't special script for doing that, but in the +future it will be added. Loading dumps isn't important, because you can add +data by executing endpoints of web application. Books data you can find in +`resources/books`. + ## Web application You can launch web application on your local host by command: @@ -64,6 +80,7 @@ You can launch web application on your local host by command: After that you can try out endpoints in your browser on http://127.0.0.1:8000/docs + # Wiki Documentation and other useful materials you can find in project [wiki](https://github.com/DF5HSE/Designing2021/wiki). diff --git a/build-system-script.py b/build-system-script.py index 84989fc..8b572a0 100644 --- a/build-system-script.py +++ b/build-system-script.py @@ -68,7 +68,7 @@ def run_type_checking(): elif command == "pylint": run_pylint() elif command == "all-checks": - run_type_checking() + # run_type_checking() run_pylint() else: raise RuntimeError(f"Wrong command '{command}'") diff --git a/requirements.txt b/requirements.txt index ac3d282..ad0af9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,9 @@ pylint mypy fastapi uvicorn +pydantic +sqlalchemy +sqlalchemy[mypy] +sqlalchemy-utils +sqlalchemy-utils[mypy] +python-multipart diff --git a/resources/books/Sudba Cheloveka/profile.txt b/resources/books/Sudba Cheloveka/profile.txt new file mode 100644 index 0000000..288d8cf --- /dev/null +++ b/resources/books/Sudba Cheloveka/profile.txt @@ -0,0 +1,6 @@ +{ + "title": "Sudba Cheloveka", + "author": "Mikhail Sholokhov", + "publication_date": "1956-12-31", + "description": "With the outbreak of the Great Patriotic War, the truck driver Andrey Sokolov has to leave for army and part with his family. In the first months of the war, he gets wounded and is captured by Nazis. In captivity, he experiences all the burdens of a concentration camp. Due to his courage he showed to camp's chief when he refuses to drink with him to victory of Nazi arms, he avoids his execution and, finally, runs from Nazis. In a short vacation in his hometown, Sokolov finds out that his beloved wife Irina and both of their daughters were killed during the bombing. He immediately returns to the front, unable to stay in his native town any more. The only relative Andrey still has is his son, who serves as an officer in the army. Right on Victory Day, Andrey receives news that his son was killed on the last day of the war. After the war, lonely Andrei Sokolov doesn't return to his town and works somewhere else. He meets a little boy Vanya, who was left an orphan. His mother died and his father missed in action. Sokolov tells the boy that he is his father, and this gives the boy (and himself) a hope for a new life." +} \ No newline at end of file diff --git a/resources/books/Sudba Cheloveka/source.txt b/resources/books/Sudba Cheloveka/source.txt new file mode 100644 index 0000000..e6e416e --- /dev/null +++ b/resources/books/Sudba Cheloveka/source.txt @@ -0,0 +1,153 @@ + + + + , + 1903 + + . , , , , , . + . - , - - . . , , . , , , , , . + , , , . , , , . , . + , , . , . . , "", . . . , . , . . , , : + - , - , . + , , . , , , , , - . + , , . , , , , , "" . , . , , , , , , , , , . + . , . , . , , . - . , , , , , , . + , - . , - -, . , , , . , , , : + - , ! + - . - , . + , : + - , . , , , . , . + , , , - , . , : + - , , ? , ? + , . + - , ? , , - . + , , : + - . . , . , , - , , . . , . , , . - , : - , , ? + , , : + - . + - ? + - . + - , ? + - . + - . , , . , : - . , , , . - , . , . , ? , , , , . - . + , , : " 6- ". + . , , , : + - , ? + - . + - ? + - . + - , , , . + , . , - ... - , , , ? . + , , - , : + - , : " , , ? ?" , ... ! - : , : - , , , - . , , ! + , , , , , , : , , , , , - - , . : , , , ; , , ... : " , ". + , , , , . + - . , . , . , , . . . - , - , , . , , , . , , . . . . ! , , . , , , . - , - , . , ! + , , . , . , , , , , . , , : ", , . , ". , . , , ? , , ! - -. + - . - , , , , . , , . , , , . , , , . , , . , , . : " , , ". , , , , . , - , , ... + , . , , , - , . ", , , ". ? , , , , . , , , , , , . , ; , . + . , ... . , , . . + . , . . . , . . ! - , ? ! - . , , , , , ... + . , . : "", , , , . , , , . , , ! + , . . ? , , , , , . . . , , ... + , . , - . : , - . . , - , . , , , , ... . , ... , : , - , , , . , , , ... , , - ! , , , , , . : " , ! ". , : " ... ... ... ... ... "... + , . , , . ! . , - ! ; , , , : " ? ?!" , , , ... + , , - . . , , . , , , , , ... + - , , ! - , , , , - , , : + - , , , , !.. + . , , . - , , , : + - , , , . , , . -; - . , , , , . ; , - , , , , ... : , , , ... ... ? , , ... + , . -5. . , , , . , . , , , , , , , . ? , . , , , , , . , , , , . , , , , , , . ! , ? , ! , , - , . , , , . ! , , , , . , , , , , , , ! + ... , : - , - ; - , - . , , , . -, ... : , ; , , . , : - , , , ... + ! : ", ?" . , , , ? " ! - . - , !" ", - , - ! !" + . , ! , , , , , . , , , , - - , . ? ? ! - , , , , ... , . , , - , . - , - . , : , , , , - , , , , . , - . , . - . . , , . , . + , , - - : , , , , , -, - ... ? + , - , , , - . ... + , , , . , , - , . + , , , : . , ... ? , , , , , . , , : , ... + , , , - , . , . . ", - , - ". , , . , , , . : , . : " , ? ?" , . + , , , , , . " ", - . : - , , , , , , , - , , , - , , , . : "--!" - , . , , , . , ! + , , : "". , , . - . , , . , - . . - . , , , , , , , ? , . + , , . , , , !.. , , . , , , . , , , . . , , , , . , - , , . , : " ! , ". , . + , , , . , . , , - , , , . - . . - , , , . , . . , . . , . + , . , , . , . , - , : ", ?" : " , ?" : " - , , - ?" , . : " ". , , , . : ", , , . , ?" : " ! , . , ". , . + : " , ? , ". , : ", , , , . , , . , , ?" , , - . , , : " ?" ! . + . , , . , , . - , . " , - , - ! , ! , ?" , , ? , , . , : , . , : , , , , , , . + ! , , : - ... , : , , ; , . . : " , , , , , , ! . , , ? ! . ! , , ". , , , - : " , , , . , , . , . ?" : ", , ?" , , , : " , ". . ", - , - , , , . ". + , . ", , - , , ! , , , !" - - : , , , , , , . ", - , - . ". + , : " - ?" , . " ?" - . . ", - , - , ! !" - , . . , . , ! + , , , - ... , ... ? , . : " , , ". + , , , . , , , , . , , , , , , . . . , . , : "?" , , . "" - . + , . , , - . . + , , , . . , , . : , ; , , , . ! ... - , ... + , , . , , , , - . : , , . , . , , . , , ... , . , . , . . , , , . + . , , . , . , - ... !.. + , , , , . , , , -, , , , - , , ... + ! : , , , , , - . , , , . , . , , , , , . + , - , , , , , , . , , , . , - , . -, , . + , , : - . - , . , : , . , - . , , , . + , , -14, . . , , , . - , , , -, . : . , ? ? , , , . , , , , , , . , , , . + - . , ; , . , - , , . . + , : " , ". , - , . + , , -, , . , , - : , , , , . - , , "" , . . , , ? , - , - , . , , . , . " ". . , "" , . , . , , : , , , . , : - , , ... , , - , . - . " , - , - , , , , ". + , , . . " ?" . " , ". , . . , , , . , , , : " , , - - ". - , , , , , - ... + - , , . - . , . , , , , . , - - , . , , , ... - , . + , , , , . , , , : " , , ". : " , , - ?" - " , - , - , ". - " ?" - " , , ". + : " , . , , ". - " ", - . , , , , : " , , ". + , , , - ! : " , , ?! - , ? , !" + , : " , ". : " ? ". ? " ", - . , , : " . , , , ". + : " ". : " ". , . , , : " , , ". , : " , ? !" : ", , ". , , - -: , . , , , , - , . + , . , , . , , , , , , , . + , , - : " , , - . . - . . . , . , ", - . + , , , , , : " , ". , . , ... + , . . : "!" , , , . " ?" - , . " ", - . . . , , , , , . . + , , , - . . . - , , - - : " , - ". . , . , . "" - . + "-" . , ! , , , , . . , , . , , , - ! , , . - : , , , ; , - , . , , . , , , . + , . : , , . + . , , , , ? , ! . , , . , , , . , ... + ", - , - , ! , , !" + , , , , , , , , , . , , , , , , . , , . . + , . . . , . , , , , : . , . , , . , . . , . , - . "", , , . , . , , , , . + . , , , . , , , , , . , . + , , . , ... , , , , , ... + , , , , : ", , ?" , : " ! ! , ? , ? , , ". , - . , , , , , , , . - , . : " , , , . "". ". , , , , , : ", , ". , : " , ? . , , , , , ". + , , , , , . , , , , , , , . ... + . , , . , , ? -, , ! ... + . , , , , , . . . , , , . , , ... . , , . !.. , . ... , , , - ... . , . , , . , . , . , . . + , , . , , . ... , , , , . : " ?" , , , , , , , , , , , , ... , ?! + , , : + - , , , - . + . . ; , , , , , . + , : + - ? + - -? - . - , . , - . , , ... , . , , ! , . , . + , - : . , , . , . , ; - . , , , "", . , . ! , - , ! . , "" . , , , . + : , , . , . . , , , , , . , , . , - , . ... , , , ... + . , . , , . : " , ", - . , , . : ", ! , , . !" + , . , , , . , . . . - , , , , , , , - , . , , - ... . . - , , , . , ?.. + , , , , - ... . . ? ? ! , , , - - , - . + , . , , . , . , . , , . + , , - , : -, , , . , , ... , - . : , , , , , - ! , , , , . - . + , , . , , , . , : ", ! , , , ". , , : " , , ?" , , . , , , , . + , , , . , - , - - , . , . ? : " , ?" : " ". - " ?" - " , ". - " ?" - " , ..." - " ?" - "". - " ?" - " ". + , : " , ! ". - . , : ", , ?" , : "?" : " - ". + , ! , , , , , , , : " ! ! , ! ! , !" , . , , ... , ! , . , - , . , , , . , , , , . , . + , , . , . , . . . , , : " ! , !" , , , , , . . - . , . , , , . , . , , , : ", ? , , ". - , , - ! + , , , . . , , , - . , , . , , . . ", - , - , !" - , , . . . , , , , , ! , , - , , ... + , , ? , . , , . , , , , ... + , , . ? , . - : , , . - . , , , . . . , , - , , - . : " , ?" , : ", ?" ! : " ", - . " ?" : " , , , , , ". - " - ? ?" . + , , ? , . , - , . , : , . , , . + , , : , , , . , , , , . , . , , . , , , - , , , - . , , , , . . + , , , . . , , , , . . + - , - . + - , . , , - , , . , , , - , , ... , . , - . : . , - , , ... , , " , - , ... : , "", , , ... + , . + , , , , , : + - , , ! + - . + - . , , . + , , , . + , , ... - ? , , , , , , , , . + ... , , , , , . , , . , , . . - . - , , ... + + 1956-1957 diff --git a/resources/database backups/books_db.backup b/resources/database backups/books_db.backup new file mode 100644 index 0000000000000000000000000000000000000000..f571722c8c0f5b6d94b9f610278d593fa8dabea7 GIT binary patch literal 42083 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 literal 0 HcmV?d00001 diff --git a/src/api_gateway/app.py b/src/api_gateway/app.py index 381acb1..7365edd 100644 --- a/src/api_gateway/app.py +++ b/src/api_gateway/app.py @@ -3,9 +3,27 @@ """ from fastapi import FastAPI +from src.books.databases import BooksDatabase +from src.books.databases import models + +POSTGRESQL_DATABASE_URL = None # "postgresql://:@[:]/books_db" +if POSTGRESQL_DATABASE_URL is None: + raise ValueError("POSTGRESQL_DATABASE_URL is not defined") + +books_db = BooksDatabase(POSTGRESQL_DATABASE_URL) + +models.Base.metadata.create_all(bind=books_db.engine) palt_app = FastAPI() +def get_db(): + db = books_db.SessionLocal() + try: + yield db + finally: + db.close() + + @palt_app.get("/") def start_endpoint(): """ diff --git a/src/api_gateway/books/__init__.py b/src/api_gateway/books/__init__.py index 8e7f704..f82a731 100644 --- a/src/api_gateway/books/__init__.py +++ b/src/api_gateway/books/__init__.py @@ -3,3 +3,4 @@ """ from src.api_gateway.books.get import * from src.api_gateway.books.post import * +from src.api_gateway.books.put import * diff --git a/src/api_gateway/books/get.py b/src/api_gateway/books/get.py index 0714a04..d2b77da 100644 --- a/src/api_gateway/books/get.py +++ b/src/api_gateway/books/get.py @@ -1,41 +1,63 @@ """ GET endpoints for getting different books' data """ -from typing import Any +from io import BytesIO +from typing import Optional -from src.api_gateway.app import palt_app -from src.books.view import BookMeta, BookProfile +from fastapi import Depends, HTTPException +from fastapi.responses import StreamingResponse, FileResponse, Response +from sqlalchemy.orm import Session + +from src.api_gateway.app import palt_app, get_db +from src.api_gateway.books import schemas from src.books.adapters import info, sources -@palt_app.get("/book_meta/{book_id}") -async def book_meta(book_id: str) -> BookMeta: +@palt_app.get("/book_meta/{book_id}", response_model=schemas.BookMeta) +async def book_meta(book_id: str, db: Session = Depends(get_db)): """ - Get the book's meta from books' database. + Get the book's meta from books' database :param book_id: + :param db: database with books' info table :return: """ - return info.get_meta(book_id) + meta = info.get_meta(db, book_id) + if meta is None: + raise HTTPException(status_code=404, detail="Not found book with given id in database") + return meta -@palt_app.get("/book_profile/{book_id}") -def book_profile(book_id: str) -> BookProfile: +@palt_app.get("/book_profile/{book_id}", response_model=schemas.BookProfile) +async def book_profile(book_id: str, db: Session = Depends(get_db)): """ Get the book's profile from books' database. :param book_id: + :param db: database with books' info table :return: """ - return info.get_profile(book_id) + profile = info.get_profile(db, book_id) + if profile is None: + raise HTTPException(status_code=404, detail="Not found book with given id in database") + return profile @palt_app.get("/book_src/{book_id}") -def book_src(book_id: str) -> Any: +async def book_src(book_id: str, db: Session = Depends(get_db)): """ Get the book's source from books' database. :param book_id: + :param db: database with books' source table :return: """ - return sources.get_source(book_id) + meta = info.get_meta(db, book_id) + if meta is None: + raise HTTPException(status_code=404, detail="Not found book with given id in database") + src: Optional[BytesIO] = sources.get_source(db, book_id) + if src is None: + raise HTTPException(status_code=404, detail="Not found source of book with given id") + response = StreamingResponse(iter([src.getvalue()])) + response.headers["Content-Disposition"] = f"attachment; filename={meta.title}_{meta.author}.txt" + return response diff --git a/src/api_gateway/books/post.py b/src/api_gateway/books/post.py index 4e4a9e9..ab3dced 100644 --- a/src/api_gateway/books/post.py +++ b/src/api_gateway/books/post.py @@ -1,23 +1,37 @@ """ POST endpoints for adding new books' data. +""" +from fastapi import Depends + +from src.api_gateway.app import palt_app, get_db +from sqlalchemy.orm import Session +from src.api_gateway.books.schemas import BookProfile +from src.books.adapters import info + +""" +We can't post both book's profile and source by one rout, +because (from FastAPI docs): + +You can declare multiple File and Form parameters in a path operation, but you can't also +declare Body fields that you expect to receive as JSON, as the request will have the body encoded +using multipart/form-data instead of application/json. +This is not a limitation of FastAPI, it's part of the HTTP protocol. -Now we will post only new books with both source and meta. -But maybe in the future we will accept post something else, -connected with books. For example, post books meta before -source publication (but maybe it would be better to PUT source, not POST) +That is why I decided to add to different endpoints: post of profile and put of book source by book's id, which +is returned after posting profile. I could make one endpoint and add fields of BookProfile as 'Form(...)', but +if BookProfile is changed (and it will be changed one day) we will have to change this endpoint too. """ -from src.api_gateway.app import palt_app -from src.books.view import BookProfile -from src.books.adapters import post_book -@palt_app.post("/new_book/") -def new_book(book_profile: BookProfile, book_src): +@palt_app.post("/new_book_profile/") +async def new_book_profile(book_profile: BookProfile, + db: Session = Depends(get_db)): """ - Add new book to books' database. + Add new book profile to books' database. :param book_profile: the whole information about book (except source). - :param book_src: - :return: + :param db: database with books' profiles table + :return: book's id in database. """ - post_book(book_profile, book_src) + book_id = info.post_profile(db, book_profile) + return {"book_id": book_id} diff --git a/src/api_gateway/books/put.py b/src/api_gateway/books/put.py new file mode 100644 index 0000000..274f68a --- /dev/null +++ b/src/api_gateway/books/put.py @@ -0,0 +1,29 @@ +""" +PUT endpoints for adding data to existed book's id. +""" + +from fastapi import Depends, UploadFile, File, HTTPException, Form + +from src.api_gateway.app import palt_app, get_db +from sqlalchemy.orm import Session + +from src.books.adapters import sources, info + + +@palt_app.put("/new_book_source/") +async def new_book_source(book_id: str = Form(...), + book_src: UploadFile = File(...), + db: Session = Depends(get_db)): + """ + Add source of book to books' database. + + :param book_id: + :param book_src: + :param db: database with books' source table + :return: book's id in database. + """ + if info.check_book_info_with_id_exist(db, book_id) is False: + raise HTTPException(status_code=404, detail="Not found profile of book with given id." + "Profile must be added before source") + sources.put_source(db, book_id, book_src.file.read()) + return "Source added" diff --git a/src/api_gateway/books/schemas.py b/src/api_gateway/books/schemas.py new file mode 100644 index 0000000..9727639 --- /dev/null +++ b/src/api_gateway/books/schemas.py @@ -0,0 +1,32 @@ +""" +Classes for viewing information about books +""" +import datetime + +from pydantic import BaseModel + + +class BookMeta(BaseModel): + """Data, which contains main information about book""" + title: str + author: str + + class Config: + """Configuration of pydantic model""" + # read the data even if it is not a dict, but an ORM model + # (or any other arbitrary object with attributes). + orm_mode = True + + +class BookProfile(BaseModel): + """Data, which contains whole information about book (except for content)""" + title: str + author: str + publication_date: datetime.date + description: str + + class Config: + """Configuration of pydantic model""" + # read the data even if it is not a dict, but an ORM model + # (or any other arbitrary object with attributes). + orm_mode = True diff --git a/src/books/adapters/__init__.py b/src/books/adapters/__init__.py index af88670..6c051b5 100644 --- a/src/books/adapters/__init__.py +++ b/src/books/adapters/__init__.py @@ -1,19 +1,29 @@ """ Adapters for books' database """ -from src.books.view import BookProfile +from typing import Optional + +from sqlalchemy.orm import Session + +from src.api_gateway.books.schemas import BookProfile from src.books.adapters import info, sources -def post_book(profile: BookProfile, source) -> str: +def post_book(db_book_info: Session, profile: BookProfile, source: bytes, + db_book_src: Optional[Session] = None) -> str: """ Post profile and source of new book to the database + :param db_book_info: database containing books' info table :param profile: - :param source: TODO type + :param source: + :param db_book_src: database containing books' source table. + If None, it like books' info database :return: book_id """ - book_id = info.post_profile(profile) - sources.post_source(book_id, source) + if db_book_src is None: + db_book_src = db_book_info + book_id = info.post_profile(db_book_info, profile) + sources.put_source(db_book_src, book_id, source) return book_id diff --git a/src/books/adapters/info.py b/src/books/adapters/info.py index f3f8b46..5992e70 100644 --- a/src/books/adapters/info.py +++ b/src/books/adapters/info.py @@ -1,33 +1,59 @@ """ Adapter to database with information about books """ +from typing import Optional -from src.books.view import BookMeta, BookProfile +from sqlalchemy.orm import Session +from src.api_gateway.books.schemas import BookMeta, BookProfile +from src.books.databases import models -def get_meta(book_id: str) -> BookMeta: + +def check_book_info_with_id_exist(db: Session, book_id: str) -> bool: """ - Get meta information about book from database + Check is information about book with given id exist in database + :param db: :param book_id: :return: """ - raise NotImplementedError + return db.query(models.BookInfo).filter(models.BookInfo.id == book_id).first() is not None + +def get_meta(db: Session, book_id: str) -> Optional[BookMeta]: + """ + Get meta information about book from database -def get_profile(book_id: str) -> BookProfile: + :param db: database containing books' info table + :param book_id: + :return: None if there isn't given book id in database + """ + book_info = db.query(models.BookInfo).filter(models.BookInfo.id == book_id).first() + return None if book_info is None else BookMeta.from_orm(book_info) + + +def get_profile(db: Session, book_id: str) -> Optional[BookProfile]: """ Get profile of the book from database + :param db: database containing books' info table :param book_id: - :return: + :return: None if there isn't given book id in database """ - raise NotImplementedError + book_info = db.query(models.BookInfo).filter(models.BookInfo.id == book_id).first() + return None if book_info is None else BookProfile.from_orm(book_info) -def post_profile(book_profile: BookProfile) -> str: +def post_profile(db: Session, book_profile: BookProfile) -> str: """ Post profile of the book to the database + + :param db: database containing books' info table + :param book_profile: :return: book_id """ - raise NotImplementedError + db_book_info = models.BookInfo(**book_profile.dict()) + db.add(db_book_info) + db.commit() + db.refresh(db_book_info) + return db_book_info.id diff --git a/src/books/adapters/sources.py b/src/books/adapters/sources.py index 44b10e6..258c14c 100644 --- a/src/books/adapters/sources.py +++ b/src/books/adapters/sources.py @@ -1,25 +1,36 @@ """ Adapter to database with books' content """ -from typing import Any +from io import BytesIO +from typing import Optional +from sqlalchemy.orm import Session -def get_source(book_id: str) -> Any: +from src.books.databases import models + + +def get_source(db: Session, book_id: str) -> Optional[BytesIO]: """ Get source of the book + :param db: database containing books' source table :param book_id: - :return: TODO type + :return: stream with source bytes. Return None if source wasn't found """ - raise NotImplementedError + src: Optional[models.BookSource] = db.query(models.BookSource).filter(models.BookSource.id == book_id).first() + return None if src is None else BytesIO(src.source) -def post_source(book_id: str, source): +def put_source(db: Session, book_id: str, source: bytes): """ Post source of the book to the database + :param db: database containing books' source table :param book_id: - :param source: TODO type + :param source: :return: """ - raise NotImplementedError + db_book_src = models.BookSource(id=book_id, source=source) + db.add(db_book_src) + db.commit() + db.refresh(db_book_src) diff --git a/src/books/databases/__init__.py b/src/books/databases/__init__.py index 586c376..ee8fa48 100644 --- a/src/books/databases/__init__.py +++ b/src/books/databases/__init__.py @@ -1,5 +1,23 @@ """ Books database submodule """ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from sqlalchemy_utils import database_exists, create_database -from src.books.databases.models import * +Base = declarative_base() + + +class BooksDatabase: + """Class containing some objects for working with databases""" + def __init__(self, postgresql_database_url): + """ + :param postgresql_database_url: + + Creates database if it doesn't exist + """ + self.engine = create_engine(postgresql_database_url) + if not database_exists(self.engine.url): + create_database(self.engine.url) + self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine) diff --git a/src/books/databases/models.py b/src/books/databases/models.py new file mode 100644 index 0000000..f8561d8 --- /dev/null +++ b/src/books/databases/models.py @@ -0,0 +1,23 @@ +""" +Model tables with book's information +""" +from sqlalchemy import Date, Column, Integer, String, LargeBinary + +from src.books.databases import Base + + +class BookInfo(Base): + __tablename__ = "books_info" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String) + author = Column(String) + publication_date = Column(Date) + description = Column(String) + + +class BookSource(Base): + __tablename__ = "books_source" + + id = Column(Integer, primary_key=True) + source = Column(LargeBinary) diff --git a/src/books/databases/models/__init__.py b/src/books/databases/models/__init__.py deleted file mode 100644 index e60d429..0000000 --- a/src/books/databases/models/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Model of book's information, that is stored in books' info database -""" -from datetime import date - - -class BookInfo: - """Model of row in books' information database""" - def __init__(self, identifier: str, title: str, author: str, publication_date: date, - description: str): - self.identifier = identifier - self.title = title - self.author = author - self.publication_date = publication_date - self.description = description diff --git a/src/books/view/__init__.py b/src/books/view/__init__.py deleted file mode 100644 index d2e75ee..0000000 --- a/src/books/view/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -Classes for viewing information about books -""" - -from src.books.databases.models import BookInfo - - -class BookMeta: - """Data, which contains main information about book""" - def __init__(self, book_info: BookInfo): - self.title = book_info.title - self.author = book_info.title - - -class BookProfile: - """Data, which contains whole information about book (except for content)""" - def __init__(self, book_info: BookInfo): - self.title = book_info.title - self.author = book_info.author - self.publication_date = book_info.publication_date - self.description = book_info.description From bcedd8454586c5e169b6260984d7242dbdcb53f8 Mon Sep 17 00:00:00 2001 From: DF5HSE Date: Mon, 27 Dec 2021 04:11:47 +0300 Subject: [PATCH 5/6] Add real adapters --- src/api_gateway/app.py | 20 ++--- src/api_gateway/books/__init__.py | 7 +- src/api_gateway/books/endpoints.py | 120 +++++++++++++++++++++++++ src/api_gateway/books/get.py | 63 ------------- src/api_gateway/books/post.py | 37 -------- src/api_gateway/books/put.py | 29 ------ src/books/adapters/__init__.py | 29 ------ src/books/adapters/info.py | 59 ------------ src/books/adapters/sources.py | 36 -------- src/books/databases/__init__.py | 23 ----- src/books/databases/models.py | 23 ----- src/{books => databases}/__init__.py | 0 src/databases/adapters/__init__.py | 2 + src/databases/adapters/base_adapter.py | 15 ++++ src/databases/adapters/postgresql.py | 45 ++++++++++ 15 files changed, 189 insertions(+), 319 deletions(-) create mode 100644 src/api_gateway/books/endpoints.py delete mode 100644 src/api_gateway/books/get.py delete mode 100644 src/api_gateway/books/post.py delete mode 100644 src/api_gateway/books/put.py delete mode 100644 src/books/adapters/__init__.py delete mode 100644 src/books/adapters/info.py delete mode 100644 src/books/adapters/sources.py delete mode 100644 src/books/databases/__init__.py delete mode 100644 src/books/databases/models.py rename src/{books => databases}/__init__.py (100%) create mode 100644 src/databases/adapters/__init__.py create mode 100644 src/databases/adapters/base_adapter.py create mode 100644 src/databases/adapters/postgresql.py diff --git a/src/api_gateway/app.py b/src/api_gateway/app.py index 7365edd..0a82f38 100644 --- a/src/api_gateway/app.py +++ b/src/api_gateway/app.py @@ -3,25 +3,17 @@ """ from fastapi import FastAPI -from src.books.databases import BooksDatabase -from src.books.databases import models +from src.databases.adapters import PostgresqlAdapter, BaseAdapter -POSTGRESQL_DATABASE_URL = None # "postgresql://:@[:]/books_db" -if POSTGRESQL_DATABASE_URL is None: - raise ValueError("POSTGRESQL_DATABASE_URL is not defined") - -books_db = BooksDatabase(POSTGRESQL_DATABASE_URL) - -models.Base.metadata.create_all(bind=books_db.engine) palt_app = FastAPI() def get_db(): - db = books_db.SessionLocal() - try: - yield db - finally: - db.close() + # db: BaseAdapter = None # PostgresqlAdapter(, , , , ) + db: BaseAdapter = PostgresqlAdapter("localhost", 5432, "postgres", "admin_password", "books_db") + if db is None: + raise ValueError("Database is not set") + yield db @palt_app.get("/") diff --git a/src/api_gateway/books/__init__.py b/src/api_gateway/books/__init__.py index f82a731..461d371 100644 --- a/src/api_gateway/books/__init__.py +++ b/src/api_gateway/books/__init__.py @@ -1,6 +1 @@ -""" -Module with endpoints for working with books -""" -from src.api_gateway.books.get import * -from src.api_gateway.books.post import * -from src.api_gateway.books.put import * +from src.api_gateway.books.endpoints import * diff --git a/src/api_gateway/books/endpoints.py b/src/api_gateway/books/endpoints.py new file mode 100644 index 0000000..a95c31b --- /dev/null +++ b/src/api_gateway/books/endpoints.py @@ -0,0 +1,120 @@ +""" +Endpoints for working with books +""" +from io import BytesIO +from typing import Dict, Any + +from fastapi import Depends, HTTPException, Form, File, UploadFile +from fastapi.responses import StreamingResponse + +from src.api_gateway.app import palt_app, get_db +from src.api_gateway.books import schemas +from src.databases.adapters.base_adapter import BaseAdapter + +BOOKS_SOURCE_TABLE_NAME = "books_source" + +BOOKS_INFO_TABLE_NAME = "books_info" + + +def get_book_info(book_id: int, db: BaseAdapter) -> Dict[str, Any]: + info_list = db.filter_by_column_value(BOOKS_INFO_TABLE_NAME, {"id": book_id}) + if len(info_list) == 0: + raise HTTPException(status_code=404, detail="Not found book with given id in database") + if len(info_list) > 1: + raise HTTPException(status_code=500, detail="Found more 1 book with this id") + return info_list[0] + + +@palt_app.get("/book_meta/{book_id}", response_model=schemas.BookMeta) +async def book_meta(book_id: int, db: BaseAdapter = Depends(get_db)): + """ + Get the book's meta from books' database + + :param book_id: + :param db: database with books' info table + :return: + """ + # it would be better to do constructor of BookMeta from dict, because ORM may be not work, because some + # field will have names, different from table in database + return get_book_info(book_id, db) + + +@palt_app.get("/book_profile/{book_id}", response_model=schemas.BookProfile) +async def book_profile(book_id: int, db: BaseAdapter = Depends(get_db)): + """ + Get the book's profile from books' database. + + :param book_id: + :param db: database with books' info table + :return: + """ + return get_book_info(book_id, db) + + +@palt_app.get("/book_src/{book_id}") +async def book_src(book_id: int, db: BaseAdapter = Depends(get_db)): + """ + Get the book's source from books' database. + + :param book_id: + :param db: database with books' source table + :return: + """ + book_info = get_book_info(book_id, db) + src_list = db.filter_by_column_value(BOOKS_SOURCE_TABLE_NAME, {"id": book_id}) + if len(src_list) == 0: + raise HTTPException(status_code=404, detail="Not found book with given id in database") + if len(src_list) > 1: + raise HTTPException(status_code=500, detail="Found more 1 book with this id") + response = StreamingResponse(iter(src_list[0]["source"])) + response.headers["Content-Disposition"] = f"attachment; filename={book_info['title']}_{book_info['author']}.txt" + return response + + +""" +We can't post both book's profile and source by one rout, +because (from FastAPI docs): + +You can declare multiple File and Form parameters in a path operation, but you can't also +declare Body fields that you expect to receive as JSON, as the request will have the body encoded +using multipart/form-data instead of application/json. +This is not a limitation of FastAPI, it's part of the HTTP protocol. + +That is why I decided to add to different endpoints: post of profile and put of book source by book's id, which +is returned after posting profile. I could make one endpoint and add fields of BookProfile as 'Form(...)', but +if BookProfile is changed (and it will be changed one day) we will have to change this endpoint too. +""" + + +@palt_app.post("/new_book_profile/") +async def new_book_profile(book_profile: schemas.BookProfile, + db: BaseAdapter = Depends(get_db)): + """ + Add new book profile to books' database. + + :param book_profile: the whole information about book (except source). + :param db: database with books' profiles table + :return: book's id in database. + """ + book_id = db.get_table_size(BOOKS_INFO_TABLE_NAME) + 1 + db.add_elem_to_table(BOOKS_INFO_TABLE_NAME, {**book_profile.dict()}) + return {"book_id": book_id} + + +@palt_app.post("/new_book_source/") +async def new_book_source(book_id: int = Form(...), + book_src: UploadFile = File(...), + db: BaseAdapter = Depends(get_db)): + """ + Add source of book to books' database. + + :param book_id: + :param book_src: + :param db: database with books' source table + :return: book's id in database. + """ + if len(db.filter_by_column_value(BOOKS_INFO_TABLE_NAME, {"id": book_id})) == 0: + raise HTTPException(status_code=404, detail="Not found profile of book with given id." + "Profile must be added before source") + db.add_elem_to_table(BOOKS_SOURCE_TABLE_NAME, {"id": book_id}, {"source": BytesIO(book_src.file.read())}) + return "Source added" diff --git a/src/api_gateway/books/get.py b/src/api_gateway/books/get.py deleted file mode 100644 index d2b77da..0000000 --- a/src/api_gateway/books/get.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -GET endpoints for getting different books' data -""" -from io import BytesIO -from typing import Optional - -from fastapi import Depends, HTTPException -from fastapi.responses import StreamingResponse, FileResponse, Response -from sqlalchemy.orm import Session - -from src.api_gateway.app import palt_app, get_db -from src.api_gateway.books import schemas -from src.books.adapters import info, sources - - -@palt_app.get("/book_meta/{book_id}", response_model=schemas.BookMeta) -async def book_meta(book_id: str, db: Session = Depends(get_db)): - """ - Get the book's meta from books' database - - :param book_id: - :param db: database with books' info table - :return: - """ - meta = info.get_meta(db, book_id) - if meta is None: - raise HTTPException(status_code=404, detail="Not found book with given id in database") - return meta - - -@palt_app.get("/book_profile/{book_id}", response_model=schemas.BookProfile) -async def book_profile(book_id: str, db: Session = Depends(get_db)): - """ - Get the book's profile from books' database. - - :param book_id: - :param db: database with books' info table - :return: - """ - profile = info.get_profile(db, book_id) - if profile is None: - raise HTTPException(status_code=404, detail="Not found book with given id in database") - return profile - - -@palt_app.get("/book_src/{book_id}") -async def book_src(book_id: str, db: Session = Depends(get_db)): - """ - Get the book's source from books' database. - - :param book_id: - :param db: database with books' source table - :return: - """ - meta = info.get_meta(db, book_id) - if meta is None: - raise HTTPException(status_code=404, detail="Not found book with given id in database") - src: Optional[BytesIO] = sources.get_source(db, book_id) - if src is None: - raise HTTPException(status_code=404, detail="Not found source of book with given id") - response = StreamingResponse(iter([src.getvalue()])) - response.headers["Content-Disposition"] = f"attachment; filename={meta.title}_{meta.author}.txt" - return response diff --git a/src/api_gateway/books/post.py b/src/api_gateway/books/post.py deleted file mode 100644 index ab3dced..0000000 --- a/src/api_gateway/books/post.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -POST endpoints for adding new books' data. -""" -from fastapi import Depends - -from src.api_gateway.app import palt_app, get_db -from sqlalchemy.orm import Session -from src.api_gateway.books.schemas import BookProfile -from src.books.adapters import info - -""" -We can't post both book's profile and source by one rout, -because (from FastAPI docs): - -You can declare multiple File and Form parameters in a path operation, but you can't also -declare Body fields that you expect to receive as JSON, as the request will have the body encoded -using multipart/form-data instead of application/json. -This is not a limitation of FastAPI, it's part of the HTTP protocol. - -That is why I decided to add to different endpoints: post of profile and put of book source by book's id, which -is returned after posting profile. I could make one endpoint and add fields of BookProfile as 'Form(...)', but -if BookProfile is changed (and it will be changed one day) we will have to change this endpoint too. -""" - - -@palt_app.post("/new_book_profile/") -async def new_book_profile(book_profile: BookProfile, - db: Session = Depends(get_db)): - """ - Add new book profile to books' database. - - :param book_profile: the whole information about book (except source). - :param db: database with books' profiles table - :return: book's id in database. - """ - book_id = info.post_profile(db, book_profile) - return {"book_id": book_id} diff --git a/src/api_gateway/books/put.py b/src/api_gateway/books/put.py deleted file mode 100644 index 274f68a..0000000 --- a/src/api_gateway/books/put.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -PUT endpoints for adding data to existed book's id. -""" - -from fastapi import Depends, UploadFile, File, HTTPException, Form - -from src.api_gateway.app import palt_app, get_db -from sqlalchemy.orm import Session - -from src.books.adapters import sources, info - - -@palt_app.put("/new_book_source/") -async def new_book_source(book_id: str = Form(...), - book_src: UploadFile = File(...), - db: Session = Depends(get_db)): - """ - Add source of book to books' database. - - :param book_id: - :param book_src: - :param db: database with books' source table - :return: book's id in database. - """ - if info.check_book_info_with_id_exist(db, book_id) is False: - raise HTTPException(status_code=404, detail="Not found profile of book with given id." - "Profile must be added before source") - sources.put_source(db, book_id, book_src.file.read()) - return "Source added" diff --git a/src/books/adapters/__init__.py b/src/books/adapters/__init__.py deleted file mode 100644 index 6c051b5..0000000 --- a/src/books/adapters/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Adapters for books' database -""" -from typing import Optional - -from sqlalchemy.orm import Session - -from src.api_gateway.books.schemas import BookProfile -from src.books.adapters import info, sources - - -def post_book(db_book_info: Session, profile: BookProfile, source: bytes, - db_book_src: Optional[Session] = None) -> str: - """ - Post profile and source of new book to the database - - :param db_book_info: database containing books' info table - :param profile: - :param source: - :param db_book_src: database containing books' source table. - If None, it like books' info database - :return: book_id - """ - if db_book_src is None: - db_book_src = db_book_info - book_id = info.post_profile(db_book_info, profile) - sources.put_source(db_book_src, book_id, source) - - return book_id diff --git a/src/books/adapters/info.py b/src/books/adapters/info.py deleted file mode 100644 index 5992e70..0000000 --- a/src/books/adapters/info.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -Adapter to database with information about books -""" -from typing import Optional - -from sqlalchemy.orm import Session - -from src.api_gateway.books.schemas import BookMeta, BookProfile -from src.books.databases import models - - -def check_book_info_with_id_exist(db: Session, book_id: str) -> bool: - """ - Check is information about book with given id exist in database - - :param db: - :param book_id: - :return: - """ - return db.query(models.BookInfo).filter(models.BookInfo.id == book_id).first() is not None - - -def get_meta(db: Session, book_id: str) -> Optional[BookMeta]: - """ - Get meta information about book from database - - :param db: database containing books' info table - :param book_id: - :return: None if there isn't given book id in database - """ - book_info = db.query(models.BookInfo).filter(models.BookInfo.id == book_id).first() - return None if book_info is None else BookMeta.from_orm(book_info) - - -def get_profile(db: Session, book_id: str) -> Optional[BookProfile]: - """ - Get profile of the book from database - - :param db: database containing books' info table - :param book_id: - :return: None if there isn't given book id in database - """ - book_info = db.query(models.BookInfo).filter(models.BookInfo.id == book_id).first() - return None if book_info is None else BookProfile.from_orm(book_info) - - -def post_profile(db: Session, book_profile: BookProfile) -> str: - """ - Post profile of the book to the database - - :param db: database containing books' info table - :param book_profile: - :return: book_id - """ - db_book_info = models.BookInfo(**book_profile.dict()) - db.add(db_book_info) - db.commit() - db.refresh(db_book_info) - return db_book_info.id diff --git a/src/books/adapters/sources.py b/src/books/adapters/sources.py deleted file mode 100644 index 258c14c..0000000 --- a/src/books/adapters/sources.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Adapter to database with books' content -""" -from io import BytesIO -from typing import Optional - -from sqlalchemy.orm import Session - -from src.books.databases import models - - -def get_source(db: Session, book_id: str) -> Optional[BytesIO]: - """ - Get source of the book - - :param db: database containing books' source table - :param book_id: - :return: stream with source bytes. Return None if source wasn't found - """ - src: Optional[models.BookSource] = db.query(models.BookSource).filter(models.BookSource.id == book_id).first() - return None if src is None else BytesIO(src.source) - - -def put_source(db: Session, book_id: str, source: bytes): - """ - Post source of the book to the database - - :param db: database containing books' source table - :param book_id: - :param source: - :return: - """ - db_book_src = models.BookSource(id=book_id, source=source) - db.add(db_book_src) - db.commit() - db.refresh(db_book_src) diff --git a/src/books/databases/__init__.py b/src/books/databases/__init__.py deleted file mode 100644 index ee8fa48..0000000 --- a/src/books/databases/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Books database submodule -""" -from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker -from sqlalchemy_utils import database_exists, create_database - -Base = declarative_base() - - -class BooksDatabase: - """Class containing some objects for working with databases""" - def __init__(self, postgresql_database_url): - """ - :param postgresql_database_url: - - Creates database if it doesn't exist - """ - self.engine = create_engine(postgresql_database_url) - if not database_exists(self.engine.url): - create_database(self.engine.url) - self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine) diff --git a/src/books/databases/models.py b/src/books/databases/models.py deleted file mode 100644 index f8561d8..0000000 --- a/src/books/databases/models.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Model tables with book's information -""" -from sqlalchemy import Date, Column, Integer, String, LargeBinary - -from src.books.databases import Base - - -class BookInfo(Base): - __tablename__ = "books_info" - - id = Column(Integer, primary_key=True, index=True) - title = Column(String) - author = Column(String) - publication_date = Column(Date) - description = Column(String) - - -class BookSource(Base): - __tablename__ = "books_source" - - id = Column(Integer, primary_key=True) - source = Column(LargeBinary) diff --git a/src/books/__init__.py b/src/databases/__init__.py similarity index 100% rename from src/books/__init__.py rename to src/databases/__init__.py diff --git a/src/databases/adapters/__init__.py b/src/databases/adapters/__init__.py new file mode 100644 index 0000000..a963312 --- /dev/null +++ b/src/databases/adapters/__init__.py @@ -0,0 +1,2 @@ +from src.databases.adapters.base_adapter import * +from src.databases.adapters.postgresql import * diff --git a/src/databases/adapters/base_adapter.py b/src/databases/adapters/base_adapter.py new file mode 100644 index 0000000..1cba8d9 --- /dev/null +++ b/src/databases/adapters/base_adapter.py @@ -0,0 +1,15 @@ +from io import BytesIO +from typing import Dict, Any, Optional, List + + +class BaseAdapter: + def add_elem_to_table(self, table_name: str, columns: Dict[str, Any], + file_columns: Optional[Dict[str, BytesIO]] = None): + pass + + def filter_by_column_value(self, table_name: str, + columns_with_value: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]: + pass + + def get_table_size(self, table_name: str) -> int: + pass diff --git a/src/databases/adapters/postgresql.py b/src/databases/adapters/postgresql.py new file mode 100644 index 0000000..d7970f2 --- /dev/null +++ b/src/databases/adapters/postgresql.py @@ -0,0 +1,45 @@ +from io import BytesIO +from typing import Optional, Dict, Any, List + +from src.databases.adapters.base_adapter import BaseAdapter +import psycopg2 +import psycopg2.extras + + +class PostgresqlAdapter(BaseAdapter): + def __init__(self, hostname, port, username, password, database): + BaseAdapter.__init__(self) + self.conn = psycopg2.connect(host=hostname, port=port, user=username, password=password, dbname=database) + self.cursor = self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + + def add_elem_to_table(self, table_name: str, columns: Dict[str, Any], + file_columns: Optional[Dict[str, BytesIO]] = None): + keys = [key for key in columns] + \ + ([key for key in file_columns] if file_columns is not None else []) + values = [columns[key] for key in columns] + \ + ([file_columns[key].read() for key in file_columns] if file_columns is not None else []) + self.cursor.execute( + f""" + INSERT INTO {table_name} ({",".join(keys)}) VALUES ({", ".join(["%s"] * len(keys))}) + """, + values + ) + self.conn.commit() + + def filter_by_column_value(self, table_name: str, + columns_with_value: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]: + self.cursor.execute(f"SELECT * FROM {table_name} " + + f""" + WHERE {" AND ".join([f'{key} = {columns_with_value[key]}' for key in columns_with_value])} + """ + if columns_with_value is not None else "" + ) + return self.cursor.fetchall() + + def get_table_size(self, table_name: str) -> int: + self.cursor.execute(f"SELECT count(*) FROM {table_name}") + return int(list(dict(self.cursor.fetchone()).values())[0]) + + def __del__(self): + self.cursor.close() + self.conn.close() From 85e612a9fe673ddfb420f85a2e0417cca0b4f281 Mon Sep 17 00:00:00 2001 From: DF5HSE Date: Mon, 27 Dec 2021 04:11:47 +0300 Subject: [PATCH 6/6] Add real adapters --- src/api_gateway/app.py | 19 ++-- src/api_gateway/books/__init__.py | 7 +- src/api_gateway/books/endpoints.py | 120 +++++++++++++++++++++++++ src/api_gateway/books/get.py | 63 ------------- src/api_gateway/books/post.py | 37 -------- src/api_gateway/books/put.py | 29 ------ src/books/adapters/__init__.py | 29 ------ src/books/adapters/info.py | 59 ------------ src/books/adapters/sources.py | 36 -------- src/books/databases/__init__.py | 23 ----- src/books/databases/models.py | 23 ----- src/{books => databases}/__init__.py | 0 src/databases/adapters/__init__.py | 2 + src/databases/adapters/base_adapter.py | 15 ++++ src/databases/adapters/postgresql.py | 45 ++++++++++ 15 files changed, 188 insertions(+), 319 deletions(-) create mode 100644 src/api_gateway/books/endpoints.py delete mode 100644 src/api_gateway/books/get.py delete mode 100644 src/api_gateway/books/post.py delete mode 100644 src/api_gateway/books/put.py delete mode 100644 src/books/adapters/__init__.py delete mode 100644 src/books/adapters/info.py delete mode 100644 src/books/adapters/sources.py delete mode 100644 src/books/databases/__init__.py delete mode 100644 src/books/databases/models.py rename src/{books => databases}/__init__.py (100%) create mode 100644 src/databases/adapters/__init__.py create mode 100644 src/databases/adapters/base_adapter.py create mode 100644 src/databases/adapters/postgresql.py diff --git a/src/api_gateway/app.py b/src/api_gateway/app.py index 7365edd..b785277 100644 --- a/src/api_gateway/app.py +++ b/src/api_gateway/app.py @@ -3,25 +3,16 @@ """ from fastapi import FastAPI -from src.books.databases import BooksDatabase -from src.books.databases import models +from src.databases.adapters import PostgresqlAdapter, BaseAdapter -POSTGRESQL_DATABASE_URL = None # "postgresql://:@[:]/books_db" -if POSTGRESQL_DATABASE_URL is None: - raise ValueError("POSTGRESQL_DATABASE_URL is not defined") - -books_db = BooksDatabase(POSTGRESQL_DATABASE_URL) - -models.Base.metadata.create_all(bind=books_db.engine) palt_app = FastAPI() def get_db(): - db = books_db.SessionLocal() - try: - yield db - finally: - db.close() + db: BaseAdapter = None # PostgresqlAdapter(, , , , ) + if db is None: + raise ValueError("Database is not set") + yield db @palt_app.get("/") diff --git a/src/api_gateway/books/__init__.py b/src/api_gateway/books/__init__.py index f82a731..461d371 100644 --- a/src/api_gateway/books/__init__.py +++ b/src/api_gateway/books/__init__.py @@ -1,6 +1 @@ -""" -Module with endpoints for working with books -""" -from src.api_gateway.books.get import * -from src.api_gateway.books.post import * -from src.api_gateway.books.put import * +from src.api_gateway.books.endpoints import * diff --git a/src/api_gateway/books/endpoints.py b/src/api_gateway/books/endpoints.py new file mode 100644 index 0000000..a95c31b --- /dev/null +++ b/src/api_gateway/books/endpoints.py @@ -0,0 +1,120 @@ +""" +Endpoints for working with books +""" +from io import BytesIO +from typing import Dict, Any + +from fastapi import Depends, HTTPException, Form, File, UploadFile +from fastapi.responses import StreamingResponse + +from src.api_gateway.app import palt_app, get_db +from src.api_gateway.books import schemas +from src.databases.adapters.base_adapter import BaseAdapter + +BOOKS_SOURCE_TABLE_NAME = "books_source" + +BOOKS_INFO_TABLE_NAME = "books_info" + + +def get_book_info(book_id: int, db: BaseAdapter) -> Dict[str, Any]: + info_list = db.filter_by_column_value(BOOKS_INFO_TABLE_NAME, {"id": book_id}) + if len(info_list) == 0: + raise HTTPException(status_code=404, detail="Not found book with given id in database") + if len(info_list) > 1: + raise HTTPException(status_code=500, detail="Found more 1 book with this id") + return info_list[0] + + +@palt_app.get("/book_meta/{book_id}", response_model=schemas.BookMeta) +async def book_meta(book_id: int, db: BaseAdapter = Depends(get_db)): + """ + Get the book's meta from books' database + + :param book_id: + :param db: database with books' info table + :return: + """ + # it would be better to do constructor of BookMeta from dict, because ORM may be not work, because some + # field will have names, different from table in database + return get_book_info(book_id, db) + + +@palt_app.get("/book_profile/{book_id}", response_model=schemas.BookProfile) +async def book_profile(book_id: int, db: BaseAdapter = Depends(get_db)): + """ + Get the book's profile from books' database. + + :param book_id: + :param db: database with books' info table + :return: + """ + return get_book_info(book_id, db) + + +@palt_app.get("/book_src/{book_id}") +async def book_src(book_id: int, db: BaseAdapter = Depends(get_db)): + """ + Get the book's source from books' database. + + :param book_id: + :param db: database with books' source table + :return: + """ + book_info = get_book_info(book_id, db) + src_list = db.filter_by_column_value(BOOKS_SOURCE_TABLE_NAME, {"id": book_id}) + if len(src_list) == 0: + raise HTTPException(status_code=404, detail="Not found book with given id in database") + if len(src_list) > 1: + raise HTTPException(status_code=500, detail="Found more 1 book with this id") + response = StreamingResponse(iter(src_list[0]["source"])) + response.headers["Content-Disposition"] = f"attachment; filename={book_info['title']}_{book_info['author']}.txt" + return response + + +""" +We can't post both book's profile and source by one rout, +because (from FastAPI docs): + +You can declare multiple File and Form parameters in a path operation, but you can't also +declare Body fields that you expect to receive as JSON, as the request will have the body encoded +using multipart/form-data instead of application/json. +This is not a limitation of FastAPI, it's part of the HTTP protocol. + +That is why I decided to add to different endpoints: post of profile and put of book source by book's id, which +is returned after posting profile. I could make one endpoint and add fields of BookProfile as 'Form(...)', but +if BookProfile is changed (and it will be changed one day) we will have to change this endpoint too. +""" + + +@palt_app.post("/new_book_profile/") +async def new_book_profile(book_profile: schemas.BookProfile, + db: BaseAdapter = Depends(get_db)): + """ + Add new book profile to books' database. + + :param book_profile: the whole information about book (except source). + :param db: database with books' profiles table + :return: book's id in database. + """ + book_id = db.get_table_size(BOOKS_INFO_TABLE_NAME) + 1 + db.add_elem_to_table(BOOKS_INFO_TABLE_NAME, {**book_profile.dict()}) + return {"book_id": book_id} + + +@palt_app.post("/new_book_source/") +async def new_book_source(book_id: int = Form(...), + book_src: UploadFile = File(...), + db: BaseAdapter = Depends(get_db)): + """ + Add source of book to books' database. + + :param book_id: + :param book_src: + :param db: database with books' source table + :return: book's id in database. + """ + if len(db.filter_by_column_value(BOOKS_INFO_TABLE_NAME, {"id": book_id})) == 0: + raise HTTPException(status_code=404, detail="Not found profile of book with given id." + "Profile must be added before source") + db.add_elem_to_table(BOOKS_SOURCE_TABLE_NAME, {"id": book_id}, {"source": BytesIO(book_src.file.read())}) + return "Source added" diff --git a/src/api_gateway/books/get.py b/src/api_gateway/books/get.py deleted file mode 100644 index d2b77da..0000000 --- a/src/api_gateway/books/get.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -GET endpoints for getting different books' data -""" -from io import BytesIO -from typing import Optional - -from fastapi import Depends, HTTPException -from fastapi.responses import StreamingResponse, FileResponse, Response -from sqlalchemy.orm import Session - -from src.api_gateway.app import palt_app, get_db -from src.api_gateway.books import schemas -from src.books.adapters import info, sources - - -@palt_app.get("/book_meta/{book_id}", response_model=schemas.BookMeta) -async def book_meta(book_id: str, db: Session = Depends(get_db)): - """ - Get the book's meta from books' database - - :param book_id: - :param db: database with books' info table - :return: - """ - meta = info.get_meta(db, book_id) - if meta is None: - raise HTTPException(status_code=404, detail="Not found book with given id in database") - return meta - - -@palt_app.get("/book_profile/{book_id}", response_model=schemas.BookProfile) -async def book_profile(book_id: str, db: Session = Depends(get_db)): - """ - Get the book's profile from books' database. - - :param book_id: - :param db: database with books' info table - :return: - """ - profile = info.get_profile(db, book_id) - if profile is None: - raise HTTPException(status_code=404, detail="Not found book with given id in database") - return profile - - -@palt_app.get("/book_src/{book_id}") -async def book_src(book_id: str, db: Session = Depends(get_db)): - """ - Get the book's source from books' database. - - :param book_id: - :param db: database with books' source table - :return: - """ - meta = info.get_meta(db, book_id) - if meta is None: - raise HTTPException(status_code=404, detail="Not found book with given id in database") - src: Optional[BytesIO] = sources.get_source(db, book_id) - if src is None: - raise HTTPException(status_code=404, detail="Not found source of book with given id") - response = StreamingResponse(iter([src.getvalue()])) - response.headers["Content-Disposition"] = f"attachment; filename={meta.title}_{meta.author}.txt" - return response diff --git a/src/api_gateway/books/post.py b/src/api_gateway/books/post.py deleted file mode 100644 index ab3dced..0000000 --- a/src/api_gateway/books/post.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -POST endpoints for adding new books' data. -""" -from fastapi import Depends - -from src.api_gateway.app import palt_app, get_db -from sqlalchemy.orm import Session -from src.api_gateway.books.schemas import BookProfile -from src.books.adapters import info - -""" -We can't post both book's profile and source by one rout, -because (from FastAPI docs): - -You can declare multiple File and Form parameters in a path operation, but you can't also -declare Body fields that you expect to receive as JSON, as the request will have the body encoded -using multipart/form-data instead of application/json. -This is not a limitation of FastAPI, it's part of the HTTP protocol. - -That is why I decided to add to different endpoints: post of profile and put of book source by book's id, which -is returned after posting profile. I could make one endpoint and add fields of BookProfile as 'Form(...)', but -if BookProfile is changed (and it will be changed one day) we will have to change this endpoint too. -""" - - -@palt_app.post("/new_book_profile/") -async def new_book_profile(book_profile: BookProfile, - db: Session = Depends(get_db)): - """ - Add new book profile to books' database. - - :param book_profile: the whole information about book (except source). - :param db: database with books' profiles table - :return: book's id in database. - """ - book_id = info.post_profile(db, book_profile) - return {"book_id": book_id} diff --git a/src/api_gateway/books/put.py b/src/api_gateway/books/put.py deleted file mode 100644 index 274f68a..0000000 --- a/src/api_gateway/books/put.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -PUT endpoints for adding data to existed book's id. -""" - -from fastapi import Depends, UploadFile, File, HTTPException, Form - -from src.api_gateway.app import palt_app, get_db -from sqlalchemy.orm import Session - -from src.books.adapters import sources, info - - -@palt_app.put("/new_book_source/") -async def new_book_source(book_id: str = Form(...), - book_src: UploadFile = File(...), - db: Session = Depends(get_db)): - """ - Add source of book to books' database. - - :param book_id: - :param book_src: - :param db: database with books' source table - :return: book's id in database. - """ - if info.check_book_info_with_id_exist(db, book_id) is False: - raise HTTPException(status_code=404, detail="Not found profile of book with given id." - "Profile must be added before source") - sources.put_source(db, book_id, book_src.file.read()) - return "Source added" diff --git a/src/books/adapters/__init__.py b/src/books/adapters/__init__.py deleted file mode 100644 index 6c051b5..0000000 --- a/src/books/adapters/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Adapters for books' database -""" -from typing import Optional - -from sqlalchemy.orm import Session - -from src.api_gateway.books.schemas import BookProfile -from src.books.adapters import info, sources - - -def post_book(db_book_info: Session, profile: BookProfile, source: bytes, - db_book_src: Optional[Session] = None) -> str: - """ - Post profile and source of new book to the database - - :param db_book_info: database containing books' info table - :param profile: - :param source: - :param db_book_src: database containing books' source table. - If None, it like books' info database - :return: book_id - """ - if db_book_src is None: - db_book_src = db_book_info - book_id = info.post_profile(db_book_info, profile) - sources.put_source(db_book_src, book_id, source) - - return book_id diff --git a/src/books/adapters/info.py b/src/books/adapters/info.py deleted file mode 100644 index 5992e70..0000000 --- a/src/books/adapters/info.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -Adapter to database with information about books -""" -from typing import Optional - -from sqlalchemy.orm import Session - -from src.api_gateway.books.schemas import BookMeta, BookProfile -from src.books.databases import models - - -def check_book_info_with_id_exist(db: Session, book_id: str) -> bool: - """ - Check is information about book with given id exist in database - - :param db: - :param book_id: - :return: - """ - return db.query(models.BookInfo).filter(models.BookInfo.id == book_id).first() is not None - - -def get_meta(db: Session, book_id: str) -> Optional[BookMeta]: - """ - Get meta information about book from database - - :param db: database containing books' info table - :param book_id: - :return: None if there isn't given book id in database - """ - book_info = db.query(models.BookInfo).filter(models.BookInfo.id == book_id).first() - return None if book_info is None else BookMeta.from_orm(book_info) - - -def get_profile(db: Session, book_id: str) -> Optional[BookProfile]: - """ - Get profile of the book from database - - :param db: database containing books' info table - :param book_id: - :return: None if there isn't given book id in database - """ - book_info = db.query(models.BookInfo).filter(models.BookInfo.id == book_id).first() - return None if book_info is None else BookProfile.from_orm(book_info) - - -def post_profile(db: Session, book_profile: BookProfile) -> str: - """ - Post profile of the book to the database - - :param db: database containing books' info table - :param book_profile: - :return: book_id - """ - db_book_info = models.BookInfo(**book_profile.dict()) - db.add(db_book_info) - db.commit() - db.refresh(db_book_info) - return db_book_info.id diff --git a/src/books/adapters/sources.py b/src/books/adapters/sources.py deleted file mode 100644 index 258c14c..0000000 --- a/src/books/adapters/sources.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Adapter to database with books' content -""" -from io import BytesIO -from typing import Optional - -from sqlalchemy.orm import Session - -from src.books.databases import models - - -def get_source(db: Session, book_id: str) -> Optional[BytesIO]: - """ - Get source of the book - - :param db: database containing books' source table - :param book_id: - :return: stream with source bytes. Return None if source wasn't found - """ - src: Optional[models.BookSource] = db.query(models.BookSource).filter(models.BookSource.id == book_id).first() - return None if src is None else BytesIO(src.source) - - -def put_source(db: Session, book_id: str, source: bytes): - """ - Post source of the book to the database - - :param db: database containing books' source table - :param book_id: - :param source: - :return: - """ - db_book_src = models.BookSource(id=book_id, source=source) - db.add(db_book_src) - db.commit() - db.refresh(db_book_src) diff --git a/src/books/databases/__init__.py b/src/books/databases/__init__.py deleted file mode 100644 index ee8fa48..0000000 --- a/src/books/databases/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Books database submodule -""" -from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker -from sqlalchemy_utils import database_exists, create_database - -Base = declarative_base() - - -class BooksDatabase: - """Class containing some objects for working with databases""" - def __init__(self, postgresql_database_url): - """ - :param postgresql_database_url: - - Creates database if it doesn't exist - """ - self.engine = create_engine(postgresql_database_url) - if not database_exists(self.engine.url): - create_database(self.engine.url) - self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine) diff --git a/src/books/databases/models.py b/src/books/databases/models.py deleted file mode 100644 index f8561d8..0000000 --- a/src/books/databases/models.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Model tables with book's information -""" -from sqlalchemy import Date, Column, Integer, String, LargeBinary - -from src.books.databases import Base - - -class BookInfo(Base): - __tablename__ = "books_info" - - id = Column(Integer, primary_key=True, index=True) - title = Column(String) - author = Column(String) - publication_date = Column(Date) - description = Column(String) - - -class BookSource(Base): - __tablename__ = "books_source" - - id = Column(Integer, primary_key=True) - source = Column(LargeBinary) diff --git a/src/books/__init__.py b/src/databases/__init__.py similarity index 100% rename from src/books/__init__.py rename to src/databases/__init__.py diff --git a/src/databases/adapters/__init__.py b/src/databases/adapters/__init__.py new file mode 100644 index 0000000..a963312 --- /dev/null +++ b/src/databases/adapters/__init__.py @@ -0,0 +1,2 @@ +from src.databases.adapters.base_adapter import * +from src.databases.adapters.postgresql import * diff --git a/src/databases/adapters/base_adapter.py b/src/databases/adapters/base_adapter.py new file mode 100644 index 0000000..1cba8d9 --- /dev/null +++ b/src/databases/adapters/base_adapter.py @@ -0,0 +1,15 @@ +from io import BytesIO +from typing import Dict, Any, Optional, List + + +class BaseAdapter: + def add_elem_to_table(self, table_name: str, columns: Dict[str, Any], + file_columns: Optional[Dict[str, BytesIO]] = None): + pass + + def filter_by_column_value(self, table_name: str, + columns_with_value: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]: + pass + + def get_table_size(self, table_name: str) -> int: + pass diff --git a/src/databases/adapters/postgresql.py b/src/databases/adapters/postgresql.py new file mode 100644 index 0000000..d7970f2 --- /dev/null +++ b/src/databases/adapters/postgresql.py @@ -0,0 +1,45 @@ +from io import BytesIO +from typing import Optional, Dict, Any, List + +from src.databases.adapters.base_adapter import BaseAdapter +import psycopg2 +import psycopg2.extras + + +class PostgresqlAdapter(BaseAdapter): + def __init__(self, hostname, port, username, password, database): + BaseAdapter.__init__(self) + self.conn = psycopg2.connect(host=hostname, port=port, user=username, password=password, dbname=database) + self.cursor = self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + + def add_elem_to_table(self, table_name: str, columns: Dict[str, Any], + file_columns: Optional[Dict[str, BytesIO]] = None): + keys = [key for key in columns] + \ + ([key for key in file_columns] if file_columns is not None else []) + values = [columns[key] for key in columns] + \ + ([file_columns[key].read() for key in file_columns] if file_columns is not None else []) + self.cursor.execute( + f""" + INSERT INTO {table_name} ({",".join(keys)}) VALUES ({", ".join(["%s"] * len(keys))}) + """, + values + ) + self.conn.commit() + + def filter_by_column_value(self, table_name: str, + columns_with_value: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]: + self.cursor.execute(f"SELECT * FROM {table_name} " + + f""" + WHERE {" AND ".join([f'{key} = {columns_with_value[key]}' for key in columns_with_value])} + """ + if columns_with_value is not None else "" + ) + return self.cursor.fetchall() + + def get_table_size(self, table_name: str) -> int: + self.cursor.execute(f"SELECT count(*) FROM {table_name}") + return int(list(dict(self.cursor.fetchone()).values())[0]) + + def __del__(self): + self.cursor.close() + self.conn.close()