use std::path::PathBuf;
use serde::Deserialize;
use crate::error::{Error, Result};
use crate::types::{ApiVersion, DEFAULT_API_VERSION, ShopDomain};
use crate::verify::SecretSet;
pub const CONFIG_SECTION: &str = "shopify";
pub const API_SECRET_ENV: &str = "SHOPIFY_API_SECRET";
pub const PREVIOUS_API_SECRET_ENV: &str = "SHOPIFY_API_SECRET_PREVIOUS";
pub const ACCESS_TOKEN_ENV: &str = "SHOPIFY_ACCESS_TOKEN";
const SECRET_KEYS: [&str; 6] = [
"api_secret",
"client_secret",
"access_token",
"secret",
"token",
"api_secret_previous",
];
#[derive(Clone)]
pub struct ShopifyConfig {
pub api_key: Option<String>,
pub shop: Option<String>,
pub api_version: String,
pub scopes: Vec<String>,
pub app_url: Option<String>,
pub after_install_path: String,
pub max_throttle_retries: u32,
api_secret: Option<String>,
previous_api_secret: Option<String>,
access_token: Option<String>,
}
impl std::fmt::Debug for ShopifyConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let redact = |value: &Option<String>| value.as_ref().map(|_| "<redacted>");
f.debug_struct("ShopifyConfig")
.field("api_key", &self.api_key)
.field("shop", &self.shop)
.field("api_version", &self.api_version)
.field("scopes", &self.scopes)
.field("app_url", &self.app_url)
.field("after_install_path", &self.after_install_path)
.field("max_throttle_retries", &self.max_throttle_retries)
.field("api_secret", &redact(&self.api_secret))
.field("previous_api_secret", &redact(&self.previous_api_secret))
.field("access_token", &redact(&self.access_token))
.finish()
}
}
impl Default for ShopifyConfig {
fn default() -> Self {
Self {
api_key: None,
shop: None,
api_version: DEFAULT_API_VERSION.to_owned(),
scopes: Vec::new(),
app_url: None,
after_install_path: "/".to_owned(),
max_throttle_retries: 3,
api_secret: None,
previous_api_secret: None,
access_token: None,
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConfig {
api_key: Option<String>,
shop: Option<String>,
api_version: Option<String>,
scopes: Option<Scopes>,
app_url: Option<String>,
after_install_path: Option<String>,
max_throttle_retries: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Scopes {
List(Vec<String>),
Csv(String),
}
impl ShopifyConfig {
pub fn load() -> Result<Self> {
let path = config_file_path();
let text = match std::fs::read_to_string(&path) {
Ok(text) => Some(text),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => {
return Err(Error::Config(format!(
"cannot read {}: {err}",
path.display()
)));
}
};
Self::from_sources(text.as_deref(), |name| std::env::var(name).ok())
}
pub fn from_sources(
toml_text: Option<&str>,
env: impl Fn(&str) -> Option<String>,
) -> Result<Self> {
let mut config = match toml_text {
Some(text) => Self::from_toml_str(text)?,
None => Self::default(),
};
config.apply_env_with(env)?;
Ok(config)
}
pub fn from_toml_str(document: &str) -> Result<Self> {
let table: toml::Table = document
.parse()
.map_err(|err| Error::Config(format!("invalid autumn.toml: {err}")))?;
let Some(section) = table.get(CONFIG_SECTION) else {
return Ok(Self::default());
};
let section = section
.as_table()
.ok_or_else(|| Error::Config("[shopify] must be a table".to_owned()))?;
if let Some(key) = section.keys().find(|key| is_secret_key(key)) {
return Err(Error::Config(format!(
"[shopify] {key} is not allowed: secrets come only from the \
{API_SECRET_ENV}, {PREVIOUS_API_SECRET_ENV}, and {ACCESS_TOKEN_ENV} \
environment variables"
)));
}
let raw: RawConfig = section
.clone()
.try_into()
.map_err(|err| Error::Config(format!("invalid [shopify] section: {err}")))?;
let defaults = Self::default();
Ok(Self {
api_key: raw.api_key,
shop: raw.shop,
api_version: raw.api_version.unwrap_or(defaults.api_version),
scopes: match raw.scopes {
Some(Scopes::List(list)) => list,
Some(Scopes::Csv(csv)) => split_csv(&csv),
None => Vec::new(),
},
app_url: raw.app_url,
after_install_path: raw
.after_install_path
.unwrap_or(defaults.after_install_path),
max_throttle_retries: raw
.max_throttle_retries
.unwrap_or(defaults.max_throttle_retries),
api_secret: None,
previous_api_secret: None,
access_token: None,
})
}
pub fn apply_env_with(&mut self, get: impl Fn(&str) -> Option<String>) -> Result<()> {
let get = |name: &str| get(name).filter(|value| !value.trim().is_empty());
if let Some(value) = get("SHOPIFY_AFTER_INSTALL_PATH") {
self.after_install_path = value;
}
if let Some(value) = get("SHOPIFY_MAX_THROTTLE_RETRIES") {
self.max_throttle_retries = value.trim().parse().map_err(|_| {
Error::Config("SHOPIFY_MAX_THROTTLE_RETRIES must be a number".to_owned())
})?;
}
if let Some(value) = get("SHOPIFY_API_KEY") {
self.api_key = Some(value);
}
if let Some(value) = get("SHOPIFY_SHOP") {
self.shop = Some(value);
}
if let Some(value) = get("SHOPIFY_API_VERSION") {
self.api_version = value;
}
if let Some(value) = get("SHOPIFY_APP_URL") {
self.app_url = Some(value);
}
if let Some(value) = get("SHOPIFY_SCOPES") {
self.scopes = split_csv(&value);
}
if let Some(value) = get(API_SECRET_ENV) {
self.api_secret = Some(value);
}
if let Some(value) = get(PREVIOUS_API_SECRET_ENV) {
self.previous_api_secret = Some(value);
}
if let Some(value) = get(ACCESS_TOKEN_ENV) {
self.access_token = Some(value);
}
Ok(())
}
pub fn validate(&self) -> Result<()> {
self.secrets()?;
self.shop_domain()?;
self.api_version()?;
if self.max_throttle_retries > MAX_THROTTLE_RETRIES {
return Err(Error::Config(format!(
"max_throttle_retries must be at most {MAX_THROTTLE_RETRIES}"
)));
}
validate_local_path(&self.after_install_path)?;
if let Some(app_url) = &self.app_url {
validate_app_url(app_url)?;
if self
.api_key
.as_deref()
.is_none_or(|key| key.trim().is_empty())
{
return Err(Error::Config(
"OAuth needs [shopify] api_key (the app client id)".to_owned(),
));
}
if self.scopes.is_empty() {
return Err(Error::Config("OAuth needs [shopify] scopes".to_owned()));
}
if let Some(scope) = self.scopes.iter().find(|scope| !is_scope(scope)) {
return Err(Error::Config(format!(
"scope {scope:?} must be lowercase letters, digits, and '_'"
)));
}
}
Ok(())
}
#[must_use]
pub const fn oauth_enabled(&self) -> bool {
self.app_url.is_some()
}
pub(crate) fn secrets(&self) -> Result<SecretSet> {
let current = self.api_secret.clone().ok_or_else(|| {
Error::Config(format!(
"the client secret is missing; set the {API_SECRET_ENV} environment variable"
))
})?;
SecretSet::new(current, self.previous_api_secret.clone())
}
#[must_use]
pub(crate) fn access_token(&self) -> Option<&str> {
self.access_token.as_deref()
}
pub fn shop_domain(&self) -> Result<Option<ShopDomain>> {
self.shop
.as_deref()
.map(|shop| {
ShopDomain::parse(shop.trim()).map_err(|_| {
Error::Config(format!(
"[shopify] shop {shop:?} must be a *.myshopify.com domain"
))
})
})
.transpose()
}
pub fn api_version(&self) -> Result<ApiVersion> {
ApiVersion::parse(&self.api_version)
}
#[must_use]
pub fn redirect_uri(&self) -> Option<String> {
self.app_url.as_deref().map(|url| {
format!(
"{}{}",
url.trim_end_matches('/'),
crate::oauth::CALLBACK_PATH
)
})
}
}
const MAX_THROTTLE_RETRIES: u32 = 10;
fn is_secret_key(key: &str) -> bool {
let key = key.to_ascii_lowercase().replace('-', "_");
SECRET_KEYS.contains(&key.as_str()) || key.ends_with("_secret") || key.ends_with("_token")
}
fn split_csv(text: &str) -> Vec<String> {
text.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn is_scope(scope: &str) -> bool {
!scope.is_empty()
&& scope
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
fn validate_app_url(app_url: &str) -> Result<()> {
let url = url::Url::parse(app_url)
.map_err(|_| Error::Config(format!("app_url {app_url:?} is not a valid URL")))?;
let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"));
let scheme_ok = url.scheme() == "https" || (url.scheme() == "http" && loopback);
if !scheme_ok {
return Err(Error::Config(
"app_url must use https (http only for localhost)".to_owned(),
));
}
if url.query().is_some() || url.fragment().is_some() {
return Err(Error::Config(
"app_url must not have a query or a fragment".to_owned(),
));
}
Ok(())
}
fn validate_local_path(path: &str) -> Result<()> {
let local = path.starts_with('/')
&& !path.starts_with("//")
&& !path.contains('\\')
&& !path.contains('#')
&& !path.chars().any(char::is_control);
if local {
Ok(())
} else {
Err(Error::Config(format!(
"after_install_path {path:?} must be a local path such as \"/\""
)))
}
}
fn config_file_path() -> PathBuf {
if let Ok(manifest_dir) = std::env::var("AUTUMN_MANIFEST_DIR") {
let candidate = PathBuf::from(manifest_dir).join("autumn.toml");
if candidate.exists() {
return candidate;
}
}
PathBuf::from("autumn.toml")
}
#[cfg(test)]
mod tests;