Skip to content

Commit 167a993

Browse files
committed
Add login command
1 parent 6d14289 commit 167a993

File tree

10 files changed

+268
-0
lines changed

10 files changed

+268
-0
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ classifiers = [
3535
dependencies = [
3636
"typer >= 0.12.3",
3737
"rich-toolkit >= 0.6.3",
38+
"pydantic >= 1.6.1",
3839
]
3940

4041
[project.optional-dependencies]

requirements-tests.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pytest >=4.4.0,<9.0.0
44
coverage[toml] >=6.2,<8.0
55
mypy ==1.10.0
66
ruff ==0.4.5
7+
respx ==0.21.1
78
# Needed explicitly by fastapi-cli-slim
89
fastapi-slim
910
uvicorn

src/fastapi_cli/cli.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@
1212
from fastapi_cli.exceptions import FastAPICLIException
1313

1414
from . import __version__
15+
from .commands.login import login
16+
from .config import settings
1517
from .logging import setup_logging
1618

1719
app = typer.Typer(rich_markup_mode="rich")
1820

1921
setup_logging()
2022
logger = getLogger(__name__)
2123

24+
2225
try:
2326
import uvicorn
2427
except ImportError: # pragma: no cover
@@ -39,6 +42,7 @@ def callback(
3942
"--version", help="Show the version and exit.", callback=version_callback
4043
),
4144
] = None,
45+
base_api_url: Union[str, None] = typer.Option(None, help="Base URL of the API"),
4246
) -> None:
4347
"""
4448
FastAPI CLI - The [bold]fastapi[/bold] command line app. 😎
@@ -47,6 +51,8 @@ def callback(
4751
4852
Read more in the docs: [link]https://fastapi.tiangolo.com/fastapi-cli/[/link].
4953
"""
54+
if base_api_url:
55+
settings.base_api_url = base_api_url
5056

5157

5258
def _run(
@@ -273,5 +279,9 @@ def run(
273279
)
274280

275281

282+
# Additional commands
283+
app.command()(login)
284+
285+
276286
def main() -> None:
277287
app()

src/fastapi_cli/commands/__init__.py

Whitespace-only changes.

src/fastapi_cli/commands/login.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import logging
2+
import time
3+
from typing import Any, Optional
4+
5+
import httpx
6+
import typer
7+
from pydantic import BaseModel
8+
9+
from fastapi_cli.config import settings
10+
from fastapi_cli.utils.api import APIClient
11+
from fastapi_cli.utils.auth import AuthConfig, write_auth_config
12+
from fastapi_cli.utils.cli import get_rich_toolkit
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class AuthorizationData(BaseModel):
18+
user_code: str
19+
device_code: str
20+
verification_uri: str
21+
verification_uri_complete: str
22+
interval: int = 5
23+
24+
25+
class TokenResponse(BaseModel):
26+
access_token: str
27+
28+
29+
def _start_device_authorization(
30+
client: httpx.Client,
31+
) -> Optional[AuthorizationData]:
32+
try:
33+
response = client.post(
34+
"/login/device/authorization", data={"client_id": settings.client_id}
35+
)
36+
37+
response.raise_for_status()
38+
39+
except httpx.HTTPError as e:
40+
logger.debug("Error: %s", e)
41+
42+
return None
43+
44+
return AuthorizationData.model_validate(response.json())
45+
46+
47+
def _fetch_access_token(
48+
client: httpx.Client, device_code: str, interval: int
49+
) -> Optional[str]:
50+
while True:
51+
response = client.post(
52+
"/login/device/token",
53+
data={
54+
"device_code": device_code,
55+
"client_id": settings.client_id,
56+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
57+
},
58+
)
59+
60+
if response.status_code not in (200, 400):
61+
logger.debug("Error: %s", response.json())
62+
return None
63+
64+
if response.status_code == 400:
65+
data = response.json()
66+
67+
if data.get("error") != "authorization_pending":
68+
logger.debug("Error: %s", data)
69+
return None
70+
71+
if response.status_code == 200:
72+
break
73+
74+
time.sleep(interval)
75+
76+
response_data = TokenResponse.model_validate(response.json())
77+
78+
return response_data.access_token
79+
80+
81+
def login() -> Any:
82+
"""
83+
Login to FastAPI Cloud. 🚀
84+
"""
85+
with get_rich_toolkit() as toolkit, APIClient() as client:
86+
toolkit.print_title("Login to FastAPI Cloud", tag="FastAPI")
87+
88+
toolkit.print_line()
89+
90+
with toolkit.progress("Starting authorization") as progress:
91+
authorization_data = _start_device_authorization(client)
92+
93+
if authorization_data is None:
94+
progress.set_error(
95+
"Something went wrong while contacting the FastAPI Cloud server. Please try again later."
96+
)
97+
98+
raise typer.Exit(1)
99+
100+
url = authorization_data.verification_uri_complete
101+
102+
progress.log(f"Opening {url}")
103+
104+
toolkit.print_line()
105+
106+
with toolkit.progress("Waiting for user to authorize...") as progress:
107+
typer.launch(url)
108+
109+
access_token = _fetch_access_token(
110+
client, authorization_data.device_code, authorization_data.interval
111+
)
112+
113+
if access_token is None:
114+
progress.set_error(
115+
"Something went wrong while contacting the FastAPI Cloud server. Please try again later."
116+
)
117+
118+
raise typer.Exit(1)
119+
120+
write_auth_config(AuthConfig(access_token=access_token))
121+
122+
progress.log("Now you are logged in! 🚀")

src/fastapi_cli/config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from pydantic import BaseModel
2+
3+
4+
class Settings(BaseModel):
5+
base_api_url: str = "http://localhost:8000/api/v1"
6+
client_id: str = "fastapi-cli"
7+
8+
9+
settings = Settings()

src/fastapi_cli/utils/api.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import httpx
2+
3+
from fastapi_cli.config import settings
4+
5+
from .auth import get_auth_token
6+
7+
8+
class APIClient(httpx.Client):
9+
def __init__(self) -> None:
10+
token = get_auth_token()
11+
12+
super().__init__(
13+
base_url=settings.base_api_url, headers={"Authorization": f"Bearer {token}"}
14+
)

src/fastapi_cli/utils/auth.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from typing import Optional
2+
3+
from pydantic import BaseModel
4+
5+
from .config import get_auth_path
6+
7+
8+
class AuthConfig(BaseModel):
9+
access_token: str
10+
11+
12+
def write_auth_config(auth_data: AuthConfig) -> None:
13+
auth_path = get_auth_path()
14+
15+
auth_path.write_text(auth_data.model_dump_json(), encoding="utf-8")
16+
17+
18+
def read_auth_config() -> Optional[AuthConfig]:
19+
auth_path = get_auth_path()
20+
21+
if not auth_path.exists():
22+
return None
23+
24+
return AuthConfig.model_validate_json(auth_path.read_text(encoding="utf-8"))
25+
26+
27+
def get_auth_token() -> Optional[str]:
28+
auth_data = read_auth_config()
29+
30+
if auth_data is None:
31+
return None
32+
33+
return auth_data.access_token

src/fastapi_cli/utils/config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from pathlib import Path
2+
3+
import typer
4+
5+
6+
def get_config_folder() -> Path:
7+
return Path(typer.get_app_dir("fastapi-cli"))
8+
9+
10+
def get_auth_path() -> Path:
11+
auth_path = get_config_folder() / "auth.json"
12+
auth_path.parent.mkdir(parents=True, exist_ok=True)
13+
14+
return auth_path

tests/test_cli_login.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from pathlib import Path
2+
from unittest.mock import patch
3+
4+
import pytest
5+
import respx
6+
from fastapi_cli.cli import app
7+
from fastapi_cli.config import settings
8+
from httpx import Response
9+
from typer.testing import CliRunner
10+
11+
runner = CliRunner()
12+
13+
assets_path = Path(__file__).parent / "assets"
14+
15+
16+
@pytest.mark.respx(base_url=settings.base_api_url)
17+
def test_shows_a_message_if_something_is_wrong(respx_mock: respx.MockRouter) -> None:
18+
with patch("fastapi_cli.commands.login.typer.launch") as mock_open:
19+
respx_mock.post(
20+
"/login/device/authorization", data={"client_id": settings.client_id}
21+
).mock(return_value=Response(500))
22+
23+
result = runner.invoke(app, ["login"])
24+
25+
assert result.exit_code == 1
26+
assert (
27+
"Something went wrong while contacting the FastAPI Cloud server."
28+
in result.output
29+
)
30+
31+
assert not mock_open.called
32+
33+
34+
@pytest.mark.respx(base_url=settings.base_api_url)
35+
def test_full_login(respx_mock: respx.MockRouter) -> None:
36+
with patch("fastapi_cli.commands.login.typer.launch") as mock_open:
37+
respx_mock.post(
38+
"/login/device/authorization", data={"client_id": settings.client_id}
39+
).mock(
40+
return_value=Response(
41+
200,
42+
json={
43+
"verification_uri_complete": "http://test.com",
44+
"verification_uri": "http://test.com",
45+
"user_code": "1234",
46+
"device_code": "5678",
47+
},
48+
)
49+
)
50+
respx_mock.post(
51+
"/login/device/token",
52+
data={
53+
"device_code": "5678",
54+
"client_id": settings.client_id,
55+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
56+
},
57+
).mock(return_value=Response(200, json={"access_token": "1234"}))
58+
59+
result = runner.invoke(app, ["login"])
60+
61+
assert result.exit_code == 0
62+
assert mock_open.called
63+
assert mock_open.call_args.args == ("http://test.com",)
64+
assert "Now you are logged in!" in result.output

0 commit comments

Comments
 (0)