Skip to content
Open
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
3 changes: 3 additions & 0 deletions bases/lif/learner_data_export_api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from lif.learner_data_export_api import core
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other bases (e.g. bases/lif/translator_restapi/__init__.py) are empty. The eager from … import core here triggers FastAPI app instantiation as a side effect of importing the package — can cause subtle test-order issues if anything imports the package without wanting the app initialized. Recommend dropping the body.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All bases sub-folders follow this pattern of:

from ... import ...
__all__ = [...]

Do you feel all the `base/lif/.../init.py files should be empty?


__all__ = ["core"]
41 changes: 41 additions & 0 deletions bases/lif/learner_data_export_api/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from lif.learner_data_export_api import learner_data_export_endpoints
from lif.logging import get_logger
from lif.mdr_auth.core import AuthMiddleware
from lif.mdr_utils.config import get_settings

logger = get_logger(__name__)
settings = get_settings()
app = FastAPI(title="LIF Learner Data Export API", description="API for the LIF Learner Data Export", version="1.0.0")

app.add_middleware(AuthMiddleware)
# Configure CORS middleware
cors_origins = [origin.strip() for origin in settings.cors_allow_origins.split(",") if origin.strip()]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These reads — settings.cors_allow_origins, cors_allow_credentials, cors_allow_methods, cors_allow_headers — assume the Settings class in components/lif/mdr_utils/config.py defines them, but I don't see them there. The docker-compose passes CORS_* env vars (no MDR__ prefix), and the existing Settings only declares mdr__*-namespaced fields. As written, the service should crash at startup with an AttributeError the first time these are dereferenced.

Either (a) add the fields to Settings namespaced as mdr__cors__* (with env aliases if you want to keep the bare CORS_* env vars in compose), or (b) read os.environ directly here. Either way the PR is missing the field definitions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those settings exist -

cors_allow_origins: str = "*" # comma-separated list of allowed origins

I plan to hold off adjusting them until we have time to generalize the auth flows.

cors_methods = [method.strip() for method in settings.cors_allow_methods.split(",") if method.strip()]
cors_headers = (
[header.strip() for header in settings.cors_allow_headers.split(",") if header.strip()]
if settings.cors_allow_headers != "*"
else ["*"]
)
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=settings.cors_allow_credentials,
allow_methods=cors_methods,
allow_headers=cors_headers,
)


# --- API Endpoints ---


@app.get("/health")
async def health_check():
"""
Health check endpoint to verify the API is running
"""
return {"status": "ok"}


app.include_router(learner_data_export_endpoints.router, prefix="")
50 changes: 50 additions & 0 deletions bases/lif/learner_data_export_api/learner_data_export_endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from typing import Any, Dict

from fastapi import APIRouter, Request
from lif.datatypes.core import TargetTransformationDataModelDTO, TargetTransformationDataModelsDTO
from lif.mdr_utils.logger_config import get_logger

router = APIRouter()
logger = get_logger(__name__)


@router.get("/exports", response_model=Dict[str, Any])
async def get_data(request: Request):
"""Endpoint to export learner data in a specified format.

The response model is intentionally generic since it will depend
on the requested data format and transformation.
"""

# TODO: Build this out.
logger.info("Received request for learner data export as %s", request.state.principal)

return {"total": "data"}


@router.get("/available-data-formats", response_model=TargetTransformationDataModelsDTO)
async def get_available_data_formats(request: Request):
# TODO: Build this out.
logger.info("Received request for available data formats as %s", request.state.principal)

data_formats = TargetTransformationDataModelsDTO(
metadata={"total": 3},
DataFormats=[
TargetTransformationDataModelDTO(
name="OpenBadges 3.0",
version="1.0.3",
contributorOrganization="OB",
TransformationVersions=["1.0.0", "1.1.0"],
),
TargetTransformationDataModelDTO(
name="CEDS", version="2.0.0", contributorOrganization="CEDS Org", TransformationVersions=["2.0.0"]
),
TargetTransformationDataModelDTO(
name="ExampleDataSource",
version="1.0.1",
contributorOrganization="Community",
TransformationVersions=["1.3.0"],
),
],
)
return data_formats
32 changes: 32 additions & 0 deletions components/lif/datatypes/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,35 @@ def __getitem__(self, item):

def __len__(self):
return len(self.root)


class TargetTransformationDataModelDTO(BaseModel):
"""
Model for a target transformation Data Model.

Attributes:
name: Data Model name
version: Data Model version
contributorOrganization: Contributor organization for the Data Model
TransformationVersions: List of transformation versions for the Data Model
"""

name: str = Field(..., description="Data Model name")
version: str = Field(..., description="Data Model version")
contributorOrganization: str = Field(..., description="Contributor organization for the Data Model")
TransformationVersions: list[str] = Field(..., description="List of transformation versions for the Data Model")


class TargetTransformationDataModelsDTO(BaseModel):
"""
Model for a list of target transformation Data Models.

Attributes:
metadata: Metadata about the available data formats
DataFormats: List of Data Models that are targets in Org LIF transformations
"""

metadata: dict[str, Any] = Field(..., description="Metadata about the available data formats")
DataFormats: list[TargetTransformationDataModelDTO] = Field(
..., description="List of Data Models that are targets in Org LIF transformations"
)
2 changes: 2 additions & 0 deletions components/lif/mdr_auth/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from lif.mdr_utils.collection_utils import convert_csv_to_set
from lif.mdr_utils.config import get_settings
from lif.mdr_utils.logger_config import get_logger
from lif.tenant_routing import resolve_tenant_schema
from starlette.middleware.base import BaseHTTPMiddleware

logger = get_logger(__name__)
Expand All @@ -32,6 +33,7 @@
settings.mdr__auth__service_api_key__semantic_search: "semantic-search-service",
settings.mdr__auth__service_api_key__translator: "translator-service",
settings.mdr__auth__service_api_key__post_confirm: "post-confirm-service",
settings.mdr__auth__service_api_key__learner_data_export: "learner-data-export-service",
}

# Cognito configuration
Expand Down
3 changes: 2 additions & 1 deletion components/lif/mdr_utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ class Settings(BaseSettings):
mdr__auth__service_api_key__graphql: str = "changeme1"
mdr__auth__service_api_key__semantic_search: str = "changeme2"
mdr__auth__service_api_key__translator: str = "changeme3"
mdr__auth__service_api_key__learner_data_export: str = "changeme6"
# Used by the Cognito post-confirmation Lambda to call POST /tenants/provision
# when a new user registers (issue #883 PR 4b).
mdr__auth__service_api_key__post_confirm: str = "changeme5"
mdr__auth__public_allowlist_exact: str = "/login,/refresh-token,/health-check"
mdr__auth__public_allowlist_exact: str = "/login,/refresh-token,/health-check,/health"
mdr__auth__public_allowlist_starts_with: str = "/docs,/openapi.json"
# Cognito configuration (empty user_pool_id = Cognito auth disabled)
mdr__auth__cognito_user_pool_id: str = ""
Expand Down
28 changes: 28 additions & 0 deletions deployments/advisor-demo-docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,34 @@ services:
- lif-net-org1 # For org1 to org3 orchestrator communication


lif-learner-data-export-api:
build:
context: ../../
dockerfile: projects/lif_learner_data_export_api/Dockerfile2
container_name: lif-learner-data-export-api
environment:
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-http://localhost:3000,http://localhost:5173,http://localhost:8080,https://lde.lif.unicon.net,https://lde.demo.lif.unicon.net}
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-true}
CORS_ALLOW_METHODS: ${CORS_ALLOW_METHODS:-GET,POST,PUT,DELETE,OPTIONS,PATCH}
CORS_ALLOW_HEADERS: ${CORS_ALLOW_HEADERS:-*}
MDR__AUTH__SERVICE_API_KEY__LEARNER_DATA_EXPORT: ${MDR__AUTH__SERVICE_API_KEY__LEARNER_DATA_EXPORT:-changeme6}
MDR__AUTH__METHODS_TO_REQUIRE_AUTH: ${MDR__AUTH__METHODS_TO_REQUIRE_AUTH:-GET,POST,PUT,DELETE}
MDR__AUTH__PUBLIC_ALLOWLIST_EXACT: ${MDR__AUTH__PUBLIC_ALLOWLIST_EXACT:-/health}
MDR__AUTH__PUBLIC_ALLOWLIST_STARTS_WITH: ${MDR__AUTH__PUBLIC_ALLOWLIST_STARTS_WITH:-/docs,/openapi.json}
ports:
- "8013:8013"
networks:
- lif-net-org1
- lif-net-org2
- lif-net-org3
healthcheck:
test: ["CMD", "python", "-c", "import sys,urllib.request; sys.exit(0) if urllib.request.urlopen('http://localhost:8013/health').status==200 else sys.exit(1)"]
interval: 15s
timeout: 10s
retries: 3
start_period: 10s


# ----------- ORG 1 Only Services -----------

lif-example-data-source-rest-api:
Expand Down
1 change: 1 addition & 0 deletions projects/lif_learner_data_export_api/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.venv
3 changes: 3 additions & 0 deletions projects/lif_learner_data_export_api/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist
requirements.txt
uv.lock
18 changes: 18 additions & 0 deletions projects/lif_learner_data_export_api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM python:3.13-slim

ARG wheel=lif_learner_data_export_api-0.1.0-py3-none-any.whl

ARG deps=requirements.txt

RUN python -m pip install --upgrade pip

WORKDIR /code

COPY ./dist/$wheel /code/$wheel
COPY ./$deps /code/$deps

# This is adjusted to install third-party dependencies separately (the --no-deps) and install from requirements
RUN pip install --no-cache-dir --upgrade --no-deps /code/$wheel
RUN pip install -r /code/$deps

CMD ["uvicorn", "lif.learner_data_export_api.core:app", "--host", "0.0.0.0", "--port", "8013"]
49 changes: 49 additions & 0 deletions projects/lif_learner_data_export_api/Dockerfile2
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Stage 1: Builder stage
FROM python:3.13-slim AS builder

# Build arguments for project configuration
ARG PROJECT_NAME=lif_learner_data_export_api

# Install uv
RUN pip install --no-cache-dir uv

# Set UV environment variables for optimal caching and behavior
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy

# Set working directory in the container
WORKDIR /code

# Copy the entire monorepo structure needed for polylith
COPY projects/${PROJECT_NAME}/ /code/projects/${PROJECT_NAME}/
COPY bases/ /code/bases/
COPY components/ /code/components/

# Change to the specific project directory
WORKDIR /code/projects/${PROJECT_NAME}

# Sync dependencies and build the project wheel
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --no-dev && \
uv build --wheel
Comment thread
cbeach47 marked this conversation as resolved.

# Stage 2: Final runtime image
FROM python:3.13-slim AS runtime

# Inherit build arguments in runtime stage
ARG PROJECT_NAME=lif_learner_data_export_api

# Install uv in runtime for faster package installation
RUN pip install --no-cache-dir uv

# Copy the built wheel from the builder stage
COPY --from=builder /code/projects/${PROJECT_NAME}/dist/*.whl /tmp/

# Install the wheel in the final image
RUN uv pip install --system /tmp/*.whl && rm /tmp/*.whl

# Set working directory
WORKDIR /code

# Use uvicorn to run the FastAPI application
CMD ["uvicorn", "lif.learner_data_export_api.core:app", "--host", "0.0.0.0", "--port", "8013"]
75 changes: 75 additions & 0 deletions projects/lif_learner_data_export_api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Learner Data Export API

The **Learner Data Export API** exports learner data in formats other than the Org LIF data model.

Offers two abilities:

- An endpoint to enumerate the available "Org LIF to other data models" that have a transformation group setup in **MDR**.
- An endpoint to consume a learner ID, dataModel, and transformation version. The endpoint will return data, for that learner, in that data model format
- Leverages **LIF Query Planner**, **MDR API**, and the **Translator**.


# Example usage

## Build the project
Navigate to this folder (where the `pyproject.toml` file is)

1. Export the dependencies (when using uv workspaces and having no project-specific lock-file):
``` shell
uv export --no-emit-project --output-file requirements.txt
```

2. Build a wheel:
``` shell
uv build --out-dir ./dist
```

## Build a docker image from root

``` shell
./build-docker.sh
```


## Run the image

``` shell
docker run --rm --name learner_data_export_api -p 8013:8013 \
lif-learner-data-export-api
```

The OpenAPI specification of this FastAPI app can now be accessed at http://localhost:8013/docs#


## API Calls for Manual Testing

You can manually test the API endpoints using `curl` and an environment variable for your access token.

*Note:* For now, the access token is a static value from the configuration.

### Health Check
```shell
curl -X GET http://localhost:8013/health-check \
-H "Content-Type: application/json"
```

### Test Auth
```shell
curl -X GET http://localhost:8013/test/auth-info \
-H "Content-Type: application/json" \
-H "X-API-Key: changeme6"
```

### Get Available Data Formats
```shell
curl -X GET http://localhost:8013/available-data-formats \
-H "Content-Type: application/json" \
-H "X-API-Key: changeme6"
```

### Export Learner Data
```shell
curl -X GET http://localhost:8013/export \
-H "Content-Type: application/json" \
-H "X-API-Key: changeme6"
```
15 changes: 15 additions & 0 deletions projects/lif_learner_data_export_api/build-docker.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/bin/bash

set -e

# Configuration
IMAGE_TAG=${1:-lif-learner-data-export-api}
PROJ_DIR=${2:-lif_learner_data_export_api}

# Change to repo root
cd "$(dirname "$0")/../.."

echo "Building ${IMAGE_TAG} from repo root..."
docker build -f projects/${PROJ_DIR}/Dockerfile2 -t ${IMAGE_TAG} .

echo "Build complete!"
3 changes: 3 additions & 0 deletions projects/lif_learner_data_export_api/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/bash
uv export --no-emit-project --output-file requirements.txt
uv build --out-dir ./dist
31 changes: 31 additions & 0 deletions projects/lif_learner_data_export_api/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[build-system]
requires = ["hatchling", "hatch-polylith-bricks"]
build-backend = "hatchling.build"

[project]
name = "lif_learner_data_export_api"
version = "0.1.0"

requires-python = ">=3.13,<3.14"

dependencies = [
"fastapi~=0.115",
"pyjwt~=2.10",
"pydantic-settings~=2.1",
"uvicorn~=0.34",
]

# This section is needed for the Hatchling build backend, to activate the polylith build hook.
[tool.hatch.build.hooks.polylith-bricks]

# This section is needed for building
[tool.hatch.build.targets.wheel]
packages = ["lif"]

[tool.polylith.bricks]
"../../bases/lif/learner_data_export_api" = "lif/learner_data_export_api"
"../../components/lif/logging" = "lif/logging"
"../../components/lif/datatypes" = "lif/datatypes"
"../../components/lif/mdr_utils" = "lif/mdr_utils"
"../../components/lif/mdr_auth" = "lif/mdr_auth"
"../../components/lif/tenant_routing" = "lif/tenant_routing"
Empty file.
Loading