diff --git a/CHANGELOG.md b/CHANGELOG.md index 48afa6bf..19692831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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 @@ -13,6 +14,7 @@ - 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 diff --git a/Cargo.lock b/Cargo.lock index bc7c629a..c258d5ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -424,6 +424,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "dirs" version = "5.0.1" @@ -1297,6 +1303,16 @@ version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69d3587f8a9e599cc7ec2c00e331f71c4e69a5f9a4b8a6efd5b07466b9736f9a" +[[package]] +name = "pretty_assertions" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro2" version = "1.0.70" @@ -1821,6 +1837,7 @@ dependencies = [ "nom", "notify", "open", + "pretty_assertions", "ratatui", "regex", "reqwest", @@ -2732,6 +2749,12 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fcb9cbac069e033553e8bb871be2fbdffcab578eb25bd0f7c508cedc6dcd75a" +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + [[package]] name = "zerocopy" version = "0.7.31" diff --git a/Cargo.toml b/Cargo.toml index cc9ec2de..5f278e6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"]} diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 88790917..f9aa79b5 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -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) diff --git a/docs/src/api/authentication.md b/docs/src/api/authentication.md new file mode 100644 index 00000000..6ee37b4e --- /dev/null +++ b/docs/src/api/authentication.md @@ -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= +``` diff --git a/docs/src/api/request_recipe.md b/docs/src/api/request_recipe.md index f0320b60..a784c89b 100644 --- a/docs/src/api/request_recipe.md +++ b/docs/src/api/request_recipe.md @@ -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 diff --git a/docs/src/user_guide/inheritance.md b/docs/src/user_guide/inheritance.md index 5ccab7f0..b86a3408 100644 --- a/docs/src/user_guide/inheritance.md +++ b/docs/src/user_guide/inheritance.md @@ -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 @@ -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: @@ -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: @@ -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" diff --git a/slumber.yml b/slumber.yml index 3b246513..c71e7974 100644 --- a/slumber.yml +++ b/slumber.yml @@ -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: diff --git a/src/collection/insomnia.rs b/src/collection/insomnia.rs index ff3985d0..60f6894a 100644 --- a/src/collection/insomnia.rs +++ b/src/collection/insomnia.rs @@ -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; @@ -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 { + pub fn from_insomnia( + insomnia_file: impl AsRef, + ) -> anyhow::Result { + let insomnia_file = insomnia_file.as_ref(); // First, deserialize into the insomnia format info!(file = ?insomnia_file, "Loading Insomnia collection"); eprintln!( @@ -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 { + None {}, + Some(T), +} + #[derive(Debug, Deserialize)] struct Environment { #[serde(rename = "_id")] @@ -96,15 +109,16 @@ struct Request { name: String, url: Template, method: String, - authentication: Authentication, + authentication: Opshit, headers: Vec
, parameters: Vec, - body: Body, + body: Opshit, } #[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 } @@ -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 for Profile { @@ -155,24 +161,31 @@ impl From for Recipe { let mut headers: IndexMap = 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(), @@ -180,8 +193,8 @@ impl From for Recipe { 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 @@ -189,6 +202,28 @@ impl From for Recipe { .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); + } +} diff --git a/src/collection/models.rs b/src/collection/models.rs index e69199e4..24cb9d01 100644 --- a/src/collection/models.rs +++ b/src/collection/models.rs @@ -14,6 +14,7 @@ use std::path::PathBuf; /// A collection of profiles, requests, etc. This is the primary Slumber unit /// of configuration. #[derive(Debug, Default, Serialize, Deserialize)] +#[cfg_attr(test, derive(PartialEq))] pub struct Collection { #[serde(default, deserialize_with = "cereal::deserialize_id_map")] pub profiles: IndexMap, @@ -31,6 +32,7 @@ pub struct Collection { /// Mutually exclusive hot-swappable config group #[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(test, derive(PartialEq))] pub struct Profile { #[serde(skip)] // This will be auto-populated from the map key pub id: ProfileId, @@ -80,6 +82,7 @@ pub enum ProfileValue { /// not called `RequestTemplate` because the word "template" has a specific /// meaning related to string interpolation. #[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(test, derive(PartialEq))] pub struct Recipe { #[serde(skip)] // This will be auto-populated from the map key pub id: RecipeId, @@ -89,6 +92,7 @@ pub struct Recipe { pub method: String, pub url: Template, pub body: Option