use crate::db::FirestoreEmulatorTokenSource;
use crate::{FirestoreDb, FirestoreDbOptions};
#[cfg(feature = "admin")]
use gcloud_sdk::google::firestore::admin::v1::{
BulkDeleteDocumentsMetadata, Field as ProtoField, Index as ProtoIndex,
};
use gcloud_sdk::google::firestore::v1::{
get_document_request, BeginTransactionResponse, CommitResponse, GetDocumentRequest,
};
#[cfg(feature = "admin")]
use gcloud_sdk::google::longrunning::{operation, Operation};
#[cfg(feature = "admin")]
use gcloud_sdk::google::rpc::Status as RpcStatus;
use gcloud_sdk::prost::Message as _;
use gcloud_sdk::tonic::Code;
use h2::server::SendResponse;
use h2::RecvStream;
use hyper::body::Bytes;
use hyper::header::HeaderValue;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{watch, Notify};
pub(super) enum FakeResponse {
Message(Vec<u8>),
Status(Code),
#[cfg(feature = "admin")]
StatusWithMessage(Code, &'static str),
Drop,
#[cfg(feature = "admin")]
Hang,
}
impl FakeResponse {
pub fn empty() -> Self {
Self::Message(Vec::new())
}
pub fn committed() -> Self {
Self::Message(CommitResponse::default().encode_to_vec())
}
}
pub(super) fn begin_response(next_id: &AtomicU8) -> (String, FakeResponse) {
let id = next_id.fetch_add(1, Ordering::SeqCst) + 1;
let transaction = BeginTransactionResponse {
transaction: vec![id],
};
(
format!("Begin→{id}"),
FakeResponse::Message(transaction.encode_to_vec()),
)
}
pub(super) fn begin_failure(code: Code) -> (String, FakeResponse) {
(
format!("Begin failed: {code:?}"),
FakeResponse::Status(code),
)
}
pub(super) fn read_transaction_id(get_document_request: &[u8]) -> u8 {
let request = GetDocumentRequest::decode(get_document_request).unwrap();
let Some(get_document_request::ConsistencySelector::Transaction(id)) =
request.consistency_selector
else {
panic!("read must belong to a transaction");
};
id[0]
}
#[cfg(feature = "admin")]
pub(super) fn list_indexes_response(indexes: Vec<ProtoIndex>) -> FakeResponse {
FakeResponse::Message(
gcloud_sdk::google::firestore::admin::v1::ListIndexesResponse {
indexes,
next_page_token: String::new(),
}
.encode_to_vec(),
)
}
#[cfg(feature = "admin")]
pub(super) fn list_fields_response(fields: Vec<ProtoField>) -> FakeResponse {
FakeResponse::Message(
gcloud_sdk::google::firestore::admin::v1::ListFieldsResponse {
fields,
next_page_token: String::new(),
}
.encode_to_vec(),
)
}
#[cfg(feature = "admin")]
pub(super) fn pending_operation_response(name: &str) -> FakeResponse {
FakeResponse::Message(
Operation {
name: name.to_string(),
metadata: None,
done: false,
result: None,
}
.encode_to_vec(),
)
}
#[cfg(feature = "admin")]
pub(super) fn done_operation_response(name: &str) -> FakeResponse {
FakeResponse::Message(
Operation {
name: name.to_string(),
metadata: None,
done: true,
result: Some(operation::Result::Response(
gcloud_sdk::prost_types::Any::default(),
)),
}
.encode_to_vec(),
)
}
#[cfg(feature = "admin")]
pub(super) fn bulk_delete_operation_response(
name: &str,
done: bool,
metadata: BulkDeleteDocumentsMetadata,
) -> FakeResponse {
FakeResponse::Message(
Operation {
name: name.to_string(),
metadata: Some(gcloud_sdk::prost_types::Any {
type_url:
"type.googleapis.com/google.firestore.admin.v1.BulkDeleteDocumentsMetadata"
.to_string(),
value: metadata.encode_to_vec(),
}),
done,
result: done
.then(|| operation::Result::Response(gcloud_sdk::prost_types::Any::default())),
}
.encode_to_vec(),
)
}
#[cfg(feature = "admin")]
pub(super) fn failed_operation_response(name: &str, code: i32, message: &str) -> FakeResponse {
FakeResponse::Message(
Operation {
name: name.to_string(),
metadata: None,
done: true,
result: Some(operation::Result::Error(RpcStatus {
code,
message: message.to_string(),
details: vec![],
})),
}
.encode_to_vec(),
)
}
type Handler = dyn Fn(&str, &[u8]) -> (String, FakeResponse) + Send + Sync;
pub(super) struct FakeFirestore {
pub db: FirestoreDb,
calls: Arc<watch::Sender<Vec<String>>>,
}
impl FakeFirestore {
pub async fn start<F>(handler: F) -> Self
where
F: Fn(&str, &[u8]) -> (String, FakeResponse) + Send + Sync + 'static,
{
Self::start_with_max_retries(3, handler).await
}
pub async fn start_with_max_retries<F>(max_retries: usize, handler: F) -> Self
where
F: Fn(&str, &[u8]) -> (String, FakeResponse) + Send + Sync + 'static,
{
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let handler: Arc<Handler> = Arc::new(handler);
let calls = Arc::new(watch::Sender::new(Vec::new()));
let accepted = calls.clone();
tokio::spawn(async move {
while let Ok((socket, _)) = listener.accept().await {
tokio::spawn(serve_connection(socket, handler.clone(), accepted.clone()));
}
});
let mut options = FirestoreDbOptions::new("fake-firestore".into());
options.firebase_api_url = Some(endpoint);
options.max_retries = max_retries;
let db = FirestoreDb::with_options_token_source(
options,
Vec::new(),
gcloud_sdk::TokenSourceType::ExternalSource(Box::new(FirestoreEmulatorTokenSource)),
)
.await
.unwrap();
Self { db, calls }
}
pub fn calls(&self) -> Vec<String> {
self.calls.borrow().clone()
}
pub async fn wait_for_calls(&self, count: usize) {
self.calls
.subscribe()
.wait_for(|calls| calls.len() >= count)
.await
.expect("the call log lives as long as the server that owns it");
}
}
async fn serve_connection(
socket: TcpStream,
handler: Arc<Handler>,
calls: Arc<watch::Sender<Vec<String>>>,
) {
let Ok(mut connection) = h2::server::handshake(socket).await else {
return;
};
let close = Arc::new(Notify::new());
let serve = async {
while let Some(Ok((request, respond))) = connection.accept().await {
tokio::spawn(answer(
request,
respond,
handler.clone(),
calls.clone(),
close.clone(),
));
}
};
tokio::select! {
() = serve => {}
() = close.notified() => {}
}
}
async fn answer(
request: hyper::Request<RecvStream>,
mut respond: SendResponse<Bytes>,
handler: Arc<Handler>,
calls: Arc<watch::Sender<Vec<String>>>,
close: Arc<Notify>,
) {
let method = request.uri().path().to_owned();
let listening = method.ends_with("/Listen");
if listening {
let headers = ok_headers().header("grpc-status", "0");
if respond
.send_response(headers.body(()).unwrap(), true)
.is_err()
{
return;
}
}
let mut body = request.into_body();
let mut bytes = Vec::new();
while let Some(Ok(chunk)) = body.data().await {
body.flow_control().release_capacity(chunk.len()).unwrap();
bytes.extend_from_slice(&chunk);
}
let (call, response) = handler(&method, bytes.get(5..).unwrap_or_default());
calls.send_modify(|calls| calls.push(call));
if listening {
return;
}
match response {
FakeResponse::Message(message) => {
let Ok(mut send) = respond.send_response(ok_headers().body(()).unwrap(), false) else {
return;
};
let mut frame = vec![0u8; 5];
frame[1..5].copy_from_slice(&u32::try_from(message.len()).unwrap().to_be_bytes());
frame.extend_from_slice(&message);
if send.send_data(frame.into(), false).is_err() {
return;
}
let mut trailers = hyper::HeaderMap::new();
trailers.insert("grpc-status", HeaderValue::from_static("0"));
let _ = send.send_trailers(trailers);
}
FakeResponse::Status(code) => {
assert_ne!(code, Code::Ok, "answer success with FakeResponse::empty");
let headers = ok_headers().header("grpc-status", (code as i32).to_string());
let _ = respond.send_response(headers.body(()).unwrap(), true);
}
#[cfg(feature = "admin")]
FakeResponse::StatusWithMessage(code, message) => {
assert_ne!(code, Code::Ok, "answer success with FakeResponse::empty");
let headers = ok_headers()
.header("grpc-status", (code as i32).to_string())
.header("grpc-message", message);
let _ = respond.send_response(headers.body(()).unwrap(), true);
}
#[cfg(feature = "admin")]
FakeResponse::Hang => std::future::pending::<()>().await,
FakeResponse::Drop => {
close.notify_one();
std::future::pending::<()>().await;
}
}
}
fn ok_headers() -> hyper::http::response::Builder {
hyper::Response::builder()
.status(200)
.header("content-type", "application/grpc")
}