AnswerDotAI / fasthtml

The fastest way to create an HTML app
https://fastht.ml/
Apache License 2.0
4.88k stars 193 forks source link

Dependency Injection #102

Open k1mbl3 opened 1 month ago

k1mbl3 commented 1 month ago

Wow! You just took this project out of my mind! With all these 'pure Python webapp' libraries using WebSockets, I wondered when someone would leverage HTMX. Anyway, is there a way to inject dependencies into a parent component? For example, a generic snippet injecting the Alpine.js source into the <head> of the page?

It would be very helpful for creating component libraries that also depend on JavaScript libraries without forcing the final user to worry about setting up those dependencies correctly.

jph00 commented 1 month ago

Agreed. We haven't finalised on a design we like yet. I'll leave this issue open and come back to it once we have something implemented.

k1mbl3 commented 1 month ago

Agreed. We haven't finalised on a design we like yet. I'll leave this issue open and come back to it once we have something implemented.

It would also be useful not only to inject dependencies into the <head> of the page but also to have the capability of injecting at any level of the tree. I can imagine an infinite number of components using, for instance, Alpine.js, like a form component that injects functions into the x-data attribute of the parent component for validation, serialization, two-way binding, etc. This feature would make implementing a global pub/sub for intra-component communication, among other exciting tools, easily achievable. It would almost give magical superpowers to fasthtml.

jph00 commented 1 month ago

You can do that already using oob swaps with HTMX, unless I'm misunderstanding. Here's an example of using Alpine with HTMX btw: https://django-htmx-alpine.nicholasmoen.com/

k1mbl3 commented 1 month ago

You can do that already using oob swaps with HTMX, unless I'm misunderstanding. Here's an example of using Alpine with HTMX btw: https://django-htmx-alpine.nicholasmoen.com/

I'm thinking about more advanced use cases where the states must stay on the client side. There is a barrier that every htmx user will hit, and it's those advanced use cases. The developers themselves recommend using their side project _hyperscript (or Alpine.js, which i prefer), when this happens. The idea I mentioned before was something like a form and field integration (note that this is more pseudocode than real code since I'm not yet fluent in FastHTML):

def TextField(name):
    x_model = f'___form.{name}'
    return Input(type="text", name=name, id=name, x_model=x_model)

def NumberField(name):
    x_model = f'___form.{name}'
    return Input(type="number", name=name, id=name, x_model=x_model)

In order for Alpine.js' two-way binding to work, they must be below a common tag with the x-data attribute set (in addition to the library being added to the <head>). The injection mechanism would inject it into the parent <form>. From the moment x-data exists, all the Alpine.js magic can be attached, such as a serialization function like serialize() { return $el._x_dataStack.reverse().reduce((a, b) => Object.assign(a, b), {}); }, which traverses the entire x-data tree looking for all the data client-side states. The global pub/sub would also work this way, adding the x-data attribute to the <body> tag with some global functions that can be called by any child component.

The use case i have in mind is a personal dashboard i wrote using only htmx. It was a form POST that retrieves data from the database, performs some manipulations with pandas, and returns a Plotly graph over the wire. However, it becomes utterly slow. Turns out i had to rewrite it using the Plotly.js library on the client side only.

TomFaulkner commented 1 month ago

I'm just looking at the library today. I came here to ask this question, though more for the reasons that FastAPI uses dependency injection. My endpoint functions typically take a database connection, and the calling user, as an injected parameter. While the latter can be taken from a session, I think it's cleaner to use the FastAPI Depends call.

It would also make porting my application over considerably easier!

k1mbl3 commented 1 month ago

I'm just looking at the library today. I came here to ask this question, though more for the reasons that FastAPI uses dependency injection. My endpoint functions typically take a database connection, and the calling user, as an injected parameter. While the latter can be taken from a session, I think it's cleaner to use the FastAPI Depends call.

It would also make porting my application over considerably easier!

Maybe it's better to rename the issue since 'dependency injection' is already a term used in another context within the FastAPI community?

TomFaulkner commented 4 weeks ago

That would seem good to me.

k1mbl3 commented 3 weeks ago

Here is a sketch of my idea for dependency injection that I brainstormed using Claude Sonnet.

I thought of 2 decorators: one to mark the components that need some dependency, and another to apply the dependencies to the final component returned by the routes:

import functools
import starlette.requests

INJECTED_ATTR_NAME = '___deps'
_valid_names = ('parent', 'head')

def register_dep(target, modifier_fn):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            if not hasattr(result, INJECTED_ATTR_NAME):
                setattr(result, INJECTED_ATTR_NAME, [])
            getattr(result, INJECTED_ATTR_NAME).append({
                'target': target,
                'modifier_fn': modifier_fn
            })
            return result
        return wrapper
    if target not in _valid_names:
        raise ValueError(f"Invalid target: {target}. Must be one of {_valid_names}")
    return decorator

def apply_deps(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        def _extract_objects(args_to_test, *types):
            ret = []
            for obj_type in types:
                found = False
                for arg in args_to_test:
                    if isinstance(arg, obj_type):
                        ret.append(arg)
                        found = True
                if not found:
                    ret.append(None)
            return None if not ret else ret[0] if ret and len(ret) == 1 else ret

        request = _extract_objects(args, starlette.requests.Request)

        def traverse_and_apply(element, path=[]):
            if isinstance(element, list) and len(element) == 3:  # FT object
                if hasattr(element, INJECTED_ATTR_NAME):
                    for dep in getattr(element, INJECTED_ATTR_NAME):
                        target = dep['target']
                        modifier_fn = dep['modifier_fn']
                        if target == 'head':
                            if not (request and hasattr(request, 'hdrs')):
                                raise Exception('Target "head" specified, but request.hdrs is not present')
                            modifier_fn(request.hdrs)
                        elif target == 'parent':
                            if path:
                                parent = path[-1]
                                modifier_fn(parent)
                            else:
                                print("Warning: 'parent' modification attempted on root element")
                tag, cs, attrs = element
                new_cs = []
                for child in cs:
                    new_child = traverse_and_apply(child, path + [element])
                    new_cs.append(new_child)
                element[1] = tuple(new_cs)
            elif isinstance(element, (list, tuple)):
                return type(element)(traverse_and_apply(item, path) for item in element)
            return element

        result = func(*args, **kwargs)
        return traverse_and_apply(result)

    return wrapper

First, you register the dependencies on the component definition:

import json
import fasthtml.common as fh

app, rt = fh.fast_app()

def GenericField(_type, name):
    x_model_key = 'x-model'
    x_model = f'___form.{name}'
    if _type == 'number':
        x_model_key += '.number'
    return fh.Input(type=_type, placeholder=name, name=name, id=name, **{x_model_key: x_model})

def add_alpinejs_to_hrds(hrds):
    for i in hrds:
        if i[0] != 'script':
            continue
        if not hasattr(i, 'src'):
            continue
        if 'alpinejs' in i.src:
            return
    hrds.append(fh.Script(src='https://unpkg.com/alpinejs', defer=True))

def init_xdata(t):
    jdata = t.attrs.get('x-data', '{}')
    jdata = json.loads(jdata)
    form = jdata.get('___form', {})
    jdata['___form'] = form
    t.attrs.update({'x-data': json.dumps(jdata)})

@register_dep('head', add_alpinejs_to_hrds)
@register_dep('parent', init_xdata)
def TextField(name):
    return fh.Div(GenericField('text', name))

@register_dep('head', add_alpinejs_to_hrds)
@register_dep('parent', init_xdata)
def NumberField(name):
    return GenericField('number', name)

Note that the modifier_fn can be a simple lambda function, but complete function definitions would work better when avoiding repetitions or solving conflicts:

@register_dep('head',  lambda hrds: hrds.append(fh.Script(src='https://unpkg.com/alpinejs', defer=True)))
@register_dep('parent', init_xdata)
def NumberField(name):
    return GenericField('number', name)

Then, apply the modifications to the FT tree

@rt("/")
@apply_deps
def get(request):
    return fh.Titled("FastHTML", fh.Div(
        fh.Div(x_text="JSON.stringify(___form)"),
        TextField('name'),
        NumberField('age'),
    ))

fh.serve()

Here is the full draft:

import functools
import starlette.requests

INJECTED_ATTR_NAME = '___deps'
_valid_names = ('parent', 'head')

def register_dep(target, modifier_fn):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            if not hasattr(result, INJECTED_ATTR_NAME):
                setattr(result, INJECTED_ATTR_NAME, [])
            getattr(result, INJECTED_ATTR_NAME).append({
                'target': target,
                'modifier_fn': modifier_fn
            })
            return result
        return wrapper
    if target not in _valid_names:
        raise ValueError(f"Invalid target: {target}. Must be one of {_valid_names}")
    return decorator

def apply_deps(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        def _extract_objects(args_to_test, *types):
            ret = []
            for obj_type in types:
                found = False
                for arg in args_to_test:
                    if isinstance(arg, obj_type):
                        ret.append(arg)
                        found = True
                if not found:
                    ret.append(None)
            return None if not ret else ret[0] if ret and len(ret) == 1 else ret

        request = _extract_objects(args, starlette.requests.Request)

        def traverse_and_apply(element, path=[]):
            if isinstance(element, list) and len(element) == 3:  # FT object
                if hasattr(element, INJECTED_ATTR_NAME):
                    for dep in getattr(element, INJECTED_ATTR_NAME):
                        target = dep['target']
                        modifier_fn = dep['modifier_fn']
                        if target == 'head':
                            if not (request and hasattr(request, 'hdrs')):
                                raise Exception('Target "head" specified, but request.hdrs is not present')
                            modifier_fn(request.hdrs)
                        elif target == 'parent':
                            if path:
                                parent = path[-1]
                                modifier_fn(parent)
                            else:
                                print("Warning: 'parent' modification attempted on root element")
                tag, cs, attrs = element
                new_cs = []
                for child in cs:
                    new_child = traverse_and_apply(child, path + [element])
                    new_cs.append(new_child)
                element[1] = tuple(new_cs)
            elif isinstance(element, (list, tuple)):
                return type(element)(traverse_and_apply(item, path) for item in element)
            return element

        result = func(*args, **kwargs)
        return traverse_and_apply(result)

    return wrapper

###

import json
import fasthtml.common as fh

app, rt = fh.fast_app()

def GenericField(_type, name):
    x_model_key = 'x-model'
    x_model = f'___form.{name}'
    if _type == 'number':
        x_model_key += '.number'
    return fh.Input(type=_type, placeholder=name, name=name, id=name, **{x_model_key: x_model})

def add_alpinejs_to_hrds(hrds):
    for i in hrds:
        if i[0] != 'script':
            continue
        if not hasattr(i, 'src'):
            continue
        if 'alpinejs' in i.src:
            return
    hrds.append(fh.Script(src='https://unpkg.com/alpinejs', defer=True))

def init_xdata(t):
    jdata = t.attrs.get('x-data', '{}')
    jdata = json.loads(jdata)
    form = jdata.get('___form', {})
    jdata['___form'] = form
    t.attrs.update({'x-data': json.dumps(jdata)})

@register_dep('head', add_alpinejs_to_hrds)
@register_dep('parent', init_xdata)
def TextField(name):
    return fh.Div(GenericField('text', name))

@register_dep('head', add_alpinejs_to_hrds)
@register_dep('parent', init_xdata)
def NumberField(name):
    return GenericField('number', name)

@rt("/")
@apply_deps
def get(request):
    return fh.Titled("FastHTML", fh.Div(
        fh.Div(x_text="JSON.stringify(___form)"),
        TextField('name'),
        NumberField('age'),
    ))

fh.serve()