Skip to content
This repository was archived by the owner on Jan 7, 2025. It is now read-only.

Commit 8a7dfa7

Browse files
committed
Add Object Endpoints
- Add the object endpoints - Refactor some existing code to account for larger code base
1 parent 0f36b90 commit 8a7dfa7

File tree

12 files changed

+302
-14
lines changed

12 files changed

+302
-14
lines changed

.github/workflows/build-dev.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ name: Build Development
22

33
on:
44
push:
5+
branches: [ '**' ]
56
tags:
67
- v[0-9]+.[0-9]+.[0-9]+-** # Semver Pre-Release
78
pull_request:

.github/workflows/build-prod.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ jobs:
2323
with:
2424
images: hub.opensciencegrid.org/macrostrat/api-v3
2525
tags: |
26+
type=ref,event=pr,suffix=-{{date 'YYYYMMDDHHmmss'}}
2627
type=ref,event=branch,suffix=-{{date 'YYYYMMDDHHmmss'}}
2728
type=semver,pattern={{version}}
2829
type=raw,value=latest,enable={{is_default_branch}}

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Macrostrat API V3
2+
3+
## Overview
4+
5+
This is a Fastapi application interfacing with a postgres database. It is designed to be deployed behind
6+
Nginx on a kubernetes cluster.

api/app.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@
2424
patch_sources_sub_table,
2525
select_sources_sub_table,
2626
)
27-
from api.models import PolygonModel, Sources, CopyColumnRequest
27+
from api.models.source import PolygonModel, Sources, CopyColumnRequest
2828
from api.query_parser import ParserException
2929
from api.routes.security import TokenData, get_groups
30+
from api.routes.object import router as object_router
3031

3132
@asynccontextmanager
3233
async def setup_engine(a: FastAPI):
@@ -56,6 +57,7 @@ async def setup_engine(a: FastAPI):
5657
)
5758

5859
app.include_router(api.routes.security.router)
60+
app.include_router(object_router)
5961

6062

6163
@app.get("/sources")
@@ -172,5 +174,8 @@ async def patch_sub_sources(
172174

173175

174176

177+
178+
179+
175180
if __name__ == "__main__":
176181
uvicorn.run(app, host="0.0.0.0", port=8000, headers=[("Access-Control-Expose-Headers", "X-Total-Count")])

api/database.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
#
88
import datetime
99
from os import environ
10-
from typing import Type
10+
from typing import Type, List
1111

12+
from pydantic import BaseModel
1213
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
1314
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
1415
from sqlalchemy import text, select, update, Table, MetaData, CursorResult, func, insert
@@ -134,6 +135,15 @@ async def get_access_token(async_session: async_sessionmaker[AsyncSession], toke
134135
# Here starts the use on the engine object directly
135136
#
136137

138+
def results_to_model(results, model: Type[BaseModel]) -> list[BaseModel]:
139+
"""Converts the results to a list of models"""
140+
141+
keys = list(results.keys())
142+
return [
143+
model(**{keys[i]: result[i] for i, v in enumerate(result)}) for result in results.fetchall()
144+
]
145+
146+
137147
class SQLResponse:
138148
def __init__(self, columns, results):
139149
self.columns = list(columns)
@@ -205,7 +215,7 @@ async def select_sources_sub_table(
205215
# Extract filters from the query parameters
206216
query_parser = QueryParser(columns=selected_columns, query_params=query_params)
207217
if query_parser.get_group_by_column() is not None:
208-
selected_columns = query_parser.get_group_by_select_columns()
218+
selected_columns = query_parser.get_select_columns()
209219

210220
stmt = (
211221
select(*selected_columns)

api/models/object.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import datetime
2+
from typing import Optional
3+
4+
from pydantic import BaseModel
5+
6+
from api.schemas import SchemeEnum
7+
8+
9+
class Object(BaseModel):
10+
scheme: SchemeEnum
11+
host: str
12+
bucket: str
13+
key: str
14+
source: dict
15+
mime_type: str
16+
sha256_hash: str
17+
18+
class Config:
19+
orm_mode = True
20+
21+
22+
class ResponseObject(Object):
23+
id: int
24+
created_on: datetime.datetime
25+
updated_on: datetime.datetime
26+
deleted_on: Optional[datetime.datetime] = None

api/models.py renamed to api/models/source.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import os
21
from typing import Optional
32

43
from geojson_pydantic import Feature, Polygon
@@ -72,4 +71,3 @@ class Config:
7271
orm_mode = True
7372

7473

75-

api/query_parser.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import starlette.requests
1111
import logging
12-
from fastapi import FastAPI, HTTPException
12+
from fastapi import FastAPI, HTTPException, Request
1313

1414

1515
from sqlalchemy.sql.expression import SQLColumnExpression
@@ -25,6 +25,12 @@ class ParserException(Exception):
2525
pass
2626

2727

28+
def get_filter_query_params(request: Request) -> list[tuple[str, str]]:
29+
"""Returns the query params that are not page or page_size"""
30+
31+
return [*filter(lambda x: x[0] not in ["page", "page_size"], request.query_params.items())]
32+
33+
2834
def cast_to_column_type(column: Column, value):
2935
try:
3036
return column.type.python_type(value)
@@ -91,11 +97,11 @@ def get_group_by_column(self):
9197

9298
return group_by_columns[0]
9399

94-
def get_group_by_select_columns(self):
100+
def get_select_columns(self) -> list[Column]:
95101
"""Returns the group by expression which does a string aggregation of distinct values in the other columns"""
96102

97103
if self.get_group_by_column() is None:
98-
raise ParserException(f"Group by column must be set")
104+
return [*self.columns.values()]
99105

100106
columns = []
101107
for column in self.columns.values():

api/routes/object.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import datetime
2+
3+
from fastapi import APIRouter, Depends, HTTPException
4+
from sqlalchemy import insert, select, update, and_
5+
6+
from api.database import (
7+
get_async_session,
8+
get_engine,
9+
results_to_model
10+
)
11+
from api.routes.security import get_groups
12+
from api.models.object import Object, ResponseObject
13+
from api.schemas import Objects
14+
from api.query_parser import get_filter_query_params, QueryParser
15+
16+
router = APIRouter(
17+
prefix="/object",
18+
tags=["file"],
19+
responses={404: {"description": "Not found"}},
20+
)
21+
22+
23+
@router.get("/", response_model=list[ResponseObject])
24+
async def get_objects(page: int = 0, page_size: int = 50, filter_query_params=Depends(get_filter_query_params), groups: list[str] = Depends(get_groups)):
25+
"""Get all objects"""
26+
27+
engine = get_engine()
28+
async_session = get_async_session(engine)
29+
30+
query_parser = QueryParser(columns=Objects.__table__.c, query_params=filter_query_params)
31+
32+
async with async_session() as session:
33+
34+
# TODO: This flow should likely be refactored into a function, lets see it used once more before making the move
35+
select_stmt = select(*query_parser.get_select_columns())\
36+
.limit(page_size)\
37+
.offset(page_size * page)\
38+
.where(and_(Objects.deleted_on == None, query_parser.where_expressions()))
39+
40+
# Add grouping
41+
if query_parser.get_group_by_column() is not None:
42+
select_stmt = select_stmt.group_by(query_parser.get_group_by_column()).order_by(
43+
query_parser.get_group_by_column()
44+
)
45+
46+
if query_parser.get_order_by_columns() is not None and \
47+
query_parser.get_group_by_column() is None:
48+
select_stmt = select_stmt.order_by(*query_parser.get_order_by_columns())
49+
50+
results = await session.execute(select_stmt)
51+
52+
return results_to_model(results, ResponseObject)
53+
54+
55+
@router.get("/{id}", response_model=ResponseObject)
56+
async def get_object(id: int, groups: list[str] = Depends(get_groups)):
57+
"""Get a single object"""
58+
59+
engine = get_engine()
60+
async_session = get_async_session(engine)
61+
62+
async with async_session() as session:
63+
64+
select_stmt = select(Objects).where(and_(Objects.id == id, Objects.deleted_on == None))
65+
66+
result = await session.scalar(select_stmt)
67+
68+
if result is None:
69+
raise HTTPException(status_code=404, detail=f"Object with id ({id}) not found")
70+
71+
response = ResponseObject(**result.__dict__)
72+
return response
73+
74+
75+
@router.post("/", response_model=ResponseObject)
76+
async def create_file(object: Object, groups: list[str] = Depends(get_groups)):
77+
"""Create/Register a new object"""
78+
79+
engine = get_engine()
80+
async_session = get_async_session(engine)
81+
82+
async with async_session() as session:
83+
84+
insert_stmt = insert(Objects).values(**object.dict()).returning(Objects)
85+
server_object = await session.scalar(insert_stmt)
86+
87+
response = ResponseObject(**server_object.__dict__)
88+
await session.commit()
89+
return response
90+
91+
92+
@router.patch("/{id}", response_model=ResponseObject)
93+
async def patch_object(id: int, object: Object, groups: list[str] = Depends(get_groups)) -> ResponseObject:
94+
"""Update a object"""
95+
96+
engine = get_engine()
97+
async_session = get_async_session(engine)
98+
99+
async with async_session() as session:
100+
101+
update_stmt = update(Objects)\
102+
.where(Objects.id == id)\
103+
.values(**object.dict())\
104+
.returning(Objects)
105+
106+
server_object = await session.scalar(update_stmt)
107+
108+
response = ResponseObject(**server_object.__dict__)
109+
await session.commit()
110+
return response
111+
112+
113+
@router.delete("/{id}")
114+
async def delete_object(id: int, groups: list[str] = Depends(get_groups)) -> ResponseObject:
115+
"""Delete a object"""
116+
117+
engine = get_engine()
118+
async_session = get_async_session(engine)
119+
120+
async with async_session() as session:
121+
122+
delete_stmt = update(Objects)\
123+
.where(Objects.id == id)\
124+
.values(deleted_on=datetime.datetime.utcnow())\
125+
.returning(Objects)
126+
127+
server_object = await session.scalar(delete_stmt)
128+
129+
response = ResponseObject(**server_object.__dict__)
130+
await session.commit()
131+
return response

api/schemas.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import enum
12
from typing import List
23
import datetime
3-
from sqlalchemy import ForeignKey, func, DateTime
4-
from sqlalchemy.dialects.postgresql import VARCHAR, TEXT, INTEGER, ARRAY, BOOLEAN
4+
from sqlalchemy import ForeignKey, func, DateTime, Enum, UniqueConstraint
5+
from sqlalchemy.dialects.postgresql import VARCHAR, TEXT, INTEGER, ARRAY, BOOLEAN, JSON
56
from sqlalchemy import String
67
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
78
from geoalchemy2 import Geometry
@@ -83,3 +84,33 @@ class Token(Base):
8384
created_on: Mapped[datetime.datetime] = mapped_column(
8485
DateTime(timezone=True), server_default=func.now()
8586
)
87+
88+
89+
class SchemeEnum(enum.Enum):
90+
http = "http"
91+
s3 = "s3"
92+
93+
94+
class Objects(Base):
95+
__tablename__ = "objects"
96+
__table_args__ = (
97+
UniqueConstraint('scheme', 'host', 'bucket', 'key', name='unique_file'),
98+
{'schema': 'macrostrat'}
99+
)
100+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
101+
scheme: Mapped[str] = mapped_column(Enum(SchemeEnum))
102+
host: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
103+
bucket: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
104+
key: Mapped[str] = mapped_column(VARCHAR(255), nullable=False)
105+
source: Mapped[dict] = mapped_column(JSON)
106+
mime_type: Mapped[str] = mapped_column(VARCHAR(255))
107+
sha256_hash: Mapped[str] = mapped_column(VARCHAR(255))
108+
created_on: Mapped[datetime.datetime] = mapped_column(
109+
DateTime(timezone=True), server_default=func.now()
110+
)
111+
updated_on: Mapped[datetime.datetime] = mapped_column(
112+
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
113+
)
114+
deleted_on: Mapped[datetime.datetime] = mapped_column(
115+
DateTime(timezone=True), nullable=True
116+
)

0 commit comments

Comments
 (0)