API versioning tests

This commit is contained in:
Antoine Gersant 2024-07-27 18:47:32 -07:00
parent caa8907297
commit caf6feea7a
10 changed files with 179 additions and 86 deletions

View file

@ -1,8 +1,30 @@
use error::APIError;
mod dto;
mod error;
#[cfg(test)]
mod test;
pub enum APIMajorVersion {
V7,
V8,
}
impl TryFrom<i32> for APIMajorVersion {
type Error = APIError;
fn try_from(value: i32) -> Result<Self, Self::Error> {
match value {
7 => Ok(Self::V7),
8 => Ok(Self::V8),
_ => Err(APIError::UnsupportedAPIVersion),
}
}
}
pub const API_MAJOR_VERSION: i32 = 8;
pub const API_MINOR_VERSION: i32 = 0;
mod axum;
pub use axum::*;

View file

@ -12,11 +12,10 @@ use percent_encoding::percent_decode_str;
use crate::{
app::{config, ddns, index, lastfm, playlist, scanner, settings, thumbnail, user, vfs, App},
server::{dto, error::APIError},
server::{dto, error::APIError, APIMajorVersion, API_MAJOR_VERSION, API_MINOR_VERSION},
};
use super::auth::{AdminRights, Auth};
use super::version::Version;
pub fn router() -> Router<App> {
Router::new()
@ -68,8 +67,8 @@ pub fn router() -> Router<App> {
async fn get_version() -> Json<dto::Version> {
let current_version = dto::Version {
major: dto::API_MAJOR_VERSION,
minor: dto::API_MINOR_VERSION,
major: API_MAJOR_VERSION,
minor: API_MINOR_VERSION,
};
Json(current_version)
}
@ -254,16 +253,19 @@ async fn post_trigger_index(
Ok(())
}
fn collection_files_to_response(files: Vec<index::CollectionFile>, version: Version) -> Response {
match version {
Version::V7 => Json(
fn collection_files_to_response(
files: Vec<index::CollectionFile>,
api_version: APIMajorVersion,
) -> Response {
match api_version {
APIMajorVersion::V7 => Json(
files
.into_iter()
.map(|f| f.into())
.collect::<Vec<dto::v7::CollectionFile>>(),
)
.into_response(),
Version::V8 => Json(
APIMajorVersion::V8 => Json(
files
.into_iter()
.map(|f| f.into())
@ -273,16 +275,16 @@ fn collection_files_to_response(files: Vec<index::CollectionFile>, version: Vers
}
}
fn songs_to_response(files: Vec<scanner::Song>, version: Version) -> Response {
match version {
Version::V7 => Json(
fn songs_to_response(files: Vec<scanner::Song>, api_version: APIMajorVersion) -> Response {
match api_version {
APIMajorVersion::V7 => Json(
files
.into_iter()
.map(|f| f.into())
.collect::<Vec<dto::v7::Song>>(),
)
.into_response(),
Version::V8 => Json(
APIMajorVersion::V8 => Json(
files
.into_iter()
.map(|f| f.into())
@ -292,16 +294,19 @@ fn songs_to_response(files: Vec<scanner::Song>, version: Version) -> Response {
}
}
fn directories_to_response(files: Vec<scanner::Directory>, version: Version) -> Response {
match version {
Version::V7 => Json(
fn directories_to_response(
files: Vec<scanner::Directory>,
api_version: APIMajorVersion,
) -> Response {
match api_version {
APIMajorVersion::V7 => Json(
files
.into_iter()
.map(|f| f.into())
.collect::<Vec<dto::v7::Directory>>(),
)
.into_response(),
Version::V8 => Json(
APIMajorVersion::V8 => Json(
files
.into_iter()
.map(|f| f.into())
@ -313,19 +318,19 @@ fn directories_to_response(files: Vec<scanner::Directory>, version: Version) ->
async fn get_browse_root(
_auth: Auth,
version: Version,
api_version: APIMajorVersion,
State(index): State<index::Index>,
) -> Response {
let result = match index.browse(std::path::Path::new("")).await {
Ok(r) => r,
Err(e) => return APIError::from(e).into_response(),
};
collection_files_to_response(result, version)
collection_files_to_response(result, api_version)
}
async fn get_browse(
_auth: Auth,
version: Version,
api_version: APIMajorVersion,
State(index): State<index::Index>,
Path(path): Path<String>,
) -> Response {
@ -334,24 +339,24 @@ async fn get_browse(
Ok(r) => r,
Err(e) => return APIError::from(e).into_response(),
};
collection_files_to_response(result, version)
collection_files_to_response(result, api_version)
}
async fn get_flatten_root(
_auth: Auth,
version: Version,
api_version: APIMajorVersion,
State(index): State<index::Index>,
) -> Response {
let songs = match index.flatten(std::path::Path::new("")).await {
Ok(s) => s,
Err(e) => return APIError::from(e).into_response(),
};
songs_to_response(songs, version)
songs_to_response(songs, api_version)
}
async fn get_flatten(
_auth: Auth,
version: Version,
api_version: APIMajorVersion,
State(index): State<index::Index>,
Path(path): Path<String>,
) -> Response {
@ -360,40 +365,48 @@ async fn get_flatten(
Ok(s) => s,
Err(e) => return APIError::from(e).into_response(),
};
songs_to_response(songs, version)
songs_to_response(songs, api_version)
}
async fn get_random(_auth: Auth, version: Version, State(index): State<index::Index>) -> Response {
async fn get_random(
_auth: Auth,
api_version: APIMajorVersion,
State(index): State<index::Index>,
) -> Response {
let directories = match index.get_random_albums(20).await {
Ok(d) => d,
Err(e) => return APIError::from(e).into_response(),
};
directories_to_response(directories, version)
directories_to_response(directories, api_version)
}
async fn get_recent(_auth: Auth, version: Version, State(index): State<index::Index>) -> Response {
async fn get_recent(
_auth: Auth,
api_version: APIMajorVersion,
State(index): State<index::Index>,
) -> Response {
let directories = match index.get_recent_albums(20).await {
Ok(d) => d,
Err(e) => return APIError::from(e).into_response(),
};
directories_to_response(directories, version)
directories_to_response(directories, api_version)
}
async fn get_search_root(
_auth: Auth,
version: Version,
api_version: APIMajorVersion,
State(index): State<index::Index>,
) -> Response {
let files = match index.search("").await {
Ok(f) => f,
Err(e) => return APIError::from(e).into_response(),
};
collection_files_to_response(files, version)
collection_files_to_response(files, api_version)
}
async fn get_search(
_auth: Auth,
version: Version,
api_version: APIMajorVersion,
State(index): State<index::Index>,
Path(query): Path<String>,
) -> Response {
@ -401,7 +414,7 @@ async fn get_search(
Ok(f) => f,
Err(e) => return APIError::from(e).into_response(),
};
collection_files_to_response(files, version)
collection_files_to_response(files, api_version)
}
async fn get_playlists(
@ -431,7 +444,7 @@ async fn put_playlist(
async fn get_playlist(
auth: Auth,
version: Version,
api_version: APIMajorVersion,
State(playlist_manager): State<playlist::Manager>,
Path(name): Path<String>,
) -> Response {
@ -442,7 +455,7 @@ async fn get_playlist(
Ok(s) => s,
Err(e) => return APIError::from(e).into_response(),
};
songs_to_response(songs, version)
songs_to_response(songs, api_version)
}
async fn delete_playlist(

View file

@ -1,15 +1,10 @@
use axum::{async_trait, extract::FromRequestParts};
use http::request::Parts;
use crate::server::{dto, error::APIError};
pub enum Version {
V7,
V8,
}
use crate::server::{error::APIError, APIMajorVersion};
#[async_trait]
impl<S> FromRequestParts<S> for Version
impl<S> FromRequestParts<S> for APIMajorVersion
where
S: Send + Sync,
{
@ -19,18 +14,14 @@ where
let version_header = match parts.headers.get("Accept-Version").map(|h| h.to_str()) {
Some(Ok(h)) => h,
Some(Err(_)) => return Err(APIError::InvalidAPIVersionHeader),
None => return Ok(Version::V7), // TODO Drop support for implicit version in future release
None => return Ok(APIMajorVersion::V7), // TODO Drop support for implicit version in future release
};
let version: dto::Version = match serde_json::from_str(version_header) {
let version = match str::parse::<i32>(version_header) {
Ok(v) => v,
Err(_) => return Err(APIError::APIVersionHeaderParseError),
};
Ok(match version.major {
7 => Version::V7,
8 => Version::V8,
_ => return Err(APIError::UnsupportedAPIVersion),
})
APIMajorVersion::try_from(version)
}
}

View file

@ -1,7 +1,4 @@
pub mod v7;
pub mod v8;
pub const API_MAJOR_VERSION: i32 = 8;
pub const API_MINOR_VERSION: i32 = 0;
pub use v8::*;

View file

@ -24,6 +24,7 @@ mod web;
use crate::server::dto;
use crate::server::test::constants::*;
use protocol::V8;
pub use crate::server::axum::test::ServiceType;
@ -116,7 +117,7 @@ pub trait TestService {
assert_eq!(response.status(), StatusCode::OK);
loop {
let browse_request = protocol::browse(Path::new(""));
let browse_request = protocol::browse::<V8>(Path::new(""));
let response = self
.fetch_json::<(), Vec<dto::CollectionFile>>(&browse_request)
.await;
@ -128,7 +129,7 @@ pub trait TestService {
}
loop {
let flatten_request = protocol::flatten(Path::new(""));
let flatten_request = protocol::flatten::<V8>(Path::new(""));
let response = self.fetch_json::<_, Vec<dto::Song>>(&flatten_request).await;
let entries = response.body();
if !entries.is_empty() {

View file

@ -1,6 +1,7 @@
use http::StatusCode;
use crate::server::dto;
use crate::server::test::protocol::V8;
use crate::server::test::{protocol, ServiceType, TestService};
use crate::test_name;
@ -47,7 +48,7 @@ async fn trigger_index_golden_path() {
service.complete_initial_setup().await;
service.login_admin().await;
let request = protocol::random();
let request = protocol::random::<V8>();
let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await;
let entries = response.body();

View file

@ -2,6 +2,7 @@ use headers::{self, HeaderMapExt};
use http::StatusCode;
use crate::server::dto;
use crate::server::test::protocol::V8;
use crate::server::test::{constants::*, protocol, ServiceType, TestService};
use crate::test_name;
@ -45,7 +46,7 @@ async fn authentication_via_bearer_http_header_rejects_bad_token() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let mut request = protocol::random();
let mut request = protocol::random::<V8>();
let bearer = headers::Authorization::bearer("garbage").unwrap();
request.headers_mut().typed_insert(bearer);
@ -67,7 +68,7 @@ async fn authentication_via_bearer_http_header_golden_path() {
service.logout().await;
let mut request = protocol::random();
let mut request = protocol::random::<V8>();
let bearer = headers::Authorization::bearer(&authorization.token).unwrap();
request.headers_mut().typed_insert(bearer);
let response = service.fetch(&request).await;
@ -79,7 +80,7 @@ async fn authentication_via_query_param_rejects_bad_token() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
let mut request = protocol::random();
let mut request = protocol::random::<V8>();
*request.uri_mut() = (request.uri().to_string() + "?auth_token=garbage-token")
.parse()
.unwrap();
@ -102,7 +103,7 @@ async fn authentication_via_query_param_golden_path() {
service.logout().await;
let mut request = protocol::random();
let mut request = protocol::random::<V8>();
*request.uri_mut() = format!("{}?auth_token={}", request.uri(), authorization.token)
.parse()
.unwrap();

View file

@ -2,13 +2,14 @@ use http::StatusCode;
use std::path::{Path, PathBuf};
use crate::server::dto;
use crate::server::test::protocol::{V7, V8};
use crate::server::test::{add_trailing_slash, constants::*, protocol, ServiceType, TestService};
use crate::test_name;
#[tokio::test]
async fn browse_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::browse(&PathBuf::new());
let request = protocol::browse::<V8>(&PathBuf::new());
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
@ -21,7 +22,7 @@ async fn browse_root() {
service.index().await;
service.login().await;
let request = protocol::browse(&PathBuf::new());
let request = protocol::browse::<V8>(&PathBuf::new());
let response = service
.fetch_json::<_, Vec<dto::CollectionFile>>(&request)
.await;
@ -39,7 +40,7 @@ async fn browse_directory() {
service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect();
let request = protocol::browse(&path);
let request = protocol::browse::<V8>(&path);
let response = service
.fetch_json::<_, Vec<dto::CollectionFile>>(&request)
.await;
@ -49,21 +50,43 @@ async fn browse_directory() {
}
#[tokio::test]
async fn browse_bad_directory() {
async fn browse_missing_directory() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let path: PathBuf = ["not_my_collection"].iter().collect();
let request = protocol::browse(&path);
let request = protocol::browse::<V8>(&path);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn browse_directory_api_v7() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect();
let request = protocol::browse::<V7>(&path);
let response = service
.fetch_json::<_, Vec<dto::v7::CollectionFile>>(&request)
.await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 5);
match &entries[0] {
dto::v7::CollectionFile::Song(s) => assert_eq!(s.artist.as_deref(), Some("Khemmis")),
_ => (),
}
}
#[tokio::test]
async fn flatten_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::flatten(&PathBuf::new());
let request = protocol::flatten::<V8>(&PathBuf::new());
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
@ -76,7 +99,7 @@ async fn flatten_root() {
service.index().await;
service.login().await;
let request = protocol::flatten(&PathBuf::new());
let request = protocol::flatten::<V8>(&PathBuf::new());
let response = service.fetch_json::<_, Vec<dto::Song>>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
@ -91,7 +114,7 @@ async fn flatten_directory() {
service.index().await;
service.login().await;
let request = protocol::flatten(Path::new(TEST_MOUNT_NAME));
let request = protocol::flatten::<V8>(Path::new(TEST_MOUNT_NAME));
let response = service.fetch_json::<_, Vec<dto::Song>>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
@ -99,21 +122,39 @@ async fn flatten_directory() {
}
#[tokio::test]
async fn flatten_bad_directory() {
async fn flatten_missing_directory() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login().await;
let path: PathBuf = ["not_my_collection"].iter().collect();
let request = protocol::flatten(&path);
let request = protocol::flatten::<V8>(&path);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn flatten_directory_api_v7() {
let mut service = ServiceType::new(&test_name!()).await;
service.complete_initial_setup().await;
service.login_admin().await;
service.index().await;
service.login().await;
let path: PathBuf = [TEST_MOUNT_NAME, "Khemmis", "Hunted"].iter().collect();
let request = protocol::flatten::<V7>(&path);
let response = service.fetch_json::<_, Vec<dto::v7::Song>>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
assert_eq!(entries.len(), 5);
assert_eq!(entries[0].artist.as_deref(), Some("Khemmis"));
}
#[tokio::test]
async fn random_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::random();
let request = protocol::random::<V8>();
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
@ -126,7 +167,7 @@ async fn random_golden_path() {
service.index().await;
service.login().await;
let request = protocol::random();
let request = protocol::random::<V8>();
let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
@ -141,7 +182,7 @@ async fn random_with_trailing_slash() {
service.index().await;
service.login().await;
let mut request = protocol::random();
let mut request = protocol::random::<V8>();
add_trailing_slash(&mut request);
let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
@ -152,7 +193,7 @@ async fn random_with_trailing_slash() {
#[tokio::test]
async fn recent_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::recent();
let request = protocol::recent::<V8>();
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
@ -165,7 +206,7 @@ async fn recent_golden_path() {
service.index().await;
service.login().await;
let request = protocol::recent();
let request = protocol::recent::<V8>();
let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
let entries = response.body();
@ -180,7 +221,7 @@ async fn recent_with_trailing_slash() {
service.index().await;
service.login().await;
let mut request = protocol::recent();
let mut request = protocol::recent::<V8>();
add_trailing_slash(&mut request);
let response = service.fetch_json::<_, Vec<dto::Directory>>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
@ -191,7 +232,7 @@ async fn recent_with_trailing_slash() {
#[tokio::test]
async fn search_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::search("");
let request = protocol::search::<V8>("");
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
@ -202,7 +243,7 @@ async fn search_without_query() {
service.complete_initial_setup().await;
service.login().await;
let request = protocol::search("");
let request = protocol::search::<V8>("");
let response = service
.fetch_json::<_, Vec<dto::CollectionFile>>(&request)
.await;
@ -217,7 +258,7 @@ async fn search_with_query() {
service.index().await;
service.login().await;
let request = protocol::search("door");
let request = protocol::search::<V8>("door");
let response = service
.fetch_json::<_, Vec<dto::CollectionFile>>(&request)
.await;

View file

@ -1,6 +1,7 @@
use http::StatusCode;
use crate::server::dto;
use crate::server::test::protocol::V8;
use crate::server::test::{constants::*, protocol, ServiceType, TestService};
use crate::test_name;
@ -63,7 +64,7 @@ async fn save_playlist_large() {
#[tokio::test]
async fn get_playlist_requires_auth() {
let mut service = ServiceType::new(&test_name!()).await;
let request = protocol::read_playlist(TEST_PLAYLIST_NAME);
let request = protocol::read_playlist::<V8>(TEST_PLAYLIST_NAME);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
@ -81,7 +82,7 @@ async fn get_playlist_golden_path() {
assert_eq!(response.status(), StatusCode::OK);
}
let request = protocol::read_playlist(TEST_PLAYLIST_NAME);
let request = protocol::read_playlist::<V8>(TEST_PLAYLIST_NAME);
let response = service.fetch_json::<_, Vec<dto::Song>>(&request).await;
assert_eq!(response.status(), StatusCode::OK);
}
@ -92,7 +93,7 @@ async fn get_playlist_bad_name_returns_not_found() {
service.complete_initial_setup().await;
service.login().await;
let request = protocol::read_playlist(TEST_PLAYLIST_NAME);
let request = protocol::read_playlist::<V8>(TEST_PLAYLIST_NAME);
let response = service.fetch(&request).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

View file

@ -5,6 +5,25 @@ use std::path::Path;
use crate::server::dto;
use crate::{app::user, server::dto::ThumbnailSize};
pub trait ProtocolVersion {
fn header_value() -> i32;
}
pub struct V7;
pub struct V8;
impl ProtocolVersion for V7 {
fn header_value() -> i32 {
7
}
}
impl ProtocolVersion for V8 {
fn header_value() -> i32 {
8
}
}
pub fn web_index() -> Request<()> {
Request::builder()
.method(Method::GET)
@ -145,45 +164,50 @@ pub fn trigger_index() -> Request<()> {
.unwrap()
}
pub fn browse(path: &Path) -> Request<()> {
pub fn browse<VERSION: ProtocolVersion>(path: &Path) -> Request<()> {
let path = path.to_string_lossy();
let endpoint = format!("/api/browse/{}", url_encode(path.as_ref()));
Request::builder()
.header("Accept-Version", VERSION::header_value())
.method(Method::GET)
.uri(&endpoint)
.body(())
.unwrap()
}
pub fn flatten(path: &Path) -> Request<()> {
pub fn flatten<VERSION: ProtocolVersion>(path: &Path) -> Request<()> {
let path = path.to_string_lossy();
let endpoint = format!("/api/flatten/{}", url_encode(path.as_ref()));
Request::builder()
.header("Accept-Version", VERSION::header_value())
.method(Method::GET)
.uri(&endpoint)
.body(())
.unwrap()
}
pub fn random() -> Request<()> {
pub fn random<VERSION: ProtocolVersion>() -> Request<()> {
Request::builder()
.header("Accept-Version", VERSION::header_value())
.method(Method::GET)
.uri("/api/random")
.body(())
.unwrap()
}
pub fn recent() -> Request<()> {
pub fn recent<VERSION: ProtocolVersion>() -> Request<()> {
Request::builder()
.header("Accept-Version", VERSION::header_value())
.method(Method::GET)
.uri("/api/recent")
.body(())
.unwrap()
}
pub fn search(query: &str) -> Request<()> {
pub fn search<VERSION: ProtocolVersion>(query: &str) -> Request<()> {
let endpoint = format!("/api/search/{}", url_encode(query));
Request::builder()
.header("Accept-Version", VERSION::header_value())
.method(Method::GET)
.uri(&endpoint)
.body(())
@ -253,9 +277,10 @@ pub fn save_playlist(
.unwrap()
}
pub fn read_playlist(name: &str) -> Request<()> {
pub fn read_playlist<VERSION: ProtocolVersion>(name: &str) -> Request<()> {
let endpoint = format!("/api/playlist/{}", url_encode(name));
Request::builder()
.header("Accept-Version", VERSION::header_value())
.method(Method::GET)
.uri(&endpoint)
.body(())