diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..7e68766ae --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.pythonProjects": [] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..121e9ae37 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.9-slim + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY ./app/* ./ + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 5c3393a97..ec909957c 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,109 @@ -![WATTIO](http://wattio.com.br/web/image/1204-212f47c3/Logo%20Wattio.png) +# Filmes API -#### Descrição +API REST para cadastro e consulta de filmes, desenvolvida com FastAPI e SQLAlchemy, para desafio proposto por Watt-io. -O desafio consiste em implementar um CRUD de filmes, utilizando [python](https://www.python.org/ "python") integrando com uma API REST e uma possível persistência de dados. -Rotas da API: - - `/filmes` - [GET] deve retornar todos os filmes cadastrados. - - `/filmes` - [POST] deve cadastrar um novo filme. - - `/filmes/{id}` - [GET] deve retornar o filme com ID especificado. +## Pré-requisitos -O Objetivo é te desafiar e reconhecer seu esforço para aprender e se adaptar. Qualquer código enviado, ficaremos muito felizes e avaliaremos com toda atenção! +- Python 3.8 ou superior +- pip (gerenciador de pacotes do Python) +- Banco de dados SQLite (padrão) +- (Opcional) Docker e Docker Compose -#### Sugestão de Ferramentas -Não é obrigatório utilizar todas as as tecnologias sugeridas, mas será um diferencial =] -- Orientação a objetos (utilizar objetos, classes para manipular os filmes) -- [FastAPI](https://fastapi.tiangolo.com/) (API com documentação auto gerada) -- [Docker](https://www.docker.com/) / [Docker-compose](https://docs.docker.com/compose/install/) (Aplicação deverá ficar em um container docker, e o start deverá seer com o comando ``` docker-compose up ``` -- Integração com banco de dados (persistir as informações em json (iniciante) /[SqLite](https://www.sqlite.org/index.html) / [SQLAlchemy](https://fastapi.tiangolo.com/tutorial/sql-databases/#sql-relational-databases) / outros DB) +## Instalação -#### Como começar? +1. **Clone o repositório** + ```sh + git clone + cd filmes_api + ``` -- Fork do repositório -- Criar branch com seu nome ``` git checkout -b feature/ana ``` -- Faça os commits de suas alterações ``` git commit -m "[ADD] Funcionalidade" ``` -- Envie a branch para seu repositório ``` git push origin feature/ana ``` -- Navegue até o [Github](https://github.com/), crie seu Pull Request apontando para a branch **```main```** -- Atualize o README.md descrevendo como subir sua aplicação +2. **Crie e ative um ambiente virtual** + ```sh + python -m venv venv + venv\Scripts\activate + ``` -#### Dúvidas? +3. **Instale as dependências** + ```sh + pip install -r requirements.txt + ``` -Qualquer dúvida / sugestão / melhoria / orientação adicional só enviar email para hendrix@wattio.com.br +4. **Sobre o banco de dados** + - Por padrão, a API usa SQLite. Se quiser usar outro banco, ajuste a string de conexão no arquivo de configuração. -Salve! + + +## Execução + +Rode a aplicação com Docker, usando o comando dentro da pasta raíz do projeto: +```sh +docker run -d -p 8000:8000 filmes_api +``` + +ou + +Execute pelo servidor de desenvolvimento, usando o comando dentro da pasta raíz do projeto: +```sh +uvicorn app.main:app --reload +``` + +Acesse a documentação auto-gerada com swagger em: [http://localhost:8000/docs] + + + +## Estrutura do Projeto + +``` +filmes_api/ +├── app/ +│ ├── api/ +| ├── v1/ +| ├── routes_movies.py +│ ├── core/ +| ├── config.py +│ ├── crud/ +| ├── movie.py +│ ├── data/ +│ ├── db/ +| ├── base.py +| ├── init_db.py +| ├── session.py +│ ├── models/ +| ├── movie.py +│ ├── schemas/ +| ├── movie.py +│ ├── __init__.py +│ └── main.py +├── data/ +├── docker-compose.yml +├── Dockerfile +├── README.md +└── requirements.txt +``` + + + +## Endpoints Principais + +- `GET /api/v1/routes_movies` — Lista todos os filmes +- `GET /api/v1/routes_movies/{id}` — Consulta filme por ID +- `GET /api/v1/routes_movies/title/{title}` — Lista filmes cujo o titulo contem string passada em {title} +- `POST /api/v1/routes_movies` — Cria um novo filme +- `PUT /api/v1/routes_movies/{id}` — Atualiza filme pelo ID +- `DELETE /api/v1/routes_movies/{id}` — Remove filme pelo ID + +Consulte a documentação do Swagger para detalhes e exemplos. + + + +## Observações + +- Escrevi o código todo em ingles, por padrão pessoal. +- Adicionei rotas para Update e Delete (na descrição do desafio era obrigatório apenas rotas para retornar todos filmes | retornar um especifico | inserir), fiz dessa maneira para atender os requisitos de [C]reate [R]ead [U]pdate [D]elete. +- Para dúvidas ou comentários, contate-me via email (jmpvboas@gmail.com) ou celular (+5535984325692). + +--- \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 000000000..5dd8fd024 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +#empty \ No newline at end of file diff --git a/app/api/v1/routes_movies.py b/app/api/v1/routes_movies.py new file mode 100644 index 000000000..fc900679c --- /dev/null +++ b/app/api/v1/routes_movies.py @@ -0,0 +1,86 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List + +from app.schemas.movie import Movie, MovieCreate, MovieUpdate +from app.crud.movie import ( + get_movies, + get_movie, + get_movies_by_title, + create_movie, + update_movie, + delete_movie, +) +from app.db.session import get_db + +router = APIRouter() + +# Define the API routes for listing movies +@router.get( + "/", + response_model=List[Movie], + summary="List movies", + description="Returns a list of all registered movies." +) +def read_movies(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): + return get_movies(db, skip=skip, limit=limit) + + +@router.get( + "/{movie_id}", + response_model=Movie, + summary="Get movie by ID", + description="Returns a specific movie by its ID." +) +def read_movie(movie_id: int, db: Session = Depends(get_db)): + db_movie = get_movie(db, movie_id) + if db_movie is None: + raise HTTPException(status_code=404, detail="Movie not found") + return db_movie + + +@router.get( + "/title/{title}", + response_model=List[Movie], + summary="Get movies by title", + description="Returns a list of movies that match the given title." +) +def read_movies_by_title(title: str, db: Session = Depends(get_db)): + return get_movies_by_title(db, title) + + +@router.post( + "/", + response_model=Movie, + status_code=201, + summary="Create new movie", + description="Adds a new movie." +) +def create_new_movie(movie: MovieCreate, db: Session = Depends(get_db)): + return create_movie(db, movie) + + +@router.put( + "/{movie_id}", + response_model=Movie, + summary="Update movie", + description="Updates an existing movie by its ID." +) +def update_existing_movie(movie_id: int, movie: MovieUpdate, db: Session = Depends(get_db)): + db_movie = update_movie(db, movie_id, movie) + if db_movie is None: + raise HTTPException(status_code=404, detail="Movie not found") + return db_movie + + +@router.delete( + "/{movie_id}", + response_model=Movie, + summary="Delete movie", + description="Removes a movie by its ID." +) +def delete_existing_movie(movie_id: int, db: Session = Depends(get_db)): + db_movie = delete_movie(db, movie_id) + if db_movie is None: + raise HTTPException(status_code=404, detail="Movie not found") + return db_movie diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 000000000..2e0e05d5a --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,11 @@ +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + PROJECT_NAME: str = "Movies API" + PROJECT_VERSION: str = "1.0.1" + DATABASE_URL: str = "sqlite:///./movies.db" + + class Config: + env_file = ".env" + +settings = Settings() diff --git a/app/crud/movie.py b/app/crud/movie.py new file mode 100644 index 000000000..6979f6d5b --- /dev/null +++ b/app/crud/movie.py @@ -0,0 +1,43 @@ +from sqlalchemy.orm import Session +from typing import List, Optional +from app.models.movie import Movie +from app.schemas.movie import MovieCreate, MovieUpdate + +def get_movies(db: Session, skip: int = 0, limit: int = 10) -> List[Movie]: + return db.query(Movie).offset(skip).limit(limit).all() + + +def get_movie(db: Session, movie_id: int) -> Optional[Movie]: + return db.query(Movie).filter(Movie.id == movie_id).first() + + +def get_movies_by_title(db: Session, title: str) -> List[Movie]: + return db.query(Movie).filter(Movie.title.ilike(f"%{title}%")).all() + + +def create_movie(db: Session, movie: MovieCreate) -> Movie: + db_movie = Movie(**movie.dict()) + db.add(db_movie) + db.commit() + db.refresh(db_movie) + return db_movie + + +def update_movie(db: Session, movie_id: int, movie: MovieUpdate) -> Optional[Movie]: + db_movie = db.query(Movie).filter(Movie.id == movie_id).first() + if not db_movie: + return None + for key, value in movie.dict(exclude_unset=True).items(): + setattr(db_movie, key, value) + db.commit() + db.refresh(db_movie) + return db_movie + + +def delete_movie(db: Session, movie_id: int) -> Optional[Movie]: + db_movie = db.query(Movie).filter(Movie.id == movie_id).first() + if not db_movie: + return None + db.delete(db_movie) + db.commit() + return db_movie diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 000000000..a70f51dfa --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.ext.declarative import declarative_base + + +Base = declarative_base() + diff --git a/app/db/init_db.py b/app/db/init_db.py new file mode 100644 index 000000000..e8cc6e165 --- /dev/null +++ b/app/db/init_db.py @@ -0,0 +1,6 @@ +from app.db.session import engine +from app.db.base import Base +from app.models.movie import Movie + +def init_db(): + Base.metadata.create_all(bind=engine) diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 000000000..c00509612 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,20 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +import os + +SQLALCHEMY_DATABASE_URL = "sqlite:///./data/movies.db" + +os.makedirs("data", exist_ok=True) + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 000000000..7a6b39396 --- /dev/null +++ b/app/main.py @@ -0,0 +1,12 @@ +from fastapi import FastAPI +from app.api.v1.routes_movies import router as movies_router +from app.core.config import settings +from app.db.init_db import init_db + +app = FastAPI(title=settings.PROJECT_NAME, version=settings.PROJECT_VERSION) + +#Starts the database +init_db() + +#Routes +app.include_router(movies_router, prefix="/api/v1/movies", tags=["Movies"]) diff --git a/app/models/movie.py b/app/models/movie.py new file mode 100644 index 000000000..d2ef7c4e3 --- /dev/null +++ b/app/models/movie.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime +from sqlalchemy.sql import func +from app.db.base import Base + +class Movie(Base): + __tablename__ = "movies" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(255), nullable=False, index=True) + director = Column(String(255), nullable=True) + year = Column(Integer, nullable=False) + synopsis = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + genre = Column(String(255), nullable=True) diff --git a/app/schemas/movie.py b/app/schemas/movie.py new file mode 100644 index 000000000..534615089 --- /dev/null +++ b/app/schemas/movie.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + +class MovieBase(BaseModel): + title: str + director: str + year: int + synopsis: Optional[str] = None + genre: Optional[str] = None + + +class MovieCreate(MovieBase): + pass + + +class MovieUpdate(BaseModel): + title: Optional[str] = None + director: Optional[str] = None + year: Optional[int] = None + synopsis: Optional[str] = None + + +class Movie(MovieBase): + id: int + created_at: datetime + updated_at: Optional[datetime] + + class Config: + orm_mode = True diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..d8d4216e3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + app: + build: . + ports: + - "8000:8000" + volumes: + - ./app:/app/app + environment: + - PYTHONUNBUFFERED=1 + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..7647e3ac7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi +pydantic +pydantic-settings +python-dotenv +sqlalchemy +uvicorn \ No newline at end of file