feeder rework

This commit is contained in:
Worph
2026-08-24 19:08:46 +02:00
parent e04cf0b656
commit 4bfbecb434
11 changed files with 2838 additions and 1 deletions
+6
View File
@@ -0,0 +1,6 @@
# FIX CRLF always when developer has not set
# Linux/Mac: git config --global core.autocrlf input
# Windows: git config --global core.autocrlf true
# Auto detect text files and perform LF normalization
* text=auto
* eol=lf
+59
View File
@@ -0,0 +1,59 @@
name: Publish image
# Build and push the feeder image to the GitHub Container Registry (ghcr.io):
# * every push to main -> the moving `main` tag
# * every `v*` tag -> semver tags (e.g. v1.0.0 -> 1.0.0, 1.0, 1)
# The build context is the repo root (the workspace) so the vendored
# `meta-feeder-sdk` path dependency resolves; the Dockerfile lives under
# feeder-plugin/paper-feeder/.
on:
push:
branches: [main]
tags: ['v*']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,format=short
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./feeder-plugin/giphy-feeder/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Generated
+1946
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
[workspace]
resolver = "2"
members = ["feeder-plugin/*"]
[workspace.package]
edition = "2021"
rust-version = "1.85"
license = "MIT"
repository = "https://github.com/worph/meta-feeder-giphy"
+18 -1
View File
@@ -1 +1,18 @@
# meta-feeder-giphy
# meta-feeder-giphy
Giphy GIFs. Opt-in: soft-skips unless GIPHY_API_KEY is set.
**Upstream:** Giphy (api.giphy.com)
**Role:** bytes
A MetaMesh gateway feeder — one upstream, one job. It implements
`FeederPlugin` from [`meta-feeder-sdk`](https://github.com/worph/meta-feeder-sdk)
and is served over the feeder HTTP contract by `serve_feeders`.
```bash
cargo build --release --bin giphy-feeder
cargo test
```
Config and scope conventions: see the SDK README and
`docs/project-architecture/feeder-architecture.md` in the meta-root.
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "giphy-feeder"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
# Giphy GIFs. Opt-in: soft-skips unless GIPHY_API_KEY is set.
# Upstream: Giphy (api.giphy.com)
[lib]
name = "giphy_feeder"
path = "src/lib.rs"
[[bin]]
name = "giphy-feeder"
path = "src/main.rs"
[dependencies]
# The SDK is a dependency, not a vendored copy. Pinned by rev until the first
# tag; see docs/project-architecture/feeder-architecture.md §8.
meta-feeder-sdk = { git = "https://github.com/worph/meta-feeder-sdk", rev = "d2c6ef4c1887be9ae9f92ebedc39a810aa57c7e4" }
tokio = { version = "1.40", features = ["full"] }
axum = { version = "0.7", features = ["json"] }
anyhow = "1"
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], default-features = false }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
bytes = "1"
[dev-dependencies]
wiremock = "0.6"
tempfile = "3"
tokio = { version = "1.40", features = ["full", "test-util"] }
+44
View File
@@ -0,0 +1,44 @@
# syntax=docker/dockerfile:1
# giphy-feeder sidecar. Multi-stage build; build context is this repo's root.
# meta-feeder-sdk arrives as a git dependency, so no vendored crate is copied
# in — `git` must be present in the builder for cargo to fetch it.
FROM rust:1.89-slim-bookworm AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml rust-toolchain.toml ./
COPY feeder-plugin/ feeder-plugin/
RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \
--mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \
--mount=type=cache,id=giphy-feeder-target,target=/build/target \
cargo build --release --bin giphy-feeder \
&& cp target/release/giphy-feeder /giphy-feeder
# -----------------------------------------------------------------------------
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /giphy-feeder /usr/local/bin/giphy-feeder
RUN useradd --system --uid 10001 --no-create-home metafeeder \
&& mkdir -p /data/meta-feeder \
&& chown metafeeder:metafeeder /data/meta-feeder
USER metafeeder
WORKDIR /data/meta-feeder
EXPOSE 8080
ENV META_FEEDER_HTTP_LISTEN=0.0.0.0:8080 \
META_FEEDER_STATE_DIR=/data/meta-feeder \
RUST_LOG=info
ENTRYPOINT ["/usr/local/bin/giphy-feeder"]
+672
View File
@@ -0,0 +1,672 @@
//! Giphy bridge via the [Giphy API](https://developers.giphy.com/docs/api).
//!
//! - Search: `GET <base>/v1/gifs/search?api_key=<KEY>&q=<q>&limit=N`
//! - Fetch one: `GET <base>/v1/gifs/<id>?api_key=<KEY>`
//! - File download: the absolute URL from `images.original.url`.
//!
//! **API key required.** Missing/empty soft-skips the plugin at startup with a
//! clear `ConfigError::MissingConfig`.
//!
//! Ported from the gateway crate's `plugins/giphy.rs`. Difference from the
//! in-gateway version: a feeder can't reach the core bitswap blockstore, so
//! instead of fetching+seeding each result's preview and emitting a `preview`
//! CID, `handle_query` emits the raw upstream `preview_url`; the gateway core
//! fetches + seeds + rewrites it to a `preview` cid (plan §2c).
use std::collections::BTreeMap;
use std::path::Path;
use async_trait::async_trait;
use serde::Deserialize;
use tracing::warn;
use meta_feeder_sdk::cache::MidhashCache;
use meta_feeder_sdk::common;
use meta_feeder_sdk::plugin::{upstream_id_field, ConfigError, FeederPlugin, GatewayQuery, HashOutcome};
use meta_feeder_sdk::types::{DiscoveryRecord, GatewayError, PluginHealth};
/// Public Giphy API root.
const DEFAULT_BASE_URL: &str = "https://api.giphy.com";
/// HTTP timeout per upstream call.
const HTTP_TIMEOUT_SECS: u64 = 30;
/// Polite identification per Giphy's API guidelines.
const USER_AGENT: &str = concat!("meta-share/", env!("CARGO_PKG_VERSION"), " (gateway:giphy)");
/// Giphy gateway plugin. Holds the api_key after `configure()`.
pub struct GiphyPlugin {
http: reqwest::Client,
base_url: String,
/// Operator-supplied Giphy API key. `None` before `configure()`.
api_key: Option<String>,
cache: Option<MidhashCache>,
}
impl Default for GiphyPlugin {
fn default() -> Self {
Self::new()
}
}
impl GiphyPlugin {
pub fn new() -> Self {
Self::with_base_url(DEFAULT_BASE_URL.to_string())
}
/// Construct against a non-default base URL. The api_key is left `None` so
/// `configure()` / `set_api_key` must supply it.
pub fn with_base_url(base_url: String) -> Self {
let http = common::build_http_client(HTTP_TIMEOUT_SECS, USER_AGENT, None);
Self {
http,
base_url,
api_key: None,
cache: None,
}
}
/// Set the api_key (from the feeder's env/config, or tests). Absent →
/// `configure()` soft-skips the plugin.
pub fn set_api_key(&mut self, key: String) {
self.api_key = Some(key);
}
/// Test-only constructor: primes the api_key + base URL together.
#[cfg(test)]
pub fn with_api_key_and_base(api_key: String, base_url: String) -> Self {
let mut p = Self::with_base_url(base_url);
p.set_api_key(api_key);
p
}
fn cache(&self) -> Result<&MidhashCache, GatewayError> {
common::require_cache(self.cache.as_ref(), "giphy")
}
fn api_key(&self) -> Result<&str, GatewayError> {
self.api_key.as_deref().ok_or_else(|| {
GatewayError::Internal(anyhow::anyhow!(
"giphy plugin api_key missing (configure() never called)"
))
})
}
/// Build a Giphy API URL with the api_key baked in.
fn api_url(&self, path: &str, extra: &str) -> Result<String, GatewayError> {
let key = self.api_key()?;
Ok(format!(
"{}{}?api_key={}{}{}",
self.base_url.trim_end_matches('/'),
path,
common::urlencode(key),
if extra.is_empty() { "" } else { "&" },
extra
))
}
async fn fetch_search(
&self,
query: &str,
max_results: usize,
) -> Result<GiphyResponse, GatewayError> {
let url = self.api_url(
"/v1/gifs/search",
&format!("q={}&limit={}", common::urlencode(query), max_results),
)?;
let resp = self
.http
.get(&url)
.send()
.await
.map_err(|e| GatewayError::Transient(format!("GET {url}: {e}")))?;
map_status(&resp)?;
resp.json::<GiphyResponse>()
.await
.map_err(|e| GatewayError::Permanent(format!("decode giphy search response: {e}")))
}
async fn fetch_one(&self, record_id: &str) -> Result<GiphyGif, GatewayError> {
let url = self.api_url(&format!("/v1/gifs/{}", common::urlencode(record_id)), "")?;
let resp = self
.http
.get(&url)
.send()
.await
.map_err(|e| GatewayError::Transient(format!("GET {url}: {e}")))?;
map_status(&resp)?;
let body: GiphySingleResponse = resp
.json()
.await
.map_err(|e| GatewayError::Permanent(format!("decode giphy detail response: {e}")))?;
body.data.ok_or(GatewayError::NotFound)
}
async fn fetch_gif_bytes(&self, url: &str) -> Result<bytes::Bytes, GatewayError> {
let resp = self
.http
.get(url)
.send()
.await
.map_err(|e| GatewayError::Transient(format!("GET {url}: {e}")))?;
map_status(&resp)?;
let bytes = resp
.bytes()
.await
.map_err(|e| GatewayError::Transient(format!("read body {url}: {e}")))?;
// Sanity check: GIF magic bytes are `GIF87a` or `GIF89a`. Guards
// against Giphy returning an HTML error page with 200 status.
if !bytes.starts_with(b"GIF8") {
return Err(GatewayError::Permanent(format!(
"giphy returned non-GIF body for {url} (CDN may have replaced the file)"
)));
}
Ok(bytes)
}
}
#[async_trait]
impl FeederPlugin for GiphyPlugin {
fn upstream_id(&self) -> &'static str {
"giphy"
}
fn configure(&mut self, cache_dir: &Path) -> Result<(), ConfigError> {
if self.api_key.is_none() {
return Err(ConfigError::MissingConfig {
plugin: "giphy",
what: "api_key (set it via the GIPHY_API_KEY env on the common-feeder)",
});
}
self.cache = Some(common::open_midhash_cache(cache_dir, "giphy")?);
Ok(())
}
async fn handle_query(
&self,
query: &GatewayQuery,
max_results: usize,
) -> Result<Vec<DiscoveryRecord>, GatewayError> {
// Layer A early-return: Giphy only serves `image` / `gif`.
if !meta_feeder_sdk::query_eval::query_accepts_plugin(
query,
self.served_file_types(),
self.served_content_kinds(),
) {
return Ok(Vec::new());
}
let q = query.free_text_or_star();
let body = self.fetch_search(q, max_results).await?;
// Emit the upstream `preview_gif` URL as a raw `preview_url` field. The
// gateway core fetches it, seeds the bytes into its bitswap blockstore,
// and rewrites it to a content-addressed `preview` cid (plan §2c) — a
// feeder can't reach the core blockstore.
let records: Vec<DiscoveryRecord> = body
.data
.into_iter()
.filter_map(|g| {
let preview_url = g
.images
.as_ref()
.and_then(|i| i.preview_gif.as_ref())
.map(|v| v.url.clone())
.filter(|s| !s.is_empty());
let mut rec = into_discovery_record(g)?;
if let Some(u) = preview_url {
rec.fields.insert("preview_url".to_string(), u);
}
Some(rec)
})
.take(max_results)
.collect();
Ok(records)
}
async fn compute_outcomes(&self, record_id: &str) -> Result<Vec<HashOutcome>, GatewayError> {
let cache = self.cache()?;
if let Some(hit) = common::cached_outcome(cache, record_id, "giphy")? {
return Ok(hit);
}
let gif = self.fetch_one(record_id).await?;
let url = gif
.images
.as_ref()
.and_then(|m| m.original.as_ref())
.map(|o| o.url.clone())
.ok_or_else(|| {
GatewayError::Permanent(format!(
"giphy record `{record_id}` has no images.original.url"
))
})?;
let bytes = self.fetch_gif_bytes(&url).await?;
let cid = meta_feeder_sdk::hash::compute_ipfs_cid(&bytes);
common::store_midhash(cache, record_id, "giphy", &cid);
let record = into_discovery_record(gif).ok_or_else(|| {
GatewayError::Permanent(format!(
"giphy record `{record_id}` lost fields between calls"
))
})?;
Ok(common::single_outcome(cid, bytes, record, Some("gif".to_string())))
}
fn health(&self) -> PluginHealth {
match (self.cache.is_some(), self.api_key.is_some()) {
(true, true) => PluginHealth::Ok,
(false, _) => PluginHealth::Degraded {
reason: "configure() not yet called".to_string(),
},
(true, false) => PluginHealth::Degraded {
reason: "no api key — set GIPHY_API_KEY".to_string(),
},
}
}
fn served_file_types(&self) -> &'static [&'static str] {
&["image"]
}
fn served_content_kinds(&self) -> &'static [&'static str] {
&["gif"]
}
}
/// Map an HTTP status to a `GatewayError`. Auth-shaped failures are permanent.
fn map_status(resp: &reqwest::Response) -> Result<(), GatewayError> {
let status = resp.status();
let code = status.as_u16();
if code == 401 || code == 403 {
warn!(target: "meta-share::gateway", upstream = "giphy", %status, "giphy auth failure");
return Err(GatewayError::Permanent(format!(
"giphy auth failed ({status}) — check the GIPHY_API_KEY"
)));
}
common::map_status(resp)
}
// -- Giphy JSON shapes (subset we use) --------------------------------------
#[derive(Debug, Default, Deserialize)]
struct GiphyResponse {
#[serde(default)]
data: Vec<GiphyGif>,
}
#[derive(Debug, Default, Deserialize)]
struct GiphySingleResponse {
data: Option<GiphyGif>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct GiphyGif {
id: Option<String>,
title: Option<String>,
url: Option<String>,
username: Option<String>,
rating: Option<String>,
import_datetime: Option<String>,
images: Option<GiphyImages>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct GiphyImages {
original: Option<GiphyImageVariant>,
/// Small preview rendition surfaced as the `preview_url` field.
preview_gif: Option<GiphyImageVariant>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct GiphyImageVariant {
url: String,
width: Option<String>,
height: Option<String>,
size: Option<String>,
}
/// Convert a `GiphyGif` to a `DiscoveryRecord`. Returns `None` when the record
/// is missing an `id`.
fn into_discovery_record(gif: GiphyGif) -> Option<DiscoveryRecord> {
let id = gif.id?;
let mut fields: BTreeMap<String, String> = BTreeMap::new();
if let Some(t) = gif.title.filter(|s| !s.is_empty()) {
fields.insert("title".to_string(), t);
} else {
fields.insert("title".to_string(), format!("giphy/{id}"));
}
fields.insert("fileType".to_string(), "image".to_string());
fields.insert("contentKind".to_string(), "gif".to_string());
fields.insert(
"sourceUrl".to_string(),
gif.url.unwrap_or_else(|| format!("https://giphy.com/gifs/{id}")),
);
fields.insert("fileName".to_string(), format!("giphy-{id}.gif"));
fields.insert(upstream_id_field("giphy"), id.clone());
fields.insert("format".to_string(), "gif".to_string());
fields.insert("mime".to_string(), "image/gif".to_string());
if let Some(u) = gif.username.filter(|s| !s.is_empty()) {
fields.insert("uploader".to_string(), u);
}
if let Some(r) = gif.rating.filter(|s| !s.is_empty()) {
fields.insert("rating".to_string(), r);
}
if let Some(dt) = gif.import_datetime.filter(|s| !s.is_empty()) {
fields.insert("imported".to_string(), dt.clone());
if dt.len() >= 4 && dt[..4].chars().all(|c| c.is_ascii_digit()) {
fields.insert("year".to_string(), dt[..4].to_string());
}
}
if let Some(orig) = gif.images.and_then(|i| i.original) {
if let Some(s) = orig.size.filter(|s| !s.is_empty()) {
fields.insert("size".to_string(), s);
}
if let Some(w) = orig.width.filter(|s| !s.is_empty()) {
fields.insert("width".to_string(), w);
}
if let Some(h) = orig.height.filter(|s| !s.is_empty()) {
fields.insert("height".to_string(), h);
}
}
Some(DiscoveryRecord {
upstream_id: "giphy".to_string(),
record_id: id,
fields,
})
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_SEARCH: &str = r#"{
"data": [
{
"id": "ABCDEF123",
"title": "Cat reaction",
"url": "https://giphy.com/gifs/funny-cat-ABCDEF123",
"username": "catlover",
"rating": "g",
"import_datetime": "2020-04-22 18:00:00",
"images": {
"original": {
"url": "https://media.giphy.com/media/ABCDEF123/giphy.gif",
"width": "480",
"height": "270",
"size": "1234567"
},
"preview_gif": {
"url": "https://media.giphy.com/media/ABCDEF123/giphy-preview.gif"
}
}
},
{
"id": "XYZ789",
"title": "",
"url": "https://giphy.com/gifs/XYZ789",
"rating": "pg",
"images": {
"original": {
"url": "https://media.giphy.com/media/XYZ789/giphy.gif",
"size": "654321"
}
}
}
],
"pagination": { "total_count": 2, "count": 2, "offset": 0 },
"meta": { "status": 200, "msg": "OK" }
}"#;
#[test]
fn into_discovery_record_emits_required_fields() {
let body: GiphyResponse = serde_json::from_str(SAMPLE_SEARCH).unwrap();
let rec = into_discovery_record(body.data.into_iter().next().unwrap()).unwrap();
assert_eq!(rec.upstream_id, "giphy");
assert_eq!(rec.record_id, "ABCDEF123");
assert_eq!(rec.fields.get("fileType").map(String::as_str), Some("image"));
assert_eq!(rec.fields.get("contentKind").map(String::as_str), Some("gif"));
assert_eq!(rec.fields.get("format").map(String::as_str), Some("gif"));
assert_eq!(rec.fields.get("mime").map(String::as_str), Some("image/gif"));
assert_eq!(
rec.fields.get("sourceUrl").map(String::as_str),
Some("https://giphy.com/gifs/funny-cat-ABCDEF123")
);
assert_eq!(
rec.fields.get("fileName").map(String::as_str),
Some("giphy-ABCDEF123.gif")
);
assert_eq!(rec.fields.get("giphyid").map(String::as_str), Some("ABCDEF123"));
assert_eq!(rec.fields.get("title").map(String::as_str), Some("Cat reaction"));
assert_eq!(rec.fields.get("uploader").map(String::as_str), Some("catlover"));
assert_eq!(rec.fields.get("rating").map(String::as_str), Some("g"));
assert_eq!(rec.fields.get("year").map(String::as_str), Some("2020"));
assert_eq!(rec.fields.get("size").map(String::as_str), Some("1234567"));
}
#[test]
fn empty_title_falls_back_to_id() {
let body: GiphyResponse = serde_json::from_str(SAMPLE_SEARCH).unwrap();
let rec = into_discovery_record(body.data.into_iter().nth(1).unwrap()).unwrap();
assert_eq!(rec.record_id, "XYZ789");
assert_eq!(rec.fields.get("title").map(String::as_str), Some("giphy/XYZ789"));
}
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn configured_plugin_against(server: &MockServer) -> (GiphyPlugin, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let mut plugin = GiphyPlugin::with_api_key_and_base("test-key".to_string(), server.uri());
plugin.configure(dir.path()).expect("configure");
(plugin, dir)
}
#[tokio::test]
async fn handle_query_maps_search_to_discovery_records() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/gifs/search"))
.and(query_param("api_key", "test-key"))
.and(query_param("q", "cat"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(SAMPLE_SEARCH)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let (plugin, _dir) = configured_plugin_against(&server);
let records = plugin
.handle_query(&GatewayQuery::from_free_text("cat"), 10)
.await
.expect("handle_query");
assert_eq!(records.len(), 2);
assert_eq!(records[0].record_id, "ABCDEF123");
// The preview rides as a raw URL for the core to seed.
assert_eq!(
records[0].fields.get("preview_url").map(String::as_str),
Some("https://media.giphy.com/media/ABCDEF123/giphy-preview.gif")
);
}
#[tokio::test]
async fn handle_query_truncates_to_max_results() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/gifs/search"))
.respond_with(ResponseTemplate::new(200).set_body_string(SAMPLE_SEARCH))
.mount(&server)
.await;
let (plugin, _dir) = configured_plugin_against(&server);
let records = plugin
.handle_query(&GatewayQuery::from_free_text("cat"), 1)
.await
.expect("handle_query");
assert_eq!(records.len(), 1);
}
#[tokio::test]
async fn compute_outcomes_fetches_gif_and_caches() {
let server = MockServer::start().await;
let gif_bytes = b"GIF89a mock gif body\n".to_vec();
let expected_cid = meta_feeder_sdk::hash::compute_ipfs_cid(&gif_bytes);
let single = format!(
r#"{{
"data": {{
"id": "ABCDEF123",
"title": "Cat reaction",
"url": "https://giphy.com/gifs/ABCDEF123",
"images": {{
"original": {{
"url": "{base}/media/ABCDEF123/giphy.gif",
"size": "{sz}"
}}
}}
}},
"meta": {{ "status": 200 }}
}}"#,
base = server.uri(),
sz = gif_bytes.len()
);
Mock::given(method("GET"))
.and(path("/v1/gifs/ABCDEF123"))
.and(query_param("api_key", "test-key"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(single)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/media/ABCDEF123/giphy.gif"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(gif_bytes.clone())
.insert_header("content-type", "image/gif"),
)
.mount(&server)
.await;
let (plugin, _dir) = configured_plugin_against(&server);
let outcome = plugin
.compute_outcomes("ABCDEF123")
.await
.expect("compute")
.into_iter()
.next()
.expect("single outcome");
assert_eq!(outcome.hash.as_str(), expected_cid);
assert_eq!(outcome.bytes.as_deref(), Some(gif_bytes.as_slice()));
assert_eq!(outcome.file_extension.as_deref(), Some("gif"));
let rec = outcome.record.expect("record present on fresh compute");
assert_eq!(rec.record_id, "ABCDEF123");
}
#[tokio::test]
async fn compute_outcomes_cache_hit_skips_http() {
let server = MockServer::start().await;
let (plugin, _dir) = configured_plugin_against(&server);
plugin
.cache
.as_ref()
.unwrap()
.put_midhash("ABCDEF123", "bafyCACHED")
.unwrap();
let outcome = plugin
.compute_outcomes("ABCDEF123")
.await
.expect("cache hit")
.into_iter()
.next()
.expect("single outcome");
assert_eq!(outcome.hash.as_str(), "bafyCACHED");
assert!(outcome.bytes.is_none());
assert!(outcome.record.is_none());
}
#[tokio::test]
async fn compute_outcomes_rejects_non_gif_body() {
let server = MockServer::start().await;
let single = format!(
r#"{{
"data": {{
"id": "ABCDEF123",
"images": {{
"original": {{ "url": "{base}/blocked", "size": "100" }}
}}
}}
}}"#,
base = server.uri()
);
Mock::given(method("GET"))
.and(path("/v1/gifs/ABCDEF123"))
.respond_with(ResponseTemplate::new(200).set_body_string(single))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/blocked"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("<html>CDN takedown</html>")
.insert_header("content-type", "text/html"),
)
.mount(&server)
.await;
let (plugin, _dir) = configured_plugin_against(&server);
let err = plugin
.compute_outcomes("ABCDEF123")
.await
.expect_err("non-GIF body");
assert!(matches!(err, GatewayError::Permanent(_)), "got {err:?}");
}
#[tokio::test]
async fn upstream_401_maps_to_permanent_auth_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/gifs/search"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let (plugin, _dir) = configured_plugin_against(&server);
let err = plugin
.handle_query(&GatewayQuery::from_free_text("anything"), 5)
.await
.expect_err("expected auth error");
match err {
GatewayError::Permanent(msg) => {
assert!(msg.contains("auth failed") && msg.contains("GIPHY_API_KEY"), "msg: {msg}")
}
other => panic!("expected Permanent, got {other:?}"),
}
}
#[tokio::test]
async fn upstream_404_on_detail_maps_to_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/gifs/missing"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let (plugin, _dir) = configured_plugin_against(&server);
let err = plugin
.compute_outcomes("missing")
.await
.expect_err("expected NotFound");
assert!(matches!(err, GatewayError::NotFound), "got {err:?}");
}
#[test]
fn configure_without_api_key_soft_skips() {
let dir = tempfile::tempdir().unwrap();
let mut plugin = GiphyPlugin::new();
match plugin.configure(dir.path()) {
Err(ConfigError::MissingConfig { plugin, .. }) => assert_eq!(plugin, "giphy"),
Err(other) => panic!("wrong error: {other:?}"),
Ok(()) => panic!("expected MissingConfig"),
}
}
}
+4
View File
@@ -0,0 +1,4 @@
//! `giphy-feeder` library surface. The binary (`main.rs`) wraps the plugin in
//! [`meta_feeder_sdk::serve_feeders`].
pub mod giphy;
+38
View File
@@ -0,0 +1,38 @@
//! `giphy-feeder` — a single-upstream feeder sidecar.
//!
//! Upstream: Giphy (api.giphy.com)
//!
//! Env:
//! - `META_FEEDER_HTTP_LISTEN` — listen addr (default `0.0.0.0:8080`)
//! - `META_FEEDER_STATE_DIR` — per-plugin cache root (default `/data/meta-feeder`)
//! - `GIPHY_API_KEY` — Giphy API key (opt-in)
//! - `RUST_LOG` — tracing filter (default `info`)
use std::net::SocketAddr;
use meta_feeder_sdk::{serve_feeders, FeederPlugin};
use giphy_feeder::giphy::GiphyPlugin;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let listen: SocketAddr = std::env::var("META_FEEDER_HTTP_LISTEN")
.unwrap_or_else(|_| "0.0.0.0:8080".to_string())
.parse()?;
let state_dir =
std::env::var("META_FEEDER_STATE_DIR").unwrap_or_else(|_| "/data/meta-feeder".to_string());
let mut plugin = GiphyPlugin::new();
if let Ok(key) = std::env::var("GIPHY_API_KEY") {
if !key.trim().is_empty() {
plugin.set_api_key(key);
}
}
let plugins: Vec<Box<dyn FeederPlugin>> = vec![Box::new(plugin)];
serve_feeders(plugins, state_dir, listen).await
}
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.89"
components = ["rustfmt", "clippy"]