From 10bd3f3e1b30a9dd2b7f25be0b83f4c44ff98f38 Mon Sep 17 00:00:00 2001 From: Christine Kim <125395064+chkim-usgs@users.noreply.github.com> Date: Mon, 29 Apr 2024 10:55:07 -0400 Subject: [PATCH] FastAPI app (#27) * Added fastapi app files * Refactor for less complicated model structure * Added redirect from root endpoint to docs endpoint * Add api deps to main env.yml * Hide root endpt from schema & set searchKernels to always true * Remove spiceql dep * Update readme * Update getTargetOrientations and getTargetStates endpoints --- environment.yml | 5 + fastapi/README.md | 26 +++++ fastapi/app/__init__.py | 0 fastapi/app/main.py | 239 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+) create mode 100644 fastapi/README.md create mode 100644 fastapi/app/__init__.py create mode 100644 fastapi/app/main.py diff --git a/environment.yml b/environment.yml index 5a8b7d2..c1f5b48 100644 --- a/environment.yml +++ b/environment.yml @@ -19,6 +19,11 @@ dependencies: - jsonschema - cereal - spdlog + # API dependencies + - fastapi + - pydantic==2.6.3 + - uvicorn + - numpy - pip: - mkdocs - mkdocs-swagger-ui-tag diff --git a/fastapi/README.md b/fastapi/README.md new file mode 100644 index 0000000..c74a1a0 --- /dev/null +++ b/fastapi/README.md @@ -0,0 +1,26 @@ +# SpiceQL FastAPI App + +## Create local instance + +### 1. Create conda environment +Create the conda environment to run your local instance in: +``` +conda env update -n spiceql-api -f environment.yaml +``` + +### 2. Set environment variables +Similarly to your SpiceQL conda environment, set `SPICEROOT` or `ISISDATA` to your ISIS data area. You may also need to set `SSPICE_DEBUG` to any value, like `True`. + +To set an environment variable within the scope of your conda environment: +``` +conda activate spiceql-api +conda env config vars set SPICEROOT=/path/to/isis_data +``` + +### 3. Run the app +Within the `fastapi/` dir but outside the `app/` dir, run the following command: +``` +uvicorn app.main:app --reload --port 8080 +``` + +You can access the Swagger UI of all the endpoints at http://127.0.0.1:8080/docs. diff --git a/fastapi/app/__init__.py b/fastapi/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastapi/app/main.py b/fastapi/app/main.py new file mode 100644 index 0000000..95fa31f --- /dev/null +++ b/fastapi/app/main.py @@ -0,0 +1,239 @@ +"""Module providing SpiceQL endpoints""" + +from ast import literal_eval +from typing import Annotated, Any +from fastapi import FastAPI, Query +from pydantic import BaseModel, Field +from starlette.responses import RedirectResponse +import numpy as np +import pyspiceql + +SEARCH_KERNELS_BOOL = True + +# Models +class MessageItem(BaseModel): + message: str + +class ResultModel(BaseModel): + result: Any = Field(serialization_alias='return') + +class ErrorModel(BaseModel): + error: str + +class ResponseModel(BaseModel): + statusCode: int + body: ResultModel | ErrorModel + +# Create FastAPI instance +app = FastAPI() + +# General endpoints +@app.get("/", include_in_schema=False) +async def root(): + return RedirectResponse(url="/docs") + +@app.post("/customMessage") +async def message( + message_item: MessageItem + ): + return {"message": message_item.message} + + +# SpiceQL endpoints +@app.get("/getTargetStates") +async def getTargetStates( + target: str, + observer: str, + frame: str, + abcorr: str, + mission: str, + ets: Annotated[list[float], Query()] | str | None = None, + startEts: float | None = None, + exposureDuration: float | None = None, + numOfExposures: int | None = None, + ckQuality: str = "", + spkQuality: str = ""): + try: + if ets is not None: + if isinstance(ets, str): + ets = literal_eval(ets) + else: + if all(v is not None for v in [startEts, exposureDuration, numOfExposures]): + stopEts = (exposureDuration * numOfExposures) + startEts + etsNpArray = np.arange(startEts, stopEts, exposureDuration) + ets = list(etsNpArray) + else: + raise Exception("Verify that a startEts, exposureDuration, and numOfExposures are being passed correctly.") + result = pyspiceql.getTargetStates(ets, target, observer, frame, abcorr, mission, ckQuality, spkQuality, SEARCH_KERNELS_BOOL) + body = ResultModel(result=result) + return ResponseModel(statusCode=200, body=body) + except Exception as e: + body = ErrorModel(error=str(e)) + return ResponseModel(statusCode=500, body=body) + +@app.get("/getTargetOrientations") +async def getTargetOrientations( + toFrame: int, + refFrame: int, + mission: str, + ets: Annotated[list[float], Query()] | str | None = None, + startEts: float | None = None, + exposureDuration: float | None = None, + numOfExposures: int | None = None, + ckQuality: str = ""): + try: + if ets is not None: + if isinstance(ets, str): + ets = literal_eval(ets) + else: + if all(v is not None for v in [startEts, exposureDuration, numOfExposures]): + stopEts = (exposureDuration * numOfExposures) + startEts + etsNpArray = np.arange(startEts, stopEts, exposureDuration) + ets = list(etsNpArray) + else: + raise Exception("Verify that a startEts, exposureDuration, and numOfExposures are being passed correctly.") + result = pyspiceql.getTargetOrientations(ets, toFrame, refFrame, mission, ckQuality, SEARCH_KERNELS_BOOL) + body = ResultModel(result=result) + return ResponseModel(statusCode=200, body=body) + except Exception as e: + body = ErrorModel(error=str(e)) + return ResponseModel(statusCode=500, body=body) + +@app.get("/strSclkToEt") +async def strSclkToEt( + frameCode: int, + sclk: str, + mission: str): + try: + result = pyspiceql.strSclkToEt(frameCode, sclk, mission, SEARCH_KERNELS_BOOL) + body = ResultModel(result=result) + return ResponseModel(statusCode=200, body=body) + except Exception as e: + body = ErrorModel(error=str(e)) + return ResponseModel(statusCode=500, body=body) + +@app.get("/doubleSclkToEt") +async def doubleSclkToEt( + frameCode: int, + sclk: float, + mission: str): + try: + result = pyspiceql.doubleSclkToEt(frameCode, sclk, mission, SEARCH_KERNELS_BOOL) + body = ResultModel(result=result) + return ResponseModel(statusCode=200, body=body) + except Exception as e: + body = ErrorModel(error=str(e)) + return ResponseModel(statusCode=500, body=body) + +@app.get("/utcToEt") +async def utcToEt( + utc: str): + try: + result = pyspiceql.utcToEt(utc, SEARCH_KERNELS_BOOL) + body = ResultModel(result=result) + return ResponseModel(statusCode=200, body=body) + except Exception as e: + body = ErrorModel(error=str(e)) + return ResponseModel(statusCode=500, body=body) + +@app.get("/translateNameToCode") +async def translateNameToCode( + frame: str, + mission: str): + try: + result = pyspiceql.translateNameToCode(frame, mission, SEARCH_KERNELS_BOOL) + body = ResultModel(result=result) + return ResponseModel(statusCode=200, body=body) + except Exception as e: + body = ErrorModel(error=str(e)) + return ResponseModel(statusCode=500, body=body) + +@app.get("/translateCodeToName") +async def translateCodeToName( + frame: int, + mission: str): + try: + result = pyspiceql.translateCodeToName(frame, mission, SEARCH_KERNELS_BOOL) + body = ResultModel(result=result) + return ResponseModel(statusCode=200, body=body) + except Exception as e: + body = ErrorModel(error=str(e)) + return ResponseModel(statusCode=500, body=body) + +@app.get("/getFrameInfo") +async def getFrameInfo( + frame: int, + mission: str): + try: + result = pyspiceql.getFrameInfo(frame, mission, SEARCH_KERNELS_BOOL) + body = ResultModel(result=result) + return ResponseModel(statusCode=200, body=body) + except Exception as e: + body = ErrorModel(error=str(e)) + return ResponseModel(statusCode=500, body=body) + +@app.get("/getTargetFrameInfo") +async def getTargetFrameInfo( + targetId: int, + mission: str): + try: + result = pyspiceql.getTargetFrameInfo(targetId, mission, SEARCH_KERNELS_BOOL) + body = ResultModel(result=result) + return ResponseModel(statusCode=200, body=body) + except Exception as e: + body = ErrorModel(error=str(e)) + return ResponseModel(statusCode=500, body=body) + +@app.get("/findMissionKeywords") +async def findMissionKeywords( + key: str, + mission: str): + try: + result = pyspiceql.findMissionKeywords(key, mission, SEARCH_KERNELS_BOOL) + body = ResultModel(result=result) + return ResponseModel(statusCode=200, body=body) + except Exception as e: + body = ErrorModel(error=str(e)) + return ResponseModel(statusCode=500, body=body) + +@app.get("/findTargetKeywords") +async def findTargetKeywords( + key: str, + mission: str): + try: + result = pyspiceql.findTargetKeywords(key, mission, SEARCH_KERNELS_BOOL) + body = ResultModel(result=result) + return ResponseModel(statusCode=200, body=body) + except Exception as e: + body = ErrorModel(error=str(e)) + return ResponseModel(statusCode=500, body=body) + +@app.get("/frameTrace") +async def frameTrace( + et: float, + initialFrame: int, + mission: str, + ckQuality: str = ""): + try: + result = pyspiceql.frameTrace(et, initialFrame, mission, ckQuality, SEARCH_KERNELS_BOOL) + body = ResultModel(result=result) + return ResponseModel(statusCode=200, body=body) + except Exception as e: + body = ErrorModel(error=str(e)) + return ResponseModel(statusCode=500, body=body) + +@app.get("/extractExactCkTimes") +async def extractExactCkTimes( + observStart: float, + observEnd: float, + targetFrame: int, + mission: str, + ckQuality: str = ""): + try: + result = pyspiceql.extractExactCkTimes(observStart, observEnd, targetFrame, mission, ckQuality, SEARCH_KERNELS_BOOL) + body = ResultModel(result=result) + return ResponseModel(statusCode=200, body=body) + except Exception as e: + body = ErrorModel(error=str(e)) + return ResponseModel(statusCode=500, body=body) + \ No newline at end of file