Tutorial: Building Your First Middleware
From zero to intercepting filesystem operations in 15 minutes
What is Middleware?
Middleware wraps a backend and intercepts operations. That’s it.
User Request → [Your Middleware] → [Backend] → Storage
↑ ↓
└── intercept ─────┘
You can:
- Block operations (ReadOnly, PathFilter)
- Transform data (Encryption, Compression)
- Count/Log operations (Counter, Tracing)
- Enforce limits (Quota, RateLimit)
Let’s build one.
The Simplest Middleware: Operation Counter
We’ll count every operation. That’s our entire goal.
Step 1: The Struct
#![allow(unused)]
fn main() {
use std::sync::atomic::{AtomicU64, Ordering};
/// Counts every operation performed on the wrapped backend.
pub struct Counter<B> {
inner: B, // The backend we're wrapping
pub count: AtomicU64, // Our counter
}
impl<B> Counter<B> {
pub fn new(inner: B) -> Self {
Self {
inner,
count: AtomicU64::new(0),
}
}
pub fn operations(&self) -> u64 {
self.count.load(Ordering::Relaxed)
}
}
}
That’s the entire struct. We wrap something (inner) and add our state (count).
Step 2: Implement FsRead
Now we implement the same traits as the inner backend, intercepting each method:
#![allow(unused)]
fn main() {
use anyfs_backend::{FsRead, FsError, Metadata};
use std::path::Path;
impl<B: FsRead> FsRead for Counter<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
self.count.fetch_add(1, Ordering::Relaxed); // COUNT IT
self.inner.read(path) // DELEGATE
}
fn read_to_string(&self, path: &Path) -> Result<String, FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.read_to_string(path)
}
fn read_range(&self, path: &Path, offset: u64, len: usize) -> Result<Vec<u8>, FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.read_range(path, offset, len)
}
fn exists(&self, path: &Path) -> Result<bool, FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.exists(path)
}
fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.metadata(path)
}
fn open_read(&self, path: &Path) -> Result<Box<dyn std::io::Read + Send>, FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.open_read(path)
}
}
}
The pattern is always the same:
- Do your thing (count)
- Call
self.inner.method(args)(delegate)
Step 3: Implement FsWrite
Same pattern:
#![allow(unused)]
fn main() {
use anyfs_backend::FsWrite;
impl<B: FsWrite> FsWrite for Counter<B> {
fn write(&self, path: &Path, data: &[u8]) -> Result<(), FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.write(path, data)
}
fn append(&self, path: &Path, data: &[u8]) -> Result<(), FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.append(path, data)
}
fn remove_file(&self, path: &Path) -> Result<(), FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.remove_file(path)
}
fn rename(&self, from: &Path, to: &Path) -> Result<(), FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.rename(from, to)
}
fn copy(&self, from: &Path, to: &Path) -> Result<(), FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.copy(from, to)
}
fn truncate(&self, path: &Path, size: u64) -> Result<(), FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.truncate(path, size)
}
fn open_write(&self, path: &Path) -> Result<Box<dyn std::io::Write + Send>, FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.open_write(path)
}
}
}
Step 4: Implement FsDir
#![allow(unused)]
fn main() {
use anyfs_backend::{FsDir, ReadDirIter};
impl<B: FsDir> FsDir for Counter<B> {
fn read_dir(&self, path: &Path) -> Result<ReadDirIter, FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.read_dir(path)
}
fn create_dir(&self, path: &Path) -> Result<(), FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.create_dir(path)
}
fn create_dir_all(&self, path: &Path) -> Result<(), FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.create_dir_all(path)
}
fn remove_dir(&self, path: &Path) -> Result<(), FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.remove_dir(path)
}
fn remove_dir_all(&self, path: &Path) -> Result<(), FsError> {
self.count.fetch_add(1, Ordering::Relaxed);
self.inner.remove_dir_all(path)
}
}
// Counter<B> now implements Fs when B: Fs (blanket impl)!
}
Step 5: Use It
use anyfs::{FileStorage, MemoryBackend};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let fs = FileStorage::new(Counter::new(MemoryBackend::new()));
fs.write("/hello.txt", b"Hello, World!")?;
fs.read("/hello.txt")?;
fs.read("/hello.txt")?;
fs.exists("/hello.txt")?;
println!("Total operations: {}", fs.operations()); // 4
Ok(())
}
That’s it. You built middleware.
Adding .layer() Support
Want the fluent .layer() syntax? Add a Layer struct:
#![allow(unused)]
fn main() {
use anyfs_backend::{Layer, Fs};
/// Layer for creating Counter middleware.
pub struct CounterLayer;
impl<B: Fs> Layer<B> for CounterLayer {
type Backend = Counter<B>;
fn layer(self, backend: B) -> Counter<B> {
Counter::new(backend)
}
}
}
Now you can do:
#![allow(unused)]
fn main() {
use anyfs::FileStorage;
let fs = FileStorage::new(
MemoryBackend::new()
.layer(CounterLayer)
);
fs.write("/test.txt", b"data")?;
println!("Operations: {}", fs.operations());
}
A More Useful Middleware: SecretBlocker
Let’s build something practical - block access to files matching a pattern:
#![allow(unused)]
fn main() {
use anyfs_backend::{FsRead, FsWrite, FsDir, FsError, Metadata, ReadDirIter};
use std::path::Path;
/// Blocks access to files containing "secret" in the path.
pub struct SecretBlocker<B> {
inner: B,
}
impl<B> SecretBlocker<B> {
pub fn new(inner: B) -> Self {
Self { inner }
}
/// Check if path is forbidden.
fn is_secret(&self, path: &Path) -> bool {
path.to_string_lossy().to_lowercase().contains("secret")
}
/// Return error if path is secret.
fn check(&self, path: &Path) -> Result<(), FsError> {
if self.is_secret(path) {
Err(FsError::AccessDenied {
path: path.to_path_buf(),
reason: "secret files are blocked".to_string(),
})
} else {
Ok(())
}
}
}
}
Implement the Traits
#![allow(unused)]
fn main() {
impl<B: FsRead> FsRead for SecretBlocker<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let path = path.as_ref();
self.check(path)?; // BLOCK if secret
self.inner.read(path) // DELEGATE otherwise
}
fn read_to_string(&self, path: &Path) -> Result<String, FsError> {
let path = path.as_ref();
self.check(path)?;
self.inner.read_to_string(path)
}
fn read_range(&self, path: &Path, offset: u64, len: usize) -> Result<Vec<u8>, FsError> {
let path = path.as_ref();
self.check(path)?;
self.inner.read_range(path, offset, len)
}
fn exists(&self, path: &Path) -> Result<bool, FsError> {
let path = path.as_ref();
self.check(path)?;
self.inner.exists(path)
}
fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
let path = path.as_ref();
self.check(path)?;
self.inner.metadata(path)
}
fn open_read(&self, path: &Path) -> Result<Box<dyn std::io::Read + Send>, FsError> {
let path = path.as_ref();
self.check(path)?;
self.inner.open_read(path)
}
}
impl<B: FsWrite> FsWrite for SecretBlocker<B> {
fn write(&self, path: &Path, data: &[u8]) -> Result<(), FsError> {
let path = path.as_ref();
self.check(path)?;
self.inner.write(path, data)
}
fn append(&self, path: &Path, data: &[u8]) -> Result<(), FsError> {
let path = path.as_ref();
self.check(path)?;
self.inner.append(path, data)
}
fn remove_file(&self, path: &Path) -> Result<(), FsError> {
let path = path.as_ref();
self.check(path)?;
self.inner.remove_file(path)
}
fn rename(&self, from: &Path, to: &Path) -> Result<(), FsError> {
let from = from.as_ref();
let to = to.as_ref();
self.check(from)?;
self.check(to)?; // Block both source and destination
self.inner.rename(from, to)
}
fn copy(&self, from: &Path, to: &Path) -> Result<(), FsError> {
let from = from.as_ref();
let to = to.as_ref();
self.check(from)?;
self.check(to)?;
self.inner.copy(from, to)
}
fn truncate(&self, path: &Path, size: u64) -> Result<(), FsError> {
let path = path.as_ref();
self.check(path)?;
self.inner.truncate(path, size)
}
fn open_write(&self, path: &Path) -> Result<Box<dyn std::io::Write + Send>, FsError> {
let path = path.as_ref();
self.check(path)?;
self.inner.open_write(path)
}
}
impl<B: FsDir> FsDir for SecretBlocker<B> {
fn read_dir(&self, path: &Path) -> Result<ReadDirIter, FsError> {
let path = path.as_ref();
self.check(path)?;
self.inner.read_dir(path)
}
fn create_dir(&self, path: &Path) -> Result<(), FsError> {
let path = path.as_ref();
self.check(path)?;
self.inner.create_dir(path)
}
fn create_dir_all(&self, path: &Path) -> Result<(), FsError> {
let path = path.as_ref();
self.check(path)?;
self.inner.create_dir_all(path)
}
fn remove_dir(&self, path: &Path) -> Result<(), FsError> {
let path = path.as_ref();
self.check(path)?;
self.inner.remove_dir(path)
}
fn remove_dir_all(&self, path: &Path) -> Result<(), FsError> {
let path = path.as_ref();
self.check(path)?;
self.inner.remove_dir_all(path)
}
}
}
Use It
fn main() -> Result<(), Box<dyn std::error::Error>> {
let fs = FileStorage::new(SecretBlocker::new(MemoryBackend::new()));
// These work fine
fs.write("/public/data.txt", b"Hello!")?;
fs.read("/public/data.txt")?;
// These are blocked
assert!(fs.write("/secret/passwords.txt", b"hunter2").is_err());
assert!(fs.read("/my-secret-diary.txt").is_err());
assert!(fs.create_dir("/SECRET").is_err());
println!("Secret files successfully blocked!");
Ok(())
}
The Middleware Pattern Cheat Sheet
| What You Want | Intercept | Delegate | Return |
|---|---|---|---|
| Count operations | Before call | Always | Inner result |
| Block some paths | Before call | If allowed | Error or inner result |
| Block writes | Write methods | Read methods | Error or inner result |
| Transform data | read/write | Everything else | Modified data |
| Log operations | Before/after | Always | Inner result |
Three Types of Middleware
1. Pass-through with side effects (Counter, Logger)
#![allow(unused)]
fn main() {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
log::info!("Reading: {:?}", path.as_ref()); // Side effect
self.inner.read(path) // Always delegate
}
}
2. Conditional blocking (PathFilter, ReadOnly)
#![allow(unused)]
fn main() {
fn write(&self, path: &Path, data: &[u8]) -> Result<(), FsError> {
if self.is_blocked(path.as_ref()) {
return Err(FsError::AccessDenied { ... }); // Block
}
self.inner.write(path, data) // Allow
}
}
3. Data transformation (Encryption, Compression)
#![allow(unused)]
fn main() {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
let encrypted = self.inner.read(path)?; // Get data
Ok(self.decrypt(&encrypted)) // Transform
}
fn write(&self, path: &Path, data: &[u8]) -> Result<(), FsError> {
let encrypted = self.encrypt(data); // Transform
self.inner.write(path, &encrypted) // Store
}
}
Example: Indexing Middleware (Future)
Use IndexLayer to keep a queryable index of file activity:
#![allow(unused)]
fn main() {
use anyfs::{IndexLayer, FileStorage, MemoryBackend};
let backend = MemoryBackend::new()
.layer(IndexLayer::builder()
.index_file("index.db")
.consistency(IndexConsistency::Strict)
.track_reads(false)
.build());
let fs = FileStorage::new(backend);
fs.write("/docs/hello.txt", b"hello")?;
}
Complete Example: ReadOnly Middleware
The classic - block all writes:
#![allow(unused)]
fn main() {
use anyfs_backend::{FsRead, FsWrite, FsDir, FsError, Metadata, ReadDirIter, Layer, Fs};
use std::path::Path;
/// Makes any backend read-only.
pub struct ReadOnly<B> {
inner: B,
}
impl<B> ReadOnly<B> {
pub fn new(inner: B) -> Self {
Self { inner }
}
}
// FsRead: delegate everything
impl<B: FsRead> FsRead for ReadOnly<B> {
fn read(&self, path: &Path) -> Result<Vec<u8>, FsError> {
self.inner.read(path)
}
fn read_to_string(&self, path: &Path) -> Result<String, FsError> {
self.inner.read_to_string(path)
}
fn read_range(&self, path: &Path, offset: u64, len: usize) -> Result<Vec<u8>, FsError> {
self.inner.read_range(path, offset, len)
}
fn exists(&self, path: &Path) -> Result<bool, FsError> {
self.inner.exists(path)
}
fn metadata(&self, path: &Path) -> Result<Metadata, FsError> {
self.inner.metadata(path)
}
fn open_read(&self, path: &Path) -> Result<Box<dyn std::io::Read + Send>, FsError> {
self.inner.open_read(path)
}
}
// FsWrite: block everything
impl<B: FsWrite> FsWrite for ReadOnly<B> {
fn write(&self, _: &Path, _: &[u8]) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "write" })
}
fn append(&self, _: &Path, _: &[u8]) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "append" })
}
fn remove_file(&self, _: &Path) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "remove_file" })
}
fn rename(&self, _: &Path, _: &Path) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "rename" })
}
fn copy(&self, _: &Path, _: &Path) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "copy" })
}
fn truncate(&self, _: &Path, _: u64) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "truncate" })
}
fn open_write(&self, _: &Path) -> Result<Box<dyn std::io::Write + Send>, FsError> {
Err(FsError::ReadOnly { operation: "open_write" })
}
}
// FsDir: delegate reads, block writes
impl<B: FsDir> FsDir for ReadOnly<B> {
fn read_dir(&self, path: &Path) -> Result<ReadDirIter, FsError> {
self.inner.read_dir(path) // Reading is OK
}
fn create_dir(&self, _: &Path) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "create_dir" })
}
fn create_dir_all(&self, _: &Path) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "create_dir_all" })
}
fn remove_dir(&self, _: &Path) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "remove_dir" })
}
fn remove_dir_all(&self, _: &Path) -> Result<(), FsError> {
Err(FsError::ReadOnly { operation: "remove_dir_all" })
}
}
// Layer for .layer() syntax
pub struct ReadOnlyLayer;
impl<B: Fs> Layer<B> for ReadOnlyLayer {
type Backend = ReadOnly<B>;
fn layer(self, backend: B) -> Self::Backend {
ReadOnly::new(backend)
}
}
}
Usage
#![allow(unused)]
fn main() {
let fs = FileStorage::new(
MemoryBackend::new()
.layer(ReadOnlyLayer)
);
// Reads work
fs.exists("/anything")?;
// Writes fail
assert!(fs.write("/file.txt", b"data").is_err());
assert!(fs.create_dir("/new").is_err());
}
Stacking Middleware
Middleware composes naturally:
#![allow(unused)]
fn main() {
let fs = MemoryBackend::new()
.layer(SecretBlockerLayer) // Block secret files
.layer(ReadOnlyLayer) // Make read-only
.layer(CounterLayer); // Count operations
// Layers wrap from inside out. For a request:
// Counter (outermost) → ReadOnly → SecretBlocker → MemoryBackend (innermost)
// The innermost middleware (closest to backend) applies first to the actual operation.
}
Middleware Checklist
Before publishing your middleware:
- Depends only on
anyfs-backend - Implements same traits as inner backend (
FsRead,FsWrite,FsDir) - Has a
Layerimplementation for.layer()syntax - Documents which operations are intercepted vs delegated
- Handles errors properly (doesn’t panic)
- Is thread-safe (
&selfmethods, use atomics/locks for state)
Summary
Middleware is just:
- A struct wrapping
inner: B - Implementing the same traits as
B - Intercepting some methods, delegating others
The three patterns:
- Side effects: Do something, then delegate
- Blocking: Check condition, return error or delegate
- Transform: Modify data on the way in/out
That’s it. Go build something useful.
“Middleware: because sometimes you need to do something between nothing and everything.”