📝 finish readme & quickstart, add responses

This commit is contained in:
Joe Kaufeld 2024-08-26 17:32:33 -04:00
parent c27a7b28aa
commit fe9927f54e
8 changed files with 326 additions and 64 deletions

View File

@ -4,7 +4,7 @@ As a professional web developer focusing on arcane uses of Django for arcane pur
> So I built one.
This is `spiderweb`, a web framework that's just big enough to hold a spider. When building it, my goals were simple:
This is `spiderweb`, a WSGI-compatible web framework that's just big enough to hold a spider. When building it, my goals were simple:
- Learn a lot
- Create an unholy blend of Django and Flask
@ -16,29 +16,117 @@ This is `spiderweb`, a web framework that's just big enough to hold a spider. Wh
> That being said, it's fun and it works, so I'm counting that as a win.
## Design & Usage Decisions
There are a couple of things that I feel strongly about that have been implemented into Spiderweb.
### Deliberate Responses
In smaller frameworks, it's often the case that "how to return the response" is guessed based on the data that you return from a view. Take this view for example:
```python
def index(request):
return "Hi"
```
In this function, we return a string, and as such there are some assumptions that we make about what the response looks like:
- a content-type header of "text/plain"
- that we are wanting to return raw HTML
- no post-processing of the response is needed
While assumptions are nice for getting running quickly, I think that there is a balance between assuming what the user wants and making them define everything. See how Spiderweb handles this:
```python
from spiderweb.response import HttpResponse
def index(request):
return HttpResponse("Hi")
```
In this case, we improve readability of exactly what type of response we expect (raw HTML, a template response, a JSON-based response, etc.) and we give the developer the tools that they need to modify the response beforehand.
The response object has everything that it needs immediately available: headers, cookies, status codes, and more. All can be modified before sending, but providing this data for the opportunity to change is what's important.
Spiderweb provides five types of responses out of the box:
- HttpResponse
- FileResponse
- RedirectResponse
- TemplateResponse
- JSONResponse
> [Read more about responses in Spiderweb](responses.md)
### Database Agnosticism (Mostly)
One of the largest selling points of Django is the Django Object Relational Mapper (ORM); while there's nothing that compares to it in functionality, there are many other ORMs and database management solutions for developers to choose from.
In order to use a database internally (and since this is not about writing an ORM too), Spiderweb depends on [peewee, a small ORM](https://github.com/coleifer/peewee). Applications using Spiderweb are more than welcome to use peewee models with first-class support or use whatever they're familiar with. Peewee supports PostgreSQL, MySQL, Sqlite, and CockroachDB; if you use one of these, Spiderweb can create the tables it needs in your database and stay out of the way. By default, Spiderweb creates a sqlite database in the application directory for its own use.
> [Read more about the using a database in Spiderweb](db.md)
### Easy to configure
Configuration is handled in different ways across different frameworks; in Flask, a simple dictionary is used that you can modify:
```python
# https://flask.palletsprojects.com/en/3.0.x/config/
app = Flask(__name__)
app.config['TESTING'] = True
```
This works, but suffers from the problem of your IDE not being able to see all the possible options that are available. Django solves this by having a `settings.py` file:
```python
# https://docs.djangoproject.com/en/5.1/topics/settings/
ALLOWED_HOSTS = ["www.example.com"]
DEBUG = False
DEFAULT_FROM_EMAIL = "webmaster@example.com"
```
Simply having these declared in a place that Django can find them is enough, and they Just Work:tm:. This also works, but can be very verbose.
Spiderweb takes a middle ground approach: it allows you to declare framework-first arguments on the SpiderwebRouter object, and if you need to pass along other data to other parts of the system (like custom middleware), you can do so by passing in any keyword argument you'd like to the constructor.
```python
from peewee import SqliteDatabase
app = SpiderwebRouter(
db=SqliteDatabase("myapp.db"),
port=4500,
session_cookie_name="myappsession",
my_middleware_data="Test!"
)
```
In this example, `db`, `port`, and `session_cookie_name` are all arguments that affect the server, but `my_middleware_data` is something new. It will be available in `app.extra_data` and is also available through middleware and on the request object later.
## tl;dr: what can Spiderweb do?
Here's a non-exhaustive list of things this can do:
* Function-based views
* Optional Flask-style URL routing
* Optional Django-style URL routing
* URLs with variables in them a lá Django
* Full middleware implementation
* Limit routes by HTTP verbs
* (Only GET and POST are implemented right now)
* Custom error routes
* Built-in dev server
* Gunicorn support
* HTML templates with Jinja2
* Static files support
* Cookies (reading and setting)
* Optional append_slash (with automatic redirects!)
* ~~CSRF middleware implementation~~ (it's there, but it's crappy and unsafe. This might be beyond my skillset.)
* Optional POST data validation middleware with Pydantic
* Database support (using Peewee, but the end user can use whatever they want as long as there's a Peewee driver for it)
* Session middleware with built-in session store
* Tests (currently a little over 80% coverage)
- Function-based views
- Optional Flask-style URL routing
- Optional Django-style URL routing
- URLs with variables in them a lá Django
- Full middleware implementation
- Limit routes by HTTP verbs
- (Only GET and POST are implemented right now)
- Custom error routes
- Built-in dev server
- Gunicorn support
- HTML templates with Jinja2
- Static files support
- Cookies (reading and setting)
- Optional append_slash (with automatic redirects!)
- ~~CSRF middleware implementation~~ (it's there, but it's crappy and unsafe. This might be beyond my skillset.)
- Optional POST data validation middleware with Pydantic
- Database support (using Peewee, but you can use whatever you want as long as there's a Peewee driver for it)
- Session middleware with built-in session store
- Tests (currently a little over 80% coverage)
The TODO list:
## What's left to build?
* Fix CSRF middleware
* Add more HTTP verbs
- Fix CSRF middleware
- Add more HTTP verbs

3
docs/db.md Normal file
View File

@ -0,0 +1,3 @@
# db
...

View File

@ -3,42 +3,59 @@
<head>
<meta charset="UTF-8">
<title>Document</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
<meta name="description" content="Description">
<link rel="icon" type="image/png" sizes="32x32" href="/_media/Favicon-32x32.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/vue.css">
</head>
<body>
<style>
<style>
@font-face {
font-family: "DMSans";
src: url('_media/DMSans-Medium.ttf') format('truetype');
}
body {
font-family: "DMSans", sans-serif;
}
</style>
<div id="app"></div>
<script>
</style>
<div id="app"></div>
<script>
window.$docsify = {
name: 'Spiderweb',
loadSidebar: true,
repo: 'https://github.com/itsthejoker/spiderweb',
maxLevel: 3,
maxLevel: 4,
subMaxLevel: 2,
coverpage: true,
'flexible-alerts': {
style: 'callout' // or 'flat'
},
auto2top: true,
search: {
// insertAfter: '.app-name',
// insertBefore: '.sidebar-nav',
noData: {
'/': 'No results!',
},
paths: 'auto',
placeholder: {
'/': 'Search',
},
}
</script>
<!-- Docsify v4 -->
<script src="//cdn.jsdelivr.net/npm/docsify@4"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-python.min.js"></script>
<!-- admonitions -->
<script src="https://unpkg.com/docsify-plugin-flexible-alerts"></script>
<script src="//unpkg.com/docsify-pagination/dist/docsify-pagination.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/docsify-tabs@1"></script>
}
</script>
<!-- Docsify v4 -->
<script src="//cdn.jsdelivr.net/npm/docsify@4"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-python.min.js"></script>
<!-- admonitions -->
<script src="https://unpkg.com/docsify-plugin-flexible-alerts"></script>
<!-- pagination -->
<script src="//unpkg.com/docsify-pagination/dist/docsify-pagination.min.js"></script>
<!-- tabs -->
<script src="https://cdn.jsdelivr.net/npm/docsify-tabs@1"></script>
<!-- search -->
<script src="//cdn.jsdelivr.net/npm/docsify/lib/plugins/search.min.js"></script>
</body>
</html>

View File

@ -48,23 +48,23 @@ That's it! You've got a working web app. Let's take a look at what these few lin
from spiderweb import SpiderwebRouter
```
The `SpiderwebRouter` class is the main object that everything stems from in `spiderweb`. It's where you'll set your options, your routes, and more.
The `SpiderwebRouter` class is the main object that everything stems from in Spiderweb. It's where you'll set your options, your routes, and more.
```python
from spiderweb.response import HttpResponse
```
Rather than trying to infer what you want, spiderweb wants you to be specific about what you want it to do. Part of that is the One Response Rule:
Rather than trying to infer what you want, Spiderweb wants you to be specific about what you want it to do. Part of that is the One Response Rule:
> Every view must return a Response, and each Response must be a specific type.
There are four different types of responses; if you want to skip ahead, hop over to [the responses page](responses.md) to learn more. For this example, we'll focus on `HttpResponse`, which is the base response.
There are five different types of responses; if you want to skip ahead, hop over to [the responses page](responses.md) to learn more. For this example, we'll focus on `HttpResponse`, which is the base response.
```python
app = SpiderwebRouter()
```
This line creates a new instance of the `SpiderwebRouter` class and assigns it to the variable `app`. This is the object that will handle all of your requests and responses. If you need to pass any options into spiderweb, you'll do that here.
This line creates a new instance of the `SpiderwebRouter` class and assigns it to the variable `app`. This is the object that will handle all of your requests and responses. If you need to pass any options into Spiderweb, you'll do that here.
```python
@app.route("/")
@ -74,10 +74,12 @@ def index(request):
This is an example view. There are a few things to note here:
- The `@app.route("/")` decorator tells spiderweb that this view should be called when the user navigates to the root of the site.
- The `@app.route("/")` decorator tells spiderweb that this view should be called when the user navigates to the root of the site. There are three different ways to declare this, but this is the easiest for demo purposes.
- The `def index(request):` function is the view itself. It takes a single argument, `request`, which is a `Request` object that contains all the information about the incoming request.
- The `return HttpResponse("HELLO, WORLD!")` line is the response. In this case, it's a simple `HttpResponse` object that contains the string `HELLO, WORLD!`. This will be sent back to the user's browser.
> See [declaring routes](routes.md) for more information.
> [!TIP]
> Every view must accept a `request` object as its first argument. This object contains all the information about the incoming request, including headers, cookies, and more.
>
@ -88,6 +90,9 @@ if __name__ == "__main__":
app.start()
```
Once you finish setting up your app, it's time to start it! You can start the dev server by just calling `app.start()` (and its counterpart `app.stop()` to stop it). This will start a simple server on `localhost:8000` that you can access in your browser. It's not a secure server; don't even think about using it in production. It's just good enough for development.
Once you finish setting up your app, it's time to start it! You can start the dev server by just calling `app.start()` (and its counterpart `app.stop()`, or just CTRL+C). This will start a simple server on `localhost:8000` that you can access in your browser.
Now that your app is done, you can also run it with Gunicorn by running `gunicorn --workers=2 {yourfile}:app` in your terminal. This will start a Gunicorn server on `localhost:8000` that you can access in your browser and is a little more robust than the dev server.
> [!WARNING]
> The dev server is just that: for development. Do not use for production.
Now that your app is done, you can also run it with Gunicorn by running `gunicorn --workers=2 {yourfile}:app` in your terminal. This will start a Gunicorn server on `localhost:8000` that you can access in your browser and is a bit more robust than the dev server.

View File

@ -1,3 +1,148 @@
# responses
...
Possibly the most important part of a view, a Response allows you to send information back to the browser. Responses also do most of the boring stuff for you of setting headers and making sure everything is encoded correctly.
There are five different types of response in Spiderweb, and each one has a slightly different function.
## HttpResponse
```python
from spiderweb.response import HttpResponse
```
The HttpResponse object is the base class for responses, and if you want to implement your own Response type, this is what you will need to subclass. More information on that at the bottom.
This response is used for raw HTML responses and also contains the helper functions used by the other responses.
Usage:
```python
resp = HttpResponse(
# the raw string data you want to return
body: str = None,
status_code: int = 200,
# If you want to specify your own headers, you can do
# so when you instantiate it.
headers: dict[str, Any] = None,
)
```
## JsonResponse
```python
from spiderweb.response import JsonResponse
```
Sometimes you just need to return JSON, and the JsonResponse is the class you need. It sets up the correct headers for you and is ready to go as soon as you call it.
Usage:
```python
resp = JsonResponse(
data: dict[str, Any] = None,
status_code: int = 200,
headers: dict[str, Any] = None,
)
```
## TemplateResponse
```python
from spiderweb.response import TemplateResponse
```
If you want to render an template using Jinja, you'll need a TemplateResponse. This one is instantiated a little differently from the other responses, but it's to make sure that all your data gets to the places it needs to be.
Usage:
```python
resp = TemplateResponse(
request: Request,
template_path: PathLike | str = None,
template_string: str = None,
context: dict[str, Any] = None,
# same as before
status_code: int = 200,
headers: dict[str, Any] = None,
)
```
In practice, this is simpler than it looks at first glance.
- `request`: Required. This is the request object that is passed into the view.
- `template_path`: Choose either this or `template_string`. This is the path to the template that you will want to be loading inside your templates directories.
- `template_string`: Choose either this or `template_path`. This is a raw string that will be treated as a template instead of reading from a file.
- `context`: a dict that contains data that will get slotted into the template.
Example (where there is a folder in the local directory called "my_templates", and a file within that directory called "index.html"):
```python
app = SpiderwebRouter(templates_dirs=["my_templates"])
@app.route("/")
def index(request):
return TemplateResponse(request, "index.html", context={"extra_data": "1, 2, 3"})
```
In this case, the TemplateResponse will load `my_templates/index.html` and use the context dictionary of `{"extra_data": "1, 2, 3"}` to populate it. (The request object will also automatically be added to the context dictionary so that it is accessible in the template during render time.)
Using the `template_string` argument looks like this:
```python
app = SpiderwebRouter(templates_dirs=["my_templates"])
@app.route("/")
def index(request):
template = """This is where I will display my extra data: {{ extra_data }}"""
return TemplateResponse(
request, template_string=template, context={"extra_data": "1, 2, 3"}
)
```
## RedirectResponse
```python
from spiderweb.response import RedirectResponse
```
Occasionally, it's handy to tell the browser to request something else. You can do that with the RedirectResponse:
```python
resp = RedirectResponse(location: str = None)
```
The RedirectResponse will automatically set the status code and headers for you on intitialization, though you can always change them after.
Example:
```python
return RedirectResponse("/")
```
## FileResponse
```python
from spiderweb.response import FileResponse
```
Generic files on the web have two distinct problems: we don't know what kind they are and we don't know how big they are. The FileResponse class handles both setting the headers automatically for the correct mimetype and also breaking the file into chunks to send in a reasonable way.
Ideally, static files and the like will be handled by the reverse proxy aimed at your app (nginx or Apache, usually), but for local developement, being able to serve files is a useful shortcut. You can also use this to serve files directly from your application in other more specific circumstances.
> [!WARNING]
> Using this response is much slower than letting your reverse proxy handle it if you have one; as this is a normal response, it will also undergo processing through the middleware stack each time it's used. For returning a file, it's a waste of computational power.
This is the complete source of the view used to serve static files when using the development server, as it shows nicely how to use this response type:
```python
def send_file(request, filename: str) -> FileResponse:
for folder in request.server.staticfiles_dirs:
requested_path = request.server.BASE_DIR / folder / filename
if os.path.exists(requested_path):
if not is_safe_path(requested_path):
raise NotFound
return FileResponse(filename=requested_path)
raise NotFound
```

0
docs/routes.md Normal file
View File

View File

@ -56,6 +56,7 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
session_cookie_same_site="lax",
session_cookie_path="/",
log=None,
**kwargs
):
self._routes = {}
self.routes = routes
@ -70,6 +71,8 @@ class SpiderwebRouter(LocalServerMixin, MiddlewareMixin, RoutesMixin, FernetMixi
self.middleware = middleware if middleware else []
self.secret_key = secret_key if secret_key else self.generate_key()
self.extra_data = kwargs
# session middleware
self.session_max_age = session_max_age
self.session_cookie_name = session_cookie_name

View File

@ -1,6 +1,7 @@
import datetime
import json
import re
from os import PathLike
from typing import Any
import urllib.parse
import mimetypes
@ -20,7 +21,7 @@ class HttpResponse:
data: dict[str, Any] = None,
context: dict[str, Any] = None,
status_code: int = 200,
headers=None,
headers: dict[str, Any] = None,
):
self.body = body
self.data = data
@ -130,7 +131,7 @@ class TemplateResponse(HttpResponse):
def __init__(
self,
request: Request,
template_path: str = None,
template_path: PathLike | str = None,
template_string: str = None,
*args,
**kwargs,