Skip to content

Commit 4431809

Browse files
authored
Add 345 response support and exception to allow auth redirecting (#180)
1 parent 7013dab commit 4431809

File tree

16 files changed

+340
-130
lines changed

16 files changed

+340
-130
lines changed

bump_npm.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import re
5+
from pathlib import Path
6+
7+
8+
def replace_package_json(package_json: Path, new_version: str, deps: bool = False) -> tuple[Path, str]:
9+
content = package_json.read_text()
10+
content, r_count = re.subn(r'"version": *".*?"', f'"version": "{new_version}"', content, count=1)
11+
assert r_count == 1 , f'Failed to update version in {package_json}, expect replacement count 1, got {r_count}'
12+
if deps:
13+
content, r_count = re.subn(r'"(@pydantic/.+?)": *".*?"', fr'"\1": "{new_version}"', content)
14+
assert r_count == 1, f'Failed to update version in {package_json}, expect replacement count 1, got {r_count}'
15+
16+
return package_json, content
17+
18+
19+
def main():
20+
this_dir = Path(__file__).parent
21+
fastui_package_json = this_dir / 'src/npm-fastui/package.json'
22+
with fastui_package_json.open() as f:
23+
old_version = json.load(f)['version']
24+
25+
rest, patch_version = old_version.rsplit('.', 1)
26+
new_version = f'{rest}.{int(patch_version) + 1}'
27+
bootstrap_package_json = this_dir / 'src/npm-fastui-bootstrap/package.json'
28+
prebuilt_package_json = this_dir / 'src/npm-fastui-prebuilt/package.json'
29+
to_update: list[tuple[Path, str]] = [
30+
replace_package_json(fastui_package_json, new_version),
31+
replace_package_json(bootstrap_package_json, new_version, deps=True),
32+
replace_package_json(prebuilt_package_json, new_version),
33+
]
34+
35+
python_init = this_dir / 'src/python-fastui/fastui/__init__.py'
36+
python_content = python_init.read_text()
37+
python_content, r_count = re.subn(r"(_PREBUILT_VERSION = )'.+'", fr"\1'{new_version}'", python_content)
38+
assert r_count == 1, f'Failed to update version in {python_init}, expect replacement count 1, got {r_count}'
39+
to_update.append((python_init, python_content))
40+
41+
# logic is finished, no update all files
42+
print(f'Updating files:')
43+
for package_json, content in to_update:
44+
print(f' {package_json.relative_to(this_dir)}')
45+
package_json.write_text(content)
46+
47+
print(f"""
48+
Bumped from `{old_version}` to `{new_version}` in {len(to_update)} files.
49+
50+
To publish the new version, run:
51+
52+
> npm --workspaces publish
53+
""")
54+
55+
56+
if __name__ == '__main__':
57+
main()

demo/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from fastapi import FastAPI
77
from fastapi.responses import HTMLResponse, PlainTextResponse
88
from fastui import prebuilt_html
9-
from fastui.auth import AuthError
9+
from fastui.auth import fastapi_auth_exception_handling
1010
from fastui.dev import dev_fastapi_app
1111
from httpx import AsyncClient
1212

@@ -32,7 +32,7 @@ async def lifespan(app_: FastAPI):
3232
else:
3333
app = FastAPI(lifespan=lifespan)
3434

35-
app.exception_handler(AuthError)(AuthError.fastapi_handle)
35+
fastapi_auth_exception_handling(app)
3636
app.include_router(components_router, prefix='/api/components')
3737
app.include_router(sse_router, prefix='/api/components')
3838
app.include_router(table_router, prefix='/api/table')

demo/auth.py

Lines changed: 60 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from fastapi import APIRouter, Depends, Request
1010
from fastui import AnyComponent, FastUI
1111
from fastui import components as c
12-
from fastui.auth import GitHubAuthProvider
12+
from fastui.auth import AuthRedirect, GitHubAuthProvider
1313
from fastui.events import AuthEvent, GoToEvent, PageEvent
1414
from fastui.forms import fastui_form
1515
from httpx import AsyncClient
@@ -20,17 +20,20 @@
2020

2121
router = APIRouter()
2222

23-
23+
GITHUB_CLIENT_ID = os.getenv('GITHUB_CLIENT_ID', '0d0315f9c2e055d032e2')
2424
# this will give an error when making requests to GitHub, but at least the app will run
2525
GITHUB_CLIENT_SECRET = SecretStr(os.getenv('GITHUB_CLIENT_SECRET', 'dummy-secret'))
26+
# use 'http://localhost:3000/auth/login/github/redirect' in development
27+
GITHUB_REDIRECT = os.getenv('GITHUB_REDIRECT')
2628

2729

2830
async def get_github_auth(request: Request) -> GitHubAuthProvider:
2931
client: AsyncClient = request.app.state.httpx_client
3032
return GitHubAuthProvider(
3133
httpx_client=client,
32-
github_client_id='9eddf87b27f71f52194a',
34+
github_client_id=GITHUB_CLIENT_ID,
3335
github_client_secret=GITHUB_CLIENT_SECRET,
36+
redirect_uri=GITHUB_REDIRECT,
3437
scopes=['user:email'],
3538
)
3639

@@ -39,44 +42,42 @@ async def get_github_auth(request: Request) -> GitHubAuthProvider:
3942

4043

4144
@router.get('/login/{kind}', response_model=FastUI, response_model_exclude_none=True)
42-
async def auth_login(
45+
def auth_login(
4346
kind: LoginKind,
44-
user: Annotated[User | None, Depends(User.from_request)],
45-
github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)],
47+
user: Annotated[User | None, Depends(User.from_request_opt)],
4648
) -> list[AnyComponent]:
47-
if user is None:
48-
return demo_page(
49-
c.LinkList(
50-
links=[
51-
c.Link(
52-
components=[c.Text(text='Password Login')],
53-
on_click=PageEvent(name='tab', push_path='/auth/login/password', context={'kind': 'password'}),
54-
active='/auth/login/password',
55-
),
56-
c.Link(
57-
components=[c.Text(text='GitHub Login')],
58-
on_click=PageEvent(name='tab', push_path='/auth/login/github', context={'kind': 'github'}),
59-
active='/auth/login/github',
60-
),
61-
],
62-
mode='tabs',
63-
class_name='+ mb-4',
64-
),
65-
c.ServerLoad(
66-
path='/auth/login/content/{kind}',
67-
load_trigger=PageEvent(name='tab'),
68-
components=await auth_login_content(kind, github_auth),
69-
),
70-
title='Authentication',
71-
)
72-
else:
73-
return [c.FireEvent(event=GoToEvent(url='/auth/profile'))]
49+
if user is not None:
50+
# already logged in
51+
raise AuthRedirect('/auth/profile')
52+
53+
return demo_page(
54+
c.LinkList(
55+
links=[
56+
c.Link(
57+
components=[c.Text(text='Password Login')],
58+
on_click=PageEvent(name='tab', push_path='/auth/login/password', context={'kind': 'password'}),
59+
active='/auth/login/password',
60+
),
61+
c.Link(
62+
components=[c.Text(text='GitHub Login')],
63+
on_click=PageEvent(name='tab', push_path='/auth/login/github', context={'kind': 'github'}),
64+
active='/auth/login/github',
65+
),
66+
],
67+
mode='tabs',
68+
class_name='+ mb-4',
69+
),
70+
c.ServerLoad(
71+
path='/auth/login/content/{kind}',
72+
load_trigger=PageEvent(name='tab'),
73+
components=auth_login_content(kind),
74+
),
75+
title='Authentication',
76+
)
7477

