diff --git a/examples/actix-app/src/main.rs b/examples/actix-app/src/main.rs index e6ea9b8b..9b83c3d8 100644 --- a/examples/actix-app/src/main.rs +++ b/examples/actix-app/src/main.rs @@ -1,3 +1,8 @@ +#![allow(async_fn_in_trait)] +#![allow(stable_features)] +#![feature(async_fn_in_trait)] +#![feature(lazy_cell)] + mod controller; mod domain; mod extension; diff --git a/examples/actix-app/src/model/mod.rs b/examples/actix-app/src/model/mod.rs index 8b137891..bf40c55c 100644 --- a/examples/actix-app/src/model/mod.rs +++ b/examples/actix-app/src/model/mod.rs @@ -1 +1,3 @@ +pub(crate) mod tag; +pub(crate) use tag::Tag; diff --git a/examples/actix-app/src/model/tag.rs b/examples/actix-app/src/model/tag.rs new file mode 100644 index 00000000..c271a27d --- /dev/null +++ b/examples/actix-app/src/model/tag.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; +use zino_core::{ + datetime::DateTime, extension::JsonObjectExt, model::Model, request::Validation, Map, Uuid, +}; +use zino_derive::{ModelAccessor, ModelHooks, Schema}; + +/// The `tag` model. +#[derive(Debug, Clone, Default, Serialize, Deserialize, Schema, ModelAccessor, ModelHooks)] +#[serde(rename_all = "snake_case")] +#[serde(default)] +pub struct Tag { + // Basic fields. + #[schema(readonly)] + id: Uuid, + #[schema(not_null, index_type = "text")] + name: String, + #[cfg(feature = "namespace")] + #[schema(default_value = "Tag::model_namespace", index_type = "hash")] + namespace: String, + #[schema(default_value = "Active", index_type = "hash")] + status: String, + #[schema(index_type = "text")] + description: String, + + // Info fields. + #[schema(not_null)] + category: String, + #[schema(reference = "Tag")] + parent_id: Option, // tag.id, tag.namespace = {tag.namespace}, tag.category = {tag.category} + + // Extensions. + content: Map, + extra: Map, + + // Revisions. + #[schema(readonly, default_value = "now", index_type = "btree")] + created_at: DateTime, + #[schema(default_value = "now", index_type = "btree")] + updated_at: DateTime, + version: u64, +} + +impl Model for Tag { + #[inline] + fn new() -> Self { + Self { + id: Uuid::new_v4(), + ..Self::default() + } + } + + fn read_map(&mut self, data: &Map) -> Validation { + let mut validation = Validation::new(); + if let Some(result) = data.parse_uuid("id") { + match result { + Ok(id) => self.id = id, + Err(err) => validation.record_fail("id", err), + } + } + if let Some(name) = data.parse_string("name") { + self.name = name.into_owned(); + } + if let Some(description) = data.parse_string("description") { + self.description = description.into_owned(); + } + if let Some(category) = data.parse_string("category") { + self.category = category.into_owned(); + } + if let Some(result) = data.parse_uuid("parent_id") { + match result { + Ok(parent_id) => self.parent_id = Some(parent_id), + Err(err) => validation.record_fail("parent_id", err), + } + } + validation + } +} diff --git a/examples/actix-app/src/router/mod.rs b/examples/actix-app/src/router/mod.rs index 0941fa3a..ee09d78c 100644 --- a/examples/actix-app/src/router/mod.rs +++ b/examples/actix-app/src/router/mod.rs @@ -1,10 +1,11 @@ use crate::{ controller::{auth, file, stats, task, user}, middleware, + model::Tag, }; use actix_web::web::{get, post, scope, ServiceConfig}; use zino::{DefaultController, RouterConfigure}; -use zino_model::{Tag, User}; +use zino_model::User; pub fn routes() -> Vec { vec![ diff --git a/examples/axum-app/src/main.rs b/examples/axum-app/src/main.rs index e6ea9b8b..9b83c3d8 100644 --- a/examples/axum-app/src/main.rs +++ b/examples/axum-app/src/main.rs @@ -1,3 +1,8 @@ +#![allow(async_fn_in_trait)] +#![allow(stable_features)] +#![feature(async_fn_in_trait)] +#![feature(lazy_cell)] + mod controller; mod domain; mod extension; diff --git a/examples/axum-app/src/model/mod.rs b/examples/axum-app/src/model/mod.rs index 8b137891..bf40c55c 100644 --- a/examples/axum-app/src/model/mod.rs +++ b/examples/axum-app/src/model/mod.rs @@ -1 +1,3 @@ +pub(crate) mod tag; +pub(crate) use tag::Tag; diff --git a/examples/axum-app/src/model/tag.rs b/examples/axum-app/src/model/tag.rs new file mode 100644 index 00000000..c271a27d --- /dev/null +++ b/examples/axum-app/src/model/tag.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; +use zino_core::{ + datetime::DateTime, extension::JsonObjectExt, model::Model, request::Validation, Map, Uuid, +}; +use zino_derive::{ModelAccessor, ModelHooks, Schema}; + +/// The `tag` model. +#[derive(Debug, Clone, Default, Serialize, Deserialize, Schema, ModelAccessor, ModelHooks)] +#[serde(rename_all = "snake_case")] +#[serde(default)] +pub struct Tag { + // Basic fields. + #[schema(readonly)] + id: Uuid, + #[schema(not_null, index_type = "text")] + name: String, + #[cfg(feature = "namespace")] + #[schema(default_value = "Tag::model_namespace", index_type = "hash")] + namespace: String, + #[schema(default_value = "Active", index_type = "hash")] + status: String, + #[schema(index_type = "text")] + description: String, + + // Info fields. + #[schema(not_null)] + category: String, + #[schema(reference = "Tag")] + parent_id: Option, // tag.id, tag.namespace = {tag.namespace}, tag.category = {tag.category} + + // Extensions. + content: Map, + extra: Map, + + // Revisions. + #[schema(readonly, default_value = "now", index_type = "btree")] + created_at: DateTime, + #[schema(default_value = "now", index_type = "btree")] + updated_at: DateTime, + version: u64, +} + +impl Model for Tag { + #[inline] + fn new() -> Self { + Self { + id: Uuid::new_v4(), + ..Self::default() + } + } + + fn read_map(&mut self, data: &Map) -> Validation { + let mut validation = Validation::new(); + if let Some(result) = data.parse_uuid("id") { + match result { + Ok(id) => self.id = id, + Err(err) => validation.record_fail("id", err), + } + } + if let Some(name) = data.parse_string("name") { + self.name = name.into_owned(); + } + if let Some(description) = data.parse_string("description") { + self.description = description.into_owned(); + } + if let Some(category) = data.parse_string("category") { + self.category = category.into_owned(); + } + if let Some(result) = data.parse_uuid("parent_id") { + match result { + Ok(parent_id) => self.parent_id = Some(parent_id), + Err(err) => validation.record_fail("parent_id", err), + } + } + validation + } +} diff --git a/examples/axum-app/src/router/mod.rs b/examples/axum-app/src/router/mod.rs index 57d3d366..57185c13 100644 --- a/examples/axum-app/src/router/mod.rs +++ b/examples/axum-app/src/router/mod.rs @@ -1,5 +1,6 @@ use crate::{ controller::{auth, file, stats, task, user}, + model::Tag, middleware, }; use axum::{ @@ -8,7 +9,7 @@ use axum::{ Router, }; use zino::DefaultController; -use zino_model::{Tag, User}; +use zino_model::User; pub fn routes() -> Vec { let mut routes = Vec::new(); diff --git a/zino-core/Cargo.toml b/zino-core/Cargo.toml index 1ef890d8..dd2d0d3e 100644 --- a/zino-core/Cargo.toml +++ b/zino-core/Cargo.toml @@ -170,6 +170,7 @@ reqwest-middleware = "0.2.3" reqwest-retry = "0.3.0" reqwest-tracing = "0.4.6" rmp-serde = "1.1.2" +serde_json = "1.0.107" serde_qs = "0.12.0" sha2 = "0.10.8" sysinfo = "0.29.10" @@ -241,10 +242,6 @@ features = [ version = "1.0.189" features = ["derive"] -[dependencies.serde_json] -version = "1.0.107" -features = ["raw_value"] - [dependencies.sm3] version = "0.4.2" optional = true @@ -310,6 +307,7 @@ data-encoding = "2.4.0" libsm = "0.5.1" ryu = "1.0.15" sm3 = "0.4.2" +sonic-rs = "0.1.3" tinyvec = { version = "1.6.0", features = ["alloc"] } uuid-simd = "0.8.0" diff --git a/zino-core/benches/json_raw_value.rs b/zino-core/benches/json_raw_value.rs index 4a6be638..da184854 100644 --- a/zino-core/benches/json_raw_value.rs +++ b/zino-core/benches/json_raw_value.rs @@ -46,4 +46,18 @@ pub fn bench(c: &mut criterion::Criterion) { serde_json::to_vec(&res) }) }); + c.bench_function("sonic_serialize_json_object", |b| { + b.iter(|| { + let mut res = Map::new(); + res.upsert("status_code", 200); + res.upsert("request_id", Uuid::new_v4().to_string()); + + let mut data = Map::new(); + data.upsert("name", "alice"); + data.upsert("age", 18); + data.upsert("roles", vec!["admin", "worker"]); + res.upsert("data", data); + sonic_rs::to_vec(&res) + }) + }); } diff --git a/zino-core/src/application/http_client.rs b/zino-core/src/application/http_client.rs index 25cfef06..e1b0793f 100644 --- a/zino-core/src/application/http_client.rs +++ b/zino-core/src/application/http_client.rs @@ -178,36 +178,84 @@ struct RequestTiming; impl ReqwestOtelSpanBackend for RequestTiming { fn on_request_start(request: &Request, extensions: &mut Extensions) -> Span { + let method = request.method(); + + // URL let url = request.url(); + let scheme = url.scheme(); + let host = url.host_str(); + let port = url.port(); + let path = url.path(); + let query = url.query(); + let full_url = remove_credentials(&url); + + // Headers let headers = request.headers(); let traceparent = headers.get_str("traceparent"); + let tracestate = headers.get_str("tracestate"); let trace_context = traceparent.and_then(TraceContext::from_traceparent); + let session_id = headers.get_str("session-id"); + let trace_id = trace_context + .as_ref() + .map(|ctx| Uuid::from_u128(ctx.trace_id()).to_string()); + let parent_id = trace_context + .and_then(|ctx| ctx.parent_id()) + .map(|parent_id| format!("{parent_id:x}")); + extensions.insert(Instant::now()); - tracing::info_span!( - "HTTP request", - "otel.kind" = "client", - "otel.name" = "zino-bot", - "http.scheme" = url.scheme(), - "http.method" = request.method().as_str(), - "http.url" = remove_credentials(url).as_ref(), - "http.request.header.traceparent" = traceparent, - "http.request.header.tracestate" = headers.get_str("tracestate"), - "http.response.header.traceparent" = Empty, - "http.response.header.tracestate" = Empty, - "http.status_code" = Empty, - "http.client.duration" = Empty, - "net.peer.name" = url.domain(), - "net.peer.port" = url.port(), - "context.request_id" = Empty, - "context.session_id" = headers.get_str("session-id"), - "context.span_id" = Empty, - "context.trace_id" = trace_context - .as_ref() - .map(|ctx| Uuid::from_u128(ctx.trace_id()).to_string()), - "context.parent_id" = trace_context - .and_then(|ctx| ctx.parent_id()) - .map(|parent_id| format!("{parent_id:x}")), - ) + if method.is_safe() { + tracing::info_span!( + "HTTP request", + "otel.kind" = "client", + "otel.name" = "zino-bot", + "otel.status_code" = Empty, + "url.scheme" = scheme, + "url.path" = path, + "url.query" = query, + "url.full" = full_url.as_ref(), + "http.request.method" = method.as_str(), + "http.request.header.traceparent" = traceparent, + "http.request.header.tracestate" = tracestate, + "http.response.header.traceparent" = Empty, + "http.response.header.tracestate" = Empty, + "http.response.header.server_timing" = Empty, + "http.response.status_code" = Empty, + "http.client.duration" = Empty, + "server.address" = host, + "server.port" = port, + "context.session_id" = session_id, + "context.trace_id" = trace_id, + "context.request_id" = Empty, + "context.span_id" = Empty, + "context.parent_id" = parent_id, + ) + } else { + tracing::warn_span!( + "HTTP request", + "otel.kind" = "client", + "otel.name" = "zino-bot", + "otel.status_code" = Empty, + "url.scheme" = scheme, + "url.path" = path, + "url.query" = query, + "url.full" = full_url.as_ref(), + "http.request.method" = method.as_str(), + "http.request.header.traceparent" = traceparent, + "http.request.header.tracestate" = tracestate, + "http.response.header.traceparent" = Empty, + "http.response.header.tracestate" = Empty, + "http.response.header.server_timing" = Empty, + "http.response.status_code" = Empty, + "http.client.duration" = Empty, + "server.address" = host, + "server.port" = port, + "context.session_id" = session_id, + "context.trace_id" = trace_id, + "context.request_id" = Empty, + "context.span_id" = Empty, + "context.parent_id" = parent_id, + ) + } } fn on_request_end( @@ -234,15 +282,29 @@ impl ReqwestOtelSpanBackend for RequestTiming { "http.response.header.tracestate", headers.get_str("tracestate"), ); + span.record( + "http.response.header.server_timing", + headers.get_str("server-timing"), + ); span.record("context.request_id", headers.get_str("x-request-id")); - span.record("http.status_code", response.status().as_u16()); + span.record("http.response.status_code", response.status().as_u16()); + span.record("otel.status_code", "OK"); tracing::info!("finished HTTP request"); } Err(err) => { if let reqwest_middleware::Error::Reqwest(err) = err { if let Some(status_code) = err.status() { - span.record("http.status_code", status_code.as_u16()); + span.record("http.response.status_code", status_code.as_u16()); + if status_code.is_server_error() { + span.record("otel.status_code", "OK"); + } else { + span.record("otel.status_code", "ERROR"); + } + } else { + span.record("otel.status_code", "ERROR"); } + } else { + span.record("otel.status_code", "ERROR"); } tracing::error!("{err}"); } diff --git a/zino-core/src/response/mod.rs b/zino-core/src/response/mod.rs index e8964562..2da7b307 100644 --- a/zino-core/src/response/mod.rs +++ b/zino-core/src/response/mod.rs @@ -14,7 +14,6 @@ use etag::EntityTag; use http::header::{self, HeaderName, HeaderValue}; use http_body::Full; use serde::Serialize; -use serde_json::value::RawValue; use std::{ marker::PhantomData, time::{Duration, Instant}, @@ -72,9 +71,6 @@ pub struct Response { /// Request ID. #[serde(skip_serializing_if = "Uuid::is_nil")] request_id: Uuid, - /// Response data. - #[serde(skip_serializing_if = "Option::is_none")] - data: Option>, /// JSON data. #[serde(rename = "data")] #[serde(skip_serializing_if = "JsonValue::is_null")] @@ -118,7 +114,6 @@ impl Response { message: None, start_time: Instant::now(), request_id: Uuid::nil(), - data: None, json_data: JsonValue::Null, bytes_data: Bytes::new(), data_transformer: None, @@ -151,7 +146,6 @@ impl Response { message: None, start_time: ctx.start_time(), request_id: ctx.request_id(), - data: None, json_data: JsonValue::Null, bytes_data: Bytes::new(), data_transformer: None, @@ -188,17 +182,14 @@ impl Response { if let Some(data) = value.as_object_mut() { let mut map = crate::Map::new(); map.append(data); - crate::view::render(template_name, map).and_then(|data| { - serde_json::value::to_raw_value(&data).map_err(|err| err.into()) - }) + crate::view::render(template_name, map) } else { Err(Error::new("invalid template data")) } }); match result { - Ok(raw_value) => { - self.data = Some(raw_value); - self.json_data = JsonValue::Null; + Ok(content) => { + self.json_data = content.into(); self.bytes_data = Bytes::new(); self.content_type = Some("text/html; charset=utf-8".into()); } @@ -211,7 +202,6 @@ impl Response { self.success = false; self.detail = Some(err.to_string().into()); self.message = None; - self.data = None; self.json_data = JsonValue::Null; self.bytes_data = Bytes::new(); } @@ -270,11 +260,10 @@ impl Response { /// Sets the response data. #[inline] - pub fn set_data(&mut self, data: &T) { - match serde_json::value::to_raw_value(data) { - Ok(raw_value) => { - self.data = Some(raw_value); - self.json_data = JsonValue::Null; + pub fn set_data(&mut self, data: &T) { + match serde_json::to_value(data) { + Ok(value) => { + self.json_data = value; self.bytes_data = Bytes::new(); } Err(err) => self.set_error_message(err), @@ -284,30 +273,22 @@ impl Response { /// Sets the JSON data. #[inline] pub fn set_json_data(&mut self, data: impl Into) { - self.data = None; self.json_data = data.into(); self.bytes_data = Bytes::new(); } /// Sets the bytes data. #[inline] - pub fn set_bytes_data(&mut self, bytes: impl Into) { - self.data = None; + pub fn set_bytes_data(&mut self, data: impl Into) { self.json_data = JsonValue::Null; - self.bytes_data = bytes.into(); + self.bytes_data = data.into(); } /// Sets the response data for the validation. #[inline] pub fn set_validation_data(&mut self, validation: Validation) { - match serde_json::value::to_raw_value(&validation.into_map()) { - Ok(raw_value) => { - self.data = Some(raw_value); - self.json_data = JsonValue::Null; - self.bytes_data = Bytes::new(); - } - Err(err) => self.set_error_message(err), - } + self.json_data = validation.into_map().into(); + self.bytes_data = Bytes::new(); } /// Sets a transformer for the response data. @@ -336,7 +317,7 @@ impl Response { self.content_type = Some(content_type.into()); } - /// Sets the response body as the form data. + /// Sets the form data as the response body. #[inline] pub fn set_form_response(&mut self, data: impl Into) { self.set_json_data(data); @@ -348,14 +329,14 @@ impl Response { }); } - /// Sets the response body as the JSON data. + /// Sets the JSON data as the response body. #[inline] pub fn set_json_response(&mut self, data: impl Into) { self.set_json_data(data); self.set_data_transformer(|data| Ok(serde_json::to_vec(&data)?.into())); } - /// Sets the response body as the JSON Lines data. + /// Sets the JSON Lines data as the response body. #[inline] pub fn set_jsonlines_response(&mut self, data: impl Into) { self.set_json_data(data); @@ -363,7 +344,7 @@ impl Response { self.set_data_transformer(|data| Ok(data.to_jsonlines(Vec::new())?.into())); } - /// Sets the response body as the MsgPack data. + /// Sets the MsgPack data as the response body. #[inline] pub fn set_msgpack_response(&mut self, data: impl Into) { self.set_json_data(data); @@ -371,7 +352,7 @@ impl Response { self.set_data_transformer(|data| Ok(data.to_msgpack(Vec::new())?.into())); } - /// Sets the response body as the CSV data. + /// Sets the CSV data as the response body. #[inline] pub fn set_csv_response(&mut self, data: impl Into) { self.set_json_data(data); @@ -379,6 +360,20 @@ impl Response { self.set_data_transformer(|data| Ok(data.to_csv(Vec::new())?.into())); } + /// Sets the plain text as the response body. + #[inline] + pub fn set_text_response(&mut self, data: impl Into) { + self.set_json_data(data.into()); + self.set_content_type("text/plain; charset=utf-8"); + } + + /// Sets the bytes data as the response body. + #[inline] + pub fn set_bytes_response(&mut self, data: impl Into) { + self.set_bytes_data(data); + self.set_content_type("application/octet-stream"); + } + /// Sets the request ID. #[inline] pub(crate) fn set_request_id(&mut self, request_id: Uuid) { @@ -508,15 +503,12 @@ impl Response { /// Reads the response into a byte buffer. pub fn read_bytes(&mut self) -> Result { - let bytes_opt = if !self.bytes_data.is_empty() { + let has_bytes_data = !self.bytes_data.is_empty(); + let has_json_data = !self.json_data.is_null(); + let bytes_opt = if has_bytes_data { Some(self.bytes_data.clone()) - } else if let Some(transformer) = self.data_transformer.as_ref() { - if !self.json_data.is_null() { - Some(transformer(&self.json_data)?) - } else { - let data = serde_json::to_value(&self.data)?; - Some(transformer(&data)?) - } + } else if let Some(transformer) = self.data_transformer.as_ref() && has_json_data { + Some(transformer(&self.json_data)?) } else { None }; @@ -528,36 +520,17 @@ impl Response { let content_type = self.content_type(); let (bytes, etag_opt) = if crate::helper::check_json_content_type(content_type) { - let (capacity, etag_opt) = if !self.json_data.is_null() { + let (capacity, etag_opt) = if has_json_data { let data = serde_json::to_vec(&self.json_data)?; let etag = EntityTag::from_data(&data); (data.len() + 128, Some(etag)) - } else if let Some(data) = &self.data { - let data = data.get().as_bytes(); - let etag = EntityTag::from_data(data); - (data.len() + 128, Some(etag)) } else { (128, None) }; let mut bytes = Vec::with_capacity(capacity); serde_json::to_writer(&mut bytes, &self)?; (bytes, etag_opt) - } else if let Some(data) = &self.data { - let capacity = data.get().len(); - let value = serde_json::to_value(data)?; - let bytes = if content_type.starts_with("text/csv") { - value.to_csv(Vec::with_capacity(capacity))? - } else if content_type.starts_with("application/jsonlines") { - value.to_jsonlines(Vec::with_capacity(capacity))? - } else if content_type.starts_with("application/msgpack") { - value.to_msgpack(Vec::with_capacity(capacity))? - } else if let JsonValue::String(s) = value { - s.into_bytes() - } else { - data.to_string().into_bytes() - }; - (bytes, None) - } else if !self.json_data.is_null() { + } else if has_json_data { let value = &self.json_data; let bytes = if content_type.starts_with("text/csv") { value.to_csv(Vec::new())? @@ -566,7 +539,7 @@ impl Response { } else if content_type.starts_with("application/msgpack") { value.to_msgpack(Vec::new())? } else if let JsonValue::String(s) = value { - s.clone().into_bytes() + s.as_bytes().to_vec() } else { value.to_string().into_bytes() }; @@ -667,8 +640,8 @@ impl From> for FullResponse { }; for (key, value) in response.finalize() { - if let Ok(header_name) = HeaderName::try_from(key.as_ref()) && - let Ok(header_value) = HeaderValue::try_from(value) + if let Ok(header_name) = HeaderName::try_from(key.as_ref()) + && let Ok(header_value) = HeaderValue::try_from(value) { res.headers_mut().insert(header_name, header_value); } diff --git a/zino/src/controller/mod.rs b/zino/src/controller/mod.rs index 72a9c61c..91ec9822 100644 --- a/zino/src/controller/mod.rs +++ b/zino/src/controller/mod.rs @@ -187,7 +187,7 @@ where } if !validations.is_empty() { let mut res = crate::Response::new(StatusCode::BAD_REQUEST); - res.set_data(&validations); + res.set_json_data(validations); Ok(res.into()) } else { let rows_affected = Self::insert_many(models).await.extract(&req)?;