Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 345 response support and exception to allow auth redirecting #180

Merged
merged 9 commits into from
Feb 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading