Skip to main content

bge_m3_embedding_server/handler/
common.rs

1// Copyright (c) 2026 J. Patrick Fulton
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Shared input validation, header utilities, and service-readiness helpers
16//! used by all handlers.
17
18use std::collections::BTreeMap;
19use std::fmt;
20use std::sync::atomic::Ordering;
21
22use axum::http::HeaderMap;
23use serde::Serialize;
24
25use crate::error::AppError;
26use crate::state::AppState;
27
28/// A sorted map of `X-*` HTTP request headers.
29///
30/// Keys are lowercase-normalized header names (e.g. `"x-request-id"`).
31/// Values are UTF-8-decoded header values; headers with non-UTF-8 values
32/// are silently skipped.
33///
34/// Serializes as a plain JSON object so it can be embedded as the
35/// `x_headers` field in structured log events.
36#[derive(Default, Serialize)]
37#[serde(transparent)]
38pub(super) struct XHeaders(pub(super) BTreeMap<String, String>);
39
40impl XHeaders {
41    /// Returns `true` when no `X-*` headers were present in the request.
42    pub(super) fn is_empty(&self) -> bool {
43        self.0.is_empty()
44    }
45}
46
47impl fmt::Display for XHeaders {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        // Compact JSON — suitable for both text and JSON log formats.
50        match serde_json::to_string(&self.0) {
51            Ok(s) => f.write_str(&s),
52            Err(_) => f.write_str("{}"),
53        }
54    }
55}
56
57/// Collects all headers whose name starts with `x-` (case-insensitive) into
58/// an [`XHeaders`] map.
59///
60/// Header names are normalized: lowercase (axum guarantees this) and hyphens
61/// replaced with underscores so each key is a valid JSON identifier and can
62/// be referenced by log-processing tools that use JSON path notation
63/// (e.g. `x-example-project` → key `x_example_project`).
64/// Headers with non-UTF-8 values are silently skipped.
65pub(super) fn collect_x_headers(headers: &HeaderMap) -> XHeaders {
66    let mut map = BTreeMap::new();
67    for (name, value) in headers {
68        let name_str = name.as_str();
69        if name_str.starts_with("x-")
70            && let Ok(val) = value.to_str()
71        {
72            map.insert(name_str.replace('-', "_"), val.to_owned());
73        }
74    }
75    XHeaders(map)
76}
77
78/// Maximum characters allowed per individual input string (SEC-3).
79pub(super) const MAX_STRING_CHARS: usize = 32_768;
80
81/// Validates a batch of input texts against size and length constraints.
82///
83/// Returns [`AppError::InvalidRequest`] if:
84/// - `texts` is empty
85/// - `texts.len() > max_batch`
86/// - any individual text exceeds [`MAX_STRING_CHARS`] characters
87pub(super) fn validate_input(texts: &[String], max_batch: usize) -> Result<(), AppError> {
88    if texts.is_empty() {
89        return Err(AppError::InvalidRequest(
90            "input must not be empty".to_string(),
91        ));
92    }
93    if texts.len() > max_batch {
94        return Err(AppError::InvalidRequest(format!(
95            "batch size {} exceeds maximum {}",
96            texts.len(),
97            max_batch
98        )));
99    }
100    for (i, text) in texts.iter().enumerate() {
101        let char_count = text.chars().count();
102        if char_count > MAX_STRING_CHARS {
103            return Err(AppError::InvalidRequest(format!(
104                "input[{i}] length {char_count} exceeds maximum {MAX_STRING_CHARS} characters"
105            )));
106        }
107    }
108    Ok(())
109}
110
111/// Checks whether the service is ready to handle embedding requests.
112pub(super) fn check_ready(state: &AppState) -> Result<(), AppError> {
113    if !state.ready.load(Ordering::Acquire) {
114        return Err(AppError::ServiceUnavailable("model not ready".to_string()));
115    }
116    if state.pool.live_worker_count() == 0 {
117        return Err(AppError::ServiceUnavailable(
118            "no workers available".to_string(),
119        ));
120    }
121    Ok(())
122}