feeder rework
This commit is contained in:
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -1 +1,18 @@
|
||||
# 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.
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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"]
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! `giphy-feeder` library surface. The binary (`main.rs`) wraps the plugin in
|
||||
//! [`meta_feeder_sdk::serve_feeders`].
|
||||
|
||||
pub mod giphy;
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.89"
|
||||
components = ["rustfmt", "clippy"]
|
||||
Reference in New Issue
Block a user