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 recursive templates #37

Merged
merged 2 commits into from
Nov 16, 2023
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased] - ReleaseDate

### Added

- Added recursive templates for profile values, using the `!template` tag before a value

### Changed

- Parse templates up front instead of during render
Expand Down
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

- [Request Collection](./api/request_collection.md)
- [Profile](./api/profile.md)
- [Profile Value](./api/profile_value.md)
- [Request Recipe](./api/request_recipe.md)
- [Chain](./api/chain.md)
- [Chain Source](./api/chain_source.md)
Expand Down
4 changes: 2 additions & 2 deletions docs/src/api/chain_source.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

A chain source defines how a [Chain](./chain.md) gets its value. It populates the `source` field of a chain. There are multiple source types, and the type is specified using [YAML's tag syntax](https://yaml.org/spec/1.2.2/#24-tags).

## Types
## Variants

| Type | Type | Value | Chained Value |
| Variant | Type | Value | Chained Value |
| --------- | ---------- | ------------------------------------ | --------------------------------------------------------------- |
| `request` | `string` | Request Recipe ID | Body of the most recent response for a specific request recipe. |
| `command` | `string[]` | `[program, ...arguments]` | Stdout of the executed command |
Expand Down
15 changes: 9 additions & 6 deletions docs/src/api/profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,23 @@

A profile is a collection of static template values. It's useful for configuring and switching between multiple different environments/settings/etc.

Profiles also support nested templates, via the `!template` tag.

## Fields

| Field | Type | Description | Default |
| ------ | ------------------------- | ------------------------------------- | ------------- |
| `id` | `string` | Unique identifier for this profile | Required |
| `name` | `string` | Descriptive name to use in the UI | Value of `id` |
| `data` | `mapping[string, string]` | Fields, mapped to their static values | `{}` |
| ≈ | Field | Type | Description | Default |
| ------ | ----------------------------------------------------- | ---------------------------------- | ------------- | ------- |
| `id` | `string` | Unique identifier for this profile | Required |
| `name` | `string` | Descriptive name to use in the UI | Value of `id` |
| `data` | [`mapping[string, ProfileValue]`](./profile_value.md) | Fields, mapped to their values | `{}` |

## Examples

```yaml
id: local
name: Local
data:
host: http://localhost:5000
host: localhost:5000
url: !template "https://{{host}}"
user_guid: abc123
```
23 changes: 23 additions & 0 deletions docs/src/api/profile_value.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Profile Value

A profile value is the value associated with a particular key in a profile. Typically profile values are just simple strings, but they can also be other variants.

In the case of a nested template, the inner template will be rendered into its own value, then injected into the outer string.

## Variants

| Variant | Type | Description |
| ---------- | --------------------------- | --------------------------------------------- |
| `raw` | `string` | Static string (key can optionally be omitted) |
| `template` | [`Template`](./template.md) | Nested template, to be rendered inline |

## Examples

```yaml
!raw http://localhost:5000
---
# The !raw key is the default, and can be omitted
http://localhost:5000
---
!template http://{{hostname}}
```
3 changes: 2 additions & 1 deletion slumber.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ profiles:
name: Works
data:
host: https://httpbin.org
username: !template "xX{{chains.username}}Xx"
user_guid: abc123
- id: init-fails
name: Request Init Fails
Expand Down Expand Up @@ -39,7 +40,7 @@ requests:
sudo: yes_please
body: |
{
"username": "{{chains.username}}",
"username": "{{username}}",
"password": "{{chains.password}}"
}

Expand Down
2 changes: 1 addition & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,6 @@ where
{
let (key, value) = s
.split_once('=')
.ok_or_else(|| format!("invalid key=value: no \"=\" found in {s:?}"))?;
.ok_or_else(|| format!("invalid key=value: no \"=\" found in `{s}`"))?;
Ok((key.parse()?, value.parse()?))
}
161 changes: 159 additions & 2 deletions src/collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@ use crate::template::Template;
use anyhow::{anyhow, Context};
use derive_more::{Deref, Display, From};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde::{
de::{EnumAccess, VariantAccess},
Deserialize, Deserializer, Serialize,
};
use serde_json_path::JsonPath;
use std::{
fmt,
future::Future,
marker::PhantomData,
path::{Path, PathBuf},
};
use tokio::fs;
Expand Down Expand Up @@ -57,7 +62,7 @@ pub struct CollectionId(String);
pub struct Profile {
pub id: ProfileId,
pub name: Option<String>,
pub data: IndexMap<String, String>,
pub data: IndexMap<String, ProfileValue>,
}

#[derive(
Expand All @@ -75,6 +80,21 @@ pub struct Profile {
)]
pub struct ProfileId(String);

