diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fefa9f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Use an official Python runtime as the base image +FROM python:3.11-slim + +# Set the working directory in the container +WORKDIR /app + +# Copy the current directory contents into the container at /app +COPY . /app + +# Install any needed packages specified in requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Expose port 5000 for Flask to run on +EXPOSE 5000 + +# Define the environment variable for Flask +ENV FLASK_APP=app.py +ENV FLASK_RUN_HOST=0.0.0.0 + +# Run the Flask application +CMD ["flask", "run"] diff --git a/README.md b/README.md index ae38280..504769d 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,76 @@ Your task is to implement simple CRUD API using a simple in-memory data structur - _title_ - book's title (string, **required**) - _author_ - book's auther name (string, **required**) - _price_ - book's price (int, **required**) - - _category_ - book's category (_array_ of _strings_, **required**) + - _category_ - book's category (_array_ of _strings_, **required**) - _publication_year_ - date and year of the book's publicaiton (date, **required**) - Requests to non-existing endpoints (e.g. _/some-non/existing/resource_) should be handled. - Internal server errors should be handled and processed correctly. - Make sure the api is accesible by frontend apps hosted on a different domain (cross-site resource sharing) + + +--- +--- + +# Simple Doc for Flask App [Version 1] + +A lightweight Flask application that runs entirely in-memory, no database required! This app provides basic book management functionality with RESTful API routes. + +## Features: +- Add, update, delete, and fetch books. +- Runs in-memory (no database required). +- Dockerized for easy deployment. + +## Getting Started: + +### Prerequisites: +- [Python 3.8+](https://www.python.org/downloads/) +- [Docker](https://www.docker.com/) + +### Run Locally: + +1. **Clone the repository** + ```bash + git clone https://github.com/Yohannes90/flask-crud-challenge.git + cd flask-crud-challenge + ``` + +2. **Install dependencies** + ```bash + pip install -r requirements.txt + ``` + +3. **Run the app** + ```bash + python -m flask --app app run + ``` + +4. **Access the API** + - Open your browser or Postman and visit: `http://127.0.0.1:5000/books` + +### Run with Docker: + +1. **Build the Docker image** + ```bash + docker build -t flask-app . + ``` + +2. **Run the Docker container** + ```bash + docker run -p 5000:5000 flask-app + ``` + +3. **Access the API** + - Open your browser or Postman and visit: `http://localhost:5000/books` + +--- + +## API Endpoints: +- **GET** `/books` - Retrieve all books. +- **GET** `/books/:id` - Retrieve a specific book by ID. +- **POST** `/books` - Add a new book. +- **PUT** `/books/:id` - Update an existing book by ID. +- **DELETE** `/books/:id` - Delete a book by ID. + +--- diff --git a/app.py b/app.py new file mode 100644 index 0000000..f5e1735 --- /dev/null +++ b/app.py @@ -0,0 +1,17 @@ +from flask import Flask +from flask_cors import CORS +from routes import book_routes +from config import DevelopmentConfig + + +app = Flask(__name__) +CORS(app) + +# Register the blueprints +app.register_blueprint(book_routes) + +app.config.from_object(DevelopmentConfig) + + +if __name__ == "__main__": + app.run() diff --git a/config.py b/config.py new file mode 100644 index 0000000..006a198 --- /dev/null +++ b/config.py @@ -0,0 +1,3 @@ +class DevelopmentConfig: + DEBUG = True + CORS_HEADERS = 'Content-Type' diff --git a/models.py b/models.py new file mode 100644 index 0000000..ade14a1 --- /dev/null +++ b/models.py @@ -0,0 +1,11 @@ +from uuid import uuid4 + + +class Book: + def __init__(self, title, author, price, category, publication_year): + self.id = str(uuid4()) + self.title = title + self.author = author + self.price = price + self.category = category + self.publication_year = publication_year diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2ed48f3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +Flask-Cors==5.0.0 +Werkzeug==3.0.4 diff --git a/routes.py b/routes.py new file mode 100644 index 0000000..09dd593 --- /dev/null +++ b/routes.py @@ -0,0 +1,97 @@ +from flask import Blueprint, jsonify, request +from uuid import uuid4 +from validators import validate_book + + +book_routes = Blueprint('book_routes', __name__) + +# In-memory data structure +books = [] + + +@book_routes.route('/books', methods=['GET']) +def get_all_books(): + return jsonify(books), 200 + + +@book_routes.route('/books/', methods=['GET']) +def get_book_by_id(book_id): + for book in books: + if book['id'] == book_id: + return jsonify(book), 200 + + return jsonify({"error": "Book not found"}), 404 + + +@book_routes.route('/books', methods=['POST']) +def add_book(): + data = request.get_json() + + if not data: + return jsonify({"error": "No data provided."}), 400 + + validation_error = validate_book(data) + if validation_error: + return jsonify(validation_error), validation_error[1] + + new_book = { + 'id': str(uuid4()), + 'title': data['title'], + 'author': data['author'], + 'price': data['price'], + 'category': data['category'], + 'publication_year': data['publication_year'] + } + books.append(new_book) + + return jsonify(new_book), 201 + + +@book_routes.route('/books/', methods=['PUT']) +def update_book_by_id(book_id): + data = request.get_json() + + if not data: + return jsonify({"error": "No data provided."}), 400 + + validation_error = validate_book(data) + if validation_error: + return jsonify(validation_error), validation_error[1] + + for book in books: + if book['id'] == book_id: + book.update(data) + return jsonify(book), 200 + + return jsonify({"error": "Book not found"}), 404 + + +@book_routes.route('/books/', methods=['DELETE']) +def delete_book_by_id(book_id): + for book in books: + if book['id'] == book_id: + books.remove(book) + return jsonify({"message": "Book deleted"}), 200 + + return jsonify({"error": "Book not found"}), 404 + + +@book_routes.errorhandler(404) +def not_found(error): + return jsonify({"error": "Resource not found"}), 404 + + +@book_routes.errorhandler(400) +def bad_request(error): + return jsonify({"error": "Bad Request", "message": str(error)}), 400 + + +@book_routes.errorhandler(500) +def internal_error(error): + return jsonify({"error": "An internal error occurred"}), 500 + + + + + + diff --git a/validators.py b/validators.py new file mode 100644 index 0000000..e7f290a --- /dev/null +++ b/validators.py @@ -0,0 +1,30 @@ +from datetime import datetime + + +# Validate the book data +def validate_book(data): + required_fields = ['title', 'author', 'price', 'category', 'publication_year'] + for field in required_fields: + if field not in data: + return {"error": f"'{field}' is a required field."}, 400 + + if not isinstance(data['title'], str): + return {"error": "'title' must be a string."}, 400 + + if not isinstance(data['author'], str): + return {"error": "'author' must be a string."}, 400 + + if not isinstance(data['price'], int): + return {"error": "'price' must be an integer."}, 400 + + if not isinstance(data['category'], list) or not all(isinstance(cat, str) for cat in data['category']): + return {"error": "'category' must be an array of strings."}, 400 + + try: + pub_year = int(data['publication_year']) + if pub_year > datetime.now().year: + return {"error": "'publication_year' cannot be in the future."}, 400 + except ValueError: + return {"error": "'publication_year' must be a valid year (integer)."}, 400 + + return None