Commit Graph

3 Commits

Author SHA1 Message Date
Elf M. Sternberg 5da8bb6b79 Interim commit. 2023-03-24 12:22:28 -07:00
Elf M. Sternberg 727ad252cc Implemented the forms reader, config, and database migrations.
This chapter introduces the Actix "Extractors" for retrieving form data. I've
added tests to the `./tests` folder to attempt to interact with those
extractors; as of this commit, a89cbe5b, they fail because the example code
isn't there.

What is there is a variant of the "Hello, World!" code from the previous
exercises (section 3.5), which uses the Actix extractor:

``` rust
// Actix, *not* Axum. Does not work with the current framework.
fn index(form: web::Form<FormData>) -> String {
    format!('Welcome {}!', form.username)
}
```

Translated and polished into Axum, it translates to:

``` rust
pub async fn index(payload: Option<Form<FormData>>) -> impl IntoResponse {
    let username = payload.map_or("World".to_string(), move |index| -> String {
        String::from(&(index.username))
    });
    (StatusCode::OK, format!("Hello, {}!\n", &username))
}
```

The Axum version is a little smarter, providing a default "World!" if you don't
specify a name.  That's what `.map_or` does, although the `or` part actually
comes first in the function.  So the result is:

``` sh
$ curl http://localhost:3000/
Hello, World!
$ curl 'http://localhost:3000/?username=Spock'
Hello, Spock!
```

Which is more or less the version we want.

**Section 3.7.3** then goes into some detail on the implementation of a Trait.
A Trait in Rust is like an Interface in other languages; it describes a
collection of functions for manipulating the values found in a defined Type.

**Types**: A Type is just a description of the value: `u16` is a sixteen-bit
unsigned integer; `char` is a Unicode character, and it's size is always 32bits,
but a `String` is a bunch of things: it's an array of characters (which are not
always `char`!), a length for that string and a capacity. If the String is
manipulated to exceed the capacity, the array is re-allocated to a new capacity
and the old array copied into it. A `Vec<String>` is an array of Strings; as a
Type, it is considered to have a single value: whatever is in it at the moment
you use it.

**Trait**: A Trait defines a collection of one or more functions that can
operate on a value.

The truly nifty thing about Traits is that they can be implemented after the
fact. By importing a Trait and an implementation of that trait specific to a
type into a module containing that type, you can extend the behavior of a type
in a deterministic way without having to modify or inherit the code, as you
would in an object-oriented language.

Axum has a valuable trait, `FromRequest`. For any structure you can imagine
passing from the client to the server, you can implement `FromRequest` for that
object and any content in the body of the message will be transformed into that
structure.

We've seen a trait before: `IntoResponse`, written as `impl IntoResponse`, and
is the output (the return type) of many of the functions that produce return
values for our application server. In this case the return type instructs Rust
to look in the current lexical scope and, for the value returned by that
function, determine if an `IntoResponse` trait has been defined for it. If it
has, the value will be returned because Axum has now been assured that there
exists a function to convert that value into something streamable and usable as
an HTTP response.

Fortunately for us, Axum has already implemented `FromRequest` for all the
native data types, as well as some structures and arrays.  Even better, it has
implemented `FromRequest` for the Serde serialization/deserialization library.

So in this example:

``` rust
pub struct FormData {
   username: String,
}

pub async fn index(payload: Option<Form<FormData>>) -> impl IntoResponse { ...
```

A `Form` (something using the `application/x-www-form-urlencoded` protocol) of
`FormData` will automatically be converted into a `payload` object of `{
username: "Spock" )`, and in this case wrapped in a `Some()` handler. (Or
`None`, if there was no form included.) <aside>So far, there's not too much
bloat in this product; with all the debugging symbols, it's 60MB or so, but
stripped to the bone it's only 3.1MB, tolerable for modern deployments.</aside>

First, though, we must adjust our `valid_subscription` test:

``` rust
    let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
    let response = hyper::Client::new()
        .request(
            Request::builder()
                .method("POST")
                .header("content-type", "application/x-www-form-urlencoded")
                .uri(format!("http://{}/subscriptions", addr))
                .body(Body::from(body))
                .unwrap(),
        )
        .await
        .expect("Failed to execute request.");
```

