How to stream HTML content with static files using FastAPI?
Asked Answered
C

1

3

Question

How to stream an HTML page with static files and hyperlinks from another service using FastAPI?

Additional Context

A common architecture in micro-services is to have a gateway layer that implements a public API that passes requests to micro-services.


                            =============== Docker Network =============
                            ||                                        ||
                            ||           -------> Backend Service A   ||
                            ||          /                             ||
                            ||         /                              ||
External Request ===> Gateway Service * --------> Backend Service B   ||
                            ||         \                              ||
                            ||          \                             ||
                            ||           -------> Backend Service C   ||
                            ||                                        ||
                            ============================================

In case of FastAPI (which is not a must in my case), this solution worked great for APIs.

I would go further and try use StreamingResponse to display HTML page with static files and hyperlinks generated dynamically. RedirectResponse will not work here, since services are not available outside of docker inner network. Mentioned solution for API's isn't working neither, due to that static files and hyperlinks have links appropriate for inner service.

Cory answered 10/9, 2022 at 11:54 Comment(0)
P
4

Streaming HTML content from local file

StreamingResponse takes an async or a normal generator/iterator, and streams the response body. You can create a generator function to iterate over a file-like object (e.g., the object returned by open()), then pass it to the StreamingResponse and return it. Make sure to specify the media_type to "text/html". Also, any static files (e.g., images, JS scripts, etc) that you may have, you can serve them from a directory using StaticFiles. In the example below, the directory="static" refers to the name of the directory that contains your static files. The first "/static" refers to the sub-path that StaticFiles will be "mounted" on; hence, any path that starts with "/static" will be handled by it. Thus, you can add static files to your .html file like this: <script src="/static/some_script.js"></script>. Additionally, if you find yield from f being rather slow when using StreamingResponse, you could instead create a custom generator, as described in this answer.

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles

app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
path = "index.html"

@app.get("/")
def main():
    def iter_file():
        with open(path, 'rb') as f:
            yield from f

    return StreamingResponse(iter_file(), media_type="text/html")

Streaming HTML content from online source (URL)

To stream HTML content from an online source, you can use the httpx library, which provides async support and Streaming responses too. See this answer as well for more details.

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import httpx

app = FastAPI()
url = "https://github.com/tiangolo/fastapi/issues/1788"

@app.get("/")
def main():
    async def iter_url():
        async with httpx.AsyncClient() as client:
            async with client.stream("GET", url) as r:
                async for chunk in r.aiter_bytes():
                    yield chunk
                    
    return StreamingResponse(iter_url(), media_type="text/html")

Solution for streaming HTML content, static files and hyperlinks from another service

Regarding your case specifically (streaming from another service), there are a couple of options to solve the issue with static files that you mentioned in the comments section.

Option 1: If both servers run on the same machine/network, and your public server has access to the folder containing the static files of the private service, then you could define a StaticFiles instance (as described earlier) with the directory pointing to that specific path on the disk (e.g., ...StaticFiles(directory="path/to/service/static/folder")), or create a symbolic link (i.e., a file pointing to a directory) and mount a StaticFiles instance on that file, as described in this answer. Make sure to have your static files in the HTML file prefixed with that specific path that you gave when mounting the StaticFiles instance. If, for example, the path was /static-files, then in your HTML content you should use, for instance: <script src="/static-files/some_script.js"></script>

Option 2: If the public server runs on a separate machine/network from the private service, then you could define an endpoint in your public server that can capture arbitrary paths, using Starlette's path convertor—as described in this answer—and serve the files in a similar way to serving the HTML content. To avoid any conflicts with other endpoints or a mounted static directory that your public server may have to serve its own static files, better have the files in the HTML content prefixed with a specific path (as shown above), which will be used for your endpoint (e.g., "/static-files/{_:path}").

Working Example:

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import httpx
from urllib.parse import urljoin

app = FastAPI()
host = "http://127.0.0.1:9001"
stream_url = host + "/stream"

@app.get("/stream")
def main():
    async def iter_url():
        async with httpx.AsyncClient() as client:
            async with client.stream("GET", stream_url) as r:
                async for chunk in r.aiter_bytes():
                    yield chunk
                    
    return StreamingResponse(iter_url(), media_type="text/html")

  
@app.get("/static-files/{_:path}")
def get_resource(request: Request):
    async def iter_url(url):
        async with httpx.AsyncClient() as client:
            async with client.stream("GET", url) as r:
                async for chunk in r.aiter_bytes():
                    yield chunk
    
    url = urljoin(host, request.url.path)
    return StreamingResponse(iter_url(url))

As for the hyperlinks (e.g., /test), you can serve them in the exact same way as the static files above, as long as you can have them prefixed with some unique path, so that they won't confict with the public server's routes. If the private service is also a FastAPI application, you can use a Sub Application, or an APIRouter, which would allow you to define a path prefix, e.g., /subapi—a hyperlink in your HTML content would then look like this: <a href="/subapi/test">Click here</a>. Hence, your endpoint would now be able to handle both static files and hyperlinks, as long as you define both paths in your path operation function, for example:

@app.get("/subapi/{_:path}")
@app.get("/static-files/{_:path}")
def get_resource(request: Request):
    ...

The working example above could be simplified by declaring the iter_url() function outside the endpoints once, and having a single endpoint to handle every request regarding the streaming. Example:

#...

@app.get("/stream")
@app.get("/subapi/{_:path}")
@app.get("/static-files/{_:path}")
def get_resource(request: Request):
    url = urljoin(host, request.url.path)
    return StreamingResponse(iter_url(url))

MIME Type

Regarding the media_type (also known as MIME type), you can either leave it out when instantiating the StreamingResponse and let the browser determine what the mime type is, or, in the case of static files, you could use Python's built-in mimetypes module to guess the MIME associated with the filename extension. For example:

import mimetypes
...
mt = mimetypes.guess_type(url)  # url here includes a filename at the end
if mt[0]:
    return StreamingResponse(iter_url(url), media_type=mt[0])

Alternatively, for both static files and hypelinks, you can use the python-magic library which identifies file types from content as well, for example:

result = magic.from_buffer(chunk, mime=True)

However, every test performed so far with StreamingResponse(iter_url(url)) has been successful, using neither mimetypes nor magic; meaning that browsers (specifically, tests were performed using FireFox, Chrome and Opera) were able to identify the MIME type and display the content correctly for HTML content, images, etc. So, you can either let the browser figure it out from context that the content delivers, or—since identifying the correct MIME type is important—use any of the two options described above. You could also create your own custom dictionary of file extensions and hyperlinks with their associated media_type, and check each requested path against it to determine the correct MIME type.

Preselector answered 10/9, 2022 at 13:50 Comment(2)
Thank you @Preselector for answer:) Upper solution is working fine with online pages, but I can't make it work with HTML pages hosted on localhost. There are 2 problems. 1) HTML page is loaded without CSS files. I can see in web-browser console that static files are missing. 2) Generated hyperlinks aren't working as expected. Let's say that http://0.0.0.0:9000/test/ doesn't exist and http://0.0.0.0:9001/test/ does exist. Once i stream http://0.0.0.0:9001/ over http://0.0.0.0:9000/stream/, the hyperlink redirects me to http://0.0.0.0:9000/test/, which is invalid.Cory
Thank you @Chris, it's awesome that you spend time and solved the problem for me:)Cory

© 2022 - 2024 — McMap. All rights reserved.