Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add shortcuts for defining authentication in a recipe #117

Merged
merged 1 commit into from
Mar 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading