Skip to content

Commit

Permalink
Add shortcuts for defining authentication in a recipe
Browse files Browse the repository at this point in the history
For now only Basic and Bearer auth are supported, but more can be added later. Closes #110
  • Loading branch information
LucasPickering committed Mar 17, 2024
1 parent bda224c commit dfc04c0
Show file tree
Hide file tree
Showing 17 changed files with 533 additions and 91 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@

- Add config option `ignore_certificate_hosts` ([#109](https://github.com/LucasPickering/slumber/issues/109))
- Add menu action to open collection file in editor ([#105](https://github.com/LucasPickering/slumber/issues/105))
- Add `authentication` field to request recipe ([#110](https://github.com/LucasPickering/slumber/issues/110))

### Fixed

- Fix prompt in TUI always rendering as sensitive ([#108](https://github.com/LucasPickering/slumber/issues/108))
- Fix content type identification for extended JSON MIME types ([#103](https://github.com/LucasPickering/slumber/issues/103))
- Use named records in binary blobs in the local DB
- This required wiping out existing binary blobs, meaning **all request history and UI state will be lost on upgrade**
- Fix basic auth in Insomnia import

## [0.13.1] - 2024-03-07

Expand Down
23 changes: 23 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ itertools = "^0.12.0"
nom = "7.1.3"
notify = {version = "^6.1.1", default-features = false, features = ["macos_fsevent"]}
open = "5.1.1"
pretty_assertions = "1.4.0"
ratatui = "^0.26.0"
regex = { version = "1.10.3", default-features = false, features = ["perf"] }
reqwest = {version = "^0.11.20", default-features = false, features = ["rustls-tls"]}
Expand Down
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- [Profile](./api/profile.md)
- [Profile Value](./api/profile_value.md)
- [Request Recipe](./api/request_recipe.md)
- [Authentication](./api/authentication.md)
- [Chain](./api/chain.md)
- [Chain Source](./api/chain_source.md)
- [Template](./api/template.md)
Expand Down
29 changes: 29 additions & 0 deletions docs/src/api/authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Authentication

Authentication provides shortcuts for common HTTP authentication schemes. It populates the `authentication` field of a recipe. There are multiple source types, and the type is specified using [YAML's tag syntax](https://yaml.org/spec/1.2.2/#24-tags).

## Variants

| Variant | Type | Value |
| -------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `basic` | [`Basic Authentication`](#basic-authentication) | [Basic authentication](https://swagger.io/docs/specification/authentication/basic-authentication/) credentials |
| `bearer` | `string` | [Bearer token](https://swagger.io/docs/specification/authentication/bearer-authentication/) |

### Basic Authentication

Basic authentication contains a username and optional password.

| Field | Type | Description | Default |
| ---------- | -------- | ----------- | -------- |
| `username` | `string` | Username | Required |
| `password` | `string` | Password | `""` |

## Examples

```yaml
!basic
username: user
password: pass
---
!bearer 4J2e0TYqKA3gFllfTu17OF7n8g1CeAxZyi/MK5g40/o=
```
17 changes: 9 additions & 8 deletions docs/src/api/request_recipe.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ A request recipe defines how to make a particular request. For a REST API, you'l

## Fields

| Field | Type | Description | Default |
| --------- | -------------------------------------------- | --------------------------------- | ---------------------- |
| `name` | `string` | Descriptive name to use in the UI | Value of key in parent |
| `method` | `string` | HTTP request method | Required |
| `url` | [`Template`](./template.md) | HTTP request URL | Required |
| `query` | [`mapping[string, Template]`](./template.md) | HTTP request query parameters | `{}` |
| `headers` | [`mapping[string, Template]`](./template.md) | HTTP request headers | `{}` |
| `body` | [`Template`](./template.md) | HTTP request body | `null` |
| Field | Type | Description | Default |
| ---------------- | -------------------------------------------- | --------------------------------- | ---------------------- |
| `name` | `string` | Descriptive name to use in the UI | Value of key in parent |
| `method` | `string` | HTTP request method | Required |
| `url` | [`Template`](./template.md) | HTTP request URL | Required |
| `query` | [`mapping[string, Template]`](./template.md) | HTTP request query parameters | `{}` |
| `headers` | [`mapping[string, Template]`](./template.md) | HTTP request headers | `{}` |
| `authentication` | [`Authentication`](./authentication.md) | Authentication scheme | `null` |
| `body` | [`Template`](./template.md) | HTTP request body | `null` |

## Examples

Expand Down
11 changes: 4 additions & 7 deletions docs/src/user_guide/inheritance.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ requests:
url: "{{host}}/fishes"
headers:
Accept: application/json
Authorization: Bearer {{chains.token}}
authentication: !bearer "{{chains.token}}"

get_fish:
method: GET
url: "{{host}}/fishes/{{fish_id}}"
headers:
Accept: application/json
Authorization: Bearer {{chains.token}}
authentication: !bearer "{{chains.token}}"
```
## The Solution
Expand All @@ -51,7 +51,7 @@ chains:
request_base: &request_base
headers:
Accept: application/json
Authorization: Bearer {{chains.auth_token}}
authentication: !bearer "{{chains.token}}"

requests:
list_fish:
Expand Down Expand Up @@ -85,7 +85,7 @@ chains:
request_base: &request_base
headers: &headers_base # This will let us pull in the header map to extend it
Accept: application/json
Authorization: Bearer {{chains.auth_token}}
authentication: !bearer "{{chains.token}}"
requests:
list_fish:
Expand All @@ -99,9 +99,6 @@ requests:
url: "{{host}}/fishes/{{chains.fish_id}}"
create_fish:
# Note: in this case, pulling in request_base doesn't do anything since we
# then overwite its only field (headers), but this is good practice in case
# you add an additional field to request_base
<<: *request_base
method: POST
url: "{{host}}/fishes"
Expand Down
2 changes: 1 addition & 1 deletion slumber.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ chains:
selector: $.headers["X-Amzn-Trace-Id"]

base: &base
authentication: !bearer "{{chains.auth_token}}"
headers:
Accept: application/json
Authorization: Bearer {{chains.auth_token}}
Content-Type: application/json

requests:
Expand Down
95 changes: 65 additions & 30 deletions src/collection/insomnia.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
//! format
use crate::{
collection::{Collection, Profile, Recipe},
collection::{self, Collection, Profile, Recipe},
template::Template,
};
use anyhow::Context;
use indexmap::IndexMap;
use reqwest::header;
use serde::Deserialize;
use std::{fs::File, path::Path};
use tracing::info;
Expand All @@ -17,7 +18,10 @@ impl Collection {
///
/// This is not async because it's only called by the CLI, where we don't
/// care about blocking. It keeps the code simpler.
pub fn from_insomnia(insomnia_file: &Path) -> anyhow::Result<Self> {
pub fn from_insomnia(
insomnia_file: impl AsRef<Path>,
) -> anyhow::Result<Self> {
let insomnia_file = insomnia_file.as_ref();
// First, deserialize into the insomnia format
info!(file = ?insomnia_file, "Loading Insomnia collection");
eprintln!(
Expand Down Expand Up @@ -80,6 +84,15 @@ enum Resource {
ApiSpec,
}

/// A shitty option type. Insomnia uses empty map instead of `null` for empty
/// values in some cases. This type makes that easy to deserialize.
#[derive(Debug, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
enum Opshit<T> {
None {},
Some(T),
}

#[derive(Debug, Deserialize)]
struct Environment {
#[serde(rename = "_id")]
Expand All @@ -96,15 +109,16 @@ struct Request {
name: String,
url: Template,
method: String,
authentication: Authentication,
authentication: Opshit<Authentication>,
headers: Vec<Header>,
parameters: Vec<Parameter>,
body: Body,
body: Opshit<Body>,
}

#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Authentication {
Basic { username: String, password: String },
Bearer { token: String },
// Punting on other types for now
}
Expand All @@ -121,19 +135,11 @@ struct Parameter {
value: Template,
}

/// This can't be an `Option` because the empty case is an empty object, not
/// null
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Body {
// This has to go *first*, otherwise all objects will match the empty case
#[serde(rename_all = "camelCase")]
Body {
mime_type: String,
text: Template,
},
// This matches empty object, so it has to be a struct variant
Empty {},
#[serde(rename_all = "camelCase")]
struct Body {
mime_type: String,
text: Template,
}

impl From<Environment> for Profile {
Expand All @@ -155,40 +161,69 @@ impl From<Request> for Recipe {
let mut headers: IndexMap<String, Template> = IndexMap::new();

// Preload headers from implicit sources
if let Body::Body { mime_type, .. } = &request.body {
if let Opshit::Some(Body { mime_type, .. }) = &request.body {
headers.insert(
"content-type".into(),
Template::dangerous_new(mime_type.clone()),
header::CONTENT_TYPE.as_str().into(),
Template::dangerous(mime_type.clone()),
);
}
match request.authentication {
Authentication::Bearer { token } => {
headers.insert(
"authorization".into(),
Template::dangerous_new(format!("Bearer {token}")),
);
}
}
// Load explicit headers *after* so we can override the implicit stuff
for header in request.headers {
headers.insert(header.name, header.value);
headers.insert(header.name.to_lowercase(), header.value);
}
headers.remove(header::USER_AGENT.as_str());

// Load authentication scheme
let authentication = match request.authentication {
Opshit::None {} => None,
Opshit::Some(Authentication::Basic { username, password }) => {
Some(collection::Authentication::Basic {
username: Template::dangerous(username),
password: Some(Template::dangerous(password)),
})
}
Opshit::Some(Authentication::Bearer { token }) => Some(
collection::Authentication::Bearer(Template::dangerous(token)),
),
};

Recipe {
id: request.id.into(),
name: Some(request.name),
method: request.method,
url: request.url,
body: match request.body {
Body::Empty {} => None,
Body::Body { text, .. } => Some(text),
Opshit::None {} => None,
Opshit::Some(Body { text, .. }) => Some(text),
},
query: request
.parameters
.into_iter()
.map(|parameter| (parameter.name, parameter.value))
.collect(),
headers,
authentication,
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::collection::CollectionFile;
use pretty_assertions::assert_eq;

const INSOMNIA_FILE: &str = "./test_data/insomnia.json";
const INSOMNIA_IMPORTED_FILE: &str = "./test_data/insomnia_imported.yml";

/// Catch-all test for insomnia import
#[tokio::test]
async fn test_insomnia_import() {
let imported = Collection::from_insomnia(INSOMNIA_FILE).unwrap();
let expected = CollectionFile::load(INSOMNIA_IMPORTED_FILE.into())
.await
.unwrap()
.collection;
assert_eq!(imported, expected);
}
}
Loading

0 comments on commit dfc04c0

Please sign in to comment.