/// The value type of a profile's data mapping
#[derive(Clone, Debug, Serialize)]
#[cfg_attr(test, derive(PartialEq))]
#[serde(rename_all = "snake_case")]
pub enum ProfileValue {
/// A raw text string
Raw(String),
/// A nested template, which allows for recursion. By requiring the user to
/// declare this up front, we can parse the template during collection
/// deserialization. It also keeps a cap on the complexity of nested
/// templates, which is a balance between usability and simplicity (both
/// for the user and the code).
Template(Template),
}

/// A definition of how to make a request. This is *not* called `Request` in
/// order to distinguish it from a single instance of an HTTP request. And it's
/// not called `RequestTemplate` because the word "template" has a specific
Expand Down Expand Up @@ -234,9 +254,146 @@ impl Profile {
}
}

impl From<String> for ProfileValue {
fn from(value: String) -> Self {
Self::Raw(value)
}
}

impl From<&str> for ProfileValue {
fn from(value: &str) -> Self {
Self::Raw(value.into())
}
}

impl RequestRecipe {
/// Get a presentable name for this recipe
pub fn name(&self) -> &str {
self.name.as_deref().unwrap_or(&self.id)
}
}

/// Deserialize a string OR enum into a ProfileValue. This is based on the
/// generated derive code, with extra logic to default to !raw for a string.
impl<'de> Deserialize<'de> for ProfileValue {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
const VARIANTS: &[&str] = &["raw", "template"];

enum Field {
Raw,
Template,
}

struct FieldVisitor;
impl<'de> serde::de::Visitor<'de> for FieldVisitor {
type Value = Field;

fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "variant identifier")
}

fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
match value {
0u64 => Ok(Field::Raw),
1u64 => Ok(Field::Template),
_ => Err(serde::de::Error::invalid_value(
serde::de::Unexpected::Unsigned(value),
&"variant index 0 <= i < 2",
)),
}
}

fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
match value {
"raw" => Ok(Field::Raw),
"template" => Ok(Field::Template),
_ => {
Err(serde::de::Error::unknown_variant(value, VARIANTS))
}
}
}

fn visit_bytes<E>(self, value: &[u8]) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
match value {
b"raw" => Ok(Field::Raw),
b"template" => Ok(Field::Template),
_ => {
let value = String::from_utf8_lossy(value);
Err(serde::de::Error::unknown_variant(&value, VARIANTS))
}
}
}
}

impl<'de> serde::Deserialize<'de> for Field {
#[inline]
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
serde::Deserializer::deserialize_identifier(
deserializer,
FieldVisitor,
)
}
}

struct Visitor<'de> {
lifetime: PhantomData<&'de ()>,
}

impl<'de> serde::de::Visitor<'de> for Visitor<'de> {
type Value = ProfileValue;

fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "enum ProfileValue or string",)
}

fn visit_enum<A>(self, data: A) -> Result<Self::Value, A::Error>
where
A: EnumAccess<'de>,
{
match EnumAccess::variant(data)? {
(Field::Raw, variant) => Result::map(
VariantAccess::newtype_variant::<String>(variant),
ProfileValue::Raw,
),
(Field::Template, variant) => Result::map(
VariantAccess::newtype_variant::<Template>(variant),
ProfileValue::Template,
),
}
}

fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(ProfileValue::Raw(value.into()))
}

fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(ProfileValue::Raw(value))
}
}

deserializer.deserialize_any(Visitor {
lifetime: PhantomData,
})
}
}
6 changes: 5 additions & 1 deletion src/collection/insomnia.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,11 @@ impl From<Environment> for Profile {
Profile {
id: environment.id.into(),
name: Some(environment.name),
data: environment.data,
data: environment
.data
.into_iter()
.map(|(k, v)| (k, v.into()))
.collect(),
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ impl RequestBuilder {
// are invalid
Ok::<_, anyhow::Error>((
HeaderName::try_from(header).with_context(|| {
format!("Error parsing header name {header:?}")
format!("Error parsing header name `{header}`")
})?,
HeaderValue::try_from(
value_template
Expand Down
2 changes: 1 addition & 1 deletion src/http/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ pub(super) fn parse_body(
let body = &response.body;
match get_content_type(response)? {
Json::HEADER => Ok(Box::new(Json::parse(body.text())?)),
other => Err(anyhow!("Response has unknown content-type {other:?}",)),
other => Err(anyhow!("Response has unknown content-type `{other}`",)),
}
}

Expand Down
Loading
Loading