Skip to content

Commit 3af3f3b

Browse files
authored
Issue #100 adview manager logic (#126)
* market channel * ad_unit min_target_score to f64 * MarketStatusType * Score - Mul & Into<f64> * ChannelSpec min_targeting_score to f64 * adview-manager - impl `apply_selection` & partially impl `get_html` * adview-manager - Cargo.toml - add `serde_json` * adview-manager - `get_html` + impl `get_unit_html` + rustfmt * adview-manager - finish up + wrapping up + some tests * cargo fmt * fix unused fn & imports * Fix is_video + fix build for all features * Fix clippy * primitives - targeting_tag - fix faker
1 parent ea24a40 commit 3af3f3b

File tree

9 files changed

+441
-11
lines changed

9 files changed

+441
-11
lines changed

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ members = [
44
"adapter",
55
"primitives",
66
"validator_worker",
7-
# "sentry",
7+
# "sentry",
8+
"adview-manager",
89
]

adview-manager/Cargo.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[package]
2+
name = "adview-manager"
3+
version = "0.1.0"
4+
authors = ["Lachezar Lechev <[email protected]>"]
5+
edition = "2018"
6+
7+
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
8+
9+
[dependencies]
10+
adex_primitives = {path = "../primitives", package = "primitives"}
11+
num-bigint = { version = "0.2", features = ["serde"] }
12+
serde = {version = "^1.0", features = ['derive']}
13+
serde_json = "^1.0"
14+
chrono = "0.4"

adview-manager/src/lib.rs

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
#![deny(rust_2018_idioms)]
2+
#![deny(clippy::all)]
3+
4+
use adex_primitives::market_channel::{MarketChannel, MarketStatusType};
5+
use adex_primitives::{AdUnit, BigNum, SpecValidators, TargetingTag};
6+
use chrono::Utc;
7+
use serde::{Deserialize, Serialize};
8+
9+
pub type TargetingScore = f64;
10+
pub type MinTargetingScore = TargetingScore;
11+
12+
const IPFS_GATEWAY: &str = "https://ipfs.moonicorn.network/ipfs/";
13+
14+
#[derive(Serialize, Deserialize)]
15+
#[serde(rename_all = "camelCase")]
16+
pub struct AdViewManagerOptions {
17+
// Defaulted via defaultOpts
18+
#[serde(rename = "marketURL")]
19+
pub market_url: String,
20+
/// Defaulted
21+
pub accepted_states: Vec<MarketStatusType>,
22+
/// Defaulted
23+
pub min_per_impression: BigNum,
24+
/// Defaulted
25+
pub min_targeting_score: MinTargetingScore,
26+
/// Defaulted
27+
pub randomize: bool,
28+
pub publisher_addr: String,
29+
pub whitelisted_token: String,
30+
pub whitelisted_type: Option<String>,
31+
/// Defaulted
32+
pub top_by_price: usize,
33+
/// Defaulted
34+
pub top_by_score: usize,
35+
#[serde(default)]
36+
pub targeting: Vec<TargetingTag>,
37+
pub width: Option<u64>,
38+
pub height: Option<u64>,
39+
pub fallback_unit: Option<String>,
40+
/// Defaulted
41+
pub disabled_video: bool,
42+
}
43+
44+
impl AdViewManagerOptions {
45+
pub fn size(&self) -> Option<(u64, u64)> {
46+
self.width
47+
.and_then(|width| self.height.and_then(|height| Some((width, height))))
48+
}
49+
}
50+
51+
#[derive(Debug)]
52+
pub struct UnitByPrice {
53+
pub unit: AdUnit,
54+
pub channel_id: String,
55+
pub validators: SpecValidators,
56+
pub min_targeting_score: MinTargetingScore,
57+
pub min_per_impression: BigNum,
58+
}
59+
60+
#[derive(Debug)]
61+
pub struct Unit {
62+
pub unit: AdUnit,
63+
pub channel_id: String,
64+
pub validators: SpecValidators,
65+
pub min_targeting_score: MinTargetingScore,
66+
pub min_per_impression: BigNum,
67+
pub targeting_score: TargetingScore,
68+
}
69+
70+
impl Unit {
71+
pub fn new(by_price: UnitByPrice, targeting_score: TargetingScore) -> Self {
72+
Self {
73+
unit: by_price.unit,
74+
channel_id: by_price.channel_id,
75+
validators: by_price.validators,
76+
min_targeting_score: by_price.min_targeting_score,
77+
min_per_impression: by_price.min_per_impression,
78+
targeting_score,
79+
}
80+
}
81+
}
82+
83+
pub fn apply_selection(campaigns: &[MarketChannel], options: &AdViewManagerOptions) -> Vec<Unit> {
84+
let eligible = campaigns.iter().filter(|campaign| {
85+
options
86+
.accepted_states
87+
.contains(&campaign.status.status_type)
88+
&& campaign
89+
.spec
90+
.active_from
91+
.map(|datetime| datetime < Utc::now())
92+
.unwrap_or(true)
93+
&& campaign.deposit_asset == options.whitelisted_token
94+
&& campaign.spec.min_per_impression >= options.min_per_impression
95+
});
96+
97+
let mut units: Vec<UnitByPrice> = eligible
98+
.flat_map(|campaign| {
99+
let mut units = vec![];
100+
for ad_unit in campaign.spec.ad_units.iter() {
101+
let unit = UnitByPrice {
102+
unit: ad_unit.clone(),
103+
channel_id: campaign.id.clone(),
104+
validators: campaign.spec.validators.clone(),
105+
min_targeting_score: ad_unit
106+
.min_targeting_score
107+
.or(campaign.spec.min_targeting_score)
108+
.unwrap_or_else(|| 0.into()),
109+
min_per_impression: campaign.spec.min_per_impression.clone(),
110+
};
111+
112+
units.push(unit);
113+
}
114+
115+
units
116+
})
117+
.collect();
118+
119+
// Sort
120+
units.sort_by(|b, a| a.min_per_impression.cmp(&b.min_per_impression));
121+
units.truncate(options.top_by_price);
122+
123+
let units = units.into_iter().filter(|unit| {
124+
options
125+
.whitelisted_type
126+
.as_ref()
127+
.map(|whitelisted_type| {
128+
whitelisted_type != &unit.unit.ad_type
129+
&& !(options.disabled_video && is_video(&unit.unit))
130+
})
131+
.unwrap_or(false)
132+
});
133+
134+
let mut by_score: Vec<Unit> = units
135+
.collect::<Vec<UnitByPrice>>()
136+
.into_iter()
137+
.filter_map(|by_price| {
138+
let targeting_score =
139+
calculate_target_score(&by_price.unit.targeting, &options.targeting);
140+
if targeting_score >= options.min_targeting_score
141+
&& targeting_score >= by_price.min_targeting_score
142+
{
143+
Some(Unit::new(by_price, targeting_score))
144+
} else {
145+
None
146+
}
147+
})
148+
.collect();
149+
by_score.sort_by(|a, b| {
150+
a.targeting_score
151+
.partial_cmp(&b.targeting_score)
152+
.expect("Should always be comparable")
153+
});
154+
by_score.truncate(options.top_by_score);
155+
156+
by_score
157+
}
158+
159+
fn is_video(ad_unit: &AdUnit) -> bool {
160+
ad_unit.media_mime.split('/').nth(0) == Some("video")
161+
}
162+
163+
fn calculate_target_score(a: &[TargetingTag], b: &[TargetingTag]) -> TargetingScore {
164+
a.iter()
165+
.map(|x| -> TargetingScore {
166+
match b.iter().find(|y| y.tag == x.tag) {
167+
Some(b) => (&x.score * &b.score).into(),
168+
None => 0.into(),
169+
}
170+
})
171+
.sum()
172+
}
173+
174+
#[derive(Serialize)]
175+
#[serde(rename_all = "camelCase")]
176+
struct Event {
177+
#[serde(rename = "type")]
178+
event_type: String,
179+
publisher: String,
180+
ad_unit: String,
181+
}
182+
183+
#[derive(Serialize)]
184+
struct EventBody {
185+
events: Vec<Event>,
186+
}
187+
188+
fn image_html(
189+
event_body: &str,
190+
on_load: &str,
191+
size: &Option<(u64, u64)>,
192+
image_url: &str,
193+
) -> String {
194+
let size = size
195+
.map(|(width, height)| format!("width=\"{}\" height=\"{}\"", width, height))
196+
.unwrap_or_else(|| "".to_string());
197+
198+
format!("<img src=\"{image_url}\" data-event-body='{event_body}' alt=\"AdEx ad\" rel=\"nofollow\" onload=\"{on_load}\" {size}>",
199+
image_url = image_url, event_body = event_body, on_load = on_load, size = size)
200+
}
201+
202+
fn normalize_url(url: &str) -> String {
203+
if url.starts_with("ipfs://") {
204+
url.replacen("ipfs://", IPFS_GATEWAY, 1)
205+
} else {
206+
url.to_string()
207+
}
208+
}
209+
210+
fn video_html(
211+
event_body: &str,
212+
on_load: &str,
213+
size: &Option<(u64, u64)>,
214+
image_url: &str,
215+
media_mime: &str,
216+
) -> String {
217+
let size = size
218+
.map(|(width, height)| format!("width=\"{}\" height=\"{}\"", width, height))
219+
.unwrap_or_else(|| "".to_string());
220+
221+
format!("<video {size} loop autoplay data-event-body='{event_body}' onloadeddata=\"${on_load}\" muted>
222+
<source src=\"{image_url}\" type=\"{media_mime}\">
223+
</video>", size = size, event_body = event_body, on_load = on_load, image_url = image_url, media_mime = media_mime)
224+
}
225+
226+
pub fn get_html(
227+
options: &AdViewManagerOptions,
228+
ad_unit: AdUnit,
229+
channel_id: &str,
230+
validators: &SpecValidators,
231+
) -> String {
232+
let ev_body = EventBody {
233+
events: vec![Event {
234+
event_type: "IMPRESSION".into(),
235+
publisher: options.publisher_addr.clone(),
236+
ad_unit: ad_unit.ipfs.clone(),
237+
}],
238+
};
239+
240+
let on_load: String = validators.into_iter().map(|validator| {
241+
let fetch_opts = "{ method: 'POST', headers: { 'content-type': 'application/json' }, body: this.dataset.eventBody }";
242+
let fetch_url = format!("{}/channel/{}/events", validator.url, channel_id);
243+
244+
format!("fetch({}, {});", fetch_url, fetch_opts)
245+
}).collect();
246+
247+
let ev_body = serde_json::to_string(&ev_body).expect("should convert");
248+
249+
get_unit_html(&options.size(), ad_unit, &ev_body, &on_load)
250+
}
251+
252+
fn get_unit_html(
253+
size: &Option<(u64, u64)>,
254+
ad_unit: AdUnit,
255+
event_body: &str,
256+
on_load: &str,
257+
) -> String {
258+
let image_url = normalize_url(&ad_unit.media_url);
259+
260+
let element_html = if is_video(&ad_unit) {
261+
video_html(event_body, on_load, size, &image_url, &ad_unit.media_mime)
262+
} else {
263+
image_html(event_body, on_load, size, &image_url)
264+
};
265+
266+
let style_size = size
267+
.map(|(width, height)| format!("width: {}; height: {};", width, height))
268+
.unwrap_or_else(|| "".to_string());
269+
270+
let adex_icon = "<a href=\"https://www.adex.network\" target=\"_blank\" rel=\"noopener noreferrer\"
271+
style=\"position: absolute; top: 0; right: 0;\"
272+
>
273+
<svg version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\" width=\"18px\"
274+
height=\"18px\" viewBox=\"0 0 18 18\" style=\"enable-background:new 0 0 18 18;\" xml:space=\"preserve\">
275+
<style type=\"text/css\">
276+
.st0{fill:#FFFFFF;}
277+
.st1{fill:#1B75BC;}
278+
</style>
279+
<defs>
280+
</defs>
281+
<rect class=\"st0\" width=\"18\" height=\"18\"/>
282+
<path class=\"st1\" d=\"M14,12.1L10.9,9L14,5.9L12.1,4L9,7.1L5.9,4L4,5.9L7.1,9L4,12.1L5.9,14L9,10.9l3.1,3.1L14,12.1z M7.9,2L6.4,3.5
283+
L7.9,5L9,3.9L10.1,5l1.5-1.5L10,1.9l-1-1L7.9,2 M7.9,16l-1.5-1.5L7.9,13L9,14.1l1.1-1.1l1.5,1.5L10,16.1l-1,1L7.9,16\"/>
284+
</svg>
285+
</a>";
286+
287+
let result = format!("
288+
<div style=\"position: relative; overflow: hidden; {size}\">
289+
<a href=\"{target_url}\" target=\"_blank\" rel=\"noopener noreferrer\">{element_html}</a>
290+
{adex_icon}
291+
</div>
292+
", target_url = ad_unit.target_url, size = style_size, element_html = element_html, adex_icon = adex_icon);
293+
294+
result.to_string()
295+
}
296+
297+
#[cfg(test)]
298+
mod test {
299+
use super::*;
300+
301+
fn get_ad_unit(media_mime: &str) -> AdUnit {
302+
AdUnit {
303+
ipfs: "".to_string(),
304+
ad_type: "".to_string(),
305+
media_url: "".to_string(),
306+
media_mime: media_mime.to_string(),
307+
target_url: "".to_string(),
308+
targeting: vec![],
309+
min_targeting_score: None,
310+
tags: vec![],
311+
owner: "".to_string(),
312+
created: Utc::now(),
313+
title: None,
314+
description: None,
315+
archived: false,
316+
modified: None,
317+
}
318+
}
319+
320+
#[test]
321+
fn test_is_video() {
322+
assert_eq!(true, is_video(&get_ad_unit("video/avi")));
323+
assert_eq!(false, is_video(&get_ad_unit("image/jpeg")));
324+
}
325+
326+
#[test]
327+
fn normalization_of_url() {
328+
// IPFS case
329+
assert_eq!(format!("{}123", IPFS_GATEWAY), normalize_url("ipfs://123"));
330+
assert_eq!(
331+
format!("{}123ipfs://", IPFS_GATEWAY),
332+
normalize_url("ipfs://123ipfs://")
333+
);
334+
335+
// Non-IPFS case
336+
assert_eq!("http://123".to_string(), normalize_url("http://123"));
337+
}
338+
}

primitives/src/ad_unit.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ pub struct AdUnit {
2929
/// Array of TargetingTag
3030
pub targeting: Vec<TargetingTag>,
3131
/// Number; minimum targeting score (optional)
32-
pub min_targeting_score: Option<u8>,
32+
pub min_targeting_score: Option<f64>,
3333
/// Array of TargetingTag (optional)
3434
/// meant for discovery between publishers/advertisers
3535
#[serde(default)]

primitives/src/channel.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ pub struct ChannelSpec {
3737
pub targeting: Vec<TargetingTag>,
3838
/// Minimum targeting score (optional)
3939
#[serde(default, skip_serializing_if = "Option::is_none")]
40-
pub min_targeting_score: Option<u64>,
40+
pub min_targeting_score: Option<f64>,
4141
/// EventSubmission object, applies to event submission (POST /channel/:id/events)
4242
pub event_submission: EventSubmission,
4343
/// A millisecond timestamp of when the campaign was created

0 commit comments

Comments
 (0)