use crate::error::LingerError;
use crate::transport::{BodyStream, HttpRequest};
use crate::RequestId;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Clone, Debug, Serialize, PartialEq)]
#[non_exhaustive]
pub struct CreateSpeechRequest {
pub model: String,
pub input: String,
pub voice: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speed: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_format: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
impl CreateSpeechRequest {
pub fn builder() -> CreateSpeechRequestBuilder {
CreateSpeechRequestBuilder::default()
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct CreateSpeechRequestBuilder {
model: Option<String>,
input: Option<String>,
voice: Option<String>,
response_format: Option<String>,
speed: Option<f32>,
instructions: Option<String>,
stream_format: Option<String>,
extra: BTreeMap<String, Value>,
}
impl CreateSpeechRequestBuilder {
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn input(mut self, input: impl Into<String>) -> Self {
self.input = Some(input.into());
self
}
pub fn voice(mut self, voice: impl Into<String>) -> Self {
self.voice = Some(voice.into());
self
}
pub fn response_format(mut self, response_format: impl Into<String>) -> Self {
self.response_format = Some(response_format.into());
self
}
pub fn speed(mut self, speed: f32) -> Self {
self.speed = Some(speed);
self
}
pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
self.instructions = Some(instructions.into());
self
}
pub fn stream_format(mut self, stream_format: impl Into<String>) -> Self {
self.stream_format = Some(stream_format.into());
self
}
pub fn extra(mut self, name: impl Into<String>, value: Value) -> Self {
self.extra.insert(name.into(), value);
self
}
pub fn build(self) -> Result<CreateSpeechRequest, LingerError> {
let model = required_string("model", self.model)?;
let input = required_string("input", self.input)?;
let voice = required_string("voice", self.voice)?;
validate_max_chars("input", &input, 4096)?;
validate_optional_string("response_format", self.response_format.as_deref())?;
validate_optional_string("instructions", self.instructions.as_deref())?;
if let Some(instructions) = self.instructions.as_deref() {
validate_max_chars("instructions", instructions, 4096)?;
}
validate_optional_string("stream_format", self.stream_format.as_deref())?;
if self
.speed
.is_some_and(|speed| !(0.25..=4.0).contains(&speed))
{
return Err(LingerError::invalid_config(
"speed must be between 0.25 and 4.0",
));
}
Ok(CreateSpeechRequest {
model,
input,
voice,
response_format: self.response_format,
speed: self.speed,
instructions: self.instructions,
stream_format: self.stream_format,
extra: self.extra,
})
}
}
pub struct AudioSpeechResponse {
request_id: Option<RequestId>,
content_type: Option<String>,
body: BodyStream,
}
impl AudioSpeechResponse {
pub(crate) fn new(
request_id: Option<RequestId>,
content_type: Option<String>,
body: BodyStream,
) -> Self {
Self {
request_id,
content_type,
body,
}
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
pub fn content_type(&self) -> Option<&str> {
self.content_type.as_deref()
}
pub fn into_stream(self) -> BodyStream {
self.body
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct AudioUpload {
pub filename: String,
pub content_type: String,
content: Bytes,
}
impl AudioUpload {
pub fn from_bytes(
filename: impl Into<String>,
content: impl Into<Bytes>,
) -> Result<Self, LingerError> {
let filename = filename.into();
validate_header_param("filename", &filename)?;
Ok(Self {
filename,
content_type: "application/octet-stream".to_string(),
content: content.into(),
})
}
pub fn content_type(mut self, content_type: impl Into<String>) -> Result<Self, LingerError> {
let content_type = content_type.into();
validate_header_value("content_type", &content_type)?;
self.content_type = content_type;
Ok(self)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct CreateVoiceConsentRequest {
pub name: String,
pub language: String,
pub recording: AudioUpload,
}
impl CreateVoiceConsentRequest {
pub fn builder() -> CreateVoiceConsentRequestBuilder {
CreateVoiceConsentRequestBuilder::default()
}
pub(crate) fn apply_multipart_body(&self, request: &mut HttpRequest) {
let fields = vec![
("name".to_string(), self.name.clone()),
("language".to_string(), self.language.clone()),
];
apply_audio_multipart(request, "recording", &self.recording, fields);
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct CreateVoiceConsentRequestBuilder {
name: Option<String>,
language: Option<String>,
recording: Option<AudioUpload>,
}
impl CreateVoiceConsentRequestBuilder {
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn language(mut self, language: impl Into<String>) -> Self {
self.language = Some(language.into());
self
}
pub fn recording(mut self, recording: AudioUpload) -> Self {
self.recording = Some(recording);
self
}
pub fn build(self) -> Result<CreateVoiceConsentRequest, LingerError> {
let name = required_string("name", self.name)?;
let language = required_string("language", self.language)?;
let recording = self
.recording
.ok_or_else(|| LingerError::invalid_config("recording is required"))?;
Ok(CreateVoiceConsentRequest {
name,
language,
recording,
})
}
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct UpdateVoiceConsentRequest {
pub name: String,
}
impl UpdateVoiceConsentRequest {
pub fn builder() -> UpdateVoiceConsentRequestBuilder {
UpdateVoiceConsentRequestBuilder::default()
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct UpdateVoiceConsentRequestBuilder {
name: Option<String>,
}
impl UpdateVoiceConsentRequestBuilder {
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn build(self) -> Result<UpdateVoiceConsentRequest, LingerError> {
let name = required_string("name", self.name)?;
Ok(UpdateVoiceConsentRequest { name })
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct AudioVoiceConsentListRequest {
pub limit: Option<u8>,
pub after: Option<String>,
}
impl AudioVoiceConsentListRequest {
pub fn builder() -> AudioVoiceConsentListRequestBuilder {
AudioVoiceConsentListRequestBuilder::default()
}
pub(crate) fn path(&self) -> String {
path_with_query(
"/v1/audio/voice_consents",
AudioListQuery {
limit: self.limit,
after: self.after.as_deref(),
},
)
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct AudioVoiceConsentListRequestBuilder {
limit: Option<u8>,
after: Option<String>,
}
impl AudioVoiceConsentListRequestBuilder {
pub fn limit(mut self, limit: u8) -> Self {
self.limit = Some(limit);
self
}
pub fn after(mut self, after: impl Into<String>) -> Self {
self.after = Some(after.into());
self
}
pub fn build(self) -> Result<AudioVoiceConsentListRequest, LingerError> {
if let Some(limit) = self.limit {
if limit == 0 || limit > 100 {
return Err(LingerError::invalid_config(
"limit must be between 1 and 100",
));
}
}
if let Some(after) = &self.after {
if after.trim().is_empty() {
return Err(LingerError::invalid_config("after must not be empty"));
}
}
Ok(AudioVoiceConsentListRequest {
limit: self.limit,
after: self.after,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct CreateVoiceRequest {
pub name: String,
pub consent: String,
pub audio_sample: AudioUpload,
}
impl CreateVoiceRequest {
pub fn builder() -> CreateVoiceRequestBuilder {
CreateVoiceRequestBuilder::default()
}
pub(crate) fn apply_multipart_body(&self, request: &mut HttpRequest) {
let fields = vec![
("name".to_string(), self.name.clone()),
("consent".to_string(), self.consent.clone()),
];
apply_audio_multipart(request, "audio_sample", &self.audio_sample, fields);
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct CreateVoiceRequestBuilder {
name: Option<String>,
consent: Option<String>,
audio_sample: Option<AudioUpload>,
}
impl CreateVoiceRequestBuilder {
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn consent(mut self, consent: impl Into<String>) -> Self {
self.consent = Some(consent.into());
self
}
pub fn audio_sample(mut self, audio_sample: AudioUpload) -> Self {
self.audio_sample = Some(audio_sample);
self
}
pub fn build(self) -> Result<CreateVoiceRequest, LingerError> {
let name = required_string("name", self.name)?;
let consent = required_string("consent", self.consent)?;
let audio_sample = self
.audio_sample
.ok_or_else(|| LingerError::invalid_config("audio_sample is required"))?;
Ok(CreateVoiceRequest {
name,
consent,
audio_sample,
})
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct CreateTranscriptionRequest {
pub file: AudioUpload,
pub model: String,
pub language: Option<String>,
pub prompt: Option<String>,
pub response_format: Option<String>,
pub temperature: Option<f32>,
pub timestamp_granularities: Vec<String>,
pub chunking_strategy: Option<String>,
pub known_speaker_names: Vec<String>,
pub known_speaker_references: Vec<String>,
include_logprobs: bool,
}
impl CreateTranscriptionRequest {
pub fn builder() -> CreateTranscriptionRequestBuilder {
CreateTranscriptionRequestBuilder::default()
}
pub(crate) fn apply_multipart_body(&self, request: &mut HttpRequest) {
let mut fields = Vec::new();
fields.push(("model".to_string(), self.model.clone()));
push_optional_field(&mut fields, "language", self.language.as_deref());
push_optional_field(&mut fields, "prompt", self.prompt.as_deref());
push_optional_field(
&mut fields,
"response_format",
self.response_format.as_deref(),
);
if let Some(temperature) = self.temperature {
fields.push(("temperature".to_string(), temperature.to_string()));
}
for granularity in &self.timestamp_granularities {
fields.push(("timestamp_granularities[]".to_string(), granularity.clone()));
}
push_optional_field(
&mut fields,
"chunking_strategy",
self.chunking_strategy.as_deref(),
);
for name in &self.known_speaker_names {
fields.push(("known_speaker_names[]".to_string(), name.clone()));
}
for reference in &self.known_speaker_references {
fields.push(("known_speaker_references[]".to_string(), reference.clone()));
}
if self.include_logprobs {
fields.push(("include[]".to_string(), "logprobs".to_string()));
}
apply_audio_multipart(request, "file", &self.file, fields);
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct CreateTranscriptionRequestBuilder {
file: Option<AudioUpload>,
model: Option<String>,
language: Option<String>,
prompt: Option<String>,
response_format: Option<String>,
temperature: Option<f32>,
timestamp_granularities: Vec<String>,
chunking_strategy: Option<String>,
known_speaker_names: Vec<String>,
known_speaker_references: Vec<String>,
include_logprobs: bool,
}
impl CreateTranscriptionRequestBuilder {
pub fn file(mut self, file: AudioUpload) -> Self {
self.file = Some(file);
self
}
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn language(mut self, language: impl Into<String>) -> Self {
self.language = Some(language.into());
self
}
pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
self.prompt = Some(prompt.into());
self
}
pub fn response_format(mut self, response_format: impl Into<String>) -> Self {
self.response_format = Some(response_format.into());
self
}
pub fn temperature(mut self, temperature: f32) -> Self {
self.temperature = Some(temperature);
self
}
pub fn timestamp_granularity(mut self, granularity: impl Into<String>) -> Self {
self.timestamp_granularities.push(granularity.into());
self
}
pub fn timestamp_granularities(
mut self,
granularities: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.timestamp_granularities = granularities.into_iter().map(Into::into).collect();
self
}
pub fn chunking_strategy_auto(mut self) -> Self {
self.chunking_strategy = Some("auto".to_string());
self
}
pub fn known_speaker_name(mut self, name: impl Into<String>) -> Self {
self.known_speaker_names.push(name.into());
self
}
pub fn known_speaker_names(
mut self,
names: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.known_speaker_names = names.into_iter().map(Into::into).collect();
self
}
pub fn known_speaker_reference(mut self, reference: impl Into<String>) -> Self {
self.known_speaker_references.push(reference.into());
self
}
pub fn known_speaker_references(
mut self,
references: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.known_speaker_references = references.into_iter().map(Into::into).collect();
self
}
pub fn include_logprobs(mut self) -> Self {
self.include_logprobs = true;
self
}
pub fn build(self) -> Result<CreateTranscriptionRequest, LingerError> {
let file = self
.file
.ok_or_else(|| LingerError::invalid_config("file is required"))?;
let model = required_string("model", self.model)?;
validate_optional_string("language", self.language.as_deref())?;
validate_optional_string("prompt", self.prompt.as_deref())?;
validate_optional_string("response_format", self.response_format.as_deref())?;
validate_optional_string("chunking_strategy", self.chunking_strategy.as_deref())?;
validate_string_items("timestamp_granularities", &self.timestamp_granularities)?;
validate_limited_string_items("known_speaker_names", &self.known_speaker_names, 4)?;
validate_limited_string_items(
"known_speaker_references",
&self.known_speaker_references,
4,
)?;
Ok(CreateTranscriptionRequest {
file,
model,
language: self.language,
prompt: self.prompt,
response_format: self.response_format,
temperature: self.temperature,
timestamp_granularities: self.timestamp_granularities,
chunking_strategy: self.chunking_strategy,
known_speaker_names: self.known_speaker_names,
known_speaker_references: self.known_speaker_references,
include_logprobs: self.include_logprobs,
})
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct CreateTranslationRequest {
pub file: AudioUpload,
pub model: String,
pub prompt: Option<String>,
pub response_format: Option<String>,
pub temperature: Option<f32>,
}
impl CreateTranslationRequest {
pub fn builder() -> CreateTranslationRequestBuilder {
CreateTranslationRequestBuilder::default()
}
pub(crate) fn apply_multipart_body(&self, request: &mut HttpRequest) {
let mut fields = Vec::new();
fields.push(("model".to_string(), self.model.clone()));
push_optional_field(&mut fields, "prompt", self.prompt.as_deref());
push_optional_field(
&mut fields,
"response_format",
self.response_format.as_deref(),
);
if let Some(temperature) = self.temperature {
fields.push(("temperature".to_string(), temperature.to_string()));
}
apply_audio_multipart(request, "file", &self.file, fields);
}
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct CreateTranslationRequestBuilder {
file: Option<AudioUpload>,
model: Option<String>,
prompt: Option<String>,
response_format: Option<String>,
temperature: Option<f32>,
}
impl CreateTranslationRequestBuilder {
pub fn file(mut self, file: AudioUpload) -> Self {
self.file = Some(file);
self
}
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
self.prompt = Some(prompt.into());
self
}
pub fn response_format(mut self, response_format: impl Into<String>) -> Self {
self.response_format = Some(response_format.into());
self
}
pub fn temperature(mut self, temperature: f32) -> Self {
self.temperature = Some(temperature);
self
}
pub fn build(self) -> Result<CreateTranslationRequest, LingerError> {
let file = self
.file
.ok_or_else(|| LingerError::invalid_config("file is required"))?;
let model = required_string("model", self.model)?;
validate_optional_string("prompt", self.prompt.as_deref())?;
validate_optional_string("response_format", self.response_format.as_deref())?;
Ok(CreateTranslationRequest {
file,
model,
prompt: self.prompt,
response_format: self.response_format,
temperature: self.temperature,
})
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[non_exhaustive]
pub struct AudioTranscription {
pub text: String,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl AudioTranscription {
pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
self.request_id = request_id;
self
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[non_exhaustive]
pub struct AudioTranslation {
pub text: String,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl AudioTranslation {
pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
self.request_id = request_id;
self
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct AudioVoiceConsent {
pub object: String,
pub id: String,
pub name: String,
pub language: String,
pub created_at: u64,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl AudioVoiceConsent {
pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
self.request_id = request_id;
self
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct AudioVoiceConsentPage {
pub object: String,
#[serde(default)]
pub data: Vec<AudioVoiceConsent>,
#[serde(default)]
pub first_id: Option<String>,
#[serde(default)]
pub last_id: Option<String>,
pub has_more: bool,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl AudioVoiceConsentPage {
pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
self.request_id = request_id;
self
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct AudioVoiceConsentDeletion {
pub id: String,
pub object: String,
pub deleted: bool,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl AudioVoiceConsentDeletion {
pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
self.request_id = request_id;
self
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct AudioVoice {
pub object: String,
pub id: String,
pub name: String,
pub created_at: u64,
#[serde(skip)]
request_id: Option<RequestId>,
}
impl AudioVoice {
pub(crate) fn with_request_id(mut self, request_id: Option<RequestId>) -> Self {
self.request_id = request_id;
self
}
pub fn request_id(&self) -> Option<&RequestId> {
self.request_id.as_ref()
}
}
fn apply_audio_multipart(
request: &mut HttpRequest,
file_field_name: &str,
file: &AudioUpload,
fields: Vec<(String, String)>,
) {
let boundary = multipart_boundary(&file.content);
request.insert_header(
"content-type",
format!("multipart/form-data; boundary={boundary}"),
);
let mut chunks = Vec::new();
for (name, value) in fields {
chunks.push(Ok(Bytes::from(format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n"
))));
}
chunks.push(Ok(Bytes::from(format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n",
escape_multipart_param(file_field_name),
escape_multipart_param(&file.filename),
file.content_type
))));
chunks.push(Ok(file.content.clone()));
chunks.push(Ok(Bytes::from(format!("\r\n--{boundary}--\r\n"))));
request.set_body_stream(futures_util::stream::iter(chunks));
}
fn push_optional_field(fields: &mut Vec<(String, String)>, name: &str, value: Option<&str>) {
if let Some(value) = value {
fields.push((name.to_string(), value.to_string()));
}
}
fn multipart_boundary(content: &Bytes) -> String {
for counter in 0.. {
let boundary = format!("linger-openai-sdk-audio-boundary-{counter}");
if !contains_bytes(content, boundary.as_bytes()) {
return boundary;
}
}
unreachable!("unbounded boundary counter")
}
fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() {
return true;
}
haystack
.windows(needle.len())
.any(|window| window == needle)
}
fn required_string(name: &str, value: Option<String>) -> Result<String, LingerError> {
value
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| LingerError::invalid_config(format!("{name} is required")))
}
fn validate_optional_string(name: &str, value: Option<&str>) -> Result<(), LingerError> {
if value.is_some_and(|value| value.trim().is_empty()) {
return Err(LingerError::invalid_config(format!(
"{name} must not be empty"
)));
}
Ok(())
}
fn validate_max_chars(name: &str, value: &str, max_chars: usize) -> Result<(), LingerError> {
if value.chars().count() > max_chars {
return Err(LingerError::invalid_config(format!(
"{name} must be at most {max_chars} characters"
)));
}
Ok(())
}
fn validate_string_items(name: &str, values: &[String]) -> Result<(), LingerError> {
if values.iter().any(|value| value.trim().is_empty()) {
return Err(LingerError::invalid_config(format!(
"{name} must not contain empty values"
)));
}
Ok(())
}
fn validate_limited_string_items(
name: &str,
values: &[String],
max: usize,
) -> Result<(), LingerError> {
validate_string_items(name, values)?;
if values.len() > max {
return Err(LingerError::invalid_config(format!(
"{name} must contain at most {max} values"
)));
}
Ok(())
}
fn validate_header_param(name: &str, value: &str) -> Result<(), LingerError> {
if value.trim().is_empty() {
return Err(LingerError::invalid_config(format!("{name} is required")));
}
validate_header_value(name, value)
}
fn validate_header_value(name: &str, value: &str) -> Result<(), LingerError> {
if value.contains('\r') || value.contains('\n') {
return Err(LingerError::invalid_config(format!(
"{name} must not contain CR or LF"
)));
}
Ok(())
}
fn escape_multipart_param(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
struct AudioListQuery<'a> {
limit: Option<u8>,
after: Option<&'a str>,
}
fn path_with_query(base: &str, params: AudioListQuery<'_>) -> String {
let mut query = Vec::new();
if let Some(limit) = params.limit {
query.push(format!("limit={limit}"));
}
if let Some(after) = params.after {
query.push(format!("after={}", encode_query_value(after)));
}
if query.is_empty() {
base.to_string()
} else {
format!("{base}?{}", query.join("&"))
}
}
fn encode_query_value(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(byte as char);
}
_ => {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
encoded.push('%');
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 0x0F) as usize] as char);
}
}
}
encoded
}