// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// A push gateway request: the method, the absolute URL path, the headers, and
/// the body. The caller sends it with whatever HTTP client it has; this package
/// never touches a socket.
pub(all) struct PushRequest {
http_method : String
path : String
headers : Array[(String, String)]
body : String
}
///|
fn normalize_gateway(gateway : String) -> String {
let trimmed = gateway.trim().to_owned()
let trimmed = if trimmed.length() > 0 && trimmed[trimmed.length() - 1] == '/' {
trimmed[0:trimmed.length() - 1].to_owned()
} else {
trimmed
}
if trimmed.contains("://") {
trimmed
} else {
"http://" + trimmed
}
}
///|
fn is_unreserved(c : Char) -> Bool {
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-' ||
c == '_' ||
c == '.' ||
c == '~'
}
///|
fn hex_digit(n : Int32) -> Char {
let digits : Array[Char] = "0123456789ABCDEF".to_array()
digits[n]
}
///|
fn quote_plus(v : String) -> String {
let sb = StringBuilder()
for c in v {
if is_unreserved(c) {
sb.write_char(c)
} else if c == ' ' {
sb.write_char('+')
} else {
for byte in @utf8.encode(c.to_string()) {
let n = byte.to_int()
sb.write_char('%')
sb.write_char(hex_digit(n >> 4))
sb.write_char(hex_digit(n & 15))
}
}
}
sb.to_string()
}
///|
fn base64url(v : String) -> String {
let alphabet : Array[Char] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".to_array()
let bytes = @utf8.encode(v)
let sb = StringBuilder()
let mut i = 0
while i + 3 <= bytes.length() {
let n = (bytes[i].to_int() << 16) |
(bytes[i + 1].to_int() << 8) |
bytes[i + 2].to_int()
sb.write_char(alphabet[(n >> 18) & 63])
sb.write_char(alphabet[(n >> 12) & 63])
sb.write_char(alphabet[(n >> 6) & 63])
sb.write_char(alphabet[n & 63])
i = i + 3
}
let rest = bytes.length() - i
if rest == 1 {
let n = bytes[i].to_int() << 16
sb.write_char(alphabet[(n >> 18) & 63])
sb.write_char(alphabet[(n >> 12) & 63])
} else if rest == 2 {
let n = (bytes[i].to_int() << 16) | (bytes[i + 1].to_int() << 8)
sb.write_char(alphabet[(n >> 18) & 63])
sb.write_char(alphabet[(n >> 12) & 63])
sb.write_char(alphabet[(n >> 6) & 63])
}
sb.to_string()
}
///|
fn escape_pair(name : String, value : String) -> String {
if value == "" {
name + "@base64/="
} else if value.contains("/") || value.contains(" ") {
name + "@base64/" + base64url(value)
} else {
name + "/" + quote_plus(value)
}
}
///|
fn push_path(
gateway : String,
job : String,
grouping_key : Array[(String, String)],
) -> String {
let sorted : Array[(String, String)] = []
for pair in grouping_key {
sorted.push(pair)
}
sorted.sort_by(fn(a, b) { String::lexical_compare(a.0, b.0) })
let sb = StringBuilder()
sb.write_string(normalize_gateway(gateway))
sb.write_string("/metrics/")
sb.write_string(escape_pair("job", job))
for pair in sorted {
sb.write_char('/')
sb.write_string(escape_pair(pair.0, pair.1))
}
sb.to_string()
}
///|
fn build(
http_method : String,
gateway : String,
job : String,
families : Array[@metric.MetricFamily],
grouping_key : Array[(String, String)],
format : @expo.Format,
) -> PushRequest {
let body = match format {
@expo.Format::Text004 => @expo.text(families)
@expo.Format::OpenMetrics100 => @expo.openmetrics(families)
}
{
http_method,
path: push_path(gateway, job, grouping_key),
headers: [("Content-Type", format.content_type())],
body,
}
}
///|
/// Build a `PUT` that replaces every series under `job` and `grouping_key` with
/// the exposition of `families`.
pub fn push(
gateway : String,
job : String,
families : Array[@metric.MetricFamily],
grouping_key? : Array[(String, String)],
format~ : @expo.Format = @expo.Format::Text004,
) -> PushRequest {
build("PUT", gateway, job, families, grouping_key.unwrap_or([]), format)
}
///|
/// Build a `POST` that replaces only the series named in `families`, leaving
/// other series under the same job and grouping key alone.
pub fn push_add(
gateway : String,
job : String,
families : Array[@metric.MetricFamily],
grouping_key? : Array[(String, String)],
format~ : @expo.Format = @expo.Format::Text004,
) -> PushRequest {
build("POST", gateway, job, families, grouping_key.unwrap_or([]), format)
}
///|
/// Build a `DELETE` that removes every series under `job` and `grouping_key`.
pub fn delete(
gateway : String,
job : String,
grouping_key? : Array[(String, String)],
) -> PushRequest {
{
http_method: "DELETE",
path: push_path(gateway, job, grouping_key.unwrap_or([])),
headers: [("Content-Type", @expo.Format::Text004.content_type())],
body: "",
}
}