Two updates from the book: first, we're sending it via POST instead of GET. This
is the correct way to do things; a GET should never (and I mean *never*) cause a
change of state on the back-end. To send something new that the server will
process and store, you use a POST. (To update something, or to send something to
a known *and unique* URI, PUT is better.) Secondly, since we're using a generic
form-data object, we need to set the content-type on the client so that the
server is informed of how to unpack this payload. The '%20' and '%40' markers in
the `body` are the space and the `@` respectively.

I completely ignored the advice in the book and went instead with
[Dbmate](https://github.com/amacneil/dbmate); Dbmate is a bit cranky; your SQL
must be very much nestled against the 'up' and 'down' markers in the migration
file, and it seems to be quite opinionated about everything being lowercase.
That said, it was trivial to create a database with it:

``` sh
$ dbmate new create_subscriptions_table
```

This will create the folder
`db/migrations/20230322174957_create_subscriptions_table.sql`,
(The timestamp will be different, obviously), and in this file you put the
following, as specified in the book:

``` sql
-- migrate:up
create table subscriptions (
   id uuid not null,
   primary key (id),
   email text not null unique,
   name text not null,
   subscribed_at timestamptz not null
);

-- migrate:down
drop table subscriptions;
```

To use Dbmate, you have to specify how to connect.  I'm using Postgres, so let's
start with creating a database and a user for it:

``` sh
$ sudo -u postgres psql
[sudo] possword for user: ...................
postgres=# create database newsletter;
CREATE DATABASE
postgres=# create user newletter with encrypted password 'redacted';
CREATE USER
postgres=# grant all privileges on database newsletter to newsletter;
GRANT
postgres=# exit
```

In your project root, create a `.env` file to specify your connection:

``` sh
DATABASE_URL="postgres://newsletter:redacted@127.0.0.1:5432/newsletter?sslmode=disable"
```

The `sslmode` flag there is necessary for localhost connections, as Dbmate
assumes an encrypted connection by default, but we're isolating to a local
connection that is, usually, safe.

With the new entry in your `.env` file, you can now run a migration:

``` sh
$ dbmate up
Writing: ./db/schema.sql
```

Running `dbmate up` will automatically create the database for you if it hasn't
already; `dbmate migrate` also performs migrations, but it will not create the
database.

Now you can re-connect to Postgres as the newsletter user and see what you've
got:

``` sh
$ psql --user newsletter -h localhost --password
Password:
psql (14.7 (Ubuntu 14.7-0ubuntu0.22.04.1), server 11.7 (Ubuntu 11.7-0ubuntu0.19.10.1))
newsletter=> \d
                List of relations
 Schema |       Name        | Type  |   Owner
--------+-------------------+-------+------------
 public | schema_migrations | table | newsletter
 public | subscriptions     | table | newsletter
(2 rows)

newsletter=> \d subscriptions
                       Table "public.subscriptions"
    Column     |           Type           | Collation | Nullable | Default
---------------+--------------------------+-----------+----------+---------
 id            | uuid                     |           | not null |
 email         | text                     |           | not null |
 name          | text                     |           | not null |
 subscribed_at | timestamp with time zone |           | not null |
Indexes:
    "subscriptions_pkey" PRIMARY KEY, btree (id)
    "subscriptions_email_key" UNIQUE CONSTRAINT, btree (email)
```

Note that Dbmate has allocated a table to itself, `schema_migrations`, for
tracking what it's done to your system and when. Try not to conflict with it,
okay?

Every complex app has a configuration, and there are plenty of different ways
the configuration can be specified. Environment variables, internal defaults,
and configuration files-- the last of which comes in so many different flavors.

Rust has a well-known [config](https://docs.rs/config/latest/config/index.html)
crate that supports all the most common configurations: YAML, JSON, TOML; you
can even add your own by writing something that implements the `config::Format`
trait.  Add it to Cargo.toml:

``` sh
$ cargo add config
```

For the meantime, we're just going to create a new file, called
`configuration.rs`, and put our configuration details in there.  Right now we
have a single configuration detail: the port.

I'm going to go above and beyond Lucas here and configure some internal defaults
for my code.  It will have expectations.  First, you have to tell Serde that
there will be default values:

``` rust
use config::Config;

pub struct Settings {
    pub port: u16,
}
```

Then, you have to set those default values. Fortunately, Rust provides a "set
default values" trait named, sensibly enough, Default:

``` rust
impl Default for Settings {
    fn default() -> Self {
        Settings { port: 3001 }
    }
}
```

Again, exceeding the book's parameters, I'm going to say that if the file is
missing the default parameters should hold:

``` rust
pub(crate) fn get_configuration() -> Result<Settings, config::ConfigError> {
    Config::builder()
        .add_source(config::File::with_name("./ztd.config").required(false))
        .build()?
        .try_deserialize()
}
```

And since this is the first time I'm doing this, I'm going to write a test to
assert that my understanding of how this all works is correct:

``` rust
mod tests {
    use super::*;

    #[test]
    fn test_for_defaults() {
        let maybe_config = get_configuration();
        assert!(!maybe_config.is_err());
        let config = maybe_config.unwrap();
        assert_eq!(config.port, 3001);
    }
}
```
2023-03-24 07:51:19 -07:00
Elf M. Sternberg 89fb8188b7 Pre-commit checks and test refactorings.
Re-reading the text, I made a number of changes.  The first is that, while it is
nice that Rust allows us to have unit tests in the file whose functionality
we're testing, it's also nice to have the tests somewhere separate, and to have
the tests be a little more modular.

In the `./tests` folder, you can now see the same `health_check` test as the
original, but in an isolated and cleaned-up form.  Most importantly, the server
startup code is now in its own function, with a correct return type that
includes a handle to the spawned thread and the address on which that server is
listening; tests can be run in parallel on many different ports and a lot of
code duplication is eliminated.

``` rust
type NullHandle = JoinHandle<()>;

async fn spawn_server() -> (SocketAddr, NullHandle) {
    let listener = TcpListener::bind("127.0.0.1:0".parse::<SocketAddr>().unwrap()).unwrap();
    let addr = listener.local_addr().unwrap();

    let handle: NullHandle = tokio::spawn(async move {
        axum::Server::from_tcp(listener)
            .unwrap()
            .serve(app().into_make_service())
            .await
            .unwrap();
    });

    (addr, handle)
}
```

It is also possible now to add new tests in a straightforward manner.  The
Hyper API is not that much different from the Actix request API, and the Axum
extractors seem to be straightforward.  I suspect that what I'm looking at here
with the handle is the idea that, when it goes out of scope, it calls a d

In the introduction I said I was going to be neglecting CI/CD, since I'm a solo
developer. That's true, but I do like my guardrails. I like not being able to
commit garbage to the repository. So I'm going to add some checks, using
[Pre-Commit](https://pre-commit.com/).

Pre-Commit is a Python program, so we'll start by installing it. I'm using a
local Python environment kickstarted with
[Pyenv](https://github.com/pyenv/pyenv).

``` sh
$ pip install pre-commit
```

And inside your project, in the project root, you hook it up with the following commands:

``` sh
$ pre-commit install
$ pre-commit sample-config > .pre-commit-config.yaml
```

I'm going with the default from the rust pre-commit collection, so my
`.pre-commit-config.yaml` file looks like this:

``` yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
  rev: v3.1.0
  hooks:
    - id: check-byte-order-marker
    - id: check-case-conflict
    - id: check-merge-conflict
    - id: check-symlinks
    - id: check-yaml
    - id: end-of-file-fixer
    - id: mixed-line-ending
    - id: trailing-whitespace
- repo: https://github.com/pre-commit/pre-commit
  rev: v2.5.1
  hooks:
    - id: validate_manifest
- repo: https://github.com/doublify/pre-commit-rust
  rev: master
  hooks:
    - id: fmt
    - id: cargo-check
    - id: clippy
```

... and with that, every time I try to commit my code, it will not let me until
these tests pass.  And I *like* that level of discipline.  This is low-level
validation; it won't catch if I put addition where I meant subtraction, or if I
have a comparison going in the wrong direction, but at least the basics are
handled and, more importantly, the formatting and styling is consistent
throughout all of my code.
2023-03-21 17:52:44 -07:00