Skip to content

Commit

Permalink
improved docs, deployed app general improvements
Browse files Browse the repository at this point in the history
  • Loading branch information
tofran committed Mar 31, 2024
1 parent d8a538c commit c115de9
Show file tree
Hide file tree
Showing 9 changed files with 133 additions and 45 deletions.
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**/__pycache__
**/venv
**/.*
**/Dockerfile*
LICENSE
README.md
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
venv
/static/*.min.js
.env
.env.*
sqlite
34 changes: 34 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1

FROM python:3.12.2-slim as base

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

WORKDIR /app

RUN apt update && \
apt install -y make && \
apt clean && \
rm -rf /var/lib/apt/lists/*

RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "10001" \
appuser

RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=bind,source=requirements.txt,target=requirements.txt \
python -m pip install -r requirements.txt

USER appuser

COPY . .

EXPOSE 8000

CMD make start
47 changes: 25 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,54 +1,57 @@
# Python FastAPI HTMX full-text-search demo

This project is a **demo** full-text-search application that leverages the power of
[SQLite FT5](https://www.sqlite.org/fts5.html) and
[Algolia SaaS](https://www.algolia.com/) to retrieve search results.
This project is a **demo** full-text-search application that compares the results from
[SQLite FT5](https://www.sqlite.org/fts5.html) and
[Algolia Search platform](https://www.algolia.com/).

Conceived as an experimental venture, this project serves as a demonstration of an *unconventional*
monolith tech stack. It features an interactive front-end, using a mix of traditional
Server Side Rendering (SSR) and zero custom JavaScript:
Server Side Rendering (SSR) declarative web framework with zero custom JS:

- [FastAPI](https://fastapi.tiangolo.com/) the server framework;
- [Jinja](https://jinja.palletsprojects.com/) for the SSR templating;
- [HTMX](https://htmx.org/) to enable front-end interactivity in a declarative way.
- [HTMX](https://htmx.org/) to enable front-end interactivity declaratively directly in the HTML.

## Description and demo

*So you don't have to spin up the project.*
**[Live demo](https://full-text-search-demo.tofran.com/)**

https://github.com/tofran/fastapi-htmx-full-text-search-demo/assets/5692603/43d642fd-52d5-4e5b-836a-6609d0c3d782

The outcome of this project is something very simple and minimal. The served content is **tiny** and
**fast**. There's no initial loading, all is pre-rendered on the server and each API request renders
HTML that is injected into the DOM - no need for Hydration,
Resumability nor even data serialization.
**fast**. There's no initial loading, everything is pre-rendered on the server, and each API request
renders HTML that is injected into the DOM - no need for Hydration, Resumability nor even data
serialization. It is compatible with most browsers, all the way back to IE11, where it struggles a
little with style, but *works*.

![OpenAPI spec (swagger)](https://github.com/tofran/fastapi-htmx-full-text-search-demo/assets/5692603/541f1f1a-fe1d-475c-8723-8f5a13e8f0df)

The application works by serving a full rendered Jinja HTML template when the user navigates to a
Front-End route. These templates are composed via smaller reusable templates (using `include`).
And then I also serve these *components* de-coupled from the whole page in the *HTML API*
(`/html-api/XXX`). HTMX handles the rest, replacing the content in the DOM when necessary.
Front-End route.
These templates are composed via smaller reusable templates (using `include`).
And then the templates (*components*) are also served, de-coupled from the whole page in the
*HTML API* (`/html-api/...`).
HTMX handles the rest, listens to DOM events and updates it when when necessary.

![Example HTML API request/response](https://github.com/tofran/fastapi-htmx-full-text-search-demo/assets/5692603/8e1aa2a0-53dd-443a-a1d2-caee11cad65c)

## Development

- Create a `.env` file based on the `.env.template`.
Your will need an Algolia account, should be pretty simple to setup to
grab the App ID and API Key
(more info in their
[Quick start guide](https://www.algolia.com/doc/guides/getting-started/quick-start/)
).
You will need an Algolia account, should be pretty simple to setup
(more info in their [Quick start guide](https://www.algolia.com/doc/guides/getting-started/quick-start/)).

- Setup a local environment with `make setup-venv`,
activate it with `source ./venv/bin/activate`.
activate it with `source ./venv/bin/activate`
(or with your favourite tool).

- Install dependencies `make install-dev`.
- Install dependencies: `make install-dev`.

- Start the development server `make dev`.
- Start the development server: `make dev`.

## Deployment

For deployment the only difference is that you can skip the dev requirements with `make install`
and run the application in a production ready server: `make start`.
For deployment one would use the `./Dockerfile` and set the required environment variables.

For running locally a production like build, install the dependencies with `make install`
and run the application with `make start`. That's it.
17 changes: 14 additions & 3 deletions templates/index.jinja.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@ <h1>Text search example</h1>
hx-get="/html-api/items"
hx-params="search_query, search_index"
hx-target="#search-results-container"
hx-trigger="keyup delay:800ms from:input queue:last changed, change from:select queue:last"
hx-trigger="
keyup delay:800ms from:input queue:last changed,
change from:select queue:last,
submit
"
>
<input
type="text"
name="search_query"
placeholder="Search..."
value="{{ search_query }}"
/>

<label for="search-index-select">Search with:</label>
Expand All @@ -22,8 +27,14 @@ <h1>Text search example</h1>
name="search_index"
hx-trigger="change"
>
<option value="SQLITE">SQLite</option>
<option value="ALGOLIA">Algolia</option>
{% for index in search_indexes %}
<option
value="{{ index.name }}"
{% if index.is_selected %}selected{% endif %}
>
{{ index.name }}
</option>
{% endfor %}
</select>
</form>

Expand Down
7 changes: 5 additions & 2 deletions text_search_app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException

from text_search_app.config import DEVELOPMENT_MODE
from text_search_app.config import DEVELOPMENT_MODE, ENABLE_INDEX_MANAGEMENT
from text_search_app.routers import (
html_api_router,
index_management_router,
Expand All @@ -32,7 +32,10 @@ async def lifespan(app: FastAPI):

app.include_router(page_router.router)
app.include_router(html_api_router.router)
app.include_router(index_management_router.router)

if ENABLE_INDEX_MANAGEMENT:
app.include_router(index_management_router.router)

app.include_router(misc_router.router)


Expand Down
11 changes: 10 additions & 1 deletion text_search_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@

load_dotenv()

DEVELOPMENT_MODE = getenv("DEVELOPMENT_MODE", "false").lower() == "true"

def get_bool_env(name: str, default: bool) -> bool:
return getenv(name, str(default)).lower() == "true"


DEVELOPMENT_MODE = get_bool_env("DEVELOPMENT_MODE", False)
ENABLE_INDEX_MANAGEMENT = get_bool_env("ENABLE_INDEX_MANAGEMENT", True)
PREVENT_HTML_API_DIRECT_ACCESS = get_bool_env(
"PREVENT_HTML_API_DIRECT_ACCESS", not DEVELOPMENT_MODE
)

ALGOLIA_APP_ID = os.environ["ALGOLIA_APP_ID"]
ALGOLIA_API_KEY = os.environ["ALGOLIA_API_KEY"]
Expand Down
28 changes: 16 additions & 12 deletions text_search_app/routers/html_api_router.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,33 @@
from fastapi import APIRouter, Depends, Query, Request, status
from fastapi import APIRouter, Depends, Header, Query, Request, status
from fastapi.responses import HTMLResponse
from starlette.exceptions import HTTPException as StarletteHTTPException

from text_search_app.config import DEVELOPMENT_MODE
from text_search_app.config import PREVENT_HTML_API_DIRECT_ACCESS
from text_search_app.search_indexes.indexes import SearchIndex, get_items
from text_search_app.templates import make_template_response


async def prevent_html_api_direct_access(
request: Request,
hx_request_header: bool = Header(
alias="HX-Request",
default_factory=lambda: False,
openapi_examples={
"true required (cannot be changed)": {
"value": True,
}
},
),
) -> None:
"""
Asserts that the HTML API is not accessed directly via the browser.
This is disabled when `DEVELOPMENT_MODE` is True.
"""
if DEVELOPMENT_MODE:
return

if request.headers.get("HX-Request") == "true":
return

# Simulate a 404 just for demonstration - I'm avoiding custom errors in this project
raise StarletteHTTPException(
status_code=status.HTTP_404_NOT_FOUND,
)
if PREVENT_HTML_API_DIRECT_ACCESS and not hx_request_header:
# Simulate a 404 just for demonstration (I'm avoiding custom errors in this project)
raise StarletteHTTPException(
status_code=status.HTTP_404_NOT_FOUND,
)


router = APIRouter(
Expand Down
27 changes: 22 additions & 5 deletions text_search_app/routers/page_router.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from fastapi import APIRouter
from fastapi import APIRouter, Query
from fastapi.requests import Request
from fastapi.responses import HTMLResponse

from text_search_app.search_indexes import indexes
from text_search_app.search_indexes.indexes import SearchIndex, get_items
from text_search_app.templates import make_template_response

router = APIRouter(
Expand All @@ -17,14 +17,31 @@
)
async def get_index_html(
request: Request,
# TODO: later one could add a search_query query params to preserve the user state
selected_search_index: SearchIndex = Query(
alias="search_index",
default=SearchIndex.SQLITE,
description="The search index to use",
),
search_query: str = Query(
default="",
description="The user query to find products that better match it",
),
):
return make_template_response(
"index",
request,
{
"items": indexes.get_items(
search_query=None,
"search_query": search_query,
"search_indexes": [
{
"name": search_index.name,
"is_selected": selected_search_index == search_index,
}
for search_index in SearchIndex
],
"items": get_items(
search_index=selected_search_index,
search_query=search_query,
),
},
)

0 comments on commit c115de9

Please sign in to comment.