Skip to content

Commit

Permalink
Add 345 response support and exception to allow auth redirecting (#180)
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelcolvin authored Feb 10, 2024
1 parent 7013dab commit 4431809
Show file tree
Hide file tree
Showing 16 changed files with 340 additions and 130 deletions.
57 changes: 57 additions & 0 deletions bump_npm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from __future__ import annotations

import json
import re
from pathlib import Path


def replace_package_json(package_json: Path, new_version: str, deps: bool = False) -> tuple[Path, str]:
content = package_json.read_text()
content, r_count = re.subn(r'"version": *".*?"', f'"version": "{new_version}"', content, count=1)
assert r_count == 1 , f'Failed to update version in {package_json}, expect replacement count 1, got {r_count}'
if deps:
content, r_count = re.subn(r'"(@pydantic/.+?)": *".*?"', fr'"\1": "{new_version}"', content)
assert r_count == 1, f'Failed to update version in {package_json}, expect replacement count 1, got {r_count}'

return package_json, content


def main():
this_dir = Path(__file__).parent
fastui_package_json = this_dir / 'src/npm-fastui/package.json'
with fastui_package_json.open() as f:
old_version = json.load(f)['version']

rest, patch_version = old_version.rsplit('.', 1)
new_version = f'{rest}.{int(patch_version) + 1}'
bootstrap_package_json = this_dir / 'src/npm-fastui-bootstrap/package.json'
prebuilt_package_json = this_dir / 'src/npm-fastui-prebuilt/package.json'
to_update: list[tuple[Path, str]] = [
replace_package_json(fastui_package_json, new_version),
replace_package_json(bootstrap_package_json, new_version, deps=True),
replace_package_json(prebuilt_package_json, new_version),
]

python_init = this_dir / 'src/python-fastui/fastui/__init__.py'
python_content = python_init.read_text()
python_content, r_count = re.subn(r"(_PREBUILT_VERSION = )'.+'", fr"\1'{new_version}'", python_content)
assert r_count == 1, f'Failed to update version in {python_init}, expect replacement count 1, got {r_count}'
to_update.append((python_init, python_content))

# logic is finished, no update all files
print(f'Updating files:')
for package_json, content in to_update:
print(f' {package_json.relative_to(this_dir)}')
package_json.write_text(content)

print(f"""
Bumped from `{old_version}` to `{new_version}` in {len(to_update)} files.
To publish the new version, run:
> npm --workspaces publish
""")


if __name__ == '__main__':
main()
4 changes: 2 additions & 2 deletions demo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, PlainTextResponse
from fastui import prebuilt_html
from fastui.auth import AuthError
from fastui.auth import fastapi_auth_exception_handling
from fastui.dev import dev_fastapi_app
from httpx import AsyncClient

Expand All @@ -32,7 +32,7 @@ async def lifespan(app_: FastAPI):
else:
app = FastAPI(lifespan=lifespan)

app.exception_handler(AuthError)(AuthError.fastapi_handle)
fastapi_auth_exception_handling(app)
app.include_router(components_router, prefix='/api/components')
app.include_router(sse_router, prefix='/api/components')
app.include_router(table_router, prefix='/api/table')
Expand Down
117 changes: 60 additions & 57 deletions demo/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from fastapi import APIRouter, Depends, Request
from fastui import AnyComponent, FastUI
from fastui import components as c
from fastui.auth import GitHubAuthProvider
from fastui.auth import AuthRedirect, GitHubAuthProvider
from fastui.events import AuthEvent, GoToEvent, PageEvent
from fastui.forms import fastui_form
from httpx import AsyncClient
Expand All @@ -20,17 +20,20 @@

router = APIRouter()


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


async def get_github_auth(request: Request) -> GitHubAuthProvider:
client: AsyncClient = request.app.state.httpx_client
return GitHubAuthProvider(
httpx_client=client,
github_client_id='9eddf87b27f71f52194a',
github_client_id=GITHUB_CLIENT_ID,
github_client_secret=GITHUB_CLIENT_SECRET,
redirect_uri=GITHUB_REDIRECT,
scopes=['user:email'],
)

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


@router.get('/login/{kind}', response_model=FastUI, response_model_exclude_none=True)
async def auth_login(
def auth_login(
kind: LoginKind,
user: Annotated[User | None, Depends(User.from_request)],
github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)],
user: Annotated[User | None, Depends(User.from_request_opt)],
) -> list[AnyComponent]:
if user is None:
return demo_page(
c.LinkList(
links=[
c.Link(
components=[c.Text(text='Password Login')],
on_click=PageEvent(name='tab', push_path='/auth/login/password', context={'kind': 'password'}),
active='/auth/login/password',
),
c.Link(
components=[c.Text(text='GitHub Login')],
on_click=PageEvent(name='tab', push_path='/auth/login/github', context={'kind': 'github'}),
active='/auth/login/github',
),
],
mode='tabs',
class_name='+ mb-4',
),
c.ServerLoad(
path='/auth/login/content/{kind}',
load_trigger=PageEvent(name='tab'),
components=await auth_login_content(kind, github_auth),
),
title='Authentication',
)
else:
return [c.FireEvent(event=GoToEvent(url='/auth/profile'))]
if user is not None:
# already logged in
raise AuthRedirect('/auth/profile')

return demo_page(
c.LinkList(
links=[
c.Link(
components=[c.Text(text='Password Login')],
on_click=PageEvent(name='tab', push_path='/auth/login/password', context={'kind': 'password'}),
active='/auth/login/password',
),
c.Link(
components=[c.Text(text='GitHub Login')],
on_click=PageEvent(name='tab', push_path='/auth/login/github', context={'kind': 'github'}),
active='/auth/login/github',
),
],
mode='tabs',
class_name='+ mb-4',
),
c.ServerLoad(
path='/auth/login/content/{kind}',
load_trigger=PageEvent(name='tab'),
components=auth_login_content(kind),
),
title='Authentication',
)