7578

7679
@router.get('/login/content/{kind}', response_model=FastUI, response_model_exclude_none=True)
77-
async def auth_login_content(
78-
kind: LoginKind, github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)]
79-
) -> list[AnyComponent]:
80+
def auth_login_content(kind: LoginKind) -> list[AnyComponent]:
8081
match kind:
8182
case 'password':
8283
return [
@@ -87,16 +88,15 @@ async def auth_login_content(
8788
'here you can "login" with any email address and password.'
8889
)
8990
),
90-
c.Paragraph(text='(Passwords are not saved and email stored in the browser via a JWT)'),
91+
c.Paragraph(text='(Passwords are not saved and is email stored in the browser via a JWT only)'),
9192
c.ModelForm(model=LoginForm, submit_url='/api/auth/login'),
9293
]
9394
case 'github':
94-
auth_url = await github_auth.authorization_url()
9595
return [
9696
c.Heading(text='GitHub Login', level=3),
9797
c.Paragraph(text='Demo of GitHub authentication.'),
98-
c.Paragraph(text='(Credentials are stored in the browser via a JWT)'),
99-
c.Button(text='Login with GitHub', on_click=GoToEvent(url=auth_url)),
98+
c.Paragraph(text='(Credentials are stored in the browser via a JWT only)'),
99+
c.Button(text='Login with GitHub', on_click=GoToEvent(url='/auth/login/github/gen')),
100100
]
101101
case _:
102102
raise ValueError(f'Invalid kind {kind!r}')
@@ -121,30 +121,33 @@ async def login_form_post(form: Annotated[LoginForm, fastui_form(LoginForm)]) ->
121121

