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

✨ new clone_study in api-server #4663

Merged
merged 16 commits into from
Aug 28, 2023
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from asyncio import Task
from collections.abc import Awaitable, Callable, Coroutine
from datetime import datetime
from typing import Any, Awaitable, Callable, Coroutine
from typing import Any, TypeAlias

from models_library.api_schemas_long_running_tasks.base import (
ProgressMessage,
Expand All @@ -15,11 +16,13 @@
)
from pydantic import BaseModel, Field, PositiveFloat

TaskName = str
TaskName: TypeAlias = str

TaskType = Callable[..., Coroutine[Any, Any, Any]]
TaskType: TypeAlias = Callable[..., Coroutine[Any, Any, Any]]

ProgressCallback = Callable[[ProgressMessage, ProgressPercent, TaskId], Awaitable[None]]
ProgressCallback: TypeAlias = Callable[
[ProgressMessage, ProgressPercent, TaskId], Awaitable[None]
]


class TrackedTask(BaseModel):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ def add_task(
task: asyncio.Task,
task_progress: TaskProgress,
task_context: TaskContext,
*,
fire_and_forget: bool,
) -> TrackedTask:
task_id = self._create_task_id(task_name)
Expand Down
2 changes: 1 addition & 1 deletion services/api-server/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,7 @@
"required": true
},
"responses": {
"200": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
import logging

from fastapi import status
from fastapi.encoders import jsonable_encoder
from httpx import HTTPStatusError
from starlette.requests import Request
from starlette.responses import JSONResponse

log = logging.getLogger(__file__)
from .http_error import create_error_json_response

_logger = logging.getLogger(__file__)


async def httpx_client_error_handler(_: Request, exc: HTTPStatusError) -> JSONResponse:
Expand All @@ -24,27 +25,26 @@ async def httpx_client_error_handler(_: Request, exc: HTTPStatusError) -> JSONRe
The response had an error HTTP status of 4xx or 5xx, and this is how is
transformed in the api-server API
"""
if 400 <= exc.response.status_code < 500:
# Forward backend client errors
if exc.response.is_client_error:
assert exc.response.is_server_error # nosec
# Forward api-server's client from backend client errors
status_code = exc.response.status_code
errors = exc.response.json()["errors"]

else:
# Hide api-server client from backend server errors
assert exc.response.status_code >= 500 # nosec
assert exc.response.is_server_error # nosec
# Hide api-server's client from backend server errors
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
message = f"{exc.request.url.host.capitalize()} service unexpectedly failed"
log.exception(
errors = [
message,
]

_logger.exception(
"%s. host=%s status-code=%s msg=%s",
message,
exc.request.url.host,
exc.response.status_code,
exc.response.text,
)
errors = [
message,
]

return JSONResponse(
content=jsonable_encoder({"errors": errors}), status_code=status_code
)
return create_error_json_response(*errors, status_code=status_code)
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
)
from ...services.solver_job_outputs import ResultsTypes, get_solver_output_results
from ...services.storage import StorageApi, to_file_api_model
from ...services.webserver import ProjectNotFoundError
from ..dependencies.application import get_product_name, get_reverse_url_mapper
from ..dependencies.authentication import get_current_user_id
from ..dependencies.database import Engine, get_db_engine
Expand Down Expand Up @@ -161,6 +162,7 @@ async def get_jobs_page(
@router.post(
"/{solver_key:path}/releases/{version}/jobs",
response_model=Job,
status_code=status.HTTP_201_CREATED,
)
async def create_job(
solver_key: SolverKeyId,
Expand Down Expand Up @@ -252,12 +254,11 @@ async def delete_job(
try:
await webserver_api.delete_project(project_id=job_id)

except HTTPException as err:
if err.status_code == status.HTTP_404_NOT_FOUND:
return create_error_json_response(
f"Cannot find job={job_name} to delete",
status_code=status.HTTP_404_NOT_FOUND,
)
except ProjectNotFoundError:
return create_error_json_response(
f"Cannot find job={job_name} to delete",
status_code=status.HTTP_404_NOT_FOUND,
)


@router.post(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,25 @@ async def get_study(


@router.post(
"/{study_id:uuid}",
"/{study_id:uuid}:clone",
response_model=Study,
status_code=status.HTTP_201_CREATED,
responses={**_COMMON_ERROR_RESPONSES},
include_in_schema=API_SERVER_DEV_FEATURES_ENABLED,
)
async def clone_study(study_id: StudyID):
msg = f"cloning study with study_id={study_id!r}. SEE https://github.com/ITISFoundation/osparc-simcore/issues/4651"
raise NotImplementedError(msg)
async def clone_study(
study_id: StudyID,
pcrespov marked this conversation as resolved.
Show resolved Hide resolved
webserver_api: Annotated[AuthSession, Depends(get_webserver_session)],
):
try:
project: ProjectGet = await webserver_api.clone_project(project_id=study_id)
return _create_study_from_project(project)

except ProjectNotFoundError:
return create_error_json_response(
f"Cannot find study={study_id!r}.",
status_code=status.HTTP_404_NOT_FOUND,
)


@router.get(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ async def list_study_jobs(
@router.post(
"/{study_id:uuid}/jobs",
response_model=Job,
status_code=status.HTTP_201_CREATED,
include_in_schema=API_SERVER_DEV_FEATURES_ENABLED,
)
async def create_study_job(study_id: StudyID):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
ListAnyDict: TypeAlias = list[AnyDict]

# Represent the type returned by e.g. json.load
JSON: TypeAlias = AnyDict | ListAnyDict
AnyJson: TypeAlias = AnyDict | ListAnyDict
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,24 @@
from ..core.settings import WebServerSettings
from ..models.pagination import MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE
from ..models.schemas.jobs import MetaValueType
from ..models.types import JSON
from ..models.types import AnyJson
from ..utils.client_base import BaseServiceClientApi, setup_client_instance

_logger = logging.getLogger(__name__)


class ProjectNotFoundError(PydanticErrorMixin, ValueError):
class WebServerValueError(PydanticErrorMixin, ValueError):
...


class ProjectNotFoundError(WebServerValueError):
code = "webserver.project_not_found"
msg_template = "Project '{project_id}' not found"


@contextmanager
def _handle_webserver_api_errors():
# Transforms httpx.errors and ValidationError -> fastapi.HTTPException
try:
yield

Expand Down Expand Up @@ -120,10 +126,21 @@ def create(cls, app: FastAPI, session_cookies: dict) -> "AuthSession":
)

@classmethod
def _get_data_or_raise_http_exception(cls, resp: Response) -> JSON | None:
def _get_data_or_raise(
cls,
resp: Response,
client_status_code_to_exception_map: dict[int, WebServerValueError]
| None = None,
) -> AnyJson | None:
"""
Raises:
WebServerValueError: any client error converted to module error
HTTPException: the rest are pre-process and raised as http errors

"""
# enveloped answer
data: JSON | None = None
error: JSON | None = None
data: AnyJson | None = None
error: AnyJson | None = None

if resp.status_code != status.HTTP_204_NO_CONTENT:
try:
Expand All @@ -146,7 +163,17 @@ def _get_data_or_raise_http_exception(cls, resp: Response) -> JSON | None:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE)

if resp.is_client_error:
# NOTE: error is can be a dict
# Maps client status code to webserver local module error
if client_status_code_to_exception_map and (
exc := client_status_code_to_exception_map.get(resp.status_code)
):
raise exc

# Otherwise, go thru with some pre-processing to make
# message cleaner
if isinstance(error, dict):
error = error.get("message")

msg = error or resp.reason_phrase
raise HTTPException(resp.status_code, detail=msg)

Expand All @@ -158,25 +185,25 @@ def _get_data_or_raise_http_exception(cls, resp: Response) -> JSON | None:
def client(self):
return self._api.client

async def get(self, path: str) -> JSON | None:
async def get(self, path: str) -> AnyJson | None:
url = path.lstrip("/")
try:
resp = await self.client.get(url, cookies=self.session_cookies)
except Exception as err:
_logger.exception("Failed to get %s", url)
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE) from err

return self._get_data_or_raise_http_exception(resp)
return self._get_data_or_raise(resp)

async def put(self, path: str, body: dict) -> JSON | None:
async def put(self, path: str, body: dict) -> AnyJson | None:
url = path.lstrip("/")
try:
resp = await self.client.put(url, json=body, cookies=self.session_cookies)
except Exception as err:
_logger.exception("Failed to put %s", url)
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE) from err

return self._get_data_or_raise_http_exception(resp)
return self._get_data_or_raise(resp)

async def _page_projects(
self, *, limit: int, offset: int, show_hidden: bool, search: str | None = None
Expand Down Expand Up @@ -204,22 +231,11 @@ async def _page_projects(

return Page[ProjectGet].parse_raw(resp.text)

# PROJECTS resource ---
async def create_project(self, project: ProjectCreateNew) -> ProjectGet:
# POST /projects --> 202
resp = await self.client.post(
"/projects",
params={"hidden": True},
json=jsonable_encoder(project, by_alias=True, exclude={"state"}),
cookies=self.session_cookies,
)
data: JSON | None = self._get_data_or_raise_http_exception(resp)
assert data # nosec
assert isinstance(data, dict) # nosec

async def _wait_for_long_running_task_results(self, data):
# NOTE: /v0 is already included in the http client base_url
status_url = data["status_href"].lstrip(f"/{self.vtag}")
result_url = data["result_href"].lstrip(f"/{self.vtag}")

# GET task status now until done
async for attempt in AsyncRetrying(
wait=wait_fixed(0.5),
Expand All @@ -228,24 +244,54 @@ async def create_project(self, project: ProjectCreateNew) -> ProjectGet:
before_sleep=before_sleep_log(_logger, logging.INFO),
):
with attempt:
data = await self.get(status_url)
task_status = TaskStatus.parse_obj(data)
status_data = await self.get(status_url)
pcrespov marked this conversation as resolved.
Show resolved Hide resolved
task_status = TaskStatus.parse_obj(status_data)
if not task_status.done:
msg = "Timed out creating project. TIP: Try again, or contact oSparc support if this is happening repeatedly"
raise TryAgain(msg)

data = await self.get(f"{result_url}")
return ProjectGet.parse_obj(data)
return await self.get(f"{result_url}")

# PROJECTS -------------------------------------------------

async def create_project(self, project: ProjectCreateNew) -> ProjectGet:
# POST /projects --> 202 Accepted
response = await self.client.post(
"/projects",
params={"hidden": True},
json=jsonable_encoder(project, by_alias=True, exclude={"state"}),
cookies=self.session_cookies,
)
data = self._get_data_or_raise(response)
assert data is not None # nosec

result = await self._wait_for_long_running_task_results(data)
return ProjectGet.parse_obj(result)

async def clone_project(self, project_id: UUID) -> ProjectGet:
response = await self.client.post(
f"/projects/{project_id}:clone",
cookies=self.session_cookies,
)
data = self._get_data_or_raise(
response,
{status.HTTP_404_NOT_FOUND: ProjectNotFoundError(project_id=project_id)},
)
assert data is not None # nosec

result = await self._wait_for_long_running_task_results(data)
return ProjectGet.parse_obj(result)

async def get_project(self, project_id: UUID) -> ProjectGet:
response = await self.client.get(
f"/projects/{project_id}",
cookies=self.session_cookies,
)
if response.status_code == status.HTTP_404_NOT_FOUND:
raise ProjectNotFoundError(project_id=project_id)

data: JSON | None = self._get_data_or_raise_http_exception(response)
data = self._get_data_or_raise(
response,
{status.HTTP_404_NOT_FOUND: ProjectNotFoundError(project_id=project_id)},
)
return ProjectGet.parse_obj(data)

async def get_projects_w_solver_page(
Expand All @@ -268,10 +314,14 @@ async def get_projects_page(self, limit: int, offset: int):
)

async def delete_project(self, project_id: ProjectID) -> None:
resp = await self.client.delete(
response = await self.client.delete(
f"/projects/{project_id}", cookies=self.session_cookies
)
self._get_data_or_raise_http_exception(resp)
data = self._get_data_or_raise(
response,
{status.HTTP_404_NOT_FOUND: ProjectNotFoundError(project_id=project_id)},
)
assert data is None # nosec

async def get_project_metadata_ports(
self, project_id: ProjectID
Expand All @@ -284,10 +334,11 @@ async def get_project_metadata_ports(
f"/projects/{project_id}/metadata/ports",
cookies=self.session_cookies,
)
if response.status_code == status.HTTP_404_NOT_FOUND:
raise ProjectNotFoundError(project_id=project_id)

data = self._get_data_or_raise_http_exception(response)
data = self._get_data_or_raise(
response,
{status.HTTP_404_NOT_FOUND: ProjectNotFoundError(project_id=project_id)},
)
assert data is not None
assert isinstance(data, list)
return data
Expand Down
Loading