Fetching Data

Like a Labrador chasing a tennis ball, we be fetchin'

Fetching should be much less setup than storing secrets, mostly because it uses the same libraries! Fetching will be done via a GET request that has the secret ID in the path and the code in a query parameter to decrypt the secret.

Let's start by adding a fetch method to the Secret model in src/models/secret.rs.

impl Secret {
    
    // Old code/insert method
    
    // New method!
    pub async fn fetch_and_decode_secret(
        pool: &Pool<Postgres>,
        id: &Uuid,
        code: &str,
    ) -> Result<String, SecretError> {
        // 1
        let secret: Secret = sqlx::query_as(
            "
            DELETE FROM secrets WHERE secret_id = $1 RETURNING secret_id, encoded_secret, salt, created_at, expires_at ",
        )
        .bind(&id)
        .fetch_one(pool)
        .await
        .map_err(|e| SecretError::SqlError(e))?;
        
        // 2
        let salt = SaltString::new(&secret.salt).map_err(|_e| SecretError::HashingError)?;
        let password_hash = Argon2::default()
            .hash_password(code.as_bytes(), &salt)
            .map_err(|_e| SecretError::HashingError)?
            .to_string();

        let parsed_hash =
            PasswordHash::new(&password_hash).map_err(|_e| SecretError::HashingError)?;

        let cipher = Aes256Gcm::new_from_slice(&parsed_hash.hash.unwrap().as_bytes())
            .map_err(|_e| SecretError::HashingError)?;
        let nonce = Nonce::from_slice(b"012345678912");

        // 3
        Ok(from_utf8(
            &cipher
                .decrypt(
                    nonce,
                    base64::decode(secret.encoded_secret)
                        .expect("Should always decrypt")
                        .as_slice(),
                )
                .map_err(|_e| SecretError::HashingError)?,
        )
        .map_err(|e| SecretError::DecodingError(e))?
        .to_string())
    }
}
  1. First we fetch the secret using the ID passed in, pretty simple stuff but we also are deleting the secret when it's fetched once. This prevents it from hanging around after access. Later, we might consider adding a counter to the secret so that you can access it n times, but we'll just delete it for now.

  2. We use the salt and the code to make a new password hash using Argon2id. Remember -- the "password" for our AES encryption is actually the hashed result of Argon2, not just the code itself.

  3. We use the hashed value to decode the encrypted secret. We also have to decode the base64 into bytes before we can decrypt it. All the errors here are wrapped similarly to before so that we can have a simple return type.

Now we need a new route to call this function. Add a new function to src/controllers/secrets.rs;

// Earlier stuff in the file up here

// 1
#[derive(Serialize, Deserialize)]
pub struct GetSecretRes {
    pub secret: String,
}

#[middleware_fn]
pub async fn get_secret(mut context: Ctx, _next: MiddlewareNext<Ctx>) -> MiddlewareResult<Ctx> {
    // 2
    let id = Uuid::parse_str(
        &context
            .params()
            .get("id")
            .expect("get_secret was called without an :id in the route")
            .param,
    )
    .map_err(|_e| ThrusterError::generic_error(Ctx::new_without_request(context.extra.clone())))?;

    // 3
    let code = context.query_params.get("code").ok_or_else(|| {
        ThrusterError::parsing_error(
            Ctx::new_without_request(context.extra.clone()),
            "code is a required query parameter",
        )
    })?;

    // 4
    let secret = Secret::fetch_and_decode_secret(&context.extra.pool, &id, &code)
        .await
        .map_err(|_e| {
            ThrusterError::not_found_error(Ctx::new_without_request(context.extra.clone()))
        })?;

    // 5
    context
        .json(&GetSecretRes { secret })
        .expect("Could not create JSON from known entities");

    Ok(context)
}
  1. First we define the response object. Similar to before, we want to derive both Serialize and Deserialize for testing.

  2. This is how route parameters are accessed in thruster. A route parameter is a part of the route that is useful for the middleware to know, and can be any string. For example, a route to fetch information about a specific user might be defined later as /users/:id, where id is the name of the route parameter. You can use as many route parameters in a route as you'd like, as long as they're separated by a / -- in other words, thruster doesn't allow compound route parameters. Note that we're also parsing the parameter into a UUID. This is only because we defined it in the database as such, if it were just a short string then you could easily use the raw string passed in.

  3. Now we fetch the code. Remember from earlier, the code isn't in the route, but is a query param, i.e. it's defined as /secrets/some-uuid?code=somesecretcode. The specific type of context we're using exposes this as a field, query_params which is a simple map of strings to strings. We want to make sure that it exists, and return a general 400 error if it wasn't passed in the URL.

  4. This is the method we just made -- yay!

  5. Just like from storing data, we use the builtin json method to serialize our response object.

Last step to make it work, we need to add the route to the app. Open up src/app.rs and add the following:

use thruster::middleware::query_params::query_params;

// All of the old code...

        .post("/secrets", m![create_secret]) // This is the old line
        .get("/secrets/:id", m![query_params, get_secret]) // This is the new line

Okay, there are a few interesting things going on in this short snippet, let's go over the interesting pieces. First, we're importing a new middleware; query_params. We have to do this because by default, thruster does not parse query parameters. It doesn't parse any query parameters because it can be an expensive operation to perform, so it doesn't bother doing it unless the developer actually needs it.

This brings us to the most powerful part of thruster; the ability to chain middleware together. In order to update the context so that it properly parses the query parameters from the incoming url, we need for it to run before our new get_secret middleware. We simply add it before in the list of middleware in the m! macro! When the route gets called, it will call the middleware, in order, passing the same context to the next middleware function as it executes. This is extremely important for composability and mirrors closely how frameworks like Koa and Express work in NodeJS.

The last piece to point out is the colon in the route string. A colon before a string signifies that this is actually one of those route params we discussed earlier. It essentially acts as a wildcard for that route, so it will match on basically any valid url that matches the pattern. This can be both powerful, and frustrating, if you forget that it's a wildcard.

With that, our basic functionality is complete! To test it out, take the code you received from the Storing Data step and run another curl command:

$ curl 'http://localhost:4321/secrets/0398567c-a172-47e1-bceb-6e46429bf5c4?code=tEUP3oFP8vUpvZW00Zrjd1qw'
{"secret":"test message"}

You should successfully get your original message back out! If you run this again, however, you should receive a 404 error (if using curl you might need to add a -v flag to see the actual status.)

Last updated