122122

123123
@router.get('/profile', response_model=FastUI, response_model_exclude_none=True)
124-
async def profile(user: Annotated[User | None, Depends(User.from_request)]) -> list[AnyComponent]:
125-
if user is None:
126-
return [c.FireEvent(event=GoToEvent(url='/auth/login'))]
127-
else:
128-
return demo_page(
129-
c.Paragraph(text=f'You are logged in as "{user.email}".'),
130-
c.Button(text='Logout', on_click=PageEvent(name='submit-form')),
131-
c.Heading(text='User Data:', level=3),
132-
c.Code(language='json', text=json.dumps(asdict(user), indent=2)),
133-
c.Form(
134-
submit_url='/api/auth/logout',
135-
form_fields=[c.FormFieldInput(name='test', title='', initial='data', html_type='hidden')],
136-
footer=[],
137-
submit_trigger=PageEvent(name='submit-form'),
138-
),
139-
title='Authentication',
140-
)
124+
async def profile(user: Annotated[User, Depends(User.from_request)]) -> list[AnyComponent]:
125+
return demo_page(
126+
c.Paragraph(text=f'You are logged in as "{user.email}".'),
127+
c.Button(text='Logout', on_click=PageEvent(name='submit-form')),
128+
c.Heading(text='User Data:', level=3),
129+
c.Code(language='json', text=json.dumps(asdict(user), indent=2)),
130+
c.Form(
131+
submit_url='/api/auth/logout',
132+
form_fields=[c.FormFieldInput(name='test', title='', initial='data', html_type='hidden')],
133+
footer=[],
134+
submit_trigger=PageEvent(name='submit-form'),
135+
),
136+
title='Authentication',
137+
)
141138

142139

143140
@router.post('/logout', response_model=FastUI, response_model_exclude_none=True)
144141
async def logout_form_post() -> list[AnyComponent]:
145142
return [c.FireEvent(event=AuthEvent(token=False, url='/auth/login/password'))]
146143

147144

145+
@router.get('/login/github/gen', response_model=FastUI, response_model_exclude_none=True)
146+
async def auth_github_gen(github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)]) -> list[AnyComponent]:
147+
auth_url = await github_auth.authorization_url()
148+
return [c.FireEvent(event=GoToEvent(url=auth_url))]
149+
150+
148151
@router.get('/login/github/redirect', response_model=FastUI, response_model_exclude_none=True)
149152
async def github_redirect(
150153
code: str,

demo/auth_user.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import json
22
from dataclasses import asdict, dataclass
3-
from datetime import datetime
3+
from datetime import datetime, timedelta
44
from typing import Annotated, Any, Self
55

66
import jwt
77
from fastapi import Header, HTTPException
8+
from fastui.auth import AuthRedirect
89

910
JWT_SECRET = 'secret'
1011

@@ -15,19 +16,35 @@ class User:
1516
extra: dict[str, Any]
1617

1718
def encode_token(self) -> str:
18-
return jwt.encode(asdict(self), JWT_SECRET, algorithm='HS256', json_encoder=CustomJsonEncoder)
19+
payload = asdict(self)
20+
payload['exp'] = datetime.now() + timedelta(hours=1)
21+
return jwt.encode(payload, JWT_SECRET, algorithm='HS256', json_encoder=CustomJsonEncoder)
1922

2023
@classmethod
21-
async def from_request(cls, authorization: Annotated[str, Header()] = '') -> Self | None:
24+
def from_request(cls, authorization: Annotated[str, Header()] = '') -> Self:
25+
user = cls.from_request_opt(authorization)
26+
if user is None:
27+
raise AuthRedirect('/auth/login/password')
28+
else:
29+
return user
30+
31+
@classmethod
32+
def from_request_opt(cls, authorization: Annotated[str, Header()] = '') -> Self | None:
2233
try:
2334
token = authorization.split(' ', 1)[1]
2435
except IndexError:
2536
return None
2637

2738
try:
28-
return cls(**jwt.decode(token, JWT_SECRET, algorithms=['HS256']))
39+
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
40+
except jwt.ExpiredSignatureError:
41+
return None
2942
except jwt.DecodeError:
3043
raise HTTPException(status_code=401, detail='Invalid token')
44+
else:
45+
# existing token might not have 'exp' field
46+
payload.pop('exp', None)
47+
return cls(**payload)
3148

3249

3350
class CustomJsonEncoder(json.JSONEncoder):

src/npm-fastui-bootstrap/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pydantic/fastui-bootstrap",
3-
"version": "0.0.16",
3+
"version": "0.0.19",
44
"description": "Boostrap renderer for FastUI",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",
@@ -29,6 +29,6 @@
2929
"sass": "^1.69.5"
3030
},
3131
"peerDependencies": {
32-
"@pydantic/fastui": "0.0.16"
32+
"@pydantic/fastui": "0.0.19"
3333
}
3434
}

src/npm-fastui-prebuilt/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pydantic/fastui-prebuilt",
3-
"version": "0.0.16",
3+
"version": "0.0.19",
44
"description": "Pre-built files for FastUI",
55
"main": "dist/index.html",
66
"type": "module",

src/npm-fastui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pydantic/fastui",
3-
"version": "0.0.16",
3+
"version": "0.0.19",
44
"description": "Build better UIs faster.",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

src/npm-fastui/src/components/ServerLoad.tsx

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -52,32 +52,31 @@ export const ServerLoadFetch: FC<{ path: string; devReload?: number }> = ({ path
5252

5353
useEffect(() => {
5454
setTransitioning(true)
55-
let componentUnloaded = false
56-
request({ url, expectedStatus: [200, 404] }).then(([status, data]) => {
57-
if (componentUnloaded) {
58-
setTransitioning(false)
59-
return
60-
}
61-
if (status === 200) {
62-
setComponentProps(data as FastProps[])
63-
// if there's a fragment, scroll to that ID once the page is loaded
64-
const fragment = getFragment(path)
65-
if (fragment) {
66-
setTimeout(() => {
67-
const element = document.getElementById(fragment)
68-
if (element) {
69-
element.scrollIntoView()
70-
}
71-
}, 50)
55+
let componentLoaded = true
56+
request({ url, expectedStatus: [200, 345, 404] }).then(([status, data]) => {
57+
if (componentLoaded) {
58+
// 345 is treat the same as 200 - the server is expected to return valid FastUI components
59+
if (status === 200 || status === 345) {
60+
setComponentProps(data as FastProps[])
61+
// if there's a fragment, scroll to that ID once the page is loaded
62+
const fragment = getFragment(path)
63+
if (fragment) {
64+
setTimeout(() => {
65+
const element = document.getElementById(fragment)
66+
if (element) {
67+
element.scrollIntoView()
68+
}
69+
}, 50)
70+
}
71+
} else {
72+
setNotFoundUrl(url)
7273
}
73-
} else {
74-
setNotFoundUrl(url)
7574
}
7675
setTransitioning(false)
7776
})
7877

7978
return () => {
80-
componentUnloaded = true
79+
componentLoaded = false
8180
}
8281
}, [url, path, request, devReload])
8382

0 commit comments

Comments
 (0)