Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 179 additions & 26 deletions src/service_schema/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ const BYTES_BODY_HEADER_OUT_MESSAGE: &str = concat!(
header"
);

const STREAM_BODY_SUCCESS_SHAPE_MESSAGE: &str = concat!(
"service_schema: `body = \"stream\"` requires a success type naming `StreamedAnswer`\n",
" answer `Result<StreamedAnswer, Error>` - the module `#[service_schema]` generates \
beside this trait publishes that type; reach it with a `use`, or write the module-qualified \
path directly"
);

const STREAM_BODY_HEADER_OUT_MESSAGE: &str = concat!(
"service_schema: `body = \"stream\"` declares no `header_out`\n",
" a streamed answer's own `Partial` case already carries `content-range`; composing a \
further declared header with a streamed body is not yet implemented"
);

const MISSING_HTTP_METHOD_MESSAGE: &str = concat!(
"service_schema: `http(...)` declares no `method`\n",
" write `method = \"GET\"` (or `\"POST\"`, `\"PUT\"`, `\"DELETE\"`, `\"PATCH\"`)"
Expand Down Expand Up @@ -210,9 +223,8 @@ pub enum OperationOutcome {
/// reading it off a materialized value here.
#[derive(Debug)]
pub struct HttpBinding {
/// How the body is carried: `Json` (the default), or `Bytes`, declared with `body = "bytes"`
/// and checked against the signature by [`build_http_binding`]. A later task extends this for
/// the streamed kind.
/// How the body is carried: `Json` (the default), `Bytes` (`body = "bytes"`) or `Stream`
/// (`body = "stream"`), each checked against the signature by [`build_http_binding`].
pub body_kind: BodyKind,
/// One entry per declared `error_status(Variant = code)`, in declaration order. Each variant
/// keeps its own span from the attribute, so a misspelling is rustc's own "no variant" error
Expand Down Expand Up @@ -303,19 +315,22 @@ pub struct HeaderIn {
}

/// How `http(...)` carries the body. `Json` is the default a group that writes no `body` gets.
/// `Bytes` is the other kind this version emits; the streamed kind is later work, its grammar slot
/// left for it.
/// `Bytes` answers raw bytes under a declared content type. `Stream` answers a pulled body source,
/// full or (through `StreamedAnswer::Partial`) a `206` range slice with `content-range`, through
/// the seam `#[service_schema]` publishes beside the trait.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BodyKind {
Bytes,
Json,
Stream,
}

impl BodyKind {
fn from_name(written: &str) -> Option<Self> {
match written {
"bytes" => Some(Self::Bytes),
"json" => Some(Self::Json),
"stream" => Some(Self::Stream),
_ => None,
}
}
Expand Down Expand Up @@ -446,6 +461,22 @@ impl OperationDirective {
}
}

/// Whether any operation in `service` declares `body = "stream"` — the body-source seam
/// (`BodySource`, `StreamedAnswer`) is published beside the trait only where at least one
/// operation needs it.
///
/// `pub`: read identically by `support` (which publishes the seam) and by the `http_rest`
/// transport (which reaches for it through `$crate`), so the two cannot disagree about whether a
/// service carries it.
pub fn service_declares_a_stream(service: &ServiceDef) -> bool {
service.operations.iter().any(|operation| {
operation
.http
.as_ref()
.is_some_and(|binding| matches!(binding.body_kind, BodyKind::Stream))
})
}

pub fn scalar_kind(ty: &Type) -> ScalarKind {
let Type::Path(named) = ty else {
return ScalarKind::Text;
Expand Down Expand Up @@ -553,7 +584,7 @@ fn unknown_http_method_message(written: &str) -> String {
fn unknown_body_kind_message(written: &str) -> String {
format!(
"service_schema: `{written}` is not a body kind this version knows\n \
write `\"json\"` (the default) or `\"bytes\"`"
write `\"json\"` (the default), `\"bytes\"` or `\"stream\"`"
)
}

Expand Down Expand Up @@ -1228,6 +1259,98 @@ fn extra_arguments(operation: &TraitItemFn) -> HashMap<String, Type> {
///
/// error: could not compile `tixschema` (test "zz_probe") due to 1 previous error
/// ```
///
/// # A `body = "stream"` declaration whose signature still claims a JSON success type is refused
///
/// `body = "stream"` requires a reply's success type to name `StreamedAnswer`, the seam type
/// `#[service_schema]` publishes beside the trait. The operation below still answers its own JSON
/// type:
///
/// ```rust,compile_fail
/// use tixschema::service_schema;
///
/// #[derive(serde::Deserialize, serde::Serialize)]
/// pub struct ThumbnailResponse {
/// pub url: String,
/// }
///
/// #[derive(serde::Deserialize, serde::Serialize)]
/// pub enum ContentError {
/// NotFound,
/// }
///
/// #[service_schema()]
/// pub trait ContentService<Ctx> {
/// #[service_schema_op(http(
/// method = "GET",
/// path = "/documents/{document_id}/content",
/// body = "stream",
/// error_status(NotFound = 404),
/// ))]
/// async fn get_content(
/// &self,
/// ctx: &Ctx,
/// document_id: String,
/// ) -> Result<ThumbnailResponse, ContentError>;
/// }
///
/// fn main() {}
/// ```
///
/// ```text
/// error: service_schema: `body = "stream"` requires a success type naming `StreamedAnswer`
/// answer `Result<StreamedAnswer, Error>` - the module `#[service_schema]` generates beside this trait publishes that type; reach it with a `use`, or write the module-qualified path directly
/// --> tests/zz_probe.rs:25:17
/// |
/// 25 | ) -> Result<ThumbnailResponse, ContentError>;
/// | ^^^^^^^^^^^^^^^^^
///
/// error: could not compile `tixschema` (test "zz_probe") due to 1 previous error
/// ```
///
/// # A `body = "stream"` declaration combined with `header_out` is refused
///
/// A streamed answer's own `Partial` case already carries `content-range`, and composing a further
/// declared header with a streamed body is not yet implemented - refused rather than silently
/// ignored, naming the entry:
///
/// ```rust,compile_fail
/// use tixschema::service_schema;
///
/// #[derive(serde::Deserialize, serde::Serialize)]
/// pub enum ContentError {
/// NotFound,
/// }
///
/// #[service_schema()]
/// pub trait ContentService<Ctx> {
/// #[service_schema_op(http(
/// method = "GET",
/// path = "/documents/{document_id}/content",
/// body = "stream",
/// header_out("etag"),
/// error_status(NotFound = 404),
/// ))]
/// async fn get_content(
/// &self,
/// ctx: &Ctx,
/// document_id: String,
/// ) -> Result<content_service_schema::StreamedAnswer, ContentError>;
/// }
///
/// fn main() {}
/// ```
///
/// ```text
/// error: service_schema: `body = "stream"` declares no `header_out`
/// a streamed answer's own `Partial` case already carries `content-range`; composing a further declared header with a streamed body is not yet implemented
/// --> tests/zz_probe.rs:14:20
/// |
/// 14 | header_out("etag"),
/// | ^^^^^^
///
/// error: could not compile `tixschema` (test "zz_probe") due to 1 previous error
/// ```
fn build_http_binding(
operation_ident: &Ident,
operation: &TraitItemFn,
Expand Down Expand Up @@ -1413,36 +1536,65 @@ fn is_bytes_success_shape(ty: &Type) -> bool {
}
}

/// Refuses a `body = "bytes"` declaration that cannot hold: combined with `header_out` (the
/// success type's second element already answers as the fixed `content-type` response header, so
/// a separately declared one would be silently ignored rather than honored), or on an operation
/// whose reply does not answer `Result<(Vec<u8>, String), Error>` — the bytes and their content
/// type, which is `body = "bytes"`'s own fixed shape. A one-way operation, having no reply to
/// shape at all, is refused under the same message: there is no success type for the bytes and
/// their content type to be.
/// Whether `ty`'s last path segment is `StreamedAnswer` — the seam type `support` publishes
/// beside the trait, named either bare (`StreamedAnswer`, in scope through `use super::*` inside
/// that module) or module-qualified (`{module}::StreamedAnswer`) from the trait's own signature.
/// Read by its last segment rather than [`is_named_type`]'s single-segment match for exactly that
/// reason: `body = "bytes"`'s own tuple elements are always bare stdlib names, this one is not.
fn is_streamed_answer_type(ty: &Type) -> bool {
let Type::Path(named) = ty else {
return false;
};
named
.path
.segments
.last()
.is_some_and(|segment| segment.ident == "StreamedAnswer")
}

/// Refuses a `body = "bytes"` or `body = "stream"` declaration that cannot hold. Both kinds refuse
/// a `header_out` declared alongside them — bytes because its success type's second element
/// already answers as `content-type`, stream because composing a further declared header with a
/// streamed body is not yet implemented, `content-range` travelling through `StreamedAnswer`'s own
/// `Partial` case instead — and both require their own fixed success shape: `(Vec<u8>, String)`
/// for bytes, a bare or module-qualified `StreamedAnswer` for stream. A one-way operation, having
/// no reply to shape at all, is refused under the same success-shape message either way.
fn body_kind_refusals(raw: &RawHttp, outcome: &OperationOutcome) -> Option<syn::Error> {
let (BodyKind::Bytes, declared) = raw.body.as_ref()? else {
return None;
let (kind, declared) = raw.body.as_ref()?;
let (header_out_message, shape_message, shaped_correctly): (&str, &str, bool) = match kind {
BodyKind::Json => return None,
BodyKind::Bytes => (
BYTES_BODY_HEADER_OUT_MESSAGE,
BYTES_BODY_SUCCESS_SHAPE_MESSAGE,
match outcome {
OperationOutcome::OneWay => false,
OperationOutcome::Reply { success, .. } => is_bytes_success_shape(success),
},
),
BodyKind::Stream => (
STREAM_BODY_HEADER_OUT_MESSAGE,
STREAM_BODY_SUCCESS_SHAPE_MESSAGE,
match outcome {
OperationOutcome::OneWay => false,
OperationOutcome::Reply { success, .. } => is_streamed_answer_type(success),
},
),
};
let mut refusals: Option<syn::Error> = None;
if let Some(first) = raw.header_out.first() {
refusals = Some(combined(
refusals.take(),
syn::Error::new(first.span(), BYTES_BODY_HEADER_OUT_MESSAGE),
syn::Error::new(first.span(), header_out_message),
));
}
let shaped_correctly = match outcome {
OperationOutcome::OneWay => false,
OperationOutcome::Reply { success, .. } => is_bytes_success_shape(success),
};
if !shaped_correctly {
let spanned = match outcome {
OperationOutcome::OneWay => declared.span(),
OperationOutcome::Reply { success, .. } => success.span(),
};
refusals = Some(combined(
refusals.take(),
syn::Error::new(spanned, BYTES_BODY_SUCCESS_SHAPE_MESSAGE),
syn::Error::new(spanned, shape_message),
));
}
refusals
Expand Down Expand Up @@ -1591,11 +1743,12 @@ fn header_out_refusals(
}),
OperationOutcome::Reply { success, .. } => {
// `body = "bytes"` requires its own fixed `(Vec<u8>, String)` tuple, whose second
// element answers as the content type rather than a `header_out` entry —
// `body_kind_refusals` is what checks that combination, with its own message naming
// the requirement, so this check stands down rather than reading the same tuple as an
// unexplained `header_out` arity.
if matches!(raw.body, Some((BodyKind::Bytes, _))) {
// element answers as the content type rather than a `header_out` entry, and
// `body = "stream"` composes no declared header with its body at all yet — both
// combinations are `body_kind_refusals`'s own to check, with their own message naming
// the requirement, so this check stands down rather than reading the same success type
// as an unexplained `header_out` arity.
if matches!(raw.body, Some((BodyKind::Bytes | BodyKind::Stream, _))) {
return None;
}
let tuple_arity = if let Type::Tuple(tuple) = success.as_ref() {
Expand Down
51 changes: 51 additions & 0 deletions src/service_schema/support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@

use super::parse::{
HttpBinding, OperationDef, OperationInputs, OperationOutcome, PathSegment, ServiceDef,
service_declares_a_stream,
};
use super::transport::Transport;
use crate::rename_rule::RenameRule;
Expand Down Expand Up @@ -195,6 +196,11 @@ pub fn emit(service: &ServiceDef, asked: &[Transport]) -> TokenStream {
let readers = violation_readers();
let anchors = root_anchors(service, asked);
let http_completeness = http_error_status_completeness(service);
let stream_seam = if service_declares_a_stream(service) {
stream_seam()
} else {
TokenStream::new()
};
quote! {
#[doc = #module_doc]
pub mod #module {
Expand All @@ -212,6 +218,51 @@ pub fn emit(service: &ServiceDef, asked: &[Transport]) -> TokenStream {
#readers
#anchors
#http_completeness
#stream_seam
}
}
}

/// The body-source seam a `body = "stream"` operation's signature names: `BodySource`, pulled
/// rather than pushed so it composes for free with any `std::io::Read` a handler already has, and
/// `StreamedAnswer`, the fixed two-case answer every streamed operation returns regardless of what
/// it streams — `Full` for a `200` body, `Partial` for a `206` range slice carrying its own
/// `content-range`. Emitted here, eagerly, rather than deferred into the `http_rest` dispatcher's
/// own `macro_rules!` body: the author's own trait signature names `StreamedAnswer` directly, and a
/// deferred macro is not expanded until a transport is placed, possibly in another crate.
///
/// Names no runtime crate — `std::io` alone — so `dispatch` (`http_rest`'s own emitted item, which
/// reaches this seam through `$crate`) answers a stream without naming `tokio`, `bytes` or
/// `futures` either.
fn stream_seam() -> TokenStream {
quote! {
/// A response body source, pulled one chunk at a time by whatever drains it onto the
/// wire. Blanket-implemented for every [`std::io::Read`], so a `File`, a chunked cursor, or
/// any other reader an author already has satisfies this for free.
pub trait BodySource {
/// Pulls the next chunk into `buf`. `Ok(0)` means exhausted, exactly [`std::io::Read::read`]'s
/// own contract.
fn pull(&mut self, buf: &mut [u8]) -> ::std::io::Result<usize>;
}

impl<R: ::std::io::Read> BodySource for R {
fn pull(&mut self, buf: &mut [u8]) -> ::std::io::Result<usize> {
self.read(buf)
}
}

/// What a `body = "stream"` operation answers with. Every such operation returns this same
/// type regardless of what it streams: `Full` answers the declared `ok_status` with the
/// whole body; `Partial` answers `206` with `content-range` set to the given string
/// (`bytes {start}-{end}/{total}`, RFC 9110 §14.4) and the body limited to that slice.
pub enum StreamedAnswer {
/// The whole body.
Full(::std::boxed::Box<dyn BodySource + Send>),
/// A byte-range slice, with the `content-range` header it answers under.
Partial {
source: ::std::boxed::Box<dyn BodySource + Send>,
content_range: ::std::string::String,
},
}
}
}
Expand Down
Loading
Loading