From 7908edc1f0ba89a0e0551936c19d5fe07d4b0c22 Mon Sep 17 00:00:00 2001 From: Chris Beach Date: Fri, 15 May 2026 13:31:23 -0500 Subject: [PATCH 1/5] Issue #906: Phase 1a: Stand up LDE microservice --- bases/lif/learner_data_export_api/__init__.py | 3 + bases/lif/learner_data_export_api/core.py | 52 +++++++++++++ .../learner_data_export_endpoints.py | 49 ++++++++++++ components/lif/datatypes/core.py | 34 +++++++++ components/lif/mdr_auth/core.py | 3 +- components/lif/mdr_utils/config.py | 1 + .../advisor-demo-docker/docker-compose.yml | 34 +++++++++ .../lif_learner_data_export_api/.dockerignore | 1 + .../lif_learner_data_export_api/.gitignore | 3 + .../lif_learner_data_export_api/Dockerfile | 18 +++++ .../lif_learner_data_export_api/Dockerfile2 | 49 ++++++++++++ .../lif_learner_data_export_api/README.md | 75 +++++++++++++++++++ .../build-docker.sh | 15 ++++ projects/lif_learner_data_export_api/build.sh | 3 + .../pyproject.toml | 39 ++++++++++ 15 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 bases/lif/learner_data_export_api/__init__.py create mode 100644 bases/lif/learner_data_export_api/core.py create mode 100644 bases/lif/learner_data_export_api/learner_data_export_endpoints.py create mode 100644 projects/lif_learner_data_export_api/.dockerignore create mode 100644 projects/lif_learner_data_export_api/.gitignore create mode 100644 projects/lif_learner_data_export_api/Dockerfile create mode 100644 projects/lif_learner_data_export_api/Dockerfile2 create mode 100644 projects/lif_learner_data_export_api/README.md create mode 100755 projects/lif_learner_data_export_api/build-docker.sh create mode 100755 projects/lif_learner_data_export_api/build.sh create mode 100644 projects/lif_learner_data_export_api/pyproject.toml diff --git a/bases/lif/learner_data_export_api/__init__.py b/bases/lif/learner_data_export_api/__init__.py new file mode 100644 index 00000000..2ab38da3 --- /dev/null +++ b/bases/lif/learner_data_export_api/__init__.py @@ -0,0 +1,3 @@ +from lif.learner_data_export_api import core + +__all__ = ["core"] diff --git a/bases/lif/learner_data_export_api/core.py b/bases/lif/learner_data_export_api/core.py new file mode 100644 index 00000000..1e5509f3 --- /dev/null +++ b/bases/lif/learner_data_export_api/core.py @@ -0,0 +1,52 @@ +from http import HTTPStatus +from typing import Any, Dict + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from lif.datatypes.core import HealthCheckResponse +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()] +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-check", response_model=HealthCheckResponse) +async def health_check(): + """ + Health check endpoint to verify the API is running + """ + return HealthCheckResponse(status=HTTPStatus.OK, message="API is healthy") + + +@app.get("/test/auth-info") +async def get_auth_info(request: Request) -> Dict[str, Any]: + """Test what the user is logged in with""" + # Need to expand to JWT token + return {"authenticated_as": "service", "service-name": request.state.principal, "auth_type": "API token"} + + +app.include_router(learner_data_export_endpoints.router, prefix="") diff --git a/bases/lif/learner_data_export_api/learner_data_export_endpoints.py b/bases/lif/learner_data_export_api/learner_data_export_endpoints.py new file mode 100644 index 00000000..c7da3435 --- /dev/null +++ b/bases/lif/learner_data_export_api/learner_data_export_endpoints.py @@ -0,0 +1,49 @@ +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("/export", 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( + root=[ + 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 diff --git a/components/lif/datatypes/core.py b/components/lif/datatypes/core.py index 6001c17a..09c1a386 100644 --- a/components/lif/datatypes/core.py +++ b/components/lif/datatypes/core.py @@ -1,4 +1,5 @@ import warnings +from http import HTTPStatus from typing import Any, Dict, List from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator @@ -247,3 +248,36 @@ 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: + data (LIFUpdatePersonPayload): Update person. + """ + + root: list[TargetTransformationDataModelDTO] = Field(..., description="List of target transformation Data Models") + + +class HealthCheckResponse(BaseModel): + status: HTTPStatus + message: str diff --git a/components/lif/mdr_auth/core.py b/components/lif/mdr_auth/core.py index 18d25a4c..3cc4f7f1 100644 --- a/components/lif/mdr_auth/core.py +++ b/components/lif/mdr_auth/core.py @@ -9,10 +9,10 @@ import jwt from fastapi import HTTPException, Request, status from fastapi.responses import JSONResponse -from lif.tenant_routing import resolve_tenant_schema 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__) @@ -30,6 +30,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 diff --git a/components/lif/mdr_utils/config.py b/components/lif/mdr_utils/config.py index 6a35a077..d8dc03f1 100644 --- a/components/lif/mdr_utils/config.py +++ b/components/lif/mdr_utils/config.py @@ -17,6 +17,7 @@ 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" diff --git a/deployments/advisor-demo-docker/docker-compose.yml b/deployments/advisor-demo-docker/docker-compose.yml index 59c5d3d7..85b89b6f 100644 --- a/deployments/advisor-demo-docker/docker-compose.yml +++ b/deployments/advisor-demo-docker/docker-compose.yml @@ -312,6 +312,40 @@ 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-check} + 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-check').status==200 else sys.exit(1)"] + interval: 15s + timeout: 10s + retries: 3 + start_period: 10s + depends_on: + - lif-mdr-api + - lif-query-planner-org1 + - lif-query-planner-org2 + - lif-query-planner-org3 + - lif-translator-org1 + + # ----------- ORG 1 Only Services ----------- lif-example-data-source-rest-api: diff --git a/projects/lif_learner_data_export_api/.dockerignore b/projects/lif_learner_data_export_api/.dockerignore new file mode 100644 index 00000000..1d17dae1 --- /dev/null +++ b/projects/lif_learner_data_export_api/.dockerignore @@ -0,0 +1 @@ +.venv diff --git a/projects/lif_learner_data_export_api/.gitignore b/projects/lif_learner_data_export_api/.gitignore new file mode 100644 index 00000000..edd99e1d --- /dev/null +++ b/projects/lif_learner_data_export_api/.gitignore @@ -0,0 +1,3 @@ +dist +requirements.txt +uv.lock diff --git a/projects/lif_learner_data_export_api/Dockerfile b/projects/lif_learner_data_export_api/Dockerfile new file mode 100644 index 00000000..6d8c24e1 --- /dev/null +++ b/projects/lif_learner_data_export_api/Dockerfile @@ -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"] diff --git a/projects/lif_learner_data_export_api/Dockerfile2 b/projects/lif_learner_data_export_api/Dockerfile2 new file mode 100644 index 00000000..aae6656f --- /dev/null +++ b/projects/lif_learner_data_export_api/Dockerfile2 @@ -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 + +# 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"] diff --git a/projects/lif_learner_data_export_api/README.md b/projects/lif_learner_data_export_api/README.md new file mode 100644 index 00000000..8f14a7c5 --- /dev/null +++ b/projects/lif_learner_data_export_api/README.md @@ -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" +``` diff --git a/projects/lif_learner_data_export_api/build-docker.sh b/projects/lif_learner_data_export_api/build-docker.sh new file mode 100755 index 00000000..cba64e7e --- /dev/null +++ b/projects/lif_learner_data_export_api/build-docker.sh @@ -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!" diff --git a/projects/lif_learner_data_export_api/build.sh b/projects/lif_learner_data_export_api/build.sh new file mode 100755 index 00000000..6feb0bba --- /dev/null +++ b/projects/lif_learner_data_export_api/build.sh @@ -0,0 +1,3 @@ +#!/bin/bash +uv export --no-emit-project --output-file requirements.txt +uv build --out-dir ./dist diff --git a/projects/lif_learner_data_export_api/pyproject.toml b/projects/lif_learner_data_export_api/pyproject.toml new file mode 100644 index 00000000..e8ab6d84 --- /dev/null +++ b/projects/lif_learner_data_export_api/pyproject.toml @@ -0,0 +1,39 @@ +[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", + "langchain~=0.3", + "langchain-mcp-adapters>=0.1.14,<0.2.0", + "langchain-openai~=0.3", + "langgraph~=0.4", + "langgraph-prebuilt~=0.2", + "langmem~=0.0", + "mcp>=1.10,<2.0", + "mcp-graphql~=0.3", + "pyjwt~=2.10", + "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/auth" = "lif/auth" +"../../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" From 5487ab6e52edf2d3f7162e50f7d1b3b6ef3cc397 Mon Sep 17 00:00:00 2001 From: Chris Beach Date: Tue, 19 May 2026 08:13:15 -0500 Subject: [PATCH 2/5] Issue #906: Upgrade TY to fix linting error on middleware --- uv.lock | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/uv.lock b/uv.lock index 83992b7d..bd5207ed 100644 --- a/uv.lock +++ b/uv.lock @@ -3316,27 +3316,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.1a33" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/60/34/82f76e63277f0a6585ea48a8d373cfee417a73755daa078250af65421c77/ty-0.0.1a33.tar.gz", hash = "sha256:1db139aa7cbc9879e93146c99bf5f1f5273ca608683f71b3a9a75f9f812b729f", size = 4704365, upload-time = "2025-12-09T22:35:19.424Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/4c/4aec80e452268432f60f17da3840ffd6fef46394300808d0af32766dc989/ty-0.0.1a33-py3-none-linux_armv6l.whl", hash = "sha256:2126e6b62a50dc807d45f56629668861bac95944c77b4b6b6dc13f629d5a5a7e", size = 9674171, upload-time = "2025-12-09T22:34:59.757Z" }, - { url = "https://files.pythonhosted.org/packages/fe/71/ad51a14e00aa0d7e57533f2a68f0865b240bd197c36b87ddab1dd12a1cdd/ty-0.0.1a33-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f171a278a242b06c2f99327dacfa9c7f2d0328140f2976a46ca46e18cde2d6e3", size = 9466420, upload-time = "2025-12-09T22:34:54.884Z" }, - { url = "https://files.pythonhosted.org/packages/79/fa/72bf596a977e5d5343893bb1eb4092fdd0f22ed8c0f11427cc2201225bdb/ty-0.0.1a33-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b4249f030d24deeae7b25949d33832b4a25b5c893d679b32df1042584b9091f", size = 9009208, upload-time = "2025-12-09T22:35:27.871Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0d/0e20c21e4473a6ea7109c252f6c6bbc41f895b18307e507d6c12a636e6b6/ty-0.0.1a33-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:176f56fc7a6ea176b36d397c42b35efebb441f1fa42524a010579d7019ca8b67", size = 9280560, upload-time = "2025-12-09T22:35:24.258Z" }, - { url = "https://files.pythonhosted.org/packages/6e/dd/627a0a3e2a270b7200c5f92cb01382a3f9ac4f072abf5e7eb3be8f2f4267/ty-0.0.1a33-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ace2379e9c915c4c6d4dfd3737b290ebe2b008c20031233f4a6e9df0758f427", size = 9457161, upload-time = "2025-12-09T22:35:02.394Z" }, - { url = "https://files.pythonhosted.org/packages/ad/5a/974a48b39c885a17471c3f0847165567a77f05beef3b2573984b9b722378/ty-0.0.1a33-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4341a1daa7857b4de3a68658bad7aaa85577a82448182af2c6b412da02b19c14", size = 9873399, upload-time = "2025-12-09T22:34:49.919Z" }, - { url = "https://files.pythonhosted.org/packages/04/0e/8c09a95b91e3ba0d75a6cea69b06b0a070f085de7dd2aabf86d999175f29/ty-0.0.1a33-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:42c45b50b242af5868198131569d9f4ea37f83212a72494b2553e60f385874cc", size = 10487274, upload-time = "2025-12-09T22:34:52.579Z" }, - { url = "https://files.pythonhosted.org/packages/56/37/8d6e898ecf85f67a9bfaaff9c5194d9eaf4d826363a7dab27460eb2d630c/ty-0.0.1a33-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:229b8d927d7815ba4af0b45f1a97766813b62ee97599199900b8ccc1be911284", size = 10244389, upload-time = "2025-12-09T22:35:09.667Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c4/f98a35b12b552d28feb4157334484aa5f472c30944418e23b4a49fad2e40/ty-0.0.1a33-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5018ac5b64865d416b098246da38d2809fdc69e9d86b4e1cf94266e102e7c77f", size = 10224857, upload-time = "2025-12-09T22:35:04.661Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5d/fffb85c5fd7bdffcef212f514b439f229ecaee14e9bf7c199a625819c502/ty-0.0.1a33-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2cd6d6304302ad28e0412d80118a5f63d01af37d3cb39abf33856d348c0819e1", size = 9792377, upload-time = "2025-12-09T22:34:44.634Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ea/0664b0e4a2c286bd880be47121c781befad8077e15cd8a50b9b1f51b8676/ty-0.0.1a33-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:38b75adc050d26a88bbf85d55a4f7633216e455b76e9ee21d6f38640aa040d73", size = 9262018, upload-time = "2025-12-09T22:34:47.274Z" }, - { url = "https://files.pythonhosted.org/packages/89/dd/3d99564d7e649326c98c72b863f0aad771abfc75140413e7b70559ae4850/ty-0.0.1a33-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:efabd881d5b00058c3945b08abbe853b19c93cd0c7148bbcfd27c5d9e6c738f3", size = 9494056, upload-time = "2025-12-09T22:35:21.967Z" }, - { url = "https://files.pythonhosted.org/packages/49/9b/3471118edc5f945e2589c66c27e71b5d9a9efe21c82ced03ea698dbe9a19/ty-0.0.1a33-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ca3b8f84fe661bfb60d1e7665e54dd9c6c84769bff117b00e76ef537473cc59c", size = 9623498, upload-time = "2025-12-09T22:35:07.072Z" }, - { url = "https://files.pythonhosted.org/packages/b8/6d/12dcb22b015a4d3e677f394ab7dc80307f2b59f898ea785ea6bfcef8cffa/ty-0.0.1a33-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f46ae07e353a54512b64b590eae4d82eb22c3a5f5947cea04f950dc1993f64f1", size = 9904193, upload-time = "2025-12-09T22:34:57.106Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e8/628063386fda2f9182089bfe0c8a27ede0c1a120bef74294008468cd2d7d/ty-0.0.1a33-py3-none-win32.whl", hash = "sha256:9020b8be11a184bbe26d07b1a8f0b2e3b75302b08b98b4b1fb6d5d2d03e64aca", size = 9095241, upload-time = "2025-12-09T22:35:14.475Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fe/8ad29c47c9499132849cd5401f67c6bdd2912be8dcb298e774b4f39e1cce/ty-0.0.1a33-py3-none-win_amd64.whl", hash = "sha256:553b5281d424c69389508a60dfd8af8e3014529ca6856dfed1f231020bc58d09", size = 9948007, upload-time = "2025-12-09T22:35:17.043Z" }, - { url = "https://files.pythonhosted.org/packages/2f/fc/1825f1f8c77d4d8fe75543882d9ad5934e568aa807e1a4cb7e999f701750/ty-0.0.1a33-py3-none-win_arm64.whl", hash = "sha256:d9937e9ddc7b383c6b1ab3065982fb2b8d0a2884ae5bd7b542e4208a807e326e", size = 9471473, upload-time = "2025-12-09T22:35:12.105Z" }, +version = "0.0.37" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/c3/60bc4829e0c1a8ff80b592067e1185a7b5ea64608acb0c676c44d5137d52/ty-0.0.37.tar.gz", hash = "sha256:f873f69627bd7f4ef8d57f716c63e5c63d7d1b7327ab3de185c7287a75223011", size = 5655422, upload-time = "2026-05-16T05:57:21.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/fe/180dd6914f9db33ad0200fbeaa429dd1fb0a4e6d98320dc1775f100a91af/ty-0.0.37-py3-none-linux_armv6l.whl", hash = "sha256:66cf7310189856e15f690559ddf37735476d2644db917d92f7cef13e5c834adf", size = 11246028, upload-time = "2026-05-16T05:57:41.744Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/fa0cfd31467ad99b2db8c81ee9e2b4574589974a3eb9723be825e15b300c/ty-0.0.37-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2048f3c44ee6c7dde6e0ca064f99c6cada8f6de8ccdcfad2d856a429f8a4ac82", size = 11001460, upload-time = "2026-05-16T05:57:35.27Z" }, + { url = "https://files.pythonhosted.org/packages/10/3f/db60ba9be8b95a464ece0ba103e534047c34b49fee12f5e101f83f8d66db/ty-0.0.37-py3-none-macosx_11_0_arm64.whl", hash = "sha256:32c7b9b5b626aacdec334b44a2698e5f7b80df55bf7338267084d00d4b9546b3", size = 10446549, upload-time = "2026-05-16T05:57:37.252Z" }, + { url = "https://files.pythonhosted.org/packages/56/6f/11dd7174b20ebcb37a3d3b68f60b3940e37e4356e0accd03e2d7f9f70690/ty-0.0.37-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9fba1bebccf1e656bc5e3787acc5a191c491041ee4d12fe8fe2eff64e7b190d", size = 10961016, upload-time = "2026-05-16T05:57:16.394Z" }, + { url = "https://files.pythonhosted.org/packages/65/dd/3c17ce2860c525817c42c82d7075391b1f5615d36c03aa2d26647a224e8a/ty-0.0.37-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f987c5fb59aa5017ee8e8c5b57a07390f584e58e572255acd0fa44b3e0b238df", size = 11022093, upload-time = "2026-05-16T05:57:32.741Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a8/e7a40b0b57660921dd3482d219add963973b52ae8507abd88f48439704b5/ty-0.0.37-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f4168f53146e7a3f52560ff433f238352591c9b1a9ed09397fbb776ddef4f89c", size = 11486333, upload-time = "2026-05-16T05:57:18.839Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/2c406b98244bc1ad42afdd35f466bcef88664210957dcbb5172254ff2462/ty-0.0.37-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11e487eafdb80a48223ce68a01f9287528216ffe0126d1629ff11e4f7c1dd3cf", size = 12093526, upload-time = "2026-05-16T05:57:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3c/5c492a38e1b21a26370727dd4b77a53f05262e53e3be232047f22e7fa1b3/ty-0.0.37-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b49f388d063668676daaa7eef57385089d1b844279c0185bd84d4dbc3bcede6", size = 11725957, upload-time = "2026-05-16T05:57:23.356Z" }, + { url = "https://files.pythonhosted.org/packages/b2/00/8a3d9ba265cd0582342c14e4980cc0351aaaa45c6305712d398c9e2446c7/ty-0.0.37-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b96bfc1cc725d9d859abef4e3aa32a6da0f7472eaaafae2d9a6cffd729c7c61", size = 11610336, upload-time = "2026-05-16T05:57:27.888Z" }, + { url = "https://files.pythonhosted.org/packages/91/4b/6ee172935cb842f5c1553b0d37215b45e9dde05a4c74fdb47fd271907122/ty-0.0.37-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c55f39b519107cf234b794718793e11793c055e89028a282a309f690def48117", size = 11797856, upload-time = "2026-05-16T05:57:11.109Z" }, + { url = "https://files.pythonhosted.org/packages/34/ef/75a7425bf9fe74483404ff11a8cbe3aa307354e0801697d6063384157776/ty-0.0.37-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c79204350de060a077bff7f027a1d53e216cad147d826ec9862be0af2f9c3c1e", size = 10941848, upload-time = "2026-05-16T05:57:30.653Z" }, + { url = "https://files.pythonhosted.org/packages/e0/2c/7ea9dccd55961375067f99ed00fb8eabb491f6a06d0e5f09c797d2b900a6/ty-0.0.37-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:49a21b4dcb2cd94cd0298c96dfb71a2dd25f08bf7e6eefd0c33c519d058908c6", size = 11058248, upload-time = "2026-05-16T05:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/98/d7/848fde96c6610b2b1fd75823d44d8977a4525c4397f27332f054ccd6cf9c/ty-0.0.37-py3-none-musllinux_1_2_i686.whl", hash = "sha256:119332095c5974fe1dabfe4fd00c6759eeec5b99f7d7a80b2833feee5a58abdb", size = 11168423, upload-time = "2026-05-16T05:57:39.297Z" }, + { url = "https://files.pythonhosted.org/packages/29/11/c1613ac4b64357b9067df68bac97bcb458cc426cd468a2782847238c539b/ty-0.0.37-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ac5dc593675414f68862c2f71cc04912b0e5ec5520a9c49fc71ed79205b95c33", size = 11698565, upload-time = "2026-05-16T05:57:14.206Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ac/961205863903881996adb5a6f9cfe570c132882922ac226540346f15df20/ty-0.0.37-py3-none-win32.whl", hash = "sha256:33b57e4095179f06c2ae01c334833645cad94bf7d7467e073cdc3aaabea565d3", size = 10518308, upload-time = "2026-05-16T05:57:25.824Z" }, + { url = "https://files.pythonhosted.org/packages/39/cd/f308edd0cd86e402fe3a1b5c54e0a0dfa0177d80c1557c4849510bb2a147/ty-0.0.37-py3-none-win_amd64.whl", hash = "sha256:3b159351e99cf6eed7aacfb69ae8437725d15599ac4f21c8b2e909b300498b6c", size = 11607159, upload-time = "2026-05-16T05:57:06.76Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ed/5ec4b501479bc5dad55467e2fe72e797cb9c178468c0d1a514536872ebc5/ty-0.0.37-py3-none-win_arm64.whl", hash = "sha256:6c3c2b997f68c71e14242b96d48cba3c086439556af02bb4613aa458950d5c23", size = 10958817, upload-time = "2026-05-16T05:57:08.907Z" }, ] [[package]] From 3b9214d5210e5d22650050cf6e4a960a7ce9c4fe Mon Sep 17 00:00:00 2001 From: Chris Beach Date: Tue, 19 May 2026 16:54:08 -0500 Subject: [PATCH 3/5] Issue #906: PR revisions, add tests --- .../learner_data_export_endpoints.py | 5 +- components/lif/datatypes/core.py | 5 +- .../pyproject.toml | 10 +- .../lif/learner_data_export_api/__init__.py | 0 .../lif/learner_data_export_api/test_core.py | 139 ++++++++++++++++++ 5 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 test/bases/lif/learner_data_export_api/__init__.py create mode 100644 test/bases/lif/learner_data_export_api/test_core.py diff --git a/bases/lif/learner_data_export_api/learner_data_export_endpoints.py b/bases/lif/learner_data_export_api/learner_data_export_endpoints.py index c7da3435..b88cf5ae 100644 --- a/bases/lif/learner_data_export_api/learner_data_export_endpoints.py +++ b/bases/lif/learner_data_export_api/learner_data_export_endpoints.py @@ -28,7 +28,8 @@ async def get_available_data_formats(request: Request): logger.info("Received request for available data formats as %s", request.state.principal) data_formats = TargetTransformationDataModelsDTO( - root=[ + metadata={"total": 3}, + dataFormats=[ TargetTransformationDataModelDTO( name="OpenBadges 3.0", version="1.0.3", @@ -44,6 +45,6 @@ async def get_available_data_formats(request: Request): contributorOrganization="Community", transformationVersions=["1.3.0"], ), - ] + ], ) return data_formats diff --git a/components/lif/datatypes/core.py b/components/lif/datatypes/core.py index 09c1a386..a550e026 100644 --- a/components/lif/datatypes/core.py +++ b/components/lif/datatypes/core.py @@ -275,7 +275,10 @@ class TargetTransformationDataModelsDTO(BaseModel): data (LIFUpdatePersonPayload): Update person. """ - root: list[TargetTransformationDataModelDTO] = Field(..., description="List of target transformation Data Models") + metadata: dict[str, Any] = Field(..., description="Metadata about the available data formats") + dataFormats: list[TargetTransformationDataModelDTO] = Field( + ..., description="List of target transformation Data Models" + ) class HealthCheckResponse(BaseModel): diff --git a/projects/lif_learner_data_export_api/pyproject.toml b/projects/lif_learner_data_export_api/pyproject.toml index e8ab6d84..7557eb26 100644 --- a/projects/lif_learner_data_export_api/pyproject.toml +++ b/projects/lif_learner_data_export_api/pyproject.toml @@ -10,15 +10,8 @@ requires-python = ">=3.13,<3.14" dependencies = [ "fastapi~=0.115", - "langchain~=0.3", - "langchain-mcp-adapters>=0.1.14,<0.2.0", - "langchain-openai~=0.3", - "langgraph~=0.4", - "langgraph-prebuilt~=0.2", - "langmem~=0.0", - "mcp>=1.10,<2.0", - "mcp-graphql~=0.3", "pyjwt~=2.10", + "pydantic-settings~=2.1", "uvicorn~=0.34", ] @@ -32,7 +25,6 @@ packages = ["lif"] [tool.polylith.bricks] "../../bases/lif/learner_data_export_api" = "lif/learner_data_export_api" "../../components/lif/logging" = "lif/logging" -"../../components/lif/auth" = "lif/auth" "../../components/lif/datatypes" = "lif/datatypes" "../../components/lif/mdr_utils" = "lif/mdr_utils" "../../components/lif/mdr_auth" = "lif/mdr_auth" diff --git a/test/bases/lif/learner_data_export_api/__init__.py b/test/bases/lif/learner_data_export_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/bases/lif/learner_data_export_api/test_core.py b/test/bases/lif/learner_data_export_api/test_core.py new file mode 100644 index 00000000..73ac1f6c --- /dev/null +++ b/test/bases/lif/learner_data_export_api/test_core.py @@ -0,0 +1,139 @@ +import pytest +from deepdiff import DeepDiff +from httpx import ASGITransport, AsyncClient +from lif.learner_data_export_api import core + +DEFAULT_API_KEY = "changeme6" + + +def get_client() -> AsyncClient: + return AsyncClient(transport=ASGITransport(app=core.app), base_url="http://test") + + +@pytest.mark.asyncio +async def test_health_check(): + async with get_client() as client: + response = await client.get("/health-check") + assert response.status_code == 200 + response_json = response.json() + expected_response = {"status": 200, "message": "API is healthy"} + diff = DeepDiff(expected_response, response_json) + assert not diff, diff # prints out the differences if any + + +@pytest.mark.asyncio +async def test_auth_info_401(): + async with get_client() as client: + response = await client.get("/test/auth-info") + assert response.status_code == 401 + response_json = response.json() + expected_response = {"detail": "Authentication required: Provide either Bearer token or API key"} + diff = DeepDiff(expected_response, response_json) + assert not diff, diff # prints out the differences if any + + +@pytest.mark.asyncio +async def test_auth_info_default_token(): + async with get_client() as client: + response = await client.get("/test/auth-info", headers={"X-API-Key": DEFAULT_API_KEY}) + assert response.status_code == 200 + response_json = response.json() + expected_response = { + "authenticated_as": "service", + "service-name": "service:learner-data-export-service", + "auth_type": "API token", + } + diff = DeepDiff(expected_response, response_json) + assert not diff, diff # prints out the differences if any + + +@pytest.mark.asyncio +async def test_auth_info_401(): + async with get_client() as client: + response = await client.get("/test/auth-info") + assert response.status_code == 401 + response_json = response.json() + expected_response = {"detail": "Authentication required: Provide either Bearer token or API key"} + diff = DeepDiff(expected_response, response_json) + assert not diff, diff # prints out the differences if any + + +@pytest.mark.asyncio +async def test_auth_info_default_token(): + async with get_client() as client: + response = await client.get("/test/auth-info", headers={"X-API-Key": DEFAULT_API_KEY}) + assert response.status_code == 200 + response_json = response.json() + expected_response = { + "authenticated_as": "service", + "service-name": "service:learner-data-export-service", + "auth_type": "API token", + } + diff = DeepDiff(expected_response, response_json) + assert not diff, diff # prints out the differences if any + + +@pytest.mark.asyncio +async def test_available_data_formats_401(): + async with get_client() as client: + response = await client.get("/available-data-formats") + assert response.status_code == 401 + response_json = response.json() + expected_response = {"detail": "Authentication required: Provide either Bearer token or API key"} + diff = DeepDiff(expected_response, response_json) + assert not diff, diff # prints out the differences if any + + +@pytest.mark.asyncio +async def test_available_data_formats_default_token(): + async with get_client() as client: + response = await client.get("/available-data-formats", headers={"X-API-Key": DEFAULT_API_KEY}) + assert response.status_code == 200 + response_json = response.json() + expected_response = { + "metadata": {"total": 3}, + "dataFormats": [ + { + "name": "OpenBadges 3.0", + "version": "1.0.3", + "contributorOrganization": "OB", + "transformationVersions": ["1.0.0", "1.1.0"], + }, + { + "name": "CEDS", + "version": "2.0.0", + "contributorOrganization": "CEDS Org", + "transformationVersions": ["2.0.0"], + }, + { + "name": "ExampleDataSource", + "version": "1.0.1", + "contributorOrganization": "Community", + "transformationVersions": ["1.3.0"], + }, + ], + } + diff = DeepDiff(expected_response, response_json) + assert not diff, diff # prints out the differences if any + + +@pytest.mark.asyncio +async def test_export_401(): + async with get_client() as client: + response = await client.get("/export") + assert response.status_code == 401 + response_json = response.json() + expected_response = {"detail": "Authentication required: Provide either Bearer token or API key"} + diff = DeepDiff(expected_response, response_json) + assert not diff, diff # prints out the differences if any + + +@pytest.mark.asyncio +async def test_export_default_token(): + async with get_client() as client: + response = await client.get("/export", headers={"X-API-Key": DEFAULT_API_KEY}) + assert response.status_code == 200 + response_json = response.json() + expected_response = {"total": "data"} + diff = DeepDiff(expected_response, response_json) + assert not diff, diff # prints out the differences if any From 803fb5204e6cb2cc989a654939d46bf039c9aa64 Mon Sep 17 00:00:00 2001 From: Chris Beach Date: Fri, 29 May 2026 12:42:11 -0500 Subject: [PATCH 4/5] LIF-609 1a: PR revisions --- bases/lif/learner_data_export_api/core.py | 17 +--- .../learner_data_export_endpoints.py | 10 +-- components/lif/datatypes/core.py | 17 ++-- components/lif/mdr_utils/config.py | 2 +- .../advisor-demo-docker/docker-compose.yml | 10 +-- .../lif_learner_data_export_api/README.md | 2 +- .../lif/learner_data_export_api/test_core.py | 83 +++---------------- 7 files changed, 29 insertions(+), 112 deletions(-) diff --git a/bases/lif/learner_data_export_api/core.py b/bases/lif/learner_data_export_api/core.py index 1e5509f3..33530cf5 100644 --- a/bases/lif/learner_data_export_api/core.py +++ b/bases/lif/learner_data_export_api/core.py @@ -1,9 +1,5 @@ -from http import HTTPStatus -from typing import Any, Dict - -from fastapi import FastAPI, Request +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from lif.datatypes.core import HealthCheckResponse from lif.learner_data_export_api import learner_data_export_endpoints from lif.logging import get_logger from lif.mdr_auth.core import AuthMiddleware @@ -34,19 +30,12 @@ # --- API Endpoints --- -@app.get("/health-check", response_model=HealthCheckResponse) +@app.get("/health") async def health_check(): """ Health check endpoint to verify the API is running """ - return HealthCheckResponse(status=HTTPStatus.OK, message="API is healthy") - - -@app.get("/test/auth-info") -async def get_auth_info(request: Request) -> Dict[str, Any]: - """Test what the user is logged in with""" - # Need to expand to JWT token - return {"authenticated_as": "service", "service-name": request.state.principal, "auth_type": "API token"} + return {"status": "ok"} app.include_router(learner_data_export_endpoints.router, prefix="") diff --git a/bases/lif/learner_data_export_api/learner_data_export_endpoints.py b/bases/lif/learner_data_export_api/learner_data_export_endpoints.py index b88cf5ae..9dbd0d9b 100644 --- a/bases/lif/learner_data_export_api/learner_data_export_endpoints.py +++ b/bases/lif/learner_data_export_api/learner_data_export_endpoints.py @@ -8,7 +8,7 @@ logger = get_logger(__name__) -@router.get("/export", response_model=Dict[str, Any]) +@router.get("/exports", response_model=Dict[str, Any]) async def get_data(request: Request): """Endpoint to export learner data in a specified format. @@ -29,21 +29,21 @@ async def get_available_data_formats(request: Request): data_formats = TargetTransformationDataModelsDTO( metadata={"total": 3}, - dataFormats=[ + DataFormats=[ TargetTransformationDataModelDTO( name="OpenBadges 3.0", version="1.0.3", contributorOrganization="OB", - transformationVersions=["1.0.0", "1.1.0"], + TransformationVersions=["1.0.0", "1.1.0"], ), TargetTransformationDataModelDTO( - name="CEDS", version="2.0.0", contributorOrganization="CEDS Org", transformationVersions=["2.0.0"] + 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"], + TransformationVersions=["1.3.0"], ), ], ) diff --git a/components/lif/datatypes/core.py b/components/lif/datatypes/core.py index a550e026..cc88c7fa 100644 --- a/components/lif/datatypes/core.py +++ b/components/lif/datatypes/core.py @@ -1,5 +1,4 @@ import warnings -from http import HTTPStatus from typing import Any, Dict, List from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator @@ -258,13 +257,13 @@ class TargetTransformationDataModelDTO(BaseModel): name: Data Model name version: Data Model version contributorOrganization: Contributor organization for the Data Model - transformationVersions: List of transformation versions 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") + TransformationVersions: list[str] = Field(..., description="List of transformation versions for the Data Model") class TargetTransformationDataModelsDTO(BaseModel): @@ -272,15 +271,11 @@ class TargetTransformationDataModelsDTO(BaseModel): Model for a list of target transformation Data Models. Attributes: - data (LIFUpdatePersonPayload): Update person. + 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 target transformation Data Models" + DataFormats: list[TargetTransformationDataModelDTO] = Field( + ..., description="List of Data Models that are targets in Org LIF transformations" ) - - -class HealthCheckResponse(BaseModel): - status: HTTPStatus - message: str diff --git a/components/lif/mdr_utils/config.py b/components/lif/mdr_utils/config.py index d8dc03f1..8790e3aa 100644 --- a/components/lif/mdr_utils/config.py +++ b/components/lif/mdr_utils/config.py @@ -21,7 +21,7 @@ class Settings(BaseSettings): # 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 = "" diff --git a/deployments/advisor-demo-docker/docker-compose.yml b/deployments/advisor-demo-docker/docker-compose.yml index 85b89b6f..633dde83 100644 --- a/deployments/advisor-demo-docker/docker-compose.yml +++ b/deployments/advisor-demo-docker/docker-compose.yml @@ -318,7 +318,7 @@ services: 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_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:-*} @@ -333,17 +333,11 @@ services: - 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-check').status==200 else sys.exit(1)"] + 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 - depends_on: - - lif-mdr-api - - lif-query-planner-org1 - - lif-query-planner-org2 - - lif-query-planner-org3 - - lif-translator-org1 # ----------- ORG 1 Only Services ----------- diff --git a/projects/lif_learner_data_export_api/README.md b/projects/lif_learner_data_export_api/README.md index 8f14a7c5..65dfe460 100644 --- a/projects/lif_learner_data_export_api/README.md +++ b/projects/lif_learner_data_export_api/README.md @@ -36,7 +36,7 @@ uv build --out-dir ./dist ``` 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# diff --git a/test/bases/lif/learner_data_export_api/test_core.py b/test/bases/lif/learner_data_export_api/test_core.py index 73ac1f6c..3638445a 100644 --- a/test/bases/lif/learner_data_export_api/test_core.py +++ b/test/bases/lif/learner_data_export_api/test_core.py @@ -1,4 +1,3 @@ -import pytest from deepdiff import DeepDiff from httpx import ASGITransport, AsyncClient from lif.learner_data_export_api import core @@ -10,81 +9,24 @@ def get_client() -> AsyncClient: return AsyncClient(transport=ASGITransport(app=core.app), base_url="http://test") -@pytest.mark.asyncio async def test_health_check(): async with get_client() as client: - response = await client.get("/health-check") + response = await client.get("/health") assert response.status_code == 200 response_json = response.json() - expected_response = {"status": 200, "message": "API is healthy"} - diff = DeepDiff(expected_response, response_json) - assert not diff, diff # prints out the differences if any - - -@pytest.mark.asyncio -async def test_auth_info_401(): - async with get_client() as client: - response = await client.get("/test/auth-info") - assert response.status_code == 401 - response_json = response.json() - expected_response = {"detail": "Authentication required: Provide either Bearer token or API key"} - diff = DeepDiff(expected_response, response_json) - assert not diff, diff # prints out the differences if any - - -@pytest.mark.asyncio -async def test_auth_info_default_token(): - async with get_client() as client: - response = await client.get("/test/auth-info", headers={"X-API-Key": DEFAULT_API_KEY}) - assert response.status_code == 200 - response_json = response.json() - expected_response = { - "authenticated_as": "service", - "service-name": "service:learner-data-export-service", - "auth_type": "API token", - } - diff = DeepDiff(expected_response, response_json) - assert not diff, diff # prints out the differences if any - + expected_response = {"status": "ok"} + assert response_json == expected_response -@pytest.mark.asyncio -async def test_auth_info_401(): - async with get_client() as client: - response = await client.get("/test/auth-info") - assert response.status_code == 401 - response_json = response.json() - expected_response = {"detail": "Authentication required: Provide either Bearer token or API key"} - diff = DeepDiff(expected_response, response_json) - assert not diff, diff # prints out the differences if any - - -@pytest.mark.asyncio -async def test_auth_info_default_token(): - async with get_client() as client: - response = await client.get("/test/auth-info", headers={"X-API-Key": DEFAULT_API_KEY}) - assert response.status_code == 200 - response_json = response.json() - expected_response = { - "authenticated_as": "service", - "service-name": "service:learner-data-export-service", - "auth_type": "API token", - } - diff = DeepDiff(expected_response, response_json) - assert not diff, diff # prints out the differences if any - -@pytest.mark.asyncio async def test_available_data_formats_401(): async with get_client() as client: response = await client.get("/available-data-formats") assert response.status_code == 401 response_json = response.json() expected_response = {"detail": "Authentication required: Provide either Bearer token or API key"} - diff = DeepDiff(expected_response, response_json) - assert not diff, diff # prints out the differences if any + assert response_json == expected_response -@pytest.mark.asyncio async def test_available_data_formats_default_token(): async with get_client() as client: response = await client.get("/available-data-formats", headers={"X-API-Key": DEFAULT_API_KEY}) @@ -92,24 +34,24 @@ async def test_available_data_formats_default_token(): response_json = response.json() expected_response = { "metadata": {"total": 3}, - "dataFormats": [ + "DataFormats": [ { "name": "OpenBadges 3.0", "version": "1.0.3", "contributorOrganization": "OB", - "transformationVersions": ["1.0.0", "1.1.0"], + "TransformationVersions": ["1.0.0", "1.1.0"], }, { "name": "CEDS", "version": "2.0.0", "contributorOrganization": "CEDS Org", - "transformationVersions": ["2.0.0"], + "TransformationVersions": ["2.0.0"], }, { "name": "ExampleDataSource", "version": "1.0.1", "contributorOrganization": "Community", - "transformationVersions": ["1.3.0"], + "TransformationVersions": ["1.3.0"], }, ], } @@ -117,21 +59,18 @@ async def test_available_data_formats_default_token(): assert not diff, diff # prints out the differences if any -@pytest.mark.asyncio async def test_export_401(): async with get_client() as client: - response = await client.get("/export") + response = await client.get("/exports") assert response.status_code == 401 response_json = response.json() expected_response = {"detail": "Authentication required: Provide either Bearer token or API key"} - diff = DeepDiff(expected_response, response_json) - assert not diff, diff # prints out the differences if any + assert response_json == expected_response -@pytest.mark.asyncio async def test_export_default_token(): async with get_client() as client: - response = await client.get("/export", headers={"X-API-Key": DEFAULT_API_KEY}) + response = await client.get("/exports", headers={"X-API-Key": DEFAULT_API_KEY}) assert response.status_code == 200 response_json = response.json() expected_response = {"total": "data"} From 5d3049017adb941176523d4d8e2736edf28c49ac Mon Sep 17 00:00:00 2001 From: Chris Beach Date: Fri, 29 May 2026 14:27:27 -0500 Subject: [PATCH 5/5] Issue #906: PR revision --- deployments/advisor-demo-docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployments/advisor-demo-docker/docker-compose.yml b/deployments/advisor-demo-docker/docker-compose.yml index 633dde83..989cb52f 100644 --- a/deployments/advisor-demo-docker/docker-compose.yml +++ b/deployments/advisor-demo-docker/docker-compose.yml @@ -324,7 +324,7 @@ services: 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-check} + 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"