Skip to content

Commit b665233

Browse files
committed
CM-69684 add api proxy endpoint
1 parent 349fe1d commit b665233

6 files changed

Lines changed: 595 additions & 7 deletions

File tree

README.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ This guide walks you through both installation and usage.
2424
5. [Advanced Configuration](#advanced-configuration)
2525
5. [Platform Command](#platform-command-beta)
2626
1. [Discovering Commands](#discovering-commands)
27-
2. [Examples](#platform-examples)
28-
3. [Notes & Limitations](#platform-notes--limitations)
27+
2. [Raw API Requests](#raw-api-requests)
28+
3. [Examples](#platform-examples)
29+
4. [Notes & Limitations](#platform-notes--limitations)
2930
6. [Scan Command](#scan-command)
3031
1. [Running a Scan](#running-a-scan)
3132
1. [Options](#options)
@@ -672,6 +673,35 @@ cycode platform projects --help # list actions on a resource
672673
cycode platform projects list --help # list options/arguments for an action
673674
```
674675

676+
## Raw API Requests
677+
678+
`cycode platform api` sends a raw authenticated request to any Cycode REST endpoint — including endpoints the spec does not expose as a generated command, and writes (`post`, `put`). It is the escape hatch for scripts: you supply the method, path, and body, and the CLI handles authentication.
679+
680+
```bash
681+
cycode platform api <get|post|put> <PATH> [-q KEY=VALUE]... [-H "KEY: VALUE"]... [-d JSON]
682+
```
683+
684+
Credentials come from the CLI itself — `cycode auth`, the `CYCODE_CLIENT_ID` / `CYCODE_CLIENT_SECRET` (or `CYCODE_ID_TOKEN`) environment variables, or the global `--client-id` / `--client-secret` / `--id-token` options. The CLI mints and refreshes the access token for you, so scripts never handle tokens — your credentials and access token are never printed. Note that `-v` / `--verbose` logs full response bodies to stderr, so avoid it in shared CI logs when the endpoint returns sensitive data.
685+
686+
```bash
687+
# GET with query parameters (repeat a key to send multiple values)
688+
cycode platform api get v4/projects -q page-size=5
689+
cycode platform api get v4/violations -q severity=High -q severity=Critical
690+
691+
# POST a JSON body — inline, from a file, or from stdin
692+
cycode platform api post v4/some/resource -d '{"name": "example"}'
693+
cycode platform api post v4/some/resource -d @body.json
694+
cat body.json | cycode platform api put v4/some/resource -d -
695+
696+
# Pipe through jq like any other platform command
697+
cycode platform api get v4/projects -q page-size=100 | jq '.items[].name'
698+
```
699+
700+
The response body is printed as formatted JSON (non-JSON bodies are printed as-is). On an HTTP error the status and response body go to stderr and the command exits non-zero, so `set -e` scripts fail as expected. `PATH` must be a v4 API path such as `v4/projects` (or `api/v4/...`), not a full URL — use `cycode configure` to point the CLI at a different Cycode installation. The `Authorization` header is owned by the CLI and cannot be overridden. Unlike the generated commands, `cycode platform api` never fetches the OpenAPI spec.
701+
702+
> [!NOTE]
703+
> `-v` / `--verbose` logs the outgoing request and the response, which is the quickest way to debug an unexpected status code.
704+
675705
## Platform Examples
676706

677707
```bash
@@ -696,7 +726,7 @@ cycode platform projects list --page-size 100 | jq '.items[].name'
696726

697727
## Platform Notes & Limitations
698728

699-
- **Read-only today.** Only `GET` endpoints are exposed in this beta.
729+
- **Generated commands are read-only.** Only `GET` endpoints are turned into commands in this beta. For writes, use [`cycode platform api`](#raw-api-requests) with `post` or `put`.
700730
- **Spec-driven.** Adding a new endpoint to the API surfaces it automatically the next time the cache is refreshed.
701731
- **No bundled spec.** The first `cycode platform` invocation after install (or after the 24h cache expires) performs a network fetch. On slow connections this first call may take a few seconds; subsequent calls are near-instant until the cache expires.
702732
- **Override the cache TTL** with `CYCODE_SPEC_CACHE_TTL=<seconds>`.

cycode/cli/apps/api/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,19 @@ def list_commands(self, ctx: click.Context) -> list[str]:
6060
return super().list_commands(ctx)
6161

6262
def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]:
63+
# Statically registered commands (like `api`) must not trigger a spec fetch.
64+
if cmd_name in self.commands:
65+
return super().get_command(ctx, cmd_name)
66+
6367
self._ensure_loaded(ctx)
6468
return super().get_command(ctx, cmd_name)
6569

6670

6771
def get_platform_group() -> click.Group:
6872
"""Return the top-level `platform` Click group (lazy-loading)."""
69-
return PlatformGroup(name='platform', help=_PLATFORM_HELP, no_args_is_help=True)
73+
from cycode.cli.apps.api.raw_api_command import build_raw_api_command
74+
75+
group = PlatformGroup(name='platform', help=_PLATFORM_HELP, no_args_is_help=True)
76+
# The raw request escape hatch is registered statically: it needs no OpenAPI spec.
77+
group.add_command(build_raw_api_command(), 'api')
78+
return group
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""Raw REST passthrough: `cycode platform api <METHOD> <PATH>`.
2+
3+
Lets scripts call any Cycode REST endpoint without handling credentials themselves.
4+
The CLI resolves credentials (client id/secret or OIDC, from flags, environment
5+
variables, or `cycode auth`), mints and refreshes the access token, and prints the
6+
response body. Tokens and secrets are never printed.
7+
"""
8+
9+
import json
10+
import re
11+
import sys
12+
from typing import TYPE_CHECKING, Any, Optional, Union
13+
14+
import click
15+
16+
from cycode.logger import get_logger
17+
18+
if TYPE_CHECKING:
19+
from requests import Response
20+
21+
logger = get_logger('Raw API Command')
22+
23+
_SUPPORTED_METHODS = ('get', 'post', 'put')
24+
_METHODS_WITH_BODY = ('post', 'put')
25+
# Allowlist: a versioned API path such as `v4/projects` or `api/v4/auth/api-token`.
26+
# Anything else (full URLs, protocol-relative paths, paths with whitespace) is rejected.
27+
_API_PATH_RE = re.compile(r'^(api/)?v4/\S+$')
28+
29+
_HELP = """[BETA] Send a raw authenticated request to the Cycode API.
30+
31+
METHOD is one of get, post, put. PATH is an API path such as `v4/projects`
32+
(a leading slash is optional).
33+
34+
Credentials come from the CLI: `cycode auth`, the CYCODE_CLIENT_ID/CYCODE_CLIENT_SECRET
35+
(or CYCODE_ID_TOKEN) environment variables, or the global `--client-id`/`--client-secret`/
36+
`--id-token` options. Your credentials and access token are never printed.
37+
38+
\b
39+
Examples:
40+
cycode platform api get v4/projects -q page-size=5
41+
cycode platform api get v4/violations -q severity=High -q severity=Critical
42+
cycode platform api post v4/sbom/import -d @body.json
43+
cat body.json | cycode platform api put v4/some/resource -d -
44+
"""
45+
46+
47+
def _validate_path(path: str) -> str:
48+
"""Validate that PATH is a versioned API path, so credentials only reach the configured Cycode host."""
49+
url_path = path.lstrip('/')
50+
if not _API_PATH_RE.match(url_path):
51+
raise click.ClickException(f'PATH must be a versioned API path such as `v4/projects`, not `{path}`.')
52+
53+
return url_path
54+
55+
56+
def _parse_query(query: tuple[str, ...]) -> dict[str, Union[str, list[str]]]:
57+
"""Parse repeatable `key=value` pairs. Repeated keys collapse into a list."""
58+
params: dict[str, Union[str, list[str]]] = {}
59+
for item in query:
60+
key, sep, value = item.partition('=')
61+
if not sep or not key:
62+
raise click.ClickException(f'Invalid query parameter "{item}". Expected format: key=value')
63+
64+
if key in params:
65+
existing = params[key]
66+
if isinstance(existing, list):
67+
existing.append(value)
68+
else:
69+
params[key] = [existing, value]
70+
else:
71+
params[key] = value
72+
73+
return params
74+
75+
76+
def _parse_headers(header: tuple[str, ...]) -> dict[str, str]:
77+
"""Parse repeatable `Key: Value` pairs."""
78+
headers: dict[str, str] = {}
79+
for item in header:
80+
key, sep, value = item.partition(':')
81+
key = key.strip()
82+
if not sep or not key:
83+
raise click.ClickException(f'Invalid header "{item}". Expected format: "Key: Value"')
84+
85+
if key.lower() == 'authorization':
86+
raise click.ClickException('The Authorization header is managed by the CLI and cannot be overridden.')
87+
88+
headers[key] = value.strip()
89+
90+
return headers
91+
92+
93+
def _read_body(data: str) -> Any:
94+
"""Read the request body from an inline JSON string, `@file`, or `-` (stdin)."""
95+
if data == '-':
96+
raw = sys.stdin.read()
97+
source = 'stdin'
98+
elif data.startswith('@'):
99+
file_path = data[1:]
100+
try:
101+
with open(file_path, encoding='utf-8') as f:
102+
raw = f.read()
103+
except OSError as e:
104+
raise click.ClickException(f'Could not read request body file "{file_path}": {e}') from e
105+
source = file_path
106+
else:
107+
raw = data
108+
source = 'the --data value'
109+
110+
try:
111+
return json.loads(raw)
112+
except json.JSONDecodeError as e:
113+
raise click.ClickException(f'Could not parse JSON from {source}: {e}') from e
114+
115+
116+
def _echo_response_body(response: 'Response') -> None:
117+
try:
118+
click.echo(json.dumps(response.json(), indent=2))
119+
except ValueError:
120+
# Not a JSON body (empty response, plain text, file download, etc.)
121+
click.echo(response.text)
122+
123+
124+
def _callback(
125+
method: str,
126+
path: str,
127+
query: tuple[str, ...],
128+
header: tuple[str, ...],
129+
data: Optional[str],
130+
timeout: Optional[int],
131+
) -> None:
132+
from cycode.cli.exceptions.custom_exceptions import RequestHttpError
133+
from cycode.cli.utils.get_api_client import get_raw_api_client
134+
135+
method = method.lower()
136+
url_path = _validate_path(path)
137+
params = _parse_query(query)
138+
headers = _parse_headers(header)
139+
140+
body = None
141+
if data is not None:
142+
if method not in _METHODS_WITH_BODY:
143+
raise click.ClickException(f'--data is not supported for the {method} method.')
144+
body = _read_body(data)
145+
146+
ctx = click.get_current_context()
147+
client = get_raw_api_client(ctx.find_root())
148+
149+
kwargs: dict[str, Any] = {'headers': headers or None, 'params': params or None}
150+
if timeout is not None:
151+
kwargs['timeout'] = timeout
152+
153+
logger.debug('Sending raw API request, %s', {'method': method, 'path': url_path})
154+
155+
try:
156+
if method == 'get':
157+
response = client.get(url_path, **kwargs)
158+
elif method == 'post':
159+
response = client.post(url_path, body=body, **kwargs)
160+
else:
161+
response = client.put(url_path, body=body, **kwargs)
162+
except RequestHttpError as e:
163+
click.echo(f'HTTP {e.status_code}: {e.error_message}', err=True)
164+
raise click.exceptions.Exit(1) from e
165+
except Exception as e:
166+
click.echo(f'Error: {e}', err=True)
167+
raise click.exceptions.Exit(1) from e
168+
169+
_echo_response_body(response)
170+
171+
172+
def build_raw_api_command() -> click.Command:
173+
"""Build the `cycode platform api` raw request command."""
174+
return click.Command(
175+
name='api',
176+
callback=_callback,
177+
help=_HELP,
178+
short_help='[BETA] Send a raw authenticated request to the Cycode API.',
179+
params=[
180+
click.Argument(['method'], type=click.Choice(_SUPPORTED_METHODS, case_sensitive=False), required=True),
181+
click.Argument(['path'], type=click.STRING, required=True),
182+
click.Option(
183+
['-q', '--query'],
184+
multiple=True,
185+
metavar='KEY=VALUE',
186+
help='Query parameter. Repeatable; repeating a key sends multiple values.',
187+
),
188+
click.Option(
189+
['-H', '--header'],
190+
multiple=True,
191+
metavar='"KEY: VALUE"',
192+
help='Additional request header. Repeatable. Authorization is managed by the CLI.',
193+
),
194+
click.Option(
195+
['-d', '--data'],
196+
metavar='JSON',
197+
help='JSON request body for post/put. Use @file to read a file, or - to read stdin.',
198+
),
199+
click.Option(
200+
['--timeout'],
201+
type=click.INT,
202+
help='Request timeout in seconds. Defaults to the CLI request timeout.',
203+
),
204+
],
205+
)

cycode/cli/utils/get_api_client.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
from typing import TYPE_CHECKING, Optional, Union
1+
from typing import TYPE_CHECKING, Callable, Optional, TypeVar, Union
22

33
import click
44

55
from cycode.cli.user_settings.credentials_manager import CredentialsManager
66
from cycode.cyclient.client_creator import (
77
create_ai_security_manager_client,
88
create_import_sbom_client,
9+
create_raw_api_client,
910
create_report_client,
1011
create_scan_client,
1112
)
@@ -14,18 +15,21 @@
1415
import typer
1516

1617
from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient
18+
from cycode.cyclient.cycode_client_base import CycodeClientBase
1719
from cycode.cyclient.import_sbom_client import ImportSbomClient
1820
from cycode.cyclient.report_client import ReportClient
1921
from cycode.cyclient.scan_client import ScanClient
2022

23+
_ClientT = TypeVar('_ClientT')
24+
2125

2226
def _get_cycode_client(
23-
create_client_func: callable,
27+
create_client_func: Callable[..., _ClientT],
2428
client_id: Optional[str],
2529
client_secret: Optional[str],
2630
hide_response_log: bool,
2731
id_token: Optional[str] = None,
28-
) -> Union['ScanClient', 'ReportClient', 'ImportSbomClient', 'AISecurityManagerClient']:
32+
) -> _ClientT:
2933
if client_id and id_token:
3034
return create_client_func(client_id, None, hide_response_log, id_token)
3135

@@ -75,6 +79,14 @@ def get_ai_security_manager_client(ctx: 'typer.Context', hide_response_log: bool
7579
return _get_cycode_client(create_ai_security_manager_client, client_id, client_secret, hide_response_log, id_token)
7680

7781

82+
def get_raw_api_client(ctx: Union['typer.Context', click.Context]) -> 'CycodeClientBase':
83+
client_id = ctx.obj.get('client_id') if ctx.obj else None
84+
client_secret = ctx.obj.get('client_secret') if ctx.obj else None
85+
id_token = ctx.obj.get('id_token') if ctx.obj else None
86+
# hide_response_log is unused by create_raw_api_client: raw responses are the command's output
87+
return _get_cycode_client(create_raw_api_client, client_id, client_secret, True, id_token)
88+
89+
7890
def _get_configured_credentials() -> tuple[str, str]:
7991
credentials_manager = CredentialsManager()
8092
return credentials_manager.get_credentials()

cycode/cyclient/client_creator.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
)
88
from cycode.cyclient.config import dev_mode
99
from cycode.cyclient.config_dev import DEV_CYCODE_API_URL
10+
from cycode.cyclient.cycode_client_base import CycodeClientBase
1011
from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient
1112
from cycode.cyclient.cycode_oidc_based_client import CycodeOidcBasedClient
1213
from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient
@@ -56,6 +57,17 @@ def create_import_sbom_client(
5657
return ImportSbomClient(client)
5758

5859

60+
def create_raw_api_client(
61+
client_id: str, client_secret: Optional[str] = None, _: bool = False, id_token: Optional[str] = None
62+
) -> CycodeClientBase:
63+
"""Create an authenticated client without any service wrapper, for raw API requests."""
64+
if dev_mode:
65+
return CycodeDevBasedClient(DEV_CYCODE_API_URL)
66+
if id_token:
67+
return CycodeOidcBasedClient(client_id, id_token)
68+
return CycodeTokenBasedClient(client_id, client_secret)
69+
70+
5971
def create_ai_security_manager_client(
6072
client_id: str, client_secret: Optional[str] = None, _: bool = False, id_token: Optional[str] = None
6173
) -> AISecurityManagerClient:

0 commit comments

Comments
 (0)