use s2_common::types::{
self,
access::{AccessTokenId, AccessTokenIdPrefix},
basin::{BasinName, BasinNamePrefix},
stream::{StreamName, StreamNamePrefix},
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub enum MaybeEmpty<T> {
Empty,
NonEmpty(T),
}
impl<T: Serialize> Serialize for MaybeEmpty<T> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::NonEmpty(v) => v.serialize(serializer),
Self::Empty => serializer.serialize_str(""),
}
}
}
impl<'de, T> Deserialize<'de> for MaybeEmpty<T>
where
T: Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if s.is_empty() {
Ok(MaybeEmpty::Empty)
} else {
T::deserialize(serde::de::value::StringDeserializer::new(s)).map(MaybeEmpty::NonEmpty)
}
}
}
use time::OffsetDateTime;
#[rustfmt::skip]
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum Operation {
ListBasins,
CreateBasin,
DeleteBasin,
ReconfigureBasin,
GetBasinConfig,
IssueAccessToken,
RevokeAccessToken,
ListAccessTokens,
ListStreams,
CreateStream,
DeleteStream,
GetStreamConfig,
ReconfigureStream,
CheckTail,
Append,
Read,
Trim,
Fence,
AccountMetrics,
BasinMetrics,
StreamMetrics,
}
impl From<Operation> for types::access::Operation {
fn from(value: Operation) -> Self {
match value {
Operation::ListBasins => Self::ListBasins,
Operation::CreateBasin => Self::CreateBasin,
Operation::DeleteBasin => Self::DeleteBasin,
Operation::ReconfigureBasin => Self::ReconfigureBasin,
Operation::GetBasinConfig => Self::GetBasinConfig,
Operation::IssueAccessToken => Self::IssueAccessToken,
Operation::RevokeAccessToken => Self::RevokeAccessToken,
Operation::ListAccessTokens => Self::ListAccessTokens,
Operation::ListStreams => Self::ListStreams,
Operation::CreateStream => Self::CreateStream,
Operation::DeleteStream => Self::DeleteStream,
Operation::GetStreamConfig => Self::GetStreamConfig,
Operation::ReconfigureStream => Self::ReconfigureStream,
Operation::CheckTail => Self::CheckTail,
Operation::Append => Self::Append,
Operation::Read => Self::Read,
Operation::Trim => Self::Trim,
Operation::Fence => Self::Fence,
Operation::AccountMetrics => Self::AccountMetrics,
Operation::BasinMetrics => Self::BasinMetrics,
Operation::StreamMetrics => Self::StreamMetrics,
}
}
}
impl From<types::access::Operation> for Operation {
fn from(value: types::access::Operation) -> Self {
use types::access::Operation::*;
match value {
ListBasins => Self::ListBasins,
CreateBasin => Self::CreateBasin,
DeleteBasin => Self::DeleteBasin,
ReconfigureBasin => Self::ReconfigureBasin,
GetBasinConfig => Self::GetBasinConfig,
IssueAccessToken => Self::IssueAccessToken,
RevokeAccessToken => Self::RevokeAccessToken,
ListAccessTokens => Self::ListAccessTokens,
ListStreams => Self::ListStreams,
CreateStream => Self::CreateStream,
DeleteStream => Self::DeleteStream,
GetStreamConfig => Self::GetStreamConfig,
ReconfigureStream => Self::ReconfigureStream,
CheckTail => Self::CheckTail,
Append => Self::Append,
Read => Self::Read,
Trim => Self::Trim,
Fence => Self::Fence,
AccountMetrics => Self::AccountMetrics,
BasinMetrics => Self::BasinMetrics,
StreamMetrics => Self::StreamMetrics,
}
}
}
#[rustfmt::skip]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct AccessTokenInfo {
pub id: types::access::AccessTokenId,
#[serde(default, with = "time::serde::rfc3339::option")]
pub expires_at: Option<OffsetDateTime>,
#[cfg_attr(feature = "utoipa", schema(value_type = bool, default = false, required = false))]
pub auto_prefix_streams: Option<bool>,
pub scope: AccessTokenScope,
}
impl TryFrom<AccessTokenInfo> for types::access::IssueAccessTokenRequest {
type Error = types::ValidationError;
fn try_from(value: AccessTokenInfo) -> Result<Self, Self::Error> {
Ok(Self {
id: value.id,
expires_at: value.expires_at,
auto_prefix_streams: value.auto_prefix_streams.unwrap_or_default(),
scope: value.scope.try_into()?,
})
}
}
impl From<types::access::AccessTokenInfo> for AccessTokenInfo {
fn from(value: types::access::AccessTokenInfo) -> Self {
Self {
id: value.id,
expires_at: Some(value.expires_at),
auto_prefix_streams: Some(value.auto_prefix_streams),
scope: value.scope.into(),
}
}
}
impl From<types::access::IssueAccessTokenRequest> for AccessTokenInfo {
fn from(value: types::access::IssueAccessTokenRequest) -> Self {
Self {
id: value.id,
expires_at: value.expires_at,
auto_prefix_streams: Some(value.auto_prefix_streams),
scope: value.scope.into(),
}
}
}
#[rustfmt::skip]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct AccessTokenScope {
pub basins: Option<ResourceSet<MaybeEmpty<BasinName>, BasinNamePrefix>>,
pub streams: Option<ResourceSet<MaybeEmpty<StreamName>, StreamNamePrefix>>,
pub access_tokens: Option<ResourceSet<MaybeEmpty<AccessTokenId>, AccessTokenIdPrefix>>,
pub op_groups: Option<PermittedOperationGroups>,
#[cfg_attr(feature = "utoipa", schema(required = false))]
pub ops: Option<Vec<Operation>>,
}
impl TryFrom<AccessTokenScope> for types::access::AccessTokenScope {
type Error = types::ValidationError;
fn try_from(value: AccessTokenScope) -> Result<Self, Self::Error> {
let AccessTokenScope {
basins,
streams,
access_tokens,
op_groups,
ops,
} = value;
Ok(Self {
basins: basins.map(Into::into).unwrap_or_default(),
streams: streams.map(Into::into).unwrap_or_default(),
access_tokens: access_tokens.map(Into::into).unwrap_or_default(),
op_groups: op_groups.map(Into::into).unwrap_or_default(),
ops: ops
.map(|o| o.into_iter().map(types::access::Operation::from).collect())
.unwrap_or_default(),
})
}
}
impl From<types::access::AccessTokenScope> for AccessTokenScope {
fn from(value: types::access::AccessTokenScope) -> Self {
let types::access::AccessTokenScope {
basins,
streams,
access_tokens,
op_groups,
ops,
} = value;
Self {
basins: ResourceSet::to_opt(basins),
streams: ResourceSet::to_opt(streams),
access_tokens: ResourceSet::to_opt(access_tokens),
op_groups: Some(op_groups.into()),
ops: Some(ops.into_iter().map(Operation::from).collect()),
}
}
}
#[rustfmt::skip]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "kebab-case")]
pub enum ResourceSet<E, P> {
#[cfg_attr(feature = "utoipa", schema(title = "exact", value_type = String))]
Exact(E),
#[cfg_attr(feature = "utoipa", schema(title = "prefix", value_type = String))]
Prefix(P),
}
impl<E, P> ResourceSet<MaybeEmpty<E>, P> {
pub fn to_opt(rs: types::access::ResourceSet<E, P>) -> Option<Self> {
match rs {
types::access::ResourceSet::None => None,
types::access::ResourceSet::Exact(e) => {
Some(ResourceSet::Exact(MaybeEmpty::NonEmpty(e)))
}
types::access::ResourceSet::Prefix(p) => Some(ResourceSet::Prefix(p)),
}
}
}
impl<E, P> From<ResourceSet<MaybeEmpty<E>, P>> for types::access::ResourceSet<E, P> {
fn from(value: ResourceSet<MaybeEmpty<E>, P>) -> Self {
match value {
ResourceSet::Exact(MaybeEmpty::Empty) => Self::None,
ResourceSet::Exact(MaybeEmpty::NonEmpty(e)) => Self::Exact(e),
ResourceSet::Prefix(p) => Self::Prefix(p),
}
}
}
#[rustfmt::skip]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct PermittedOperationGroups {
pub account: Option<ReadWritePermissions>,
pub basin: Option<ReadWritePermissions>,
pub stream: Option<ReadWritePermissions>,
}
impl From<PermittedOperationGroups> for types::access::PermittedOperationGroups {
fn from(value: PermittedOperationGroups) -> Self {
let PermittedOperationGroups {
account,
basin,
stream,
} = value;
Self {
account: account.map(Into::into).unwrap_or_default(),
basin: basin.map(Into::into).unwrap_or_default(),
stream: stream.map(Into::into).unwrap_or_default(),
}
}
}
impl From<types::access::PermittedOperationGroups> for PermittedOperationGroups {
fn from(value: types::access::PermittedOperationGroups) -> Self {
let types::access::PermittedOperationGroups {
account,
basin,
stream,
} = value;
Self {
account: Some(account.into()),
basin: Some(basin.into()),
stream: Some(stream.into()),
}
}
}
#[rustfmt::skip]
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct ReadWritePermissions {
#[cfg_attr(feature = "utoipa", schema(value_type = bool, default = false, required = false))]
pub read: Option<bool>,
#[cfg_attr(feature = "utoipa", schema(value_type = bool, default = false, required = false))]
pub write: Option<bool>,
}
impl From<ReadWritePermissions> for types::access::ReadWritePermissions {
fn from(value: ReadWritePermissions) -> Self {
let ReadWritePermissions { read, write } = value;
Self {
read: read.unwrap_or_default(),
write: write.unwrap_or_default(),
}
}
}
impl From<types::access::ReadWritePermissions> for ReadWritePermissions {
fn from(value: types::access::ReadWritePermissions) -> Self {
let types::access::ReadWritePermissions { read, write } = value;
Self {
read: Some(read),
write: Some(write),
}
}
}
#[rustfmt::skip]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))]
#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))]
pub struct ListAccessTokensRequest {
#[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))]
pub prefix: Option<types::access::AccessTokenIdPrefix>,
#[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))]
pub start_after: Option<types::access::AccessTokenIdStartAfter>,
#[cfg_attr(feature = "utoipa", param(value_type = usize, maximum = 1000, default = 1000, required = false))]
pub limit: Option<usize>,
}
super::impl_list_request_conversions!(
ListAccessTokensRequest,
types::access::AccessTokenIdPrefix,
types::access::AccessTokenIdStartAfter
);
#[rustfmt::skip]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct ListAccessTokensResponse {
#[cfg_attr(feature = "utoipa", schema(max_items = 1000))]
pub access_tokens: Vec<AccessTokenInfo>,
pub has_more: bool,
}
#[rustfmt::skip]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct IssueAccessTokenResponse {
pub access_token: String,
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::*;
fn random_basin_resource_set() -> impl Strategy<Value = serde_json::Value> {
prop_oneof![
Just(serde_json::json!({"exact": ""})),
"[a-z][a-z0-9]{7,20}".prop_map(|s| serde_json::json!({"exact": s})),
Just(serde_json::json!({"prefix": ""})),
"[a-z][a-z0-9]{0,10}".prop_map(|s| serde_json::json!({"prefix": s})),
]
}
fn random_resource_set() -> impl Strategy<Value = serde_json::Value> {
prop_oneof![
Just(serde_json::json!({"exact": ""})),
"[a-z][a-z0-9]{0,20}".prop_map(|s| serde_json::json!({"exact": s})),
Just(serde_json::json!({"prefix": ""})),
"[a-z][a-z0-9]{0,10}".prop_map(|s| serde_json::json!({"prefix": s})),
]
}
fn random_access_token_info() -> impl Strategy<Value = serde_json::Value> {
(
"[a-z][a-z0-9]{0,20}",
proptest::option::of(random_basin_resource_set()),
proptest::option::of(random_resource_set()),
proptest::option::of(random_resource_set()),
)
.prop_map(|(id, basins, streams, access_tokens)| {
serde_json::json!({
"id": id,
"scope": {
"basins": basins,
"streams": streams,
"access_tokens": access_tokens
}
})
})
}
proptest! {
#[test]
fn access_token_info_roundtrip(json in random_access_token_info()) {
let parsed: AccessTokenInfo = serde_json::from_value(json).unwrap();
let internal: types::access::IssueAccessTokenRequest = parsed.clone().try_into().unwrap();
let back: AccessTokenInfo = internal.into();
prop_assert_eq!(parsed.id, back.id);
}
}
#[test]
fn empty_exact_converts_to_resource_set_none() {
let json = serde_json::json!({
"id": "test-token",
"scope": {
"streams": {"exact": ""},
"basins": {"exact": ""},
"access_tokens": {"exact": ""}
}
});
let parsed: AccessTokenInfo = serde_json::from_value(json).unwrap();
let internal: types::access::IssueAccessTokenRequest = parsed.try_into().unwrap();
assert!(matches!(
internal.scope.streams,
types::access::ResourceSet::None
));
assert!(matches!(
internal.scope.basins,
types::access::ResourceSet::None
));
assert!(matches!(
internal.scope.access_tokens,
types::access::ResourceSet::None
));
}
#[test]
fn missing_scope_fields_default_to_resource_set_none() {
let json = serde_json::json!({
"id": "test-token",
"scope": {}
});
let parsed: AccessTokenInfo = serde_json::from_value(json).unwrap();
let internal: types::access::IssueAccessTokenRequest = parsed.try_into().unwrap();
assert!(matches!(
internal.scope.streams,
types::access::ResourceSet::None
));
assert!(matches!(
internal.scope.basins,
types::access::ResourceSet::None
));
assert!(matches!(
internal.scope.access_tokens,
types::access::ResourceSet::None
));
}
}