@router.get('/login/content/{kind}', response_model=FastUI, response_model_exclude_none=True)
async def auth_login_content(
kind: LoginKind, github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)]
) -> list[AnyComponent]:
def auth_login_content(kind: LoginKind) -> list[AnyComponent]:
match kind:
case 'password':
return [
Expand All @@ -87,16 +88,15 @@ async def auth_login_content(
'here you can "login" with any email address and password.'
)
),
c.Paragraph(text='(Passwords are not saved and email stored in the browser via a JWT)'),
c.Paragraph(text='(Passwords are not saved and is email stored in the browser via a JWT only)'),
c.ModelForm(model=LoginForm, submit_url='/api/auth/login'),
]
case 'github':
auth_url = await github_auth.authorization_url()
return [
c.Heading(text='GitHub Login', level=3),
c.Paragraph(text='Demo of GitHub authentication.'),
c.Paragraph(text='(Credentials are stored in the browser via a JWT)'),
c.Button(text='Login with GitHub', on_click=GoToEvent(url=auth_url)),
c.Paragraph(text='(Credentials are stored in the browser via a JWT only)'),
c.Button(text='Login with GitHub', on_click=GoToEvent(url='/auth/login/github/gen')),
]
case _:
raise ValueError(f'Invalid kind {kind!r}')
Expand All @@ -121,30 +121,33 @@ async def login_form_post(form: Annotated[LoginForm, fastui_form(LoginForm)]) ->


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


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


@router.get('/login/github/gen', response_model=FastUI, response_model_exclude_none=True)
async def auth_github_gen(github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)]) -> list[AnyComponent]:
auth_url = await github_auth.authorization_url()
return [c.FireEvent(event=GoToEvent(url=auth_url))]


@router.get('/login/github/redirect', response_model=FastUI, response_model_exclude_none=True)
async def github_redirect(
code: str,
Expand Down
25 changes: 21 additions & 4 deletions demo/auth_user.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import json
from dataclasses import asdict, dataclass
from datetime import datetime
from datetime import datetime, timedelta
from typing import Annotated, Any, Self

import jwt
from fastapi import Header, HTTPException
from fastui.auth import AuthRedirect

JWT_SECRET = 'secret'

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

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

@classmethod
async def from_request(cls, authorization: Annotated[str, Header()] = '') -> Self | None:
def from_request(cls, authorization: Annotated[str, Header()] = '') -> Self:
user = cls.from_request_opt(authorization)
if user is None:
raise AuthRedirect('/auth/login/password')
else:
return user

@classmethod
def from_request_opt(cls, authorization: Annotated[str, Header()] = '') -> Self | None:
try:
token = authorization.split(' ', 1)[1]
except IndexError:
return None

try:
return cls(**jwt.decode(token, JWT_SECRET, algorithms=['HS256']))
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
except jwt.ExpiredSignatureError:
return None
except jwt.DecodeError:
raise HTTPException(status_code=401, detail='Invalid token')
else:
# existing token might not have 'exp' field
payload.pop('exp', None)
return cls(**payload)


class CustomJsonEncoder(json.JSONEncoder):
Expand Down
4 changes: 2 additions & 2 deletions src/npm-fastui-bootstrap/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pydantic/fastui-bootstrap",
"version": "0.0.16",
"version": "0.0.19",
"description": "Boostrap renderer for FastUI",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down Expand Up @@ -29,6 +29,6 @@
"sass": "^1.69.5"
},
"peerDependencies": {
"@pydantic/fastui": "0.0.16"
"@pydantic/fastui": "0.0.19"
}
}
2 changes: 1 addition & 1 deletion src/npm-fastui-prebuilt/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pydantic/fastui-prebuilt",
"version": "0.0.16",
"version": "0.0.19",
"description": "Pre-built files for FastUI",
"main": "dist/index.html",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion src/npm-fastui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pydantic/fastui",
"version": "0.0.16",
"version": "0.0.19",
"description": "Build better UIs faster.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
39 changes: 19 additions & 20 deletions src/npm-fastui/src/components/ServerLoad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,32 +52,31 @@ export const ServerLoadFetch: FC<{ path: string; devReload?: number }> = ({ path

useEffect(() => {
setTransitioning(true)
let componentUnloaded = false
request({ url, expectedStatus: [200, 404] }).then(([status, data]) => {
if (componentUnloaded) {
setTransitioning(false)
return
}
if (status === 200) {
setComponentProps(data as FastProps[])
// if there's a fragment, scroll to that ID once the page is loaded
const fragment = getFragment(path)
if (fragment) {
setTimeout(() => {
const element = document.getElementById(fragment)
if (element) {
element.scrollIntoView()
}
}, 50)
let componentLoaded = true
request({ url, expectedStatus: [200, 345, 404] }).then(([status, data]) => {
if (componentLoaded) {
// 345 is treat the same as 200 - the server is expected to return valid FastUI components
if (status === 200 || status === 345) {
setComponentProps(data as FastProps[])
// if there's a fragment, scroll to that ID once the page is loaded
const fragment = getFragment(path)
if (fragment) {
setTimeout(() => {
const element = document.getElementById(fragment)
if (element) {
element.scrollIntoView()
}
}, 50)
}
} else {
setNotFoundUrl(url)
}
} else {
setNotFoundUrl(url)
}
setTransitioning(false)
})

return () => {
componentUnloaded = true
componentLoaded = false
}
}, [url, path, request, devReload])

Expand Down
Loading

0 comments on commit 4431809

Please sign in to comment.