@@ -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
|
# 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