Skip to content

Commit 047dd2f

Browse files
authored
Merge pull request #12 from globusonline/sc-46817-willdelete-print-command
Add willdelete print CLI command for OpenAPI target extraction
2 parents 25a0cd2 + 1f68764 commit 047dd2f

24 files changed

+1859
-0
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
2+
Development
3+
-----------
4+
5+
* Add ``willdelete print`` CLI command for OpenAPI target extraction.
6+
This temporary development command extracts a targeted endpoint from
7+
an OpenAPI spec and outputs it in the format expected by
8+
``POST /registered_api``.

justfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ help:
55

66
# Install a development virtual environment (at `./.venv`).
77
install:
8+
#!/usr/bin/env bash
89
if [ ! -d .venv ]; then
910
python -m venv .venv
1011
fi

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ classifiers = [
2020
dependencies = [
2121
"click >=8,<9",
2222
"globus-sdk >=4",
23+
"openapi-pydantic >=0.5.1,<0.6.0",
24+
"pyyaml >=6.0",
2325
]
2426

2527
[project.urls]

requirements/mypy/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ name = "dependencies"
66
requires-python = ">=3.10"
77
dependencies = [
88
"mypy",
9+
"types-PyYAML",
910
]

requirements/mypy/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ mypy-extensions==1.1.0 ; python_version >= "3.10"
22
mypy==1.18.2 ; python_version >= "3.10"
33
pathspec==0.12.1 ; python_version >= "3.10"
44
tomli==2.3.0 ; python_version == "3.10"
5+
types-pyyaml==6.0.12.20250402 ; python_version >= "3.10"
56
typing-extensions==4.15.0 ; python_version >= "3.10"

src/globus_registered_api/cli.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
from globus_sdk import UserApp
2121

2222
from globus_registered_api.extended_flows_client import ExtendedFlowsClient
23+
from globus_registered_api.openapi import AmbiguousContentTypeError
24+
from globus_registered_api.openapi import OpenAPILoadError
25+
from globus_registered_api.openapi import TargetNotFoundError
26+
from globus_registered_api.openapi import TargetSpecifier
27+
from globus_registered_api.openapi import process_target
2328

2429
# Constants
2530
RAPI_NATIVE_CLIENT_ID = "9dc7dfff-cfe8-4339-927b-28d29e1b2f42"
@@ -202,3 +207,43 @@ def list_registered_apis(
202207
click.echo("-------------------------------------|-----")
203208
first = False
204209
click.echo(f"{api['id']} | {api['name']}")
210+
211+
212+
# --- willdelete command group ---
213+
214+
215+
@cli.group()
216+
def willdelete() -> None:
217+
"""Temporary commands for OpenAPI processing development."""
218+
219+
220+
@willdelete.command("print")
221+
@click.argument("openapi_spec", type=click.Path(exists=False))
222+
@click.argument("route")
223+
@click.argument("method")
224+
@click.option(
225+
"--content-type",
226+
default="*",
227+
help="Content-type for request body (required if multiple exist)",
228+
)
229+
def willdelete_print(
230+
openapi_spec: str, route: str, method: str, content_type: str
231+
) -> None:
232+
"""
233+
Print a reduced OpenAPI spec for a target endpoint.
234+
235+
OPENAPI_SPEC is the path to an OpenAPI specification (JSON or YAML).
236+
ROUTE is the path to match (e.g., /items or /items/{id}).
237+
METHOD is the HTTP method (e.g., get, post, put, delete).
238+
"""
239+
try:
240+
target = TargetSpecifier.create(method, route, content_type)
241+
except ValueError as e:
242+
raise click.ClickException(str(e))
243+
244+
try:
245+
result = process_target(openapi_spec, target)
246+
except (OpenAPILoadError, TargetNotFoundError, AmbiguousContentTypeError) as e:
247+
raise click.ClickException(str(e))
248+
249+
click.echo(json.dumps(result.to_dict(), indent=2))
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# This file is a part of globus-registered-api.
2+
# https://github.com/globusonline/globus-registered-api
3+
# Copyright 2025 Globus <support@globus.org>
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
from globus_registered_api.openapi.loader import OpenAPILoadError
7+
from globus_registered_api.openapi.processor import process_target
8+
from globus_registered_api.openapi.processor import ProcessingResult
9+
from globus_registered_api.openapi.selector import AmbiguousContentTypeError
10+
from globus_registered_api.openapi.selector import TargetNotFoundError
11+
from globus_registered_api.openapi.selector import TargetSpecifier
12+
13+
__all__ = [
14+
"OpenAPILoadError",
15+
"process_target",
16+
"ProcessingResult",
17+
"AmbiguousContentTypeError",
18+
"TargetNotFoundError",
19+
"TargetSpecifier",
20+
]
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# This file is a part of globus-registered-api.
2+
# https://github.com/globusonline/globus-registered-api
3+
# Copyright 2025 Globus <support@globus.org>
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
from __future__ import annotations
7+
8+
import json
9+
from pathlib import Path
10+
11+
import openapi_pydantic as oa
12+
import yaml
13+
from pydantic import ValidationError
14+
15+
16+
class OpenAPILoadError(Exception):
17+
"""Raised when an OpenAPI spec cannot be loaded or parsed."""
18+
19+
20+
def load_openapi_spec(path: str | Path) -> oa.OpenAPI:
21+
"""
22+
Load and parse an OpenAPI specification from a file.
23+
24+
Supports both JSON and YAML formats. The file format is determined
25+
by the file extension (.json, .yaml, or .yml).
26+
27+
:param path: Path to the OpenAPI specification file
28+
:return: Parsed OpenAPI specification object
29+
:raises OpenAPILoadError: If the file cannot be found, parsed, or validated
30+
"""
31+
path = Path(path)
32+
33+
if not path.exists():
34+
raise OpenAPILoadError(f"File not found: {path}")
35+
36+
try:
37+
content = path.read_text()
38+
except OSError as e:
39+
raise OpenAPILoadError(f"Failed to read file: {path} - {e}") from e
40+
41+
suffix = path.suffix.lower()
42+
try:
43+
if suffix == ".json":
44+
data = json.loads(content)
45+
elif suffix in (".yaml", ".yml"):
46+
data = yaml.safe_load(content)
47+
else:
48+
# Try JSON first, then YAML
49+
try:
50+
data = json.loads(content)
51+
except json.JSONDecodeError:
52+
data = yaml.safe_load(content)
53+
except json.JSONDecodeError as e:
54+
raise OpenAPILoadError(f"Failed to parse JSON: {e}") from e
55+
except yaml.YAMLError as e:
56+
raise OpenAPILoadError(f"Failed to parse YAML: {e}") from e
57+
58+
try:
59+
return oa.OpenAPI.model_validate(data)
60+
except ValidationError as e:
61+
raise OpenAPILoadError(f"Invalid OpenAPI specification: {e}") from e
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# This file is a part of globus-registered-api.
2+
# https://github.com/globusonline/globus-registered-api
3+
# Copyright 2025 Globus <support@globus.org>
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
"""
7+
Facade for OpenAPI target processing.
8+
9+
This module provides a simplified interface for OpenAPI processing
10+
by coordinating loader, selector, and reducer components.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from dataclasses import dataclass
16+
from dataclasses import field
17+
from pathlib import Path
18+
from typing import Any
19+
20+
import openapi_pydantic as oa
21+
22+
from globus_registered_api.openapi.loader import load_openapi_spec
23+
from globus_registered_api.openapi.reducer import OpenAPITarget
24+
from globus_registered_api.openapi.reducer import reduce_to_target
25+
from globus_registered_api.openapi.selector import TargetSpecifier
26+
from globus_registered_api.openapi.selector import find_target
27+
28+
29+
@dataclass
30+
class ProcessingResult:
31+
"""Result of target processing."""
32+
33+
target: OpenAPITarget
34+
warnings: list[str] = field(default_factory=list)
35+
36+
def to_dict(self) -> dict[str, Any]:
37+
"""Convert to the format expected by POST /registered_api."""
38+
return self.target.to_dict()
39+
40+
41+
def process_target(
42+
spec_or_path: oa.OpenAPI | str | Path,
43+
target: TargetSpecifier,
44+
) -> ProcessingResult:
45+
"""
46+
Process an OpenAPI spec to extract a target endpoint.
47+
48+
This is the primary entry point for target extraction. It coordinates
49+
loading, selection, and reduction into a single operation.
50+
51+
For fine-grained control, use the underlying modules directly:
52+
- `loader.load_openapi_spec()` - Parse OpenAPI files
53+
- `selector.find_target()` - Locate targets in a spec
54+
- `reducer.reduce_to_target()` - Extract target with dependencies
55+
56+
:param spec_or_path: OpenAPI spec object or path to spec file
57+
:param target: Target specifier (method, path, optional content-type)
58+
:return: ProcessingResult containing the reduced spec
59+
:raises OpenAPILoadError: If spec file cannot be loaded
60+
:raises TargetNotFoundError: If route or method not found
61+
:raises AmbiguousContentTypeError: If content-type selection is ambiguous
62+
"""
63+
# Load if given a path
64+
if isinstance(spec_or_path, (str, Path)):
65+
spec = load_openapi_spec(spec_or_path)
66+
else:
67+
spec = spec_or_path
68+
69+
# Find and reduce
70+
target_info = find_target(spec, target)
71+
openapi_target = reduce_to_target(spec, target_info)
72+
73+
return ProcessingResult(target=openapi_target)

0 commit comments

Comments
 (0)