diff --git a/.appcd.yaml b/.appcd.yaml new file mode 100644 index 0000000..713615e --- /dev/null +++ b/.appcd.yaml @@ -0,0 +1,5 @@ +version: 0.0.1 +name: dogeapi +imageRegistry: ghcr.io/appcd-dev/dogeapi/dogeapi +imageTag: latest +dockerFile: Dockerfile diff --git a/.appcd.yml b/.appcd.yml deleted file mode 100644 index 987d72d..0000000 --- a/.appcd.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: 0.0.1 -services: - dogeapi: - dtr: ghcr.io/appcd-dev/dogeapi/dogeapi - tag: latest - dockerFile: Dockerfile \ No newline at end of file diff --git a/.appcd/aug-manifest.json b/.appcd/aug-manifest.json new file mode 100644 index 0000000..c5339b1 --- /dev/null +++ b/.appcd/aug-manifest.json @@ -0,0 +1,32 @@ +{ + "metadata": { + "repository": "some-repo-url", + "commit": "some-commit-hash", + "branch": "branch-name" + }, + "projects": [ + { + "metadata": { + "languages": [ + "python" + ], + "frameworks": [ + "fastapi" + ] + }, + "services": [ + { + "name": "dogeapi", + "image_registry": "ghcr.io/appcd-dev/dogeapi/dogeapi", + "image_tag": "latest", + "server_port": 8000, + "ports": [ + { + "listen": 8000 + } + ] + } + ] + } + ] +} diff --git a/.appcd/manifest.json b/.appcd/manifest.json index c5339b1..22bde65 100644 --- a/.appcd/manifest.json +++ b/.appcd/manifest.json @@ -1,32 +1,126 @@ { - "metadata": { - "repository": "some-repo-url", - "commit": "some-commit-hash", - "branch": "branch-name" + "version": "0.0.1", + "graph": { + "dogeapi": [ + "dogeapi-presign" + ], + "dogeapi-presign": [] }, - "projects": [ - { - "metadata": { - "languages": [ - "python" + "components": { + "dogeapi": { + "image_registry": "ghcr.io/appcd-dev/dogeapi/dogeapi", + "image_tag": "latest", + "server_port": 8000, + "ports": [ + { + "listen": 8000 + } + ], + "http_egress": [ + { + "endpoint": "${PRESIGN_SERVICE_URL}/presign", + "operations": [ + "GET" + ] + } + ], + "http_ingress": [ + { + "endpoint": "/login", + "operations": [ + "POST" + ] + }, + { + "endpoint": "/blog", + "operations": [ + "GET", + "POST" + ] + }, + { + "endpoint": "/blog/{id}", + "operations": [ + "GET", + "PUT", + "DELETE" + ] + }, + { + "endpoint": "/users", + "operations": [ + "GET", + "POST" + ] + }, + { + "endpoint": "/users/{id}", + "operations": [ + "GET" + ] + }, + { + "endpoint": "/", + "operations": [ + "GET" + ] + } + ], + "dependencies": { + "s3": [ + { + "bucket": "${S3_BUCKET_NAME}", + "methods": [ + "GetObject", + "PutObject" + ] + } ], - "frameworks": [ - "fastapi" + "database": [ + { + "methods:": [ + "READ", + "WRITE" + ], + "dsn": "${CONNECTION_STRING}", + "db_engine": "postgres" + } + ], + "env": [ + { + "name": "DEVELOPER_FLAG_1" + }, + { + "name": "DEBUG" + } ] - }, - "services": [ - { - "name": "dogeapi", - "image_registry": "ghcr.io/appcd-dev/dogeapi/dogeapi", - "image_tag": "latest", - "server_port": 8000, - "ports": [ - { - "listen": 8000 - } + } + }, + "dogeapi-presign": { + "image_registry": "ghcr.io/appcd-dev/dogeapi-presign/presign", + "image_tag": "latest", + "server_port": 5000, + "ports": [ + { + "listen": 5000 + } + ], + "http_ingress": [ + { + "endpoint": "/presign/{s3_key}", + "operations": [ + "GET" + ] + } + ], + "dependencies": { + "s3": { + "bucket": "${S3_BUCKET}", + "methods": [ + "GetObject" ] } - ] + } } - ] + } } diff --git a/Dockerfile b/Dockerfile index 1920df8..0957544 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ USER 1000 ENV ACCESS_LOG=${ACCESS_LOG:-/proc/1/fd/1} ENV ERROR_LOG=${ERROR_LOG:-/proc/1/fd/2} -EXPOSE 8443 +EXPOSE 8000 # Define the Uvicorn command to run our application -CMD ["uvicorn", "main:app", "--reload", "--workers", "1", "--host", "0.0.0.0", "--port", "8443"] +CMD ["uvicorn", "main:app", "--reload", "--workers", "1", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile index 5d7006e..7c2afc1 100644 --- a/Makefile +++ b/Makefile @@ -19,4 +19,38 @@ build: docker-compose build lint: - docker-compose run --rm backend pre-commit run --all-files \ No newline at end of file + docker-compose run --rm backend pre-commit run --all-files + +configure: + echo "Creating user" + curl -X 'POST' \ + 'http://0.0.0.0:8000/users/' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ "name": "cesar", "email": "cesar@cesar.com", "password": "cesar" }' + echo "login" + export BEARER_TOKEN=$(shell curl -X 'POST' \ + 'http://0.0.0.0:8000/login/' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'grant_type=&username=cesar%40cesar.com&password=cesar' | jq -r '.access_token') + +create-blog: + curl -X 'POST' \ + 'http://0.0.0.0:8000/blog/' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer ${BEARER_TOKEN}' \ + -H 'Content-Type: application/json' \ + -d '{"title": "Test blog title", "body": "Test blog body"}' + +get-blogs: + curl -X 'GET' \ + 'http://0.0.0.0:8000/blog/' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer ${BEARER_TOKEN}' + +get-presigned-url: + curl -X 'GET' \ + 'http://localhost:8000/blog/2/download' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer ${BEARER_TOKEN}' diff --git a/api/blog.py b/api/blog.py index 1c331d9..5ee56b5 100644 --- a/api/blog.py +++ b/api/blog.py @@ -1,5 +1,7 @@ #!/usr/bin/python3 +import uuid + from fastapi import HTTPException, status from sqlalchemy.orm import Session @@ -31,7 +33,11 @@ def create(request: schemas.Blog, db: Session): Returns: models.Blog: Blog object """ - new_blog = models.Blog(title=request.title, body=request.body, user_id=1) + s3_key = str(uuid.uuid4()) # Generating a unique UUID for the S3 key + + new_blog = models.Blog(s3_key=s3_key, user_id=1, title=request.title) + new_blog.body = request.body + db.add(new_blog) db.commit() db.refresh(new_blog) diff --git a/core/blog.py b/core/blog.py index f8fb1c8..73e66df 100644 --- a/core/blog.py +++ b/core/blog.py @@ -1,9 +1,11 @@ #!/usr/bin/python3 from typing import List +import os -from fastapi import APIRouter, Depends, Response, status +from fastapi import APIRouter, Depends, Response, status, HTTPException from sqlalchemy.orm import Session +import httpx from api import blog from database import configuration @@ -12,7 +14,7 @@ router = APIRouter(tags=["Blogs"], prefix="/blog") get_db = configuration.get_db - +PRESIGN_SERVICE_URL = os.getenv("PRESIGN_SERVICE_URL", "http://presign:5000") @router.get("/", response_model=List[schemas.ShowBlog]) def get_all_blogs( @@ -114,3 +116,52 @@ def update_blog( schemas.Blog: Updated blog """ return blog.update(id, request, db) + + +async def get_presigned_url_from_microservice(s3_key: str) -> str: + # Using Docker Compose service discovery to get presign service by its name. + url = f"{PRESIGN_SERVICE_URL}/presign/{s3_key}" + + async with httpx.AsyncClient() as client: + response = await client.get(url) + + if response.status_code != 200: + # Handle unexpected response or error from the microservice + raise HTTPException( + status_code=500, detail="Failed to obtain presigned URL" + ) + + return response.text + + +@router.get("/{id}/download", status_code=status.HTTP_200_OK, response_model=str) +async def get_download_url( + id: int, + db: Session = Depends(get_db), + current_user: schemas.User = Depends(get_current_user), +): + """ + Get a pre-signed download URL for a blog's associated S3 object. + + Args: + id (int): Blog id + db (Session, optional): Database session. Defaults to None. + current_user (schemas.User, optional): Current user. Defaults to None. + + Returns: + str: Pre-signed download URL + """ + fetched_blog = blog.show(id, db) + + if not fetched_blog: + raise HTTPException(status_code=404, detail="Blog not found") + + if not fetched_blog.s3_key: + raise HTTPException( + status_code=404, detail="S3 key not found for the specified blog" + ) + + s3_key = fetched_blog.s3_key + presigned_url = await get_presigned_url_from_microservice(s3_key) + + return presigned_url diff --git a/docker-compose.yaml b/docker-compose.yaml index fbb9517..4a7caba 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -15,8 +15,13 @@ services: #- DATABASE_URL=sqlite:////var/run/dogeapi/dogeapi.sqlite - SECRET_KEY=dev - ACCESS_TOKEN_EXPIRE_MINUTES=30 + - S3_BUCKET_NAME=appcd-demo-dogeapi + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + - AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN} depends_on: - db + - presign db: image: postgres restart: always @@ -26,3 +31,16 @@ services: POSTGRES_DB: postgres ports: - "5432:5432" + presign: + #build: + #context: ../DogeFlaskAPI + #dockerfile: Dockerfile + image: ghcr.io/appcd-dev/dogeapi-presign/presign:latest + ports: + - 5000:5000 + environment: + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + - AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN} + - S3_BUCKET=appcd-demo-dogeapi + diff --git a/models/models.py b/models/models.py index 5a19f0e..c07f7dd 100644 --- a/models/models.py +++ b/models/models.py @@ -1,5 +1,8 @@ #!/usr/bin/python3 +import boto3 +import os +from io import BytesIO from sqlalchemy import Column, ForeignKey, Integer, String from sqlalchemy.orm import relationship @@ -17,10 +20,78 @@ class Blog(Base): __tablename__ = "blogs" id = Column(Integer, primary_key=True, index=True) title = Column(String) - body = Column(String) + _s3_key = Column("s3_key", String) user_id = Column(Integer, ForeignKey("users.id")) creator = relationship("User", back_populates="blogs") + @property + def s3_key(self): + return self._s3_key + + @s3_key.setter + def s3_key(self, value): + self._s3_key = value + + def get_body(self): + """ + Get the body of the blog post from S3. + + Returns: + str: The body of the blog post. + """ + if not self.s3_key: + # Handle the case where s3_key is None + print("Blog post does not have a valid S3 key.") + return "" + + s3 = boto3.client("s3") + bucket_name = os.environ.get("S3_BUCKET_NAME") + obj = s3.get_object(Bucket=bucket_name, Key=self.s3_key) + body = obj["Body"].read().decode("utf-8") + return body + + def set_body(self, body): + """ + Set the body of the blog post in S3. + + Args: + body (str): The body of the blog post. + """ + if not self.s3_key: + raise ValueError("s3_key must be set before storing the blog body") + + if body is None: + raise ValueError("Blog body must not be empty") + + s3 = boto3.client("s3") + bucket_name = os.environ.get("S3_BUCKET_NAME") + buffer = BytesIO(body.encode("utf-8")) + s3.upload_fileobj(buffer, bucket_name, self.s3_key) + + def delete_body(self): + """ + Delete the body of the blog post from S3. + """ + s3 = boto3.client("s3") + bucket_name = os.environ.get("S3_BUCKET_NAME") + s3.delete_object(Bucket=bucket_name, Key=self.s3_key) + + body = property(get_body, set_body) + + def delete(self, session): + """ + Delete the blog post and its body from S3. + + Args: + session (sqlalchemy.orm.session.Session): The database session. + """ + self.delete_body() + session.delete(self) + + def __init__(self, *args, **kwargs): + self._s3_key = None + super().__init__(*args, **kwargs) + class User(Base): """ diff --git a/requirements.txt b/requirements.txt index 13d554f..12cd882 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,3 +25,5 @@ typing-extensions uvicorn pre-commit psycopg2 +boto3 +httpx diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..fbcdb39 --- /dev/null +++ b/start.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +CREDS=$(aws sts assume-role --role-arn arn:aws:iam::180217099948:role/atlantis-access --role-session-name local-test) + +export AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r ".Credentials.AccessKeyId") +export AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r ".Credentials.SecretAccessKey") +export AWS_SESSION_TOKEN=$(echo $CREDS | jq -r ".Credentials.SessionToken") + +docker-compose up --build -d +