Govern wire schemas from TypeSpec

This commit is contained in:
ms2k 2026-07-19 02:00:47 +09:00
commit 2020dd4ed3
3 changed files with 10189 additions and 1571 deletions

View file

@ -0,0 +1,448 @@
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Rfc3339DateTime(time::OffsetDateTime);
impl serde::Serialize for Rfc3339DateTime {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let value = self
.0
.format(&time::format_description::well_known::Rfc3339)
.map_err(serde::ser::Error::custom)?;
serializer.serialize_str(&value)
}
}
impl<'de> serde::Deserialize<'de> for Rfc3339DateTime {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = <String as serde::Deserialize>::deserialize(deserializer)?;
time::OffsetDateTime::parse(&value, &time::format_description::well_known::Rfc3339)
.map(Self)
.map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
#[serde(transparent)]
pub struct ConstU64<const VALUE: u64>(u64);
impl<'de, const VALUE: u64> serde::Deserialize<'de> for ConstU64<VALUE> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = <u64 as serde::Deserialize>::deserialize(deserializer)?;
if value == VALUE {
Ok(Self(value))
} else {
Err(serde::de::Error::custom("incorrect integer literal"))
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
#[serde(transparent)]
pub struct ConstBool<const VALUE: bool>(bool);
impl<'de, const VALUE: bool> serde::Deserialize<'de> for ConstBool<VALUE> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = <bool as serde::Deserialize>::deserialize(deserializer)?;
if value == VALUE {
Ok(Self(value))
} else {
Err(serde::de::Error::custom("incorrect boolean literal"))
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
#[serde(transparent)]
pub struct BoundedU64<const MIN: u64, const MAX: u64>(u64);
impl<'de, const MIN: u64, const MAX: u64> serde::Deserialize<'de> for BoundedU64<MIN, MAX> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = <u64 as serde::Deserialize>::deserialize(deserializer)?;
if (MIN..=MAX).contains(&value) {
Ok(Self(value))
} else {
Err(serde::de::Error::custom("integer outside bounds"))
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
#[serde(transparent)]
pub struct BoundedString<const MIN: usize, const MAX: usize>(String);
impl<'de, const MIN: usize, const MAX: usize> serde::Deserialize<'de> for BoundedString<MIN, MAX> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = <String as serde::Deserialize>::deserialize(deserializer)?;
if (MIN..=MAX).contains(&value.chars().count()) {
Ok(Self(value))
} else {
Err(serde::de::Error::custom("string length outside bounds"))
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
#[serde(transparent)]
pub struct OpaqueId(String);
impl<'de> serde::Deserialize<'de> for OpaqueId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = <String as serde::Deserialize>::deserialize(deserializer)?;
let valid = (8..=128).contains(&value.len())
&& value
.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphanumeric)
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-');
if valid {
Ok(Self(value))
} else {
Err(serde::de::Error::custom("invalid opaque ID"))
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
#[serde(transparent)]
pub struct BucketName(String);
impl<'de> serde::Deserialize<'de> for BucketName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = <String as serde::Deserialize>::deserialize(deserializer)?;
let pattern = fancy_regex::Regex::new("^(?!.*\\.\\.)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$")
.map_err(serde::de::Error::custom)?;
match pattern.is_match(&value).map_err(serde::de::Error::custom)? {
true => Ok(Self(value)),
false => Err(serde::de::Error::custom(
"string does not match required pattern",
)),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
#[serde(transparent)]
pub struct HuggingFaceRepositoryId(String);
impl<'de> serde::Deserialize<'de> for HuggingFaceRepositoryId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = <String as serde::Deserialize>::deserialize(deserializer)?;
let pattern = fancy_regex::Regex::new("^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
.map_err(serde::de::Error::custom)?;
match pattern.is_match(&value).map_err(serde::de::Error::custom)? {
true => Ok(Self(value)),
false => Err(serde::de::Error::custom(
"string does not match required pattern",
)),
}
}
}
pub type CredentialId = OpaqueId;
pub type ResultPath = BoundedString<1, 4096>;
pub type EventCursor = BoundedString<16, 2048>;
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum JobPhase {
#[serde(rename = "Queued")]
Queued,
#[serde(rename = "Running")]
Running,
#[serde(rename = "RetryWait")]
RetryWait,
#[serde(rename = "Cancelling")]
Cancelling,
#[serde(rename = "Succeeded")]
Succeeded,
#[serde(rename = "Failed")]
Failed,
#[serde(rename = "Cancelled")]
Cancelled,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum JobLifecycle {
#[serde(rename = "visible")]
Visible,
#[serde(rename = "soft_deleted")]
SoftDeleted,
#[serde(rename = "hard_deleting")]
HardDeleting,
#[serde(rename = "hard_deleted")]
HardDeleted,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct TargetOverride {
pub bucket: Option<BucketName>,
pub prefix: Option<BoundedString<0, 1024>>,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct HttpSourceSubmission {
pub url: url::Url,
pub result_path: ResultPath,
pub credential_id: Option<CredentialId>,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ObjectStoreSourceSubmission {
pub store: BoundedString<1, 128>,
pub bucket: BoundedString<3, 63>,
pub key: BoundedString<1, 4096>,
pub version_id: Option<BoundedString<0, 1024>>,
pub result_path: ResultPath,
pub credential_id: Option<CredentialId>,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct HuggingFaceSelection {
pub paths: Option<Vec<ResultPath>>,
pub prefixes: Option<Vec<ResultPath>>,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum HuggingFaceSourceSubmissionRepositoryType {
#[serde(rename = "model")]
Model,
#[serde(rename = "dataset")]
Dataset,
#[serde(rename = "space")]
Space,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct HuggingFaceSourceSubmission {
pub repository_type: HuggingFaceSourceSubmissionRepositoryType,
pub repository_id: HuggingFaceRepositoryId,
pub revision: BoundedString<1, 256>,
pub selection: Option<HuggingFaceSelection>,
pub credential_id: Option<CredentialId>,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct JobSubmission {
pub source: SourceSubmission,
pub target: Option<TargetOverride>,
pub label: Option<BoundedString<1, 200>>,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum PermanentJobFailureClass {
#[serde(rename = "validation")]
Validation,
#[serde(rename = "authorization")]
Authorization,
#[serde(rename = "source_not_found")]
SourceNotFound,
#[serde(rename = "source_changed")]
SourceChanged,
#[serde(rename = "cancelled")]
Cancelled,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct PermanentJobFailure {
pub class: PermanentJobFailureClass,
pub retryable: ConstBool<false>,
pub message: BoundedString<0, 500>,
pub safe_details: Option<std::collections::BTreeMap<String, SafeDetailValue>>,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum RetryableJobFailureClass {
#[serde(rename = "transient_source")]
TransientSource,
#[serde(rename = "transient_target")]
TransientTarget,
#[serde(rename = "integrity")]
Integrity,
#[serde(rename = "capacity")]
Capacity,
#[serde(rename = "internal")]
Internal,
#[serde(rename = "lost_attempt")]
LostAttempt,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct RetryableJobFailure {
pub class: RetryableJobFailureClass,
pub retryable: ConstBool<true>,
pub message: BoundedString<0, 500>,
pub safe_details: Option<std::collections::BTreeMap<String, SafeDetailValue>>,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct EventAttempt {
pub attempt_id: OpaqueId,
pub fence: BoundedU64<1, 9007199254740991>,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct EventProgress {
pub attempt_id: OpaqueId,
pub fence: BoundedU64<1, 9007199254740991>,
pub completed_bytes: BoundedU64<0, 9007199254740991>,
pub total_bytes: Option<Option<i64>>,
pub completed_members: BoundedU64<0, 9007199254740991>,
pub total_members: Option<Option<i64>>,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct JobChanged {
pub job_id: OpaqueId,
pub job_revision: BoundedU64<1, 9007199254740991>,
pub phase: JobPhase,
pub lifecycle: JobLifecycle,
pub authoritative_attempt: Option<EventAttempt>,
pub progress: Option<EventProgress>,
pub schema_version: ConstU64<1>,
pub event_id: OpaqueId,
pub sequence: BoundedU64<1, 9007199254740991>,
pub cursor: EventCursor,
pub occurred_at: Rfc3339DateTime,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum DeletionChangedState {
#[serde(rename = "queued")]
Queued,
#[serde(rename = "running")]
Running,
#[serde(rename = "retry_wait")]
RetryWait,
#[serde(rename = "blocked")]
Blocked,
#[serde(rename = "completed")]
Completed,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct DeletionChanged {
pub job_id: OpaqueId,
pub operation_id: OpaqueId,
pub state: DeletionChangedState,
pub schema_version: ConstU64<1>,
pub event_id: OpaqueId,
pub sequence: BoundedU64<1, 9007199254740991>,
pub cursor: EventCursor,
pub occurred_at: Rfc3339DateTime,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum ResyncRequiredReason {
#[serde(rename = "cursor_absent")]
CursorAbsent,
#[serde(rename = "cursor_invalid")]
CursorInvalid,
#[serde(rename = "cursor_expired")]
CursorExpired,
#[serde(rename = "authorization_changed")]
AuthorizationChanged,
#[serde(rename = "history_unavailable")]
HistoryUnavailable,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ResyncRequired {
pub reason: ResyncRequiredReason,
pub schema_version: ConstU64<1>,
pub event_id: OpaqueId,
pub sequence: BoundedU64<1, 9007199254740991>,
pub cursor: EventCursor,
pub occurred_at: Rfc3339DateTime,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct Heartbeat {
pub schema_version: ConstU64<1>,
pub event_id: OpaqueId,
pub sequence: BoundedU64<1, 9007199254740991>,
pub cursor: EventCursor,
pub occurred_at: Rfc3339DateTime,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(tag = "kind")]
pub enum SourceSubmission {
#[serde(rename = "http")]
Http(HttpSourceSubmission),
#[serde(rename = "object_store")]
ObjectStore(ObjectStoreSourceSubmission),
#[serde(rename = "hugging_face")]
HuggingFace(HuggingFaceSourceSubmission),
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(untagged)]
pub enum SafeDetailValue {
Text(String),
Integer(i64),
Boolean(bool),
SymbolName(()),
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(untagged)]
pub enum JobFailure {
Permanent(PermanentJobFailure),
Retryable(RetryableJobFailure),
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(tag = "kind")]
pub enum EventFrame {
#[serde(rename = "job_changed")]
JobChanged(JobChanged),
#[serde(rename = "deletion_changed")]
DeletionChanged(DeletionChanged),
#[serde(rename = "resync_required")]
ResyncRequired(ResyncRequired),
#[serde(rename = "heartbeat")]
Heartbeat(Heartbeat),
}

View file

@ -1,8 +1,10 @@
mod credentials;
mod dto;
mod events;
pub mod generated_wire;
mod internal;
mod public;
mod validation;
mod wire;
pub use credentials::{
@ -46,6 +48,7 @@ pub use public::{
ResultMemberAccessDto, ResultMemberDto, ResultMemberPageDto, SourceProjectionDto,
StagingTargetDto, TerminalJobPhaseDto, TombstoneKindDto,
};
pub use validation::request_is_valid;
pub use wire::{
CursorDto, DateTimeDto, NullableDto, PositiveIntegerDto, SafeIntegerDto, SchemaVersionDto,
SignedSafeIntegerDto,

View file

@ -0,0 +1,61 @@
use std::{collections::HashMap, sync::LazyLock};
use jsonschema::Validator;
use serde::Serialize;
use serde_json::{Value, json};
const REQUEST_SCHEMAS: &[&str] = &[
"JobSubmission",
"SourceCredentialCreate",
"SourceCredentialRotate",
"ClaimRequest",
"AttemptRenewRequest",
"AttemptActivateRequest",
"AttemptStopRequest",
"AttemptStagingObjectRequest",
"AttemptStagingWriteIntentRequest",
"AttemptStagingMultipartRequest",
"AttemptResolvedSourceRequest",
"AttemptCheckpointRequest",
"AttemptProgressRequest",
"AttemptOutcomeRequest",
];
static REQUEST_VALIDATORS: LazyLock<HashMap<&'static str, Validator>> = LazyLock::new(|| {
let bundle: Value =
serde_json::from_str(include_str!("../../../docs/api/events.schema.json")).unwrap();
REQUEST_SCHEMAS
.iter()
.map(|name| {
let mut schema = bundle.clone();
schema
.as_object_mut()
.unwrap()
.insert("$ref".into(), json!(format!("#/$defs/{name}")));
let validator = jsonschema::draft202012::options()
.should_validate_formats(true)
.build(&schema)
.unwrap();
(*name, validator)
})
.collect()
});
pub fn request_is_valid(name: &str, value: &impl Serialize) -> bool {
let Some(validator) = REQUEST_VALIDATORS.get(name) else {
return false;
};
serde_json::to_value(value)
.ok()
.is_some_and(|value| validator.is_valid(&value))
}
#[cfg(test)]
mod tests {
use super::request_is_valid;
#[test]
fn rejects_unknown_schema_names() {
assert!(!request_is_valid("Unknown", &serde_json::json!({})));
}
}