fix(api): use TokenResponse DTO to exclude sensitive token_hash field
Co-authored-by: jnsgruk <668505+jnsgruk@users.noreply.github.com>
This commit is contained in:
parent
5c05f35a59
commit
97aaf9311d
5 changed files with 345 additions and 9 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::client::BrewlogClient;
|
use crate::client::BrewlogClient;
|
||||||
use crate::domain::tokens::Token;
|
|
||||||
|
|
||||||
pub struct TokensClient<'a> {
|
pub struct TokensClient<'a> {
|
||||||
client: &'a BrewlogClient,
|
client: &'a BrewlogClient,
|
||||||
|
|
@ -37,7 +37,7 @@ impl<'a> TokensClient<'a> {
|
||||||
self.client.handle_response(response).await
|
self.client.handle_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list(&self) -> Result<Vec<Token>> {
|
pub async fn list(&self) -> Result<Vec<TokenInfo>> {
|
||||||
let url = self.client.endpoint("api/v1/tokens")?;
|
let url = self.client.endpoint("api/v1/tokens")?;
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
|
|
@ -49,7 +49,7 @@ impl<'a> TokensClient<'a> {
|
||||||
self.client.handle_response(response).await
|
self.client.handle_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn revoke(&self, id: &str) -> Result<Token> {
|
pub async fn revoke(&self, id: &str) -> Result<TokenInfo> {
|
||||||
let url = self
|
let url = self
|
||||||
.client
|
.client
|
||||||
.endpoint(&format!("api/v1/tokens/{}/revoke", id))?;
|
.endpoint(&format!("api/v1/tokens/{}/revoke", id))?;
|
||||||
|
|
@ -77,3 +77,13 @@ pub struct TokenResponse {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub token: String,
|
pub token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct TokenInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
pub revoked_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use chrono::Utc;
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::domain::ids::generate_id;
|
use crate::domain::ids::generate_id;
|
||||||
|
|
@ -24,6 +24,29 @@ pub struct CreateTokenResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TokenResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
pub revoked_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Token> for TokenResponse {
|
||||||
|
fn from(token: Token) -> Self {
|
||||||
|
Self {
|
||||||
|
id: token.id,
|
||||||
|
user_id: token.user_id,
|
||||||
|
name: token.name,
|
||||||
|
created_at: token.created_at,
|
||||||
|
last_used_at: token.last_used_at,
|
||||||
|
revoked_at: token.revoked_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_token(
|
pub async fn create_token(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<CreateTokenRequest>,
|
Json(payload): Json<CreateTokenRequest>,
|
||||||
|
|
@ -72,21 +95,23 @@ pub async fn create_token(
|
||||||
pub async fn list_tokens(
|
pub async fn list_tokens(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthenticatedUser,
|
auth_user: AuthenticatedUser,
|
||||||
) -> Result<Json<Vec<Token>>, StatusCode> {
|
) -> Result<Json<Vec<TokenResponse>>, StatusCode> {
|
||||||
let tokens = state
|
let tokens = state
|
||||||
.token_repo
|
.token_repo
|
||||||
.list_by_user(auth_user.0.id)
|
.list_by_user(auth_user.0.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
Ok(Json(tokens))
|
let token_responses: Vec<TokenResponse> = tokens.into_iter().map(TokenResponse::from).collect();
|
||||||
|
|
||||||
|
Ok(Json(token_responses))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn revoke_token(
|
pub async fn revoke_token(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthenticatedUser,
|
auth_user: AuthenticatedUser,
|
||||||
Path(token_id): Path<String>,
|
Path(token_id): Path<String>,
|
||||||
) -> Result<Json<Token>, StatusCode> {
|
) -> Result<Json<TokenResponse>, StatusCode> {
|
||||||
// Get the token to ensure it exists and belongs to the user
|
// Get the token to ensure it exists and belongs to the user
|
||||||
let token = state
|
let token = state
|
||||||
.token_repo
|
.token_repo
|
||||||
|
|
@ -106,5 +131,5 @@ pub async fn revoke_token(
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
Ok(Json(revoked_token))
|
Ok(Json(TokenResponse::from(revoked_token)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
259
tests/server/auth_api.rs
Normal file
259
tests/server/auth_api.rs
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
use reqwest::{Client, StatusCode};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::helpers::spawn_app_with_auth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_token_with_valid_credentials() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
// Create a token
|
||||||
|
let response = client
|
||||||
|
.post(&app.api_url("/tokens"))
|
||||||
|
.json(&json!({
|
||||||
|
"username": "admin",
|
||||||
|
"password": "test_password",
|
||||||
|
"name": "test-token"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body: serde_json::Value = response.json().await.expect("Failed to parse response");
|
||||||
|
assert!(body.get("id").is_some());
|
||||||
|
assert_eq!(body.get("name").unwrap(), "test-token");
|
||||||
|
assert!(body.get("token").is_some());
|
||||||
|
|
||||||
|
// Token should be a non-empty string
|
||||||
|
let token = body.get("token").unwrap().as_str().unwrap();
|
||||||
|
assert!(!token.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_token_with_invalid_credentials() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
// Try to create a token with wrong password
|
||||||
|
let response = client
|
||||||
|
.post(&app.api_url("/tokens"))
|
||||||
|
.json(&json!({
|
||||||
|
"username": "admin",
|
||||||
|
"password": "wrong_password",
|
||||||
|
"name": "test-token"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_tokens_requires_authentication() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
// Try to list tokens without authentication
|
||||||
|
let response = client
|
||||||
|
.get(&app.api_url("/tokens"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_tokens_with_authentication() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
// First, create a token
|
||||||
|
let create_response = client
|
||||||
|
.post(&app.api_url("/tokens"))
|
||||||
|
.json(&json!({
|
||||||
|
"username": "admin",
|
||||||
|
"password": "test_password",
|
||||||
|
"name": "test-token"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
|
let create_body: serde_json::Value = create_response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("Failed to parse response");
|
||||||
|
let token = create_body.get("token").unwrap().as_str().unwrap();
|
||||||
|
|
||||||
|
// Now list tokens with authentication
|
||||||
|
let response = client
|
||||||
|
.get(&app.api_url("/tokens"))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let tokens: Vec<serde_json::Value> = response.json().await.expect("Failed to parse response");
|
||||||
|
assert_eq!(tokens.len(), 1);
|
||||||
|
assert_eq!(tokens[0].get("name").unwrap(), "test-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_revoke_token() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
// First, create a token
|
||||||
|
let create_response = client
|
||||||
|
.post(&app.api_url("/tokens"))
|
||||||
|
.json(&json!({
|
||||||
|
"username": "admin",
|
||||||
|
"password": "test_password",
|
||||||
|
"name": "test-token"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
|
let create_body: serde_json::Value = create_response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("Failed to parse response");
|
||||||
|
let token = create_body.get("token").unwrap().as_str().unwrap();
|
||||||
|
let token_id = create_body.get("id").unwrap().as_str().unwrap();
|
||||||
|
|
||||||
|
// Revoke the token
|
||||||
|
let response = client
|
||||||
|
.post(&app.api_url(&format!("/tokens/{}/revoke", token_id)))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body: serde_json::Value = response.json().await.expect("Failed to parse response");
|
||||||
|
assert!(body.get("revoked_at").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_revoked_token_cannot_be_used() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
// Create a token
|
||||||
|
let create_response = client
|
||||||
|
.post(&app.api_url("/tokens"))
|
||||||
|
.json(&json!({
|
||||||
|
"username": "admin",
|
||||||
|
"password": "test_password",
|
||||||
|
"name": "test-token"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
|
let create_body: serde_json::Value = create_response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("Failed to parse response");
|
||||||
|
let token = create_body.get("token").unwrap().as_str().unwrap();
|
||||||
|
let token_id = create_body.get("id").unwrap().as_str().unwrap();
|
||||||
|
|
||||||
|
// Revoke the token
|
||||||
|
client
|
||||||
|
.post(&app.api_url(&format!("/tokens/{}/revoke", token_id)))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
|
// Try to use the revoked token
|
||||||
|
let response = client
|
||||||
|
.get(&app.api_url("/tokens"))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_protected_endpoints_require_authentication() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
// Try to create a roaster without authentication
|
||||||
|
let response = client
|
||||||
|
.post(&app.api_url("/roasters"))
|
||||||
|
.json(&json!({
|
||||||
|
"name": "Test Roaster",
|
||||||
|
"country": "UK"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_protected_endpoints_work_with_authentication() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
// Create a token
|
||||||
|
let create_response = client
|
||||||
|
.post(&app.api_url("/tokens"))
|
||||||
|
.json(&json!({
|
||||||
|
"username": "admin",
|
||||||
|
"password": "test_password",
|
||||||
|
"name": "test-token"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
|
let create_body: serde_json::Value = create_response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("Failed to parse response");
|
||||||
|
let token = create_body.get("token").unwrap().as_str().unwrap();
|
||||||
|
|
||||||
|
// Create a roaster with authentication
|
||||||
|
let response = client
|
||||||
|
.post(&app.api_url("/roasters"))
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.json(&json!({
|
||||||
|
"name": "Test Roaster",
|
||||||
|
"country": "UK"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_read_endpoints_dont_require_authentication() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
// List roasters without authentication should work
|
||||||
|
let response = client
|
||||||
|
.get(&app.api_url("/roasters"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use brewlog::domain::repositories::{RoastRepository, RoasterRepository, TimelineEventRepository};
|
use brewlog::domain::repositories::{
|
||||||
|
RoastRepository, RoasterRepository, TimelineEventRepository, TokenRepository, UserRepository,
|
||||||
|
};
|
||||||
use brewlog::domain::roasters::{NewRoaster, Roaster};
|
use brewlog::domain::roasters::{NewRoaster, Roaster};
|
||||||
|
use brewlog::domain::users::User;
|
||||||
|
use brewlog::infrastructure::auth::hash_password;
|
||||||
use brewlog::infrastructure::database::Database;
|
use brewlog::infrastructure::database::Database;
|
||||||
use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository;
|
use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository;
|
||||||
use brewlog::infrastructure::repositories::roasts::SqlRoastRepository;
|
use brewlog::infrastructure::repositories::roasts::SqlRoastRepository;
|
||||||
use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository;
|
use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository;
|
||||||
|
use brewlog::infrastructure::repositories::tokens::SqlTokenRepository;
|
||||||
|
use brewlog::infrastructure::repositories::users::SqlUserRepository;
|
||||||
use brewlog::server::routes::app_router;
|
use brewlog::server::routes::app_router;
|
||||||
use brewlog::server::server::AppState;
|
use brewlog::server::server::AppState;
|
||||||
|
use chrono::Utc;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
|
@ -17,6 +24,10 @@ pub struct TestApp {
|
||||||
pub roast_repo: Arc<dyn RoastRepository>,
|
pub roast_repo: Arc<dyn RoastRepository>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub timeline_repo: Arc<dyn TimelineEventRepository>,
|
pub timeline_repo: Arc<dyn TimelineEventRepository>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub user_repo: Option<Arc<dyn UserRepository>>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub token_repo: Option<Arc<dyn TokenRepository>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestApp {
|
impl TestApp {
|
||||||
|
|
@ -41,12 +52,18 @@ pub async fn spawn_app() -> TestApp {
|
||||||
let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool()));
|
let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool()));
|
||||||
let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool()));
|
let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool()));
|
||||||
let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool()));
|
let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool()));
|
||||||
|
let user_repo: Arc<dyn UserRepository> =
|
||||||
|
Arc::new(SqlUserRepository::new(database.clone_pool()));
|
||||||
|
let token_repo: Arc<dyn TokenRepository> =
|
||||||
|
Arc::new(SqlTokenRepository::new(database.clone_pool()));
|
||||||
|
|
||||||
// Create application state
|
// Create application state
|
||||||
let state = AppState::new(
|
let state = AppState::new(
|
||||||
roaster_repo.clone(),
|
roaster_repo.clone(),
|
||||||
roast_repo.clone(),
|
roast_repo.clone(),
|
||||||
timeline_repo.clone(),
|
timeline_repo.clone(),
|
||||||
|
user_repo.clone(),
|
||||||
|
token_repo.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create router
|
// Create router
|
||||||
|
|
@ -72,9 +89,33 @@ pub async fn spawn_app() -> TestApp {
|
||||||
roaster_repo,
|
roaster_repo,
|
||||||
roast_repo,
|
roast_repo,
|
||||||
timeline_repo,
|
timeline_repo,
|
||||||
|
user_repo: Some(user_repo),
|
||||||
|
token_repo: Some(token_repo),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn spawn_app_with_auth() -> TestApp {
|
||||||
|
let app = spawn_app().await;
|
||||||
|
|
||||||
|
// Create admin user with known password
|
||||||
|
let password_hash = hash_password("test_password").expect("Failed to hash password");
|
||||||
|
let admin_user = User::new(
|
||||||
|
"test_admin_id".to_string(),
|
||||||
|
"admin".to_string(),
|
||||||
|
password_hash,
|
||||||
|
Utc::now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.user_repo
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.insert(admin_user)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create admin user");
|
||||||
|
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_roaster_with_payload(app: &TestApp, payload: NewRoaster) -> Roaster {
|
pub async fn create_roaster_with_payload(app: &TestApp, payload: NewRoaster) -> Roaster {
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
let response = client
|
let response = client
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod auth_api;
|
||||||
pub mod helpers;
|
pub mod helpers;
|
||||||
pub mod roasters_api;
|
pub mod roasters_api;
|
||||||
pub mod roasts_api;
|
pub mod roasts_api;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue