Repository: p2panda/aquadoggo (language: rust)

Issue:
Select current view for blob using custom middleware
this would mean we don't need to symlink between blobs and their current view


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83ee86c04..c12547750 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,11 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+
 - Serve static files from `blobs` directory [#480](https://github.com/p2panda/aquadoggo/pull/480)
 - Add method to store for pruning document views [#491](https://github.com/p2panda/aquadoggo/pull/491)
 - Introduce `BlobStore` [#484](https://github.com/p2panda/aquadoggo/pull/484)
 - Task for automatic garbage collection of unused documents and views [#500](https://github.com/p2panda/aquadoggo/pull/500)
 
+### Changed
+
+- HTTP routes to serve files with correct content type headers [#544](https://github.com/p2panda/aquadoggo/pull/544)
+
 ## [0.5.0]
 
 ### Added
diff --git a/Cargo.lock b/Cargo.lock
index a09973914..1519d5f25 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -201,6 +201,7 @@ dependencies = [
  "thiserror",
  "tokio",
  "tokio-stream",
+ "tokio-util",
  "tower",
  "tower-http",
  "tower-service",
@@ -2836,16 +2837,6 @@ version = "0.3.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
 
-[[package]]
-name = "mime_guess"
-version = "2.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef"
-dependencies = [
- "mime",
- "unicase",
-]
-
 [[package]]
 name = "minimal-lexical"
 version = "0.2.1"
@@ -4783,16 +4774,9 @@ dependencies = [
  "http",
  "http-body",
  "http-range-header",
- "httpdate",
- "mime",
- "mime_guess",
- "percent-encoding",
  "pin-project-lite",
- "tokio",
- "tokio-util",
  "tower-layer",
  "tower-service",
- "tracing",
 ]
 
 [[package]]
@@ -4956,15 +4940,6 @@ dependencies = [
  "version_check",
 ]
 
-[[package]]
-name = "unicase"
-version = "2.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6"
-dependencies = [
- "version_check",
-]
-
 [[package]]
 name = "unicode-bidi"
 version = "0.3.13"
diff --git a/aquadoggo/Cargo.toml b/aquadoggo/Cargo.toml
index b2185efba..58492a563 100644
--- a/aquadoggo/Cargo.toml
+++ b/aquadoggo/Cargo.toml
@@ -24,7 +24,7 @@ async-graphql = { version = "5.0.6", features = ["dynamic-schema"] }
 async-graphql-axum = "5.0.6"
 async-trait = "0.1.64"
 asynchronous-codec = { version = "0.6.2", features = ["cbor"] }
-axum = "0.6.10"
+axum = { version = "0.6.10", features = ["headers"] }
 bamboo-rs-core-ed25519-yasmf = "0.1.1"
 bs58 = "0.4.0"
 deadqueue = { version = "0.2.3", default-features = false, features = [
@@ -76,11 +76,12 @@ tokio = { version = "1.28.2", features = [
     "rt-multi-thread",
     "sync",
     "time",
+    "fs",
 ] }
 tokio-stream = { version = "0.1.14", features = ["sync"] }
+tokio-util = { version = "0.7.8", features = ["io"] }
 tower-http = { version = "0.4.0", default-features = false, features = [
     "cors",
-    "fs",
 ] }
 triggered = "0.1.2"
 void = "1.0.2"
diff --git a/aquadoggo/src/config.rs b/aquadoggo/src/config.rs
index 317e4cf90..4614ccecf 100644
--- a/aquadoggo/src/config.rs
+++ b/aquadoggo/src/config.rs
@@ -6,11 +6,9 @@ use p2panda_rs::schema::SchemaId;
 
 use crate::network::NetworkConfiguration;
 
-/// Blobs directory
+/// Blobs directory name.
 pub const BLOBS_DIR_NAME: &str = "blobs";
 
-pub const BLOBS_SYMLINK_DIR_NAME: &str = "documents";
-
 /// Configuration object holding all important variables throughout the application.
 #[derive(Debug, Clone)]
 pub struct Configuration {
diff --git a/aquadoggo/src/db/stores/blob.rs b/aquadoggo/src/db/stores/blob.rs
index 1cc491d67..f90e28c4f 100644
--- a/aquadoggo/src/db/stores/blob.rs
+++ b/aquadoggo/src/db/stores/blob.rs
@@ -19,12 +19,12 @@ use crate::db::SqlStore;
 /// p2panda-rs blob validation too.
 const MAX_BLOB_PIECES: u64 = 10000;
 
-pub type BlobData = String;
+pub type BlobData = Vec<u8>;
 
 impl SqlStore {
     /// Get the data for one blob from the store, identified by it's document id.
     pub async fn get_blob(&self, id: &DocumentId) -> Result<Option<BlobData>, BlobStoreError> {
-        // Get the root blob document.
+        // Get the root blob document
         let blob_document = match self.get_document(id).await? {
             Some(document) => {
                 if document.schema_id != SchemaId::Blob(1) {
@@ -42,7 +42,7 @@ impl SqlStore {
         &self,
         view_id: &DocumentViewId,
     ) -> Result<Option<BlobData>, BlobStoreError> {
-        // Get the root blob document.
+        // Get the root blob document
         let blob_document = match self.get_document_by_view_id(view_id).await? {
             Some(document) => {
                 if document.schema_id != SchemaId::Blob(1) {
@@ -71,9 +71,9 @@ impl SqlStore {
                     operation_fields_v1.value
                 FROM
                     operation_fields_v1
-                LEFT JOIN 
+                LEFT JOIN
                     operations_v1
-                ON 
+                ON
                     operations_v1.operation_id = operation_fields_v1.operation_id
                 WHERE
                     operations_v1.document_id = $1
@@ -126,9 +126,9 @@ async fn reverse_relations(
 
     query_scalar(&format!(
         "
-        SELECT 
-            document_view_fields.document_view_id 
-        FROM 
+        SELECT
+            document_view_fields.document_view_id
+        FROM
             document_view_fields
         LEFT JOIN
             operation_fields_v1
@@ -136,18 +136,18 @@ async fn reverse_relations(
             document_view_fields.operation_id = operation_fields_v1.operation_id
         AND
             document_view_fields.name = operation_fields_v1.name
-        LEFT JOIN 
+        LEFT JOIN
             document_views
         ON
             document_view_fields.document_view_id = document_views.document_view_id
         WHERE
             operation_fields_v1.field_type
-        IN 
+        IN
             ('pinned_relation', 'pinned_relation_list', 'relation', 'relation_list')
         {schema_id_condition}
-        AND 
+        AND
             operation_fields_v1.value IN (
-                SELECT document_views.document_view_id 
+                SELECT document_views.document_view_id
                 FROM document_views
                 WHERE document_views.document_id = $1
             ) OR operation_fields_v1.value = $1
@@ -178,8 +178,8 @@ async fn document_to_blob_data(
 
     // Now collect all existing pieces for the blob.
     //
-    // We do this using the stores' query method, targeting pieces which are in the relation
-    // list of the blob.
+    // We do this using the stores' query method, targeting pieces which are in the relation list
+    // of the blob.
     let schema = Schema::get_system(SchemaId::BlobPiece(1)).unwrap();
     let list = RelationList::new_pinned(blob.view_id(), "pieces");
     let pagination = Pagination {
@@ -224,7 +224,7 @@ async fn document_to_blob_data(
         return Err(BlobStoreError::IncorrectLength);
     };
 
-    Ok(Some(blob_data))
+    Ok(Some(blob_data.into_bytes()))
 }
 
 #[cfg(test)]
@@ -245,18 +245,18 @@ mod tests {
     #[rstest]
     fn get_blob(key_pair: KeyPair) {
         test_runner(|mut node: TestNode| async move {
-            let blob_data = "Hello, World!".to_string();
-            let blob_view_id = add_blob(&mut node, &blob_data, &key_pair).await;
+            let blob_data = "Hello, World!".as_bytes();
+            let blob_view_id = add_blob(&mut node, &blob_data, 6, "text/plain", &key_pair).await;
 
             let document_id: DocumentId = blob_view_id.to_string().parse().unwrap();
 
-            // Get blob by document id.
+            // Get blob by document id
             let blob = node.context.store.get_blob(&document_id).await.unwrap();
 
             assert!(blob.is_some());
             assert_eq!(blob.unwrap(), blob_data);
 
-            // Get blob by view id.
+            // Get blob by view id
             let blob = node
                 .context
                 .store
@@ -376,8 +376,8 @@ mod tests {
     #[rstest]
     fn purge_blob(key_pair: KeyPair) {
         test_runner(|mut node: TestNode| async move {
-            let blob_data = "Hello, World!".to_string();
-            let blob_view_id = add_blob(&mut node, &blob_data, &key_pair).await;
+            let blob_data = "Hello, World!".as_bytes();
+            let blob_view_id = add_blob(&mut node, &blob_data, 7, "text/plain", &key_pair).await;
 
             // There is one blob and two blob pieces in database.
             //
@@ -419,8 +419,8 @@ mod tests {
         test_runner(|mut node: TestNode| async move {
             let _ = populate_and_materialize(&mut node, &config).await;
 
-            let blob_data = "Hello, World!".to_string();
-            let blob_view_id = add_blob(&mut node, &blob_data, &key_pair).await;
+            let blob_data = "Hello, World!".as_bytes();
+            let blob_view_id = add_blob(&mut node, &blob_data, 7, "text/plain", &key_pair).await;
 
             // There is one blob and two blob pieces in database.
             //
@@ -453,8 +453,8 @@ mod tests {
     #[rstest]
     fn does_not_purge_blob_if_still_pinned(key_pair: KeyPair) {
         test_runner(|mut node: TestNode| async move {
-            let blob_data = "Hello, World!".to_string();
-            let blob_view_id = add_blob(&mut node, &blob_data, &key_pair).await;
+            let blob_data = "Hello, World!".as_bytes();
+            let blob_view_id = add_blob(&mut node, &blob_data, 7, "text/plain", &key_pair).await;
 
             let _ = add_schema_and_documents(
                 &mut node,
@@ -497,8 +497,8 @@ mod tests {
     #[rstest]
     fn purge_all_pieces_of_updated_blob(key_pair: KeyPair) {
         test_runner(|mut node: TestNode| async move {
-            let blob_data = "Hello, World!".to_string();
-            let blob_view_id = add_blob(&mut node, &blob_data, &key_pair).await;
+            let blob_data = "Hello, World!".as_bytes();
+            let blob_view_id = add_blob(&mut node, &blob_data, 7, "text/plain", &key_pair).await;
 
             // Create a new blob piece.
             let new_blob_pieces = add_document(
diff --git a/aquadoggo/src/graphql/mutations/publish.rs b/aquadoggo/src/graphql/mutations/publish.rs
index f9c493827..745603ed9 100644
--- a/aquadoggo/src/graphql/mutations/publish.rs
+++ b/aquadoggo/src/graphql/mutations/publish.rs
@@ -125,9 +125,9 @@ mod tests {
 
     use crate::bus::ServiceMessage;
     use crate::graphql::GraphQLSchemaManager;
-    use crate::http::{HttpServiceContext, BLOBS_ROUTE};
+    use crate::http::HttpServiceContext;
     use crate::test_utils::{
-        add_schema, doggo_fields, doggo_schema, graphql_test_client, populate_and_materialize,
+        add_schema, doggo_fields, doggo_schema, http_test_client, populate_and_materialize,
         populate_store_config, test_runner, TestNode,
     };
 
@@ -237,7 +237,11 @@ mod tests {
                 node.context.schema_provider.clone(),
             )
             .await;
-            let context = HttpServiceContext::new(manager, BLOBS_ROUTE.into());
+            let context = HttpServiceContext::new(
+                node.context.store.clone(),
+                manager,
+                node.context.config.blob_dir.as_ref().unwrap().to_path_buf(),
+            );
 
             let response = context.schema.execute(publish_request).await;
 
@@ -298,7 +302,11 @@ mod tests {
                 node.context.schema_provider.clone(),
             )
             .await;
-            let context = HttpServiceContext::new(manager, BLOBS_ROUTE.into());
+            let context = HttpServiceContext::new(
+                node.context.store.clone(),
+                manager,
+                node.context.config.blob_dir.as_ref().unwrap().to_path_buf(),
+            );
 
             let response = context
                 .schema
@@ -326,7 +334,11 @@ mod tests {
                 node.context.schema_provider.clone(),
             )
             .await;
-            let context = HttpServiceContext::new(manager, BLOBS_ROUTE.into());
+            let context = HttpServiceContext::new(
+                node.context.store.clone(),
+                manager,
+                node.context.config.blob_dir.as_ref().unwrap().to_path_buf(),
+            );
 
             context.schema.execute(publish_request).await;
 
@@ -354,7 +366,7 @@ mod tests {
             populate_and_materialize(&mut node, &config).await;
 
             // Init the test client.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
 
             let response = client
                 .post("/graphql")
@@ -573,7 +585,7 @@ mod tests {
             populate_and_materialize(&mut node, &config).await;
 
             // Init the test client
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
 
             // Prepare the GQL publish request
             let publish_request = publish_request(&entry_encoded, &encoded_operation);
@@ -701,7 +713,7 @@ mod tests {
             populate_and_materialize(&mut node, &config).await;
 
             // Init the test client.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
 
             let publish_request = publish_request(&entry_encoded, &encoded_operation);
 
@@ -736,7 +748,7 @@ mod tests {
             populate_and_materialize(&mut node, &config).await;
 
             // Init the test client.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
 
             // Two key pairs representing two different authors
             let key_pairs = vec![KeyPair::new(), KeyPair::new()];
@@ -828,7 +840,7 @@ mod tests {
             populate_and_materialize(&mut node, &config).await;
 
             // Init the test client.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
 
             // Get the one entry from the store.
             let entries = node
@@ -871,7 +883,7 @@ mod tests {
     ) {
         test_runner(|node: TestNode| async move {
             // Init the test client.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
 
             // Prepare a publish entry request for the entry.
             let publish_entry = publish_request(
diff --git a/aquadoggo/src/graphql/queries/collection.rs b/aquadoggo/src/graphql/queries/collection.rs
index c149cabe1..44655b9d1 100644
--- a/aquadoggo/src/graphql/queries/collection.rs
+++ b/aquadoggo/src/graphql/queries/collection.rs
@@ -56,7 +56,7 @@ mod tests {
     use serde_json::{json, Value as JsonValue};
 
     use crate::test_utils::{
-        add_document, add_schema, add_schema_and_documents, graphql_test_client, test_runner,
+        add_document, add_schema, add_schema_and_documents, http_test_client, test_runner,
         TestClient, TestNode,
     };
 
@@ -594,7 +594,7 @@ mod tests {
             .await;
 
             // Configure and send test query.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
             let query = format!(
                 r#"{{
                 collection: all_{type_name}{query_args} {{
@@ -697,7 +697,7 @@ mod tests {
             .await;
 
             // Configure and send test query.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
             let query = format!(
                 r#"{{
                     collection: all_{type_name} {{
@@ -742,7 +742,7 @@ mod tests {
             add_document(&mut node, schema.id(), document_values, &key_pair).await;
 
             // Configure and send test query.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
             let query = format!(
                 r#"{{
                     collection: all_{type_name} {{
@@ -852,7 +852,7 @@ mod tests {
                 here_be_some_karaoke_hits(&mut node, &view_ids, &lyric_schema, &key_pair).await;
 
             // Init a GraphQL client we'll use to query the node.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
 
             // Perform a paginated collection query for the songs.
             query_songs(&client, song_schema.id(), &song_args, &lyric_args).await;
@@ -870,7 +870,7 @@ mod tests {
                 here_be_some_karaoke_hits(&mut node, &view_ids, &lyric_schema, &key_pair).await;
 
             // Init a GraphQL client we'll use to query the node.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
 
             // Perform a paginated collection query for the songs on the node identified by the
             // schema id. We don't pass any arguments and so will get up to the default number of
@@ -1103,7 +1103,7 @@ mod tests {
                 here_be_some_karaoke_hits(&mut node, &view_ids, &lyric_schema, &key_pair).await;
 
             // Init a GraphQL client we'll use to query the node.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
 
             let data = query_songs(&client, song_schema.id(), "(first: 4)", "").await;
             assert_eq!(data["query"]["documents"].as_array().unwrap().len(), 3);
@@ -1144,7 +1144,7 @@ mod tests {
                 here_be_some_karaoke_hits(&mut node, &view_ids, &lyric_schema, &key_pair).await;
 
             // Init a GraphQL client we'll use to query the node.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
 
             let data = query_songs_meta_fields_only(&client, song_schema.id(), "(first: 4)").await;
             assert_eq!(data["query"]["documents"].as_array().unwrap().len(), 3);
diff --git a/aquadoggo/src/graphql/queries/document.rs b/aquadoggo/src/graphql/queries/document.rs
index c882f9f4d..0e0603b97 100644
--- a/aquadoggo/src/graphql/queries/document.rs
+++ b/aquadoggo/src/graphql/queries/document.rs
@@ -100,7 +100,7 @@ mod test {
     use rstest::rstest;
     use serde_json::json;
 
-    use crate::test_utils::{add_document, add_schema, graphql_test_client, test_runner, TestNode};
+    use crate::test_utils::{add_document, add_schema, http_test_client, test_runner, TestNode};
 
     #[rstest]
     fn single_query(#[from(random_key_pair)] key_pair: KeyPair) {
@@ -137,7 +137,7 @@ mod test {
             let document_id = document.id();
 
             // Configure and send test query.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
             let query = format!(
                 r#"{{
                 byViewId: {type_name}(viewId: "{view_id}") {{
@@ -213,7 +213,7 @@ mod test {
         // Test single query parameter variations.
         test_runner(move |node: TestNode| async move {
             // Configure and send test query.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
             let query = format!(
                 r#"{{
                 view: schema_definition_v1{params} {{
@@ -269,7 +269,7 @@ mod test {
             .await;
 
             // Configure and send test query.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
             let query = format!(
                 r#"{{
                     single: {schema_id}(id: "{view_id}") {{
diff --git a/aquadoggo/src/graphql/queries/next_args.rs b/aquadoggo/src/graphql/queries/next_args.rs
index 51bc98e07..642730136 100644
--- a/aquadoggo/src/graphql/queries/next_args.rs
+++ b/aquadoggo/src/graphql/queries/next_args.rs
@@ -97,13 +97,13 @@ mod tests {
     use serde_json::json;
 
     use crate::test_utils::{
-        graphql_test_client, populate_and_materialize, populate_store_config, test_runner, TestNode,
+        http_test_client, populate_and_materialize, populate_store_config, test_runner, TestNode,
     };
 
     #[rstest]
     fn next_args_valid_query() {
         test_runner(|node: TestNode| async move {
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
             // Selected fields need to be alphabetically sorted because that's what the `json`
             // macro that is used in the assert below produces.
             let received_entry_args = client
@@ -149,7 +149,7 @@ mod tests {
             // Populates the store and materialises documents and schema.
             let (key_pairs, document_ids) = populate_and_materialize(&mut node, &config).await;
 
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
             let document_id = document_ids.get(0).expect("There should be a document id");
             let public_key = key_pairs
                 .get(0)
@@ -201,7 +201,7 @@ mod tests {
     #[rstest]
     fn next_args_error_response() {
         test_runner(|node: TestNode| async move {
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
             let response = client
                 .post("/graphql")
                 .json(&json!({
diff --git a/aquadoggo/src/graphql/schema.rs b/aquadoggo/src/graphql/schema.rs
index 354901501..50244d7e9 100644
--- a/aquadoggo/src/graphql/schema.rs
+++ b/aquadoggo/src/graphql/schema.rs
@@ -271,15 +271,15 @@ mod test {
     use rstest::rstest;
     use serde_json::{json, Value};
 
-    use crate::test_utils::{add_schema, graphql_test_client, test_runner, TestNode};
+    use crate::test_utils::{add_schema, http_test_client, test_runner, TestNode};
 
     #[rstest]
     fn schema_updates() {
         test_runner(|mut node: TestNode| async move {
             // Create test client in the beginning so it is initialised with just the system
-            // schemas. Then we create a new application schema to test that the graphql schema
-            // is updated and we can query the changed schema.
-            let client = graphql_test_client(&node).await;
+            // schemas. Then we create a new application schema to test that the graphql schema is
+            // updated and we can query the changed schema.
+            let client = http_test_client(&node).await;
 
             // This test uses a fixed private key to allow us to anticipate the schema typename.
             let key_pair = key_pair(PRIVATE_KEY);
diff --git a/aquadoggo/src/http/api.rs b/aquadoggo/src/http/api.rs
index 8ec063003..f94c8c94b 100644
--- a/aquadoggo/src/http/api.rs
+++ b/aquadoggo/src/http/api.rs
@@ -1,21 +1,403 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
+use std::path::PathBuf;
+use std::str::FromStr;
+
+use anyhow::{anyhow, Result};
 use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
 use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
-use axum::extract::Extension;
-use axum::response::{self, IntoResponse};
+use axum::body::StreamBody;
+use axum::extract::{Extension, Path};
+use axum::headers::{ETag, IfNoneMatch};
+use axum::http::StatusCode;
+use axum::response::{self, IntoResponse, Response};
+use axum::TypedHeader;
+use http::header;
+use log::warn;
+use p2panda_rs::document::traits::AsDocument;
+use p2panda_rs::document::{DocumentId, DocumentViewId};
+use p2panda_rs::schema::SchemaId;
+use p2panda_rs::storage_provider::traits::DocumentStore;
+use p2panda_rs::Human;
+use tokio::fs::File;
+use tokio_util::io::ReaderStream;
 
 use crate::http::context::HttpServiceContext;
 
-/// Handle graphql playground requests at the given path.
+/// Handle GraphQL playground requests at the given path.
 pub async fn handle_graphql_playground(path: &str) -> impl IntoResponse {
     response::Html(playground_source(GraphQLPlaygroundConfig::new(path)))
 }
 
-/// Handle graphql requests.
+/// Handle GraphQL requests.
 pub async fn handle_graphql_query(
     Extension(context): Extension<HttpServiceContext>,
     req: GraphQLRequest,
 ) -> GraphQLResponse {
     context.schema.execute(req.into_inner()).await.into()
 }
+
+/// Handle requests for a blob document served via HTTP.
+///
+/// This method automatically returns the "latest" version of the document.
+pub async fn handle_blob_document(
+    TypedHeader(if_none_match): TypedHeader<IfNoneMatch>,
+    Extension(context): Extension<HttpServiceContext>,
+    Path(document_id): Path<String>,
+) -> Result<Response, BlobHttpError> {
+    let document_id: DocumentId = DocumentId::from_str(&document_id)
+        .map_err(|err| BlobHttpError::InvalidFormat(err.into()))?;
+
+    let document = context
+        .store
+        .get_document(&document_id)
+        .await
+        .map_err(|err| BlobHttpError::InternalError(err.into()))?
+        .ok_or_else(|| BlobHttpError::NotFound)?;
+
+    // Requested document is not a blob, treat this as a "not found" error
+    if document.schema_id() != &SchemaId::Blob(1) {
+        return Err(BlobHttpError::NotFound);
+    }
+
+    respond_with_blob(if_none_match, context.blob_dir_path, document).await
+}
+
+/// Handle requests for a blob document view served via HTTP.
+///
+/// This method returns the version which was specified by the document view id.
+pub async fn handle_blob_view(
+    TypedHeader(if_none_match): TypedHeader<IfNoneMatch>,
+    Extension(context): Extension<HttpServiceContext>,
+    Path((document_id, view_id)): Path<(String, String)>,
+) -> Result<Response, BlobHttpError> {
+    let document_id = DocumentId::from_str(&document_id)
+        .map_err(|err| BlobHttpError::InvalidFormat(err.into()))?;
+    let view_id = DocumentViewId::from_str(&view_id)
+        .map_err(|err| BlobHttpError::InvalidFormat(err.into()))?;
+
+    let document = context
+        .store
+        .get_document_by_view_id(&view_id)
+        .await
+        .map_err(|err| BlobHttpError::InternalError(err.into()))?
+        .ok_or(BlobHttpError::NotFound)?;
+
+    if document.id() != &document_id || document.schema_id() != &SchemaId::Blob(1) {
+        return Err(BlobHttpError::NotFound);
+    }
+
+    respond_with_blob(if_none_match, context.blob_dir_path, document).await
+}
+
+/// Returns HTTP response with the contents, ETag and given MIME type of a blob.
+///
+/// Supports basic caching by handling "IfNoneMatch" headers matching the latest ETag.
+async fn respond_with_blob(
+    if_none_match: IfNoneMatch,
+    blob_dir_path: PathBuf,
+    document: impl AsDocument,
+) -> Result<Response, BlobHttpError> {
+    let view_id = document.view_id();
+
+    // Convert document view id into correct ETag value (with quotation marks defined in
+    // https://datatracker.ietf.org/doc/html/rfc7232#section-2.3)
+    let to_etag_str = || format!("\"{}\"", view_id);
+
+    // Respond with 304 "not modified" if ETag still matches (document did not get updated)
+    let etag =
+        ETag::from_str(&to_etag_str()).map_err(|err| BlobHttpError::InternalError(err.into()))?;
+    if !if_none_match.precondition_passes(&etag) {
+        return Ok(StatusCode::NOT_MODIFIED.into_response());
+    }
+
+    // Get MIME type of blob
+    let mime_type_str = match document.get("mime_type") {
+        Some(p2panda_rs::operation::OperationValue::String(value)) => Ok(value),
+        _ => Err(BlobHttpError::InternalError(anyhow!(
+            "Blob document did not contain a valid 'mime_type' field"
+        ))),
+    }?;
+
+    // Get body from read-stream of stored file on file system
+    let mut file_path = blob_dir_path;
+    file_path.push(format!("{view_id}"));
+    match File::open(&file_path).await {
+        Ok(file) => {
+            let headers = [
+                // MIME type to allow browsers to correctly handle this specific blob format
+                (header::CONTENT_TYPE, mime_type_str),
+                // ETag to allow browsers handle caching
+                (header::ETAG, &to_etag_str()),
+            ];
+
+            let stream = ReaderStream::new(file);
+            let body = StreamBody::new(stream);
+
+            Ok((headers, body).into_response())
+        }
+        Err(_) => {
+            warn!(
+                "Data inconsistency detected: Blob document {} exists in database but not on file
+                system at path {}!",
+                view_id.display(),
+                file_path.display()
+            );
+
+            Err(BlobHttpError::NotFound)
+        }
+    }
+}
+
+#[derive(Debug)]
+pub enum BlobHttpError {
+    NotFound,
+    InvalidFormat(anyhow::Error),
+    InternalError(anyhow::Error),
+}
+
+impl IntoResponse for BlobHttpError {
+    fn into_response(self) -> Response {
+        match self {
+            BlobHttpError::NotFound => {
+                (StatusCode::NOT_FOUND, "Could not find document").into_response()
+            }
+            BlobHttpError::InvalidFormat(err) => (
+                StatusCode::BAD_REQUEST,
+                format!("Could not parse identifier: {}", err),
+            )
+                .into_response(),
+            BlobHttpError::InternalError(err) => (
+                StatusCode::INTERNAL_SERVER_ERROR,
+                format!("Something went wrong: {}", err),
+            )
+                .into_response(),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use http::{header, StatusCode};
+    use p2panda_rs::document::DocumentId;
+    use p2panda_rs::identity::KeyPair;
+    use p2panda_rs::test_utils::fixtures::key_pair;
+    use rstest::rstest;
+
+    use crate::materializer::tasks::blob_task;
+    use crate::materializer::TaskInput;
+    use crate::test_utils::{add_blob, http_test_client, test_runner, update_blob, TestNode};
+
+    // @TODO: Would be nice if this would come out of p2panda_rs
+    const MAX_BLOB_PIECE_LENGTH: usize = 256;
+
+    #[rstest]
+    fn responds_with_blob_in_http_body(key_pair: KeyPair) {
+        test_runner(|mut node: TestNode| async move {
+            let blob_data = "Hello, World!".as_bytes();
+            let blob_view_id = add_blob(&mut node, &blob_data, 6, "text/plain", &key_pair).await;
+            let document_id: DocumentId = blob_view_id.to_string().parse().unwrap();
+
+            // Make sure to materialize blob on file system
+            blob_task(
+                node.context.clone(),
+                TaskInput::DocumentViewId(blob_view_id.clone()),
+            )
+            .await
+            .unwrap();
+
+            let client = http_test_client(&node).await;
+
+            // "/blobs/<document_id>" path
+            let response = client.get(&format!("/blobs/{}", document_id)).send().await;
+            let status_code = response.status();
+            let body = response.text().await;
+
+            assert_eq!(status_code, StatusCode::OK);
+            assert_eq!(body, "Hello, World!");
+
+            // "/blobs/<document_id>/<view_id>" path
+            let response = client
+                .get(&format!("/blobs/{}/{}", document_id, blob_view_id))
+                .send()
+                .await;
+            let status_code = response.status();
+            let body = response.text().await;
+
+            assert_eq!(status_code, StatusCode::OK);
+            assert_eq!(body, "Hello, World!");
+        })
+    }
+
+    #[rstest]
+    fn document_route_responds_with_latest_view(key_pair: KeyPair) {
+        test_runner(|mut node: TestNode| async move {
+            let blob_data = "Hello, World!".as_bytes();
+            let blob_view_id = add_blob(&mut node, &blob_data, 6, "text/plain", &key_pair).await;
+            let document_id: DocumentId = blob_view_id.to_string().parse().unwrap();
+
+            // Make sure to materialize blob on file system
+            blob_task(
+                node.context.clone(),
+                TaskInput::DocumentViewId(blob_view_id.clone()),
+            )
+            .await
+            .unwrap();
+
+            // Update the blob
+            let blob_data = "Hello, Panda!".as_bytes();
+            let blob_view_id_2 =
+                update_blob(&mut node, &blob_data, 6, &blob_view_id, &key_pair).await;
+
+            blob_task(
+                node.context.clone(),
+                TaskInput::DocumentViewId(blob_view_id_2.clone()),
+            )
+            .await
+            .unwrap();
+
+            // Expect to receive latest version
+            let client = http_test_client(&node).await;
+            let response = client.get(&format!("/blobs/{}", document_id)).send().await;
+            let status_code = response.status();
+            let body = response.text().await;
+
+            assert_eq!(status_code, StatusCode::OK);
+            assert_eq!(body, "Hello, Panda!");
+        })
+    }
+
+    #[rstest]
+    fn responds_with_content_type_header(key_pair: KeyPair) {
+        test_runner(|mut node: TestNode| async move {
+            let blob_data = r#"
+                <svg version="1.1" width="300" height="200" xmlns="http://www.w3.org/2000/svg">
+                  <rect width="100%" height="100%" fill="red" />
+                  <circle cx="150" cy="100" r="80" fill="green" />
+                </svg>
+            "#
+            .as_bytes();
+            let blob_view_id = add_blob(
+                &mut node,
+                &blob_data,
+                MAX_BLOB_PIECE_LENGTH,
+                "image/svg+xml",
+                &key_pair,
+            )
+            .await;
+            let document_id: DocumentId = blob_view_id.to_string().parse().unwrap();
+
+            // Make sure to materialize blob on file system
+            blob_task(
+                node.context.clone(),
+                TaskInput::DocumentViewId(blob_view_id.clone()),
+            )
+            .await
+            .unwrap();
+
+            // Expect correctly set content type header and body in response
+            let client = http_test_client(&node).await;
+            let response = client.get(&format!("/blobs/{}", document_id)).send().await;
+            let status_code = response.status();
+            let headers = response.headers();
+            let body = response.bytes().await;
+            let content_type = headers
+                .get(header::CONTENT_TYPE)
+                .expect("ContentType to exist in header");
+
+            assert_eq!(content_type, "image/svg+xml");
+            assert_eq!(status_code, StatusCode::OK);
+            assert_eq!(body, blob_data);
+        })
+    }
+
+    #[rstest]
+    fn handles_etag_and_if_none_match_precondition(key_pair: KeyPair) {
+        test_runner(|mut node: TestNode| async move {
+            let blob_data = "Hello, World!".as_bytes();
+            let blob_view_id = add_blob(&mut node, &blob_data, 6, "text/plain", &key_pair).await;
+            let document_id: DocumentId = blob_view_id.to_string().parse().unwrap();
+
+            // Make sure to materialize blob on file system
+            blob_task(
+                node.context.clone(),
+                TaskInput::DocumentViewId(blob_view_id.clone()),
+            )
+            .await
+            .unwrap();
+
+            let client = http_test_client(&node).await;
+
+            // 1. Get blob and ETag connected to it
+            let response = client.get(&format!("/blobs/{}", document_id)).send().await;
+            let status_code = response.status();
+            let headers = response.headers();
+            let body = response.text().await;
+            let etag = headers.get(header::ETAG).expect("ETag to exist in header");
+
+            assert_eq!(status_code, StatusCode::OK);
+            assert_eq!(body, "Hello, World!");
+
+            // 2. Send another request, including the received ETag inside a "IfNoneMatch" header
+            let response = client
+                .get(&format!("/blobs/{}", document_id))
+                .header(header::IF_NONE_MATCH, etag)
+                .send()
+                .await;
+            let status_code = response.status();
+            let body = response.text().await;
+            assert_eq!(status_code, StatusCode::NOT_MODIFIED);
+            assert_eq!(body, "");
+
+            // 3. Update the blob
+            let blob_data = "Hello, Panda!".as_bytes();
+            let blob_view_id_2 =
+                update_blob(&mut node, &blob_data, 6, &blob_view_id, &key_pair).await;
+
+            // Make sure to materialize blob on file system
+            blob_task(
+                node.context.clone(),
+                TaskInput::DocumentViewId(blob_view_id_2.clone()),
+            )
+            .await
+            .unwrap();
+
+            // 4. Send request again, including the (now outdated) ETag
+            let response = client
+                .get(&format!("/blobs/{}", document_id))
+                .header(header::IF_NONE_MATCH, etag)
+                .send()
+                .await;
+            let status_code = response.status();
+            let headers = response.headers();
+            let body = response.text().await;
+            let etag_2 = headers.get(header::ETAG).expect("ETag to exist in header");
+
+            assert_ne!(etag, etag_2);
+            assert_eq!(status_code, StatusCode::OK);
+            assert_eq!(body, "Hello, Panda!");
+        })
+    }
+
+    #[rstest]
+    #[case::inexisting_document_id(
+        "/blobs/0020aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+        StatusCode::NOT_FOUND
+    )]
+    #[case::inexisting_document_view_id(
+        "/blobs/0020aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/0020bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+        StatusCode::NOT_FOUND
+    )]
+    #[case::invalid_document_id("/blobs/not_valid", StatusCode::BAD_REQUEST)]
+    #[case::invalid_document_view_id(
+        "/blobs/0020aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/not_valid",
+        StatusCode::BAD_REQUEST
+    )]
+    fn error_responses(#[case] path: &'static str, #[case] expected_status_code: StatusCode) {
+        test_runner(move |node: TestNode| async move {
+            let client = http_test_client(&node).await;
+            let response = client.get(path).send().await;
+            assert_eq!(response.status(), expected_status_code);
+        })
+    }
+}
diff --git a/aquadoggo/src/http/context.rs b/aquadoggo/src/http/context.rs
index 758d07bd9..f431ee7c9 100644
--- a/aquadoggo/src/http/context.rs
+++ b/aquadoggo/src/http/context.rs
@@ -2,10 +2,14 @@
 
 use std::path::PathBuf;
 
+use crate::db::SqlStore;
 use crate::graphql::GraphQLSchemaManager;
 
 #[derive(Clone)]
 pub struct HttpServiceContext {
+    /// SQL database.
+    pub store: SqlStore,
+
     /// Dynamic GraphQL schema manager.
     pub schema: GraphQLSchemaManager,
 
@@ -14,9 +18,9 @@ pub struct HttpServiceContext {
 }
 
 impl HttpServiceContext {
-    /// Create a new HttpServiceContext.
-    pub fn new(schema: GraphQLSchemaManager, blob_dir_path: PathBuf) -> Self {
+    pub fn new(store: SqlStore, schema: GraphQLSchemaManager, blob_dir_path: PathBuf) -> Self {
         Self {
+            store,
             schema,
             blob_dir_path,
         }
diff --git a/aquadoggo/src/http/mod.rs b/aquadoggo/src/http/mod.rs
index 312cb4a5b..db475580e 100644
--- a/aquadoggo/src/http/mod.rs
+++ b/aquadoggo/src/http/mod.rs
@@ -5,4 +5,4 @@ mod context;
 mod service;
 
 pub use context::HttpServiceContext;
-pub use service::{build_server, http_service, BLOBS_ROUTE};
+pub use service::{build_server, http_service};
diff --git a/aquadoggo/src/http/service.rs b/aquadoggo/src/http/service.rs
index 8613a0d7d..7c045e50b 100644
--- a/aquadoggo/src/http/service.rs
+++ b/aquadoggo/src/http/service.rs
@@ -10,21 +10,19 @@ use axum::Router;
 use http::header::CONTENT_TYPE;
 use log::{debug, warn};
 use tower_http::cors::{Any, CorsLayer};
-use tower_http::services::ServeDir;
 
 use crate::bus::ServiceSender;
 use crate::context::Context;
 use crate::graphql::GraphQLSchemaManager;
-use crate::http::api::{handle_graphql_playground, handle_graphql_query};
+use crate::http::api::{
+    handle_blob_document, handle_blob_view, handle_graphql_playground, handle_graphql_query,
+};
 use crate::http::context::HttpServiceContext;
 use crate::manager::{ServiceReadySender, Shutdown};
 
 /// Route to the GraphQL playground
 const GRAPHQL_ROUTE: &str = "/graphql";
 
-/// Route to the blobs static file server
-pub const BLOBS_ROUTE: &str = "/blobs";
-
 /// Build HTTP server with GraphQL API.
 pub fn build_server(http_context: HttpServiceContext) -> Router {
     // Configure CORS middleware
@@ -34,17 +32,15 @@ pub fn build_server(http_context: HttpServiceContext) -> Router {
         .allow_credentials(false)
         .allow_origin(Any);
 
-    // Construct static file server
-    let blob_service = ServeDir::new(http_context.blob_dir_path.clone());
-
     Router::new()
-        // Add blobs static file server
-        .nest_service(BLOBS_ROUTE, blob_service)
         // Add GraphQL routes
         .route(
             GRAPHQL_ROUTE,
             get(|| handle_graphql_playground(GRAPHQL_ROUTE)).post(handle_graphql_query),
         )
+        // Add blob routes
+        .route("/blobs/:document_id", get(handle_blob_document))
+        .route("/blobs/:document_id/:view_hash", get(handle_blob_view))
         // Add middlewares
         .layer(cors)
         // Add shared context
@@ -68,7 +64,11 @@ pub async fn http_service(
     let blob_dir_path = context.config.blob_dir.as_ref().expect("Base path not set");
 
     // Introduce a new context for all HTTP routes
-    let http_context = HttpServiceContext::new(graphql_schema_manager, blob_dir_path.to_owned());
+    let http_context = HttpServiceContext::new(
+        context.store.clone(),
+        graphql_schema_manager,
+        blob_dir_path.to_owned(),
+    );
 
     // Start HTTP server with given port and re-attempt with random port if it was taken already
     let builder = if let Ok(builder) = axum::Server::try_bind(&http_address) {
@@ -121,7 +121,11 @@ mod tests {
             let schema_provider = SchemaProvider::default();
             let graphql_schema_manager =
                 GraphQLSchemaManager::new(node.context.store.clone(), tx, schema_provider).await;
-            let context = HttpServiceContext::new(graphql_schema_manager, BLOBS_DIR_NAME.into());
+            let context = HttpServiceContext::new(
+                node.context.store.clone(),
+                graphql_schema_manager,
+                BLOBS_DIR_NAME.into(),
+            );
             let client = TestClient::new(build_server(context));
 
             let response = client
diff --git a/aquadoggo/src/materializer/tasks/blob.rs b/aquadoggo/src/materializer/tasks/blob.rs
index 456e83863..8366cbba6 100644
--- a/aquadoggo/src/materializer/tasks/blob.rs
+++ b/aquadoggo/src/materializer/tasks/blob.rs
@@ -1,20 +1,16 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
-use std::fs::{self, File};
-use std::io::Write;
-use std::os::unix::fs::symlink;
-
 use log::{debug, info};
 use p2panda_rs::document::traits::AsDocument;
 use p2panda_rs::document::DocumentViewId;
 use p2panda_rs::operation::OperationValue;
 use p2panda_rs::schema::SchemaId;
-use p2panda_rs::storage_provider::traits::{DocumentStore, OperationStore};
+use p2panda_rs::storage_provider::traits::DocumentStore;
+use tokio::fs::File;
+use tokio::io::AsyncWriteExt;
 
-use crate::config::BLOBS_SYMLINK_DIR_NAME;
 use crate::context::Context;
 use crate::db::types::StorageDocument;
-use crate::db::SqlStore;
 use crate::materializer::worker::{TaskError, TaskResult};
 use crate::materializer::TaskInput;
 
@@ -75,38 +71,33 @@ pub async fn blob_task(context: Context, input: TaskInput) -> TaskResult<TaskInp
             // We don't raise a critical error here, as it is possible that this method returns an
             // error.
             .map_err(|err| TaskError::Failure(err.to_string()))?
-            .unwrap();
+            .expect("Blob data exists at this point");
 
         // Compose, and when needed create, the path for the blob file.
         let base_path = match &context.config.blob_dir {
             Some(base_path) => base_path,
             None => return Err(TaskError::Critical("No base path configured".to_string())),
         };
-
-        let blob_dir = base_path.join(blob_document.id().as_str());
-
-        fs::create_dir_all(&blob_dir).map_err(|err| TaskError::Critical(err.to_string()))?;
-        let blob_view_path = blob_dir.join(blob_document.view_id().to_string());
+        let blob_view_path = base_path.join(blob_document.view_id().to_string());
 
         // Write the blob to the filesystem.
-        info!("Creating blob at path {blob_view_path:?}");
-
-        let mut file = File::create(&blob_view_path).unwrap();
-        file.write_all(blob_data.as_bytes()).unwrap();
-
-        // create a symlink from `../documents/<document_id>` -> `../<document_id>/<current_view_id>`
-        if is_current_view(&context.store, blob_document.view_id()).await? {
-            info!("Creating symlink from document id to current view");
-
-            let link_path = base_path
-                .join(BLOBS_SYMLINK_DIR_NAME)
-                .join(blob_document.id().as_str());
-
-            let _ = fs::remove_file(&link_path);
-
-            symlink(blob_view_path, link_path)
-                .map_err(|err| TaskError::Critical(err.to_string()))?;
-        }
+        info!("Creating blob at path {}", blob_view_path.display());
+
+        let mut file = File::create(&blob_view_path).await.map_err(|err| {
+            TaskError::Critical(format!(
+                "Could not create blob file @ {}: {}",
+                blob_view_path.display(),
+                err
+            ))
+        })?;
+
+        file.write_all(&blob_data).await.map_err(|err| {
+            TaskError::Critical(format!(
+                "Could not write blob file @ {}: {}",
+                blob_view_path.display(),
+                err
+            ))
+        })?;
     }
 
     Ok(None)
@@ -152,118 +143,47 @@ async fn get_related_blobs(
     Ok(related_blobs)
 }
 
-// Check if this is the current view for this blob.
-async fn is_current_view(
-    store: &SqlStore,
-    document_view_id: &DocumentViewId,
-) -> Result<bool, TaskError> {
-    let blob_document_id = store
-        .get_document_id_by_operation_id(document_view_id.graph_tips().first().unwrap())
-        .await
-        .map_err(|err| TaskError::Critical(err.to_string()))?
-        .expect("Document for blob exists");
-
-    let current_blob_document = store
-        .get_document(&blob_document_id)
-        .await
-        .map_err(|err| TaskError::Critical(err.to_string()))?
-        .expect("Document for blob exists");
-
-    Ok(current_blob_document.view_id() == document_view_id)
-}
-
 #[cfg(test)]
 mod tests {
-    use std::fs;
-
-    use p2panda_rs::document::DocumentId;
     use p2panda_rs::identity::KeyPair;
-    use p2panda_rs::schema::SchemaId;
     use p2panda_rs::test_utils::fixtures::key_pair;
     use rstest::rstest;
+    use tokio::fs;
 
-    use crate::config::BLOBS_SYMLINK_DIR_NAME;
     use crate::materializer::tasks::blob_task;
     use crate::materializer::TaskInput;
-    use crate::test_utils::{add_document, test_runner, TestNode};
+    use crate::test_utils::{add_blob, test_runner, TestNode};
 
     #[rstest]
     fn materializes_blob_to_filesystem(key_pair: KeyPair) {
         test_runner(|mut node: TestNode| async move {
-            let blob_data = "Hello, World!".to_string();
-
-            // Publish blob pieces and blob.
-            let blob_piece_view_id_1 = add_document(
-                &mut node,
-                &SchemaId::BlobPiece(1),
-                vec![("data", blob_data[..5].into())],
-                &key_pair,
-            )
-            .await;
-
-            let blob_piece_view_id_2 = add_document(
-                &mut node,
-                &SchemaId::BlobPiece(1),
-                vec![("data", blob_data[5..].into())],
-                &key_pair,
-            )
-            .await;
-
-            // Publish blob.
-            let blob_view_id = add_document(
-                &mut node,
-                &SchemaId::Blob(1),
-                vec![
-                    ("length", { blob_data.len() as i64 }.into()),
-                    ("mime_type", "text/plain".into()),
-                    (
-                        "pieces",
-                        vec![blob_piece_view_id_1, blob_piece_view_id_2].into(),
-                    ),
-                ],
-                &key_pair,
-            )
-            .await;
+            // Publish blob
+            let blob_data = "Hello, World!";
+            let blob_view_id =
+                add_blob(&mut node, blob_data.as_bytes(), 5, "plain/text", &key_pair).await;
 
-            // Run blob task.
+            // Run blob task
             let result = blob_task(
                 node.context.clone(),
                 TaskInput::DocumentViewId(blob_view_id.clone()),
             )
             .await;
 
-            // It shouldn't fail.
+            // It shouldn't fail
             assert!(result.is_ok(), "{:#?}", result);
-            // It should return no extra tasks.
+            // It should return no extra tasks
             assert!(result.unwrap().is_none());
 
-            // Convert blob view id to document id.
-            let document_id: DocumentId = blob_view_id.to_string().parse().unwrap();
-
-            // Construct the expected path to the blob view file.
+            // Construct the expected path to the blob view file
             let base_path = node.context.config.blob_dir.as_ref().unwrap();
-            let blob_path = base_path
-                .join(document_id.as_str())
-                .join(blob_view_id.to_string());
+            let blob_path = base_path.join(blob_view_id.to_string());
 
             // Read from this file
-            let retrieved_blob_data = fs::read_to_string(blob_path);
+            let retrieved_blob_data = fs::read_to_string(blob_path).await;
 
-            // It should match the complete published blob data.
+            // It should match the complete published blob data
             assert!(retrieved_blob_data.is_ok(), "{:?}", retrieved_blob_data);
             assert_eq!(blob_data, retrieved_blob_data.unwrap());
-
-            // Construct the expected path to the blob symlink file location.
-            let blob_path = base_path
-                .join(BLOBS_SYMLINK_DIR_NAME)
-                .join(document_id.as_str());
-
-            // Read from this file
-            let retrieved_blob_data = fs::read_to_string(blob_path);
-
-            // It should match the complete published blob data.
-            assert!(retrieved_blob_data.is_ok(), "{:?}", retrieved_blob_data);
-            assert_eq!(blob_data, retrieved_blob_data.unwrap())
         })
     }
 }
diff --git a/aquadoggo/src/materializer/tasks/garbage_collection.rs b/aquadoggo/src/materializer/tasks/garbage_collection.rs
index 36963b3a7..6138273cc 100644
--- a/aquadoggo/src/materializer/tasks/garbage_collection.rs
+++ b/aquadoggo/src/materializer/tasks/garbage_collection.rs
@@ -376,11 +376,18 @@ mod tests {
     #[rstest]
     fn purges_blobs(key_pair: KeyPair) {
         test_runner(|mut node: TestNode| async move {
-            // Publish a blob.
-            let blob_document_view = add_blob(&mut node, "Hello World!", &key_pair).await;
+            // Publish a blob
+            let blob_document_view = add_blob(
+                &mut node,
+                "Hello World!".as_bytes(),
+                6,
+                "text/plain",
+                &key_pair,
+            )
+            .await;
             let blob_document_id: DocumentId = blob_document_view.to_string().parse().unwrap();
 
-            // Check the blob is there.
+            // Check the blob is there
             let blob = node
                 .context
                 .store
@@ -389,7 +396,7 @@ mod tests {
                 .unwrap();
             assert!(blob.is_some());
 
-            // Run a garbage collection task for the blob document.
+            // Run a garbage collection task for the blob document
             let next_tasks = garbage_collection_task(
                 node.context.clone(),
                 TaskInput::DocumentId(blob_document_id.clone()),
@@ -397,10 +404,10 @@ mod tests {
             .await
             .unwrap();
 
-            // It shouldn't return any new tasks.
+            // It shouldn't return any new tasks
             assert!(next_tasks.is_none());
 
-            // The blob should no longer be available.
+            // The blob should no longer be available
             let blob = node
                 .context
                 .store
@@ -423,12 +430,12 @@ mod tests {
     #[rstest]
     fn purges_newly_detached_blobs(key_pair: KeyPair) {
         test_runner(|mut node: TestNode| async move {
-            // Create a blob document.
-            let blob_data = "Hello, World!".to_string();
-            let blob_view_id = add_blob(&mut node, &blob_data, &key_pair).await;
+            // Create a blob document
+            let blob_data = "Hello, World!".as_bytes();
+            let blob_view_id = add_blob(&mut node, &blob_data, 6, "text/plain", &key_pair).await;
             let blob_document_id: DocumentId = blob_view_id.to_string().parse().unwrap();
 
-            // Relate to the blob from a new document.
+            // Relate to the blob from a new document
             let (schema, documents_pinning_blob) = add_schema_and_documents(
                 &mut node,
                 "img",
@@ -441,8 +448,8 @@ mod tests {
             )
             .await;
 
-            // Now update the document to relate to another blob. This means the previously
-            // created blob is now "dangling".
+            // Now update the document to relate to another blob. This means the previously created
+            // blob is now "dangling"
             update_document(
                 &mut node,
                 schema.id(),
@@ -452,7 +459,7 @@ mod tests {
             )
             .await;
 
-            // Run a task for the parent document.
+            // Run a task for the parent document
             let document_id: DocumentId = documents_pinning_blob[0].to_string().parse().unwrap();
             let next_tasks =
                 garbage_collection_task(node.context.clone(), TaskInput::DocumentId(document_id))
@@ -460,16 +467,16 @@ mod tests {
                     .unwrap()
                     .unwrap();
 
-            // It issues one new task which is for the blob document.
+            // It issues one new task which is for the blob document
             assert_eq!(next_tasks.len(), 1);
             let next_tasks =
                 garbage_collection_task(node.context.clone(), next_tasks[0].input().to_owned())
                     .await
                     .unwrap();
-            // No new tasks issued.
+            // No new tasks issued
             assert!(next_tasks.is_none());
 
-            // The blob has correctly been purged.
+            // The blob has correctly been purged
             let blob = node
                 .context
                 .store
@@ -485,8 +492,8 @@ mod tests {
     fn other_documents_keep_blob_alive(key_pair: KeyPair) {
         test_runner(|mut node: TestNode| async move {
             // Create a blob document.
-            let blob_data = "Hello, World!".to_string();
-            let blob_view_id = add_blob(&mut node, &blob_data, &key_pair).await;
+            let blob_data = "Hello, World!".as_bytes();
+            let blob_view_id = add_blob(&mut node, &blob_data, 6, "text/plain", &key_pair).await;
             let blob_document_id: DocumentId = blob_view_id.to_string().parse().unwrap();
 
             // Relate to the blob from a new document.
@@ -558,12 +565,12 @@ mod tests {
     #[rstest]
     fn all_relation_types_keep_blobs_alive(key_pair: KeyPair) {
         test_runner(|mut node: TestNode| async move {
-            let blob_data = "Hello, World!".to_string();
+            let blob_data = "Hello, World!".as_bytes();
 
             // Any type of relation can keep a blob alive, here we create one of each and run
             // garbage collection tasks for each blob.
 
-            let blob_view_id_1 = add_blob(&mut node, &blob_data, &key_pair).await;
+            let blob_view_id_1 = add_blob(&mut node, &blob_data, 6, "text/plain", &key_pair).await;
             let _ = add_schema_and_documents(
                 &mut node,
                 "img",
@@ -576,7 +583,7 @@ mod tests {
             )
             .await;
 
-            let blob_view_id_2 = add_blob(&mut node, &blob_data, &key_pair).await;
+            let blob_view_id_2 = add_blob(&mut node, &blob_data, 6, "text/plain", &key_pair).await;
             let _ = add_schema_and_documents(
                 &mut node,
                 "img",
@@ -589,7 +596,7 @@ mod tests {
             )
             .await;
 
-            let blob_view_id_3 = add_blob(&mut node, &blob_data, &key_pair).await;
+            let blob_view_id_3 = add_blob(&mut node, &blob_data, 6, "text/plain", &key_pair).await;
             let _ = add_schema_and_documents(
                 &mut node,
                 "img",
@@ -606,7 +613,7 @@ mod tests {
             )
             .await;
 
-            let blob_view_id_4 = add_blob(&mut node, &blob_data, &key_pair).await;
+            let blob_view_id_4 = add_blob(&mut node, &blob_data, 6, "text/plain", &key_pair).await;
             let _ = add_schema_and_documents(
                 &mut node,
                 "img",
diff --git a/aquadoggo/src/node.rs b/aquadoggo/src/node.rs
index 165357327..c92fd32cc 100644
--- a/aquadoggo/src/node.rs
+++ b/aquadoggo/src/node.rs
@@ -1,12 +1,12 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
-use std::fs;
-
 use anyhow::Result;
 use p2panda_rs::identity::KeyPair;
+use tempfile::TempDir;
+use tokio::fs;
 
 use crate::bus::ServiceMessage;
-use crate::config::{Configuration, BLOBS_DIR_NAME, BLOBS_SYMLINK_DIR_NAME};
+use crate::config::{Configuration, BLOBS_DIR_NAME};
 use crate::context::Context;
 use crate::db::SqlStore;
 use crate::db::{connection_pool, create_database, run_pending_migrations, Pool};
@@ -64,12 +64,13 @@ impl Node {
         let schema_provider =
             SchemaProvider::new(application_schema, config.allow_schema_ids.clone());
 
-        // Create tmp dirs for blob storage.
+        // Create temporary dirs for blob storage.
         //
-        // @TODO: implement configuring this path for persistent storage.
-        let tmp_dir = tempfile::TempDir::new().unwrap();
+        // @TODO: Implement configuring this path for persistent storage, see related issue:
+        // https://github.com/p2panda/aquadoggo/issues/542
+        let tmp_dir = TempDir::new().unwrap();
         let blob_dir_path = tmp_dir.path().join(BLOBS_DIR_NAME);
-        fs::create_dir_all(blob_dir_path.join(BLOBS_SYMLINK_DIR_NAME)).unwrap();
+        fs::create_dir_all(&blob_dir_path).await.unwrap();
         config.blob_dir = Some(blob_dir_path);
 
         // Create service manager with shared data between services


Test patch:
diff --git a/aquadoggo/src/graphql/tests.rs b/aquadoggo/src/graphql/tests.rs
index a40e0f900..39dac3579 100644
--- a/aquadoggo/src/graphql/tests.rs
+++ b/aquadoggo/src/graphql/tests.rs
@@ -9,7 +9,7 @@ use p2panda_rs::{document::DocumentId, schema::FieldType};
 use rstest::rstest;
 use serde_json::json;
 
-use crate::test_utils::{add_document, add_schema, graphql_test_client, test_runner, TestNode};
+use crate::test_utils::{add_document, add_schema, http_test_client, test_runner, TestNode};
 
 // Test querying application documents with scalar fields (no relations) by document id and by view
 // id.
@@ -44,7 +44,7 @@ fn scalar_fields() {
         let view_id = add_document(&mut node, schema.id(), doc_fields, &key_pair).await;
 
         // Configure and send test query
-        let client = graphql_test_client(&node).await;
+        let client = http_test_client(&node).await;
         let query = format!(
             r#"{{
                 scalarDoc: {type_name}(viewId: "{view_id}") {{
@@ -149,7 +149,7 @@ fn relation_fields() {
             add_document(&mut node, parent_schema.id(), parent_fields, &key_pair).await;
 
         // Configure and send test query
-        let client = graphql_test_client(&node).await;
+        let client = http_test_client(&node).await;
         let query = format!(
             r#"{{
                 result: {}(viewId: "{}") {{
diff --git a/aquadoggo/src/proptests/tests.rs b/aquadoggo/src/proptests/tests.rs
index 8aa72e367..a57ed87fe 100644
--- a/aquadoggo/src/proptests/tests.rs
+++ b/aquadoggo/src/proptests/tests.rs
@@ -15,7 +15,7 @@ use crate::proptests::schema_strategies::{schema_strategy, SchemaAST};
 use crate::proptests::utils::{
     add_documents_from_ast, add_schemas_from_ast, parse_filter, parse_selected_fields, FieldName,
 };
-use crate::test_utils::{graphql_test_client, test_runner, TestClient, TestNode};
+use crate::test_utils::{http_test_client, test_runner, TestClient, TestNode};
 
 use super::filter_strategies::{
     application_filters_strategy, meta_field_filter_strategy, Filter, MetaField,
@@ -212,7 +212,7 @@ proptest! {
             sanity_checks(&node, &documents, &schemas).await;
 
             // Create a GraphQL client.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
 
             // Run the test for each schema and related documents that have been generated.
             for schema_id in schemas {
@@ -249,7 +249,7 @@ proptest! {
             sanity_checks(&node, &documents, &schemas).await;
 
             // Create a GraphQL client.
-            let client = graphql_test_client(&node).await;
+            let client = http_test_client(&node).await;
 
             // Get the root schema from the provider.
             let schema = node.context.schema_provider.get(&schema_ast.id).await.expect("Schema should exist on node");
diff --git a/aquadoggo/src/test_utils/client.rs b/aquadoggo/src/test_utils/client.rs
index cef4e1003..b8a4b29fb 100644
--- a/aquadoggo/src/test_utils/client.rs
+++ b/aquadoggo/src/test_utils/client.rs
@@ -2,21 +2,22 @@
 
 use std::convert::TryFrom;
 use std::net::{SocketAddr, TcpListener};
+use std::time::Duration;
 
 use axum::body::HttpBody;
 use axum::BoxError;
 use http::header::{HeaderName, HeaderValue};
-use http::{Request, StatusCode};
+use http::{HeaderMap, Request, StatusCode};
 use hyper::{Body, Server};
 use tokio::sync::broadcast;
 use tower::make::Shared;
 use tower_service::Service;
 
 use crate::graphql::GraphQLSchemaManager;
-use crate::http::{build_server, HttpServiceContext, BLOBS_ROUTE};
+use crate::http::{build_server, HttpServiceContext};
 use crate::test_utils::TestNode;
 
-/// GraphQL client which can be used for querying a node in tests.
+/// HTTP client for testing request and responses.
 pub struct TestClient {
     client: reqwest::Client,
     addr: SocketAddr,
@@ -44,14 +45,14 @@ impl TestClient {
         });
 
         let client = reqwest::Client::builder()
-            .redirect(reqwest::redirect::Policy::none())
+            .timeout(Duration::from_secs(10))
+            .redirect(reqwest::redirect::Policy::default())
             .build()
             .unwrap();
 
         TestClient { client, addr }
     }
 
-    #[allow(dead_code)]
     pub(crate) fn get(&self, url: &str) -> RequestBuilder {
         RequestBuilder {
             builder: self.client.get(format!("http://{}{}", self.addr, url)),
@@ -65,16 +66,23 @@ impl TestClient {
     }
 }
 
-/// Configures a test client that can be used for GraphQL testing.
-pub async fn graphql_test_client(node: &TestNode) -> TestClient {
+/// Configures a test client that can be used for HTTP API testing.
+pub async fn http_test_client(node: &TestNode) -> TestClient {
     let (tx, _) = broadcast::channel(120);
+
     let manager = GraphQLSchemaManager::new(
         node.context.store.clone(),
         tx,
         node.context.schema_provider.clone(),
     )
     .await;
-    let http_context = HttpServiceContext::new(manager, BLOBS_ROUTE.into());
+
+    let http_context = HttpServiceContext::new(
+        node.context.store.clone(),
+        manager,
+        node.context.config.blob_dir.as_ref().unwrap().to_path_buf(),
+    );
+
     TestClient::new(build_server(http_context))
 }
 
@@ -103,7 +111,6 @@ impl RequestBuilder {
         self
     }
 
-    #[allow(dead_code)]
     pub(crate) fn header<K, V>(mut self, key: K, value: V) -> Self
     where
         HeaderName: TryFrom<K>,
@@ -121,6 +128,10 @@ pub(crate) struct TestResponse {
 }
 
 impl TestResponse {
+    pub(crate) async fn bytes(self) -> Vec<u8> {
+        self.response.bytes().await.unwrap().to_vec()
+    }
+
     pub(crate) async fn text(self) -> String {
         self.response.text().await.unwrap()
     }
@@ -132,8 +143,11 @@ impl TestResponse {
         self.response.json().await.unwrap()
     }
 
-    #[allow(dead_code)]
     pub(crate) fn status(&self) -> StatusCode {
         self.response.status()
     }
+
+    pub(crate) fn headers(&self) -> HeaderMap {
+        self.response.headers().clone()
+    }
 }
diff --git a/aquadoggo/src/test_utils/mod.rs b/aquadoggo/src/test_utils/mod.rs
index eb0548ee3..809354444 100644
--- a/aquadoggo/src/test_utils/mod.rs
+++ b/aquadoggo/src/test_utils/mod.rs
@@ -7,12 +7,12 @@ pub mod helpers;
 mod node;
 mod runner;
 
-pub use client::{graphql_test_client, TestClient};
+pub use client::{http_test_client, TestClient};
 pub use config::TestConfiguration;
 pub use db::{drop_database, initialize_db, initialize_sqlite_db};
 pub use helpers::{build_document, doggo_fields, doggo_schema, schema_from_fields};
 pub use node::{
     add_blob, add_document, add_schema, add_schema_and_documents, assert_query,
-    populate_and_materialize, populate_store_config, update_document, TestNode,
+    populate_and_materialize, populate_store_config, update_blob, update_document, TestNode,
 };
 pub use runner::{test_runner, test_runner_with_manager, TestNodeManager};
diff --git a/aquadoggo/src/test_utils/node.rs b/aquadoggo/src/test_utils/node.rs
index 5c7fae147..eec2a8b48 100644
--- a/aquadoggo/src/test_utils/node.rs
+++ b/aquadoggo/src/test_utils/node.rs
@@ -161,7 +161,7 @@ pub async fn add_document(
         // We only want to issue dependency tasks.
         let dependency_tasks = tasks
             .iter()
-            .filter(|task| task.worker_name() == "depenedency");
+            .filter(|task| task.worker_name() == "dependency");
 
         for task in dependency_tasks {
             dependency_task(node.context.clone(), task.input().to_owned())
@@ -334,35 +334,77 @@ pub async fn update_document(
     DocumentViewId::from(entry_signed.hash())
 }
 
-pub async fn add_blob(node: &mut TestNode, blob_data: &str, key_pair: &KeyPair) -> DocumentViewId {
-    // Publish blob pieces and blob.
-    let (blob_data_a, blob_data_b) = blob_data.split_at(blob_data.len() / 2);
-    let blob_piece_view_id_1 = add_document(
-        node,
-        &SchemaId::BlobPiece(1),
-        vec![("data", blob_data_a.into())],
-        &key_pair,
-    )
-    .await;
+/// Splits bytes into chunks with a defined maximum length (256 bytes is the specified maximum) and
+/// publishes a blob_piece_v1 document for each chunk.
+pub async fn add_blob_pieces(
+    node: &mut TestNode,
+    body: &[u8],
+    max_piece_length: usize,
+    key_pair: &KeyPair,
+) -> Vec<DocumentViewId> {
+    let blob_pieces = body.chunks(max_piece_length);
+
+    let mut blob_pieces_view_ids = Vec::with_capacity(blob_pieces.len());
+    for piece in blob_pieces {
+        // @TODO: No need to convert bytes into a string when we introduced our new bytes operation
+        // field type. Related issue: https://github.com/p2panda/aquadoggo/issues/543
+        let byte_str = std::str::from_utf8(piece).expect("Invalid UTF-8 sequence");
+
+        let view_id = add_document(
+            node,
+            &SchemaId::BlobPiece(1),
+            vec![("data", byte_str.into())],
+            &key_pair,
+        )
+        .await;
+
+        blob_pieces_view_ids.push(view_id);
+    }
+
+    blob_pieces_view_ids
+}
 
-    let blob_piece_view_id_2 = add_document(
+pub async fn add_blob(
+    node: &mut TestNode,
+    body: &[u8],
+    max_piece_length: usize,
+    mime_type: &str,
+    key_pair: &KeyPair,
+) -> DocumentViewId {
+    let blob_pieces_view_ids = add_blob_pieces(node, body, max_piece_length, key_pair).await;
+
+    let blob_view_id = add_document(
         node,
-        &SchemaId::BlobPiece(1),
-        vec![("data", blob_data_b.into())],
+        &SchemaId::Blob(1),
+        vec![
+            ("length", { body.len() as i64 }.into()),
+            ("mime_type", mime_type.into()),
+            ("pieces", blob_pieces_view_ids.into()),
+        ],
         &key_pair,
     )
     .await;
-    let blob_view_id = add_document(
+
+    blob_view_id
+}
+
+pub async fn update_blob(
+    node: &mut TestNode,
+    body: &[u8],
+    max_piece_length: usize,
+    previous: &DocumentViewId,
+    key_pair: &KeyPair,
+) -> DocumentViewId {
+    let blob_pieces_view_ids = add_blob_pieces(node, body, max_piece_length, key_pair).await;
+
+    let blob_view_id = update_document(
         node,
         &SchemaId::Blob(1),
         vec![
-            ("length", { blob_data.len() as i64 }.into()),
-            ("mime_type", "text/plain".into()),
-            (
-                "pieces",
-                vec![blob_piece_view_id_1, blob_piece_view_id_2].into(),
-            ),
+            ("length", { body.len() as i64 }.into()),
+            ("pieces", blob_pieces_view_ids.into()),
         ],
+        &previous,
         &key_pair,
     )
     .await;
diff --git a/aquadoggo/src/test_utils/runner.rs b/aquadoggo/src/test_utils/runner.rs
index b700bb695..7349a3287 100644
--- a/aquadoggo/src/test_utils/runner.rs
+++ b/aquadoggo/src/test_utils/runner.rs
@@ -8,7 +8,7 @@ use p2panda_rs::identity::KeyPair;
 use tokio::runtime::Builder;
 use tokio::sync::Mutex;
 
-use crate::config::{BLOBS_DIR_NAME, BLOBS_SYMLINK_DIR_NAME};
+use crate::config::BLOBS_DIR_NAME;
 use crate::context::Context;
 use crate::db::Pool;
 use crate::db::SqlStore;
@@ -102,12 +102,12 @@ pub fn test_runner<F: AsyncTestFn + Send + Sync + 'static>(test: F) {
         let (_config, pool) = initialize_db().await;
         let store = SqlStore::new(pool);
 
-        // Construct tempfile directory for the test runner.
+        // Construct temporary directory for the test runner
         let tmp_dir = tempfile::TempDir::new().unwrap();
         let blob_dir_path = tmp_dir.path().join(BLOBS_DIR_NAME);
-        fs::create_dir_all(blob_dir_path.join(BLOBS_SYMLINK_DIR_NAME)).unwrap();
+        fs::create_dir_all(&blob_dir_path).unwrap();
 
-        // Construct node config supporting any schema.
+        // Construct node config supporting any schema
         let mut cfg = Configuration::default();
         cfg.blob_dir = Some(blob_dir_path);