use enum_dispatch::enum_dispatch;
use serde::{Deserialize, Serialize};
use strum::Display;
use thiserror::Error;
use std::{borrow::Cow, fmt::Debug, hash::Hash};
use crate::Type;
#[derive(Debug, PartialEq, Eq, Clone, Copy, Display, Error)]
pub enum IdError {
InvalidPrefix,
InvalidFormat,
InvalidType,
InvalidId,
}
#[enum_dispatch]
pub trait Id {
fn id(&self) -> &str;
fn _type(&self) -> Type;
fn uri(&self) -> String {
format!("spotify:{}:{}", self._type(), self.id())
}
fn url(&self) -> String {
format!("https://open.spotify.com/{}/{}", self._type(), self.id())
}
}
pub fn parse_uri(uri: &str) -> Result<(Type, &str), IdError> {
let mut chars = uri
.strip_prefix("spotify")
.ok_or(IdError::InvalidPrefix)?
.chars();
let sep = match chars.next() {
Some(ch) if ch == '/' || ch == ':' => ch,
_ => return Err(IdError::InvalidPrefix),
};
let rest = chars.as_str();
let (tpe, id) = rest
.rfind(sep)
.map(|mid| rest.split_at(mid))
.ok_or(IdError::InvalidFormat)?;
match tpe.parse::<Type>() {
Ok(tpe) => Ok((tpe, &id[1..])),
_ => Err(IdError::InvalidType),
}
}
macro_rules! define_idtypes {
($($type:ident => {
name: $name:ident,
validity: $validity:expr
}),+) => {
$(
#[doc = concat!(
"ID of type [`Type::", stringify!($type), "`]. The validity of \
its characters is defined by the closure `",
stringify!($validity), "`.\n\nRefer to the [module-level \
docs][`crate::idtypes`] for more information. "
)]
#[repr(transparent)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)]
pub struct $name<'a>(Cow<'a, str>);
impl<'a> $name<'a> {
const TYPE: Type = Type::$type;
#[must_use]
pub fn id_is_valid(id: &str) -> bool {
const VALID_FN: fn(&str) -> bool = $validity;
VALID_FN(id)
}
pub unsafe fn from_id_unchecked<S>(id: S) -> Self
where
S: Into<Cow<'a, str>>
{
Self(id.into())
}
pub fn from_id<S>(id: S) -> Result<Self, IdError>
where
S: Into<Cow<'a, str>>
{
let id = id.into();
if Self::id_is_valid(&id) {
Ok(unsafe { Self::from_id_unchecked(id) })
} else {
Err(IdError::InvalidId)
}
}
pub fn from_uri(uri: &'a str) -> Result<Self, IdError> {
let (tpe, id) = parse_uri(&uri)?;
if tpe == Type::$type {
Self::from_id(id)
} else {
Err(IdError::InvalidType)
}
}
pub fn from_id_or_uri(id_or_uri: &'a str) -> Result<Self, IdError> {
match Self::from_uri(id_or_uri) {
Ok(id) => Ok(id),
Err(IdError::InvalidPrefix) => Self::from_id(id_or_uri),
Err(error) => Err(error),
}
}
#[must_use]
pub fn as_ref(&'a self) -> Self {
Self(Cow::Borrowed(self.0.as_ref()))
}
#[must_use]
pub fn into_static(self) -> $name<'static> {
$name(Cow::Owned(self.0.into_owned()))
}
#[must_use]
pub fn clone_static(&self) -> $name<'static> {
$name(Cow::Owned(self.0.clone().into_owned()))
}
}
impl Id for $name<'_> {
fn id(&self) -> &str {
&self.0
}
fn _type(&self) -> Type {
Self::TYPE
}
}
impl<'de> Deserialize<'de> for $name<'static> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct IdVisitor;
impl<'de> serde::de::Visitor<'de> for IdVisitor {
type Value = $name<'static>;
fn expecting(
&self, formatter: &mut std::fmt::Formatter<'_>
) -> Result<(), std::fmt::Error>
{
let msg = concat!("ID or URI for struct ", stringify!($name));
formatter.write_str(msg)
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
$name::from_id_or_uri(value)
.map($name::into_static)
.map_err(serde::de::Error::custom)
}
fn visit_newtype_struct<A>(
self,
deserializer: A,
) -> Result<Self::Value, A::Error>
where
A: serde::Deserializer<'de>,
{
deserializer.deserialize_str(self)
}
fn visit_seq<A>(
self,
mut seq: A,
) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let field: &str = seq.next_element()?
.ok_or_else(|| serde::de::Error::invalid_length(0, &self))?;
$name::from_id_or_uri(field)
.map($name::into_static)
.map_err(serde::de::Error::custom)
}
}
deserializer.deserialize_newtype_struct(stringify!($name), IdVisitor)
}
}
impl std::borrow::Borrow<str> for $name<'_> {
fn borrow(&self) -> &str {
self.id()
}
}
impl std::fmt::Display for $name<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", self.uri())
}
}
)+
}
}
define_idtypes!(
Artist => {
name: ArtistId,
validity: |id| id.chars().all(|ch| ch.is_ascii_alphanumeric())
},
Album => {
name: AlbumId,
validity: |id| id.chars().all(|ch| ch.is_ascii_alphanumeric())
},
Track => {
name: TrackId,
validity: |id| id.chars().all(|ch| ch.is_ascii_alphanumeric())
},
Playlist => {
name: PlaylistId,
validity: |id| id.chars().all(|ch| ch.is_ascii_alphanumeric())
},
Show => {
name: ShowId,
validity: |id| id.chars().all(|ch| ch.is_ascii_alphanumeric())
},
Episode => {
name: EpisodeId,
validity: |id| id.chars().all(|ch| ch.is_ascii_alphanumeric())
},
User => {
name: UserId,
validity: |_| true
}
);
#[enum_dispatch(Id)]
pub enum PlayContextId<'a> {
Artist(ArtistId<'a>),
Album(AlbumId<'a>),
Playlist(PlaylistId<'a>),
Show(ShowId<'a>),
}
impl<'a> PlayContextId<'a> {
#[must_use]
pub fn as_ref(&'a self) -> Self {
match self {
PlayContextId::Artist(x) => PlayContextId::Artist(x.as_ref()),
PlayContextId::Album(x) => PlayContextId::Album(x.as_ref()),
PlayContextId::Playlist(x) => PlayContextId::Playlist(x.as_ref()),
PlayContextId::Show(x) => PlayContextId::Show(x.as_ref()),
}
}
#[must_use]
pub fn into_static(self) -> PlayContextId<'static> {
match self {
PlayContextId::Artist(x) => PlayContextId::Artist(x.into_static()),
PlayContextId::Album(x) => PlayContextId::Album(x.into_static()),
PlayContextId::Playlist(x) => PlayContextId::Playlist(x.into_static()),
PlayContextId::Show(x) => PlayContextId::Show(x.into_static()),
}
}
#[must_use]
pub fn clone_static(&'a self) -> PlayContextId<'static> {
match self {
PlayContextId::Artist(x) => PlayContextId::Artist(x.clone_static()),
PlayContextId::Album(x) => PlayContextId::Album(x.clone_static()),
PlayContextId::Playlist(x) => PlayContextId::Playlist(x.clone_static()),
PlayContextId::Show(x) => PlayContextId::Show(x.clone_static()),
}
}
}
#[enum_dispatch(Id)]
pub enum PlayableId<'a> {
Track(TrackId<'a>),
Episode(EpisodeId<'a>),
}
impl<'a> PlayableId<'a> {
#[must_use]
pub fn as_ref(&'a self) -> Self {
match self {
PlayableId::Track(x) => PlayableId::Track(x.as_ref()),
PlayableId::Episode(x) => PlayableId::Episode(x.as_ref()),
}
}
#[must_use]
pub fn into_static(self) -> PlayableId<'static> {
match self {
PlayableId::Track(x) => PlayableId::Track(x.into_static()),
PlayableId::Episode(x) => PlayableId::Episode(x.into_static()),
}
}
#[must_use]
pub fn clone_static(&'a self) -> PlayableId<'static> {
match self {
PlayableId::Track(x) => PlayableId::Track(x.clone_static()),
PlayableId::Episode(x) => PlayableId::Episode(x.clone_static()),
}
}
}
#[cfg(test)]
mod test {
use super::*;
use std::{borrow::Cow, error::Error};
const ID: &str = "4iV5W9uYEdYUVa79Axb7Rh";
const URI: &str = "spotify:track:4iV5W9uYEdYUVa79Axb7Rh";
const URI_SLASHES: &str = "spotify/track/4iV5W9uYEdYUVa79Axb7Rh";
const URI_EMPTY: &str = "spotify::4iV5W9uYEdYUVa79Axb7Rh";
const URI_WRONGTYPE1: &str = "spotify:unknown:4iV5W9uYEdYUVa79Axb7Rh";
const URI_SHORT: &str = "track:4iV5W9uYEdYUVa79Axb7Rh";
const URI_MIXED1: &str = "spotify/track:4iV5W9uYEdYUVa79Axb7Rh";
const URI_MIXED2: &str = "spotify:track/4iV5W9uYEdYUVa79Axb7Rh";
#[test]
fn test_id_parse() {
assert!(TrackId::from_id(ID).is_ok());
assert_eq!(TrackId::from_id(URI), Err(IdError::InvalidId));
assert_eq!(TrackId::from_id(URI_SLASHES), Err(IdError::InvalidId));
assert_eq!(TrackId::from_id(URI_EMPTY), Err(IdError::InvalidId));
assert_eq!(TrackId::from_id(URI_WRONGTYPE1), Err(IdError::InvalidId));
assert_eq!(TrackId::from_id(URI_SHORT), Err(IdError::InvalidId));
assert_eq!(TrackId::from_id(URI_MIXED1), Err(IdError::InvalidId));
assert_eq!(TrackId::from_id(URI_MIXED2), Err(IdError::InvalidId));
}
#[test]
fn test_uri_parse() {
assert!(TrackId::from_uri(URI).is_ok());
assert!(TrackId::from_uri(URI_SLASHES).is_ok());
assert_eq!(TrackId::from_uri(ID), Err(IdError::InvalidPrefix));
assert_eq!(TrackId::from_uri(URI_SHORT), Err(IdError::InvalidPrefix));
assert_eq!(TrackId::from_uri(URI_EMPTY), Err(IdError::InvalidType));
assert_eq!(TrackId::from_uri(URI_WRONGTYPE1), Err(IdError::InvalidType));
assert_eq!(TrackId::from_uri(URI_MIXED1), Err(IdError::InvalidFormat));
assert_eq!(TrackId::from_uri(URI_MIXED2), Err(IdError::InvalidFormat));
}
#[test]
fn test_id_or_uri_and_deserialize() {
fn test_any<F, E>(check: F)
where
F: Fn(&str) -> Result<TrackId<'_>, E>,
E: Error,
{
assert!(check(ID).is_ok());
assert_eq!(check(ID).unwrap().id(), ID);
assert!(check(URI).is_ok());
assert_eq!(check(URI).unwrap().id(), ID);
assert!(check(URI_SLASHES).is_ok());
assert_eq!(check(URI_SLASHES).unwrap().id(), ID);
assert!(check(URI_SHORT).is_err());
assert!(check(URI_EMPTY).is_err());
assert!(check(URI_WRONGTYPE1).is_err());
assert!(check(URI_MIXED1).is_err());
assert!(check(URI_MIXED2).is_err());
}
test_any(|s| TrackId::from_id_or_uri(s));
test_any(|s| {
let json = format!("\"{s}\"");
serde_json::from_str::<'_, TrackId>(&json)
});
}
#[test]
fn test_serialize() {
let json_expected = format!("\"{ID}\"");
let track = TrackId::from_uri(URI).unwrap();
let json = serde_json::to_string(&track).unwrap();
assert_eq!(json, json_expected);
}
#[test]
fn test_multiple_types() {
fn endpoint<'a>(_ids: impl IntoIterator<Item = PlayableId<'a>>) {}
let tracks: Vec<PlayableId> = vec![
PlayableId::Track(TrackId::from_id(ID).unwrap()),
PlayableId::Track(TrackId::from_id(ID).unwrap()),
PlayableId::Episode(EpisodeId::from_id(ID).unwrap()),
PlayableId::Episode(EpisodeId::from_id(ID).unwrap()),
];
endpoint(tracks);
}
#[test]
fn test_unknown_at_compile_time() {
fn endpoint1(input: &str, is_episode: bool) -> PlayableId<'_> {
if is_episode {
PlayableId::Episode(EpisodeId::from_id(input).unwrap())
} else {
PlayableId::Track(TrackId::from_id(input).unwrap())
}
}
fn endpoint2(_id: &[PlayableId]) {}
let id = endpoint1(ID, false);
endpoint2(&[id]);
}
#[test]
fn test_constructor() {
let _ = EpisodeId::from_id(ID).unwrap();
let _ = EpisodeId::from_id(ID.to_string()).unwrap();
let _ = EpisodeId::from_id(Cow::Borrowed(ID)).unwrap();
let _ = EpisodeId::from_id(Cow::Owned(ID.to_string())).unwrap();
}
#[test]
fn test_owned() {
fn check_static(_: EpisodeId<'static>) {}
let local_id = String::from(ID);
let id: EpisodeId<'_> = EpisodeId::from_id(local_id.as_str()).unwrap();
check_static(id.clone_static());
check_static(id.into_static());
let id = EpisodeId::from_id(local_id.clone()).unwrap();
check_static(id.clone());
check_static(id);
}
}