Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Build custom business logic stack using eoAPI #15

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,5 @@ node_modules/

.ruff_cache/
.env-cdk

.envrc
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ Once the applications are *up*, you'll need to add STAC **Collections** and **It

Then you can start exploring your dataset with:

- the STAC Metadata service [http://localhost:8081](http://localhost:8081)
- the Raster service [http://localhost:8082](http://localhost:8082)
- the browser UI [http://localhost:8085](http://localhost:8085)
- the STAC Metadata service [http://localhost:8081](http://localhost:8081)
- the Raster service [http://localhost:8082](http://localhost:8082)
- the browser UI [http://localhost:8085](http://localhost:8085)

If you've added a vector dataset to the `public` schema in the Postgres database, they will be available through the **Vector** service at [http://localhost:8083](http://localhost:8083).

Expand Down Expand Up @@ -113,3 +113,16 @@ Then, deploy
```
npx cdk deploy --all --require-approval never
```

## Development

```shell
source .venv/bin/activate

python -m pip install -e \
'runtimes/business/logic' \
'runtimes/eoapi/raster' \
'runtimes/eoapi/stac' \
'runtimes/eoapi/vector'

```
44 changes: 42 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,15 @@ services:
- HOST=0.0.0.0
- PORT=8083
- WEB_CONCURRENCY=10
- POSTGRES_USER=username
- POSTGRES_PASS=password
- POSTGRES_USER=business
- POSTGRES_PASS=casual
- POSTGRES_DBNAME=postgis
- POSTGRES_HOST=database
- POSTGRES_PORT=5432
- DB_MIN_CONN_SIZE=1
- DB_MAX_CONN_SIZE=10
- EOAPI_VECTOR_DEBUG=TRUE
- EOAPI_VECTOR_SCHEMAS=["business"]
env_file:
- path: .env
required: false
Expand All @@ -153,6 +154,44 @@ services:
volumes:
- ./dockerfiles/scripts:/tmp/scripts

business:
build:
context: .
dockerfile: dockerfiles/Dockerfile.business
ports:
- "${MY_DOCKER_IP:-127.0.0.1}:8084:8084"
environment:
- PYTHONUNBUFFERED=1
# Application
- HOST=0.0.0.0
- PORT=8084
- WEB_CONCURRENCY=10
- POSTGRES_USER=business
- POSTGRES_PASS=casual
- POSTGRES_DBNAME=postgis
- POSTGRES_HOST=database
- POSTGRES_PORT=5432
- DB_MIN_CONN_SIZE=1
- DB_MAX_CONN_SIZE=10
- DEBUG=True
env_file:
- path: .env
required: false
- path: .business.env
required: false
command: bash -c "bash /tmp/scripts/wait-for-it.sh -t 120 -h database -p 5432 && /start.sh"
develop:
watch:
- action: sync+restart
path: ./runtimes/business/logic/business
target: /opt/bitnami/python/lib/python3.11/site-packages/business
- action: rebuild
path: ./runtimes/business/logic/pyproject.toml
depends_on:
- database
volumes:
- ./dockerfiles/scripts:/tmp/scripts

database:
image: ghcr.io/stac-utils/pgstac:v0.8.5
environment:
Expand All @@ -167,6 +206,7 @@ services:
command: postgres -N 500
volumes:
- ./.pgdata:/var/lib/postgresql/data
- ./scripts/init-business-user.sql:/docker-entrypoint-initdb.d/zzz-init-business-user.sql

networks:
default:
Expand Down
12 changes: 12 additions & 0 deletions dockerfiles/Dockerfile.business
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ARG PYTHON_VERSION=3.11

FROM ghcr.io/vincentsarago/uvicorn-gunicorn:${PYTHON_VERSION}

ENV CURL_CA_BUNDLE /etc/ssl/certs/ca-certificates.crt

COPY runtimes/business/logic /tmp/logic
RUN pip install /tmp/logic
RUN rm -rf /tmp/logic

ENV MODULE_NAME business.logic.main
ENV VARIABLE_NAME app
1 change: 1 addition & 0 deletions infrastructure/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__ = "0.1.0"
4 changes: 3 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
eoapi-cdk==7.2.0
eoapi-cdk==7.2.1
pydantic==2.7
pydantic-settings[yaml]==2.2.1
pystac_client==0.8.3
pypgstac[psycopg]==0.8.5
boto3==1.24.15
typing-extensions
1 change: 1 addition & 0 deletions runtimes/business/logic/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Business Logic
3 changes: 3 additions & 0 deletions runtimes/business/logic/business/logic/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""business.app."""

__version__ = "0.1.0"
61 changes: 61 additions & 0 deletions runtimes/business/logic/business/logic/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Settings."""

from enum import Enum
from typing import Any

from pydantic_core.core_schema import FieldValidationInfo
from pydantic import PostgresDsn, field_validator
from pydantic_settings import BaseSettings


class ModeEnum(str, Enum):
development = "development"
production = "production"
testing = "testing"


class Settings(BaseSettings):
"""Settings"""

mode: ModeEnum = ModeEnum.development
postgres_user: str
postgres_pass: str
postgres_dbname: str
postgres_host: str
postgres_port: int
async_database_uri: PostgresDsn | str = ""

cors_origins: str = "*"
cors_methods: str = "GET,POST,OPTIONS"
cachecontrol: str = "public, max-age=3600"
debug: bool = False
root_path: str = ""

model_config = {
"env_file": ".env",
"extra": "allow",
}

@field_validator("async_database_uri", mode="after")
def assemble_db_connection(cls, v: str | None, info: FieldValidationInfo) -> Any:
if isinstance(v, str):
if v == "":
return PostgresDsn.build(
scheme="postgresql+asyncpg",
username=info.data["postgres_user"],
password=info.data["postgres_pass"],
host=info.data["postgres_host"],
port=info.data["postgres_port"],
path=info.data["postgres_dbname"],
)
return v

@field_validator("cors_origins")
def parse_cors_origin(cls, v):
"""Parse CORS origins."""
return [origin.strip() for origin in v.split(",")]

@field_validator("cors_methods")
def parse_cors_methods(cls, v):
"""Parse CORS methods."""
return [method.strip() for method in v.split(",")]
65 changes: 65 additions & 0 deletions runtimes/business/logic/business/logic/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from contextlib import asynccontextmanager
from typing import Annotated, List, Union

from geojson_pydantic import Feature, FeatureCollection
from fastapi import Depends, FastAPI, HTTPException
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession

from business.logic import __version__ as version
from business.logic import models
from business.logic.session import get_session, engine


@asynccontextmanager
async def lifespan(app: FastAPI):
# startup
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)

yield

# shutdown


Session = Annotated[AsyncSession, Depends(get_session)]

app = FastAPI(
title="Business Logic",
version=version,
lifespan=lifespan,
)


@app.post("/properties")
async def create_property(
session: Session, geojson: Union[Feature, FeatureCollection]
) -> List[int]:
if isinstance(geojson, Feature):
geojson = FeatureCollection(
type="FeatureCollection",
features=[geojson],
)

ids = []
for feature in geojson.features:
property = models.Property(geometry=feature.geometry.wkt)
session.add(property)
await session.flush()
ids.append(property.id)

await session.commit()

return ids


@app.get("/properties/{id}", response_model=models.Property)
async def get_property(session: Session, id: int):
property = await session.get(models.Property, id)

if not property:
raise HTTPException(
status_code=404, detail=f"No properties with id {id} found!"
)

return property
14 changes: 14 additions & 0 deletions runtimes/business/logic/business/logic/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from typing import Optional
from geoalchemy2 import Geometry
from pydantic import BaseModel
from sqlmodel import Column, Field, SQLModel


class PropertyCreate(BaseModel):
geometry: str = Field(
default=None, sa_column=Column(Geometry("MULTIPOLYGON", srid=4326))
)


class Property(PropertyCreate, SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
22 changes: 22 additions & 0 deletions runtimes/business/logic/business/logic/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from collections.abc import AsyncGenerator
from sqlalchemy.orm import sessionmaker
from business.logic.config import ModeEnum, Settings
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool

settings = Settings()

engine = create_async_engine(
str(settings.async_database_uri),
poolclass=NullPool
if settings.mode == ModeEnum.testing
else AsyncAdaptedQueuePool, # Asincio pytest works with NullPool
echo=settings.mode == ModeEnum.development,
)


async def get_session() -> AsyncGenerator:
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
30 changes: 30 additions & 0 deletions runtimes/business/logic/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[project]
name = "business.logic"
description = "Business logic"
readme = "README.md"
requires-python = ">=3.8"
authors = [
{name = "Henry Rodman", email = "[email protected]"},
]
license = {text = "MIT"}
dynamic = ["version"]
dependencies = [
"asyncpg",
"fastapi>=0.112.1",
"geoalchemy2>=0.15.2",
"geojson_pydantic>=1.1.0",
"pydantic_settings>=2.4.0",
"sqlmodel>=0.0.21",
]

[build-system]
requires = ["pdm-pep517"]
build-backend = "pdm.pep517.api"

[tool.pdm.version]
source = "file"
path = "business/logic/__init__.py"

[tool.pdm.build]
includes = ["business/logic"]
excludes = ["tests/", "**/.mypy_cache", "**/.DS_Store"]
5 changes: 2 additions & 3 deletions runtimes/eoapi/vector/eoapi/vector/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,15 @@ async def lifespan(app: FastAPI):
await connect_to_db(
app,
settings=postgres_settings,
# We enable both pgstac and public schemas (pgstac will be used by custom functions)
schemas=["pgstac", "public"],
schemas=settings.schemas,
user_sql_files=list(CUSTOM_SQL_DIRECTORY.glob("*.sql")), # type: ignore
)

logger.debug("Registering collection catalog...")
await register_collection_catalog(
app,
# For the Tables' Catalog we only use the `public` schema
schemas=["public"],
schemas=settings.schemas,
# We exclude public functions
exclude_function_schemas=["public"],
# We allow non-spatial tables
Expand Down
17 changes: 4 additions & 13 deletions runtimes/eoapi/vector/eoapi/vector/config.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
"""API settings."""

from pydantic import field_validator
from typing import List
from pydantic_settings import BaseSettings


class ApiSettings(BaseSettings):
"""API settings"""

name: str = "eoAPI-vector"
cors_origins: str = "*"
cors_methods: str = "GET"
schemas: List[str] = ["pgstac", "public"]
cors_origins: List[str] = ["*"]
cors_methods: List[str] = ["GET"]
cachecontrol: str = "public, max-age=3600"
debug: bool = False
root_path: str = ""
Expand All @@ -21,13 +22,3 @@ class ApiSettings(BaseSettings):
"env_file": ".env",
"extra": "allow",
}

@field_validator("cors_origins")
def parse_cors_origin(cls, v):
"""Parse CORS origins."""
return [origin.strip() for origin in v.split(",")]

@field_validator("cors_methods")
def parse_cors_methods(cls, v):
"""Parse CORS methods."""
return [method.strip() for method in v.split(",")]
Loading
Loading