1pub mod body;
9pub mod codec;
10pub mod error;
11pub mod metadata;
12pub mod request;
13
14use axum::extract::{Path, RawQuery, State};
15use axum::http::{HeaderMap, StatusCode};
16use axum::response::{IntoResponse, Response};
17use axum::routing::{delete, get, patch, post, put, MethodRouter};
18use axum::{Json, Router};
19use futures::StreamExt;
20use prost_reflect::{DescriptorPool, DynamicMessage, MethodDescriptor, SerializeOptions};
21use tonic::client::Grpc;
22
23use crate::config::AliasConfig;
24
25pub trait TranscodeState: Clone + Send + Sync + 'static {
30 fn grpc_channel(&self) -> tonic::transport::Channel;
32 fn forwarded_headers(&self) -> &[String];
34}
35
36impl TranscodeState for crate::ProxyState {
37 fn grpc_channel(&self) -> tonic::transport::Channel {
38 self.grpc_channel.clone()
39 }
40 fn forwarded_headers(&self) -> &[String] {
41 &self.forwarded_headers
42 }
43}
44
45#[derive(Debug, Clone)]
47struct RouteEntry {
48 http_path: String,
50 http_method: HttpMethod,
52 grpc_path: axum::http::uri::PathAndQuery,
55 method: MethodDescriptor,
57 body: request::BodyMapping,
59 response_body: Option<String>,
61}
62
63#[derive(Debug, Clone, Copy)]
64enum HttpMethod {
65 Get,
66 Post,
67 Put,
68 Patch,
69 Delete,
70}
71
72pub fn routes<S: TranscodeState>(pool: &DescriptorPool, aliases: &[AliasConfig]) -> Router<S> {
77 let entries = extract_routes(pool);
78 if entries.is_empty() {
79 tracing::warn!("No HTTP-annotated RPCs found in proto descriptors");
80 return Router::new();
81 }
82
83 tracing::info!("Registering {} transcoded REST→gRPC routes", entries.len());
84
85 let mut router: Router<S> = Router::new();
86 for entry in &entries {
87 let entry_clone = std::sync::Arc::new(entry.clone());
88
89 let handler = move |proxy_state: State<S>,
90 headers: HeaderMap,
91 path_params: Path<std::collections::HashMap<String, String>>,
92 raw_query: RawQuery,
93 body: axum::body::Bytes| {
94 transcode_handler(
95 proxy_state,
96 headers,
97 path_params,
98 raw_query,
99 body,
100 entry_clone,
101 )
102 };
103
104 let method_router: MethodRouter<S> = match entry.http_method {
105 HttpMethod::Get => get(handler),
106 HttpMethod::Post => post(handler),
107 HttpMethod::Put => put(handler),
108 HttpMethod::Patch => patch(handler),
109 HttpMethod::Delete => delete(handler),
110 };
111
112 let axum_path = proto_path_to_axum(&entry.http_path);
113 router = router.route(&axum_path, method_router);
114
115 for alias in aliases {
117 if let Some(suffix) = entry.http_path.strip_prefix(&alias.to) {
118 let alias_path = if alias.from.ends_with("/{path}") {
120 let prefix = alias.from.trim_end_matches("/{path}");
121 format!("{}{}", prefix, suffix)
122 } else {
123 continue;
124 };
125
126 let alias_entry = std::sync::Arc::new(entry.clone());
127 let alias_handler =
128 move |proxy_state: State<S>,
129 headers: HeaderMap,
130 path_params: Path<std::collections::HashMap<String, String>>,
131 raw_query: RawQuery,
132 body: axum::body::Bytes| {
133 transcode_handler(
134 proxy_state,
135 headers,
136 path_params,
137 raw_query,
138 body,
139 alias_entry,
140 )
141 };
142 let alias_method: MethodRouter<S> = match entry.http_method {
143 HttpMethod::Get => get(alias_handler),
144 HttpMethod::Post => post(alias_handler),
145 HttpMethod::Put => put(alias_handler),
146 HttpMethod::Patch => patch(alias_handler),
147 HttpMethod::Delete => delete(alias_handler),
148 };
149 router = router.route(&alias_path, alias_method);
150 }
151 }
152 }
153
154 let streaming_entries = extract_streaming_routes(pool);
156 for entry in &streaming_entries {
157 let entry_clone = std::sync::Arc::new(entry.clone());
158 let axum_path = proto_path_to_axum(&entry.http_path);
159
160 let handler = move |proxy_state: State<S>, headers: HeaderMap| {
161 streaming_handler(proxy_state, headers, entry_clone)
162 };
163
164 let method_router: MethodRouter<S> = match entry.http_method {
165 HttpMethod::Get => get(handler),
166 HttpMethod::Post => post(handler),
167 _ => continue,
168 };
169
170 router = router.route(&axum_path, method_router);
171 }
172
173 router
174}
175
176async fn streaming_handler<S: TranscodeState>(
178 State(proxy_state): State<S>,
179 headers: HeaderMap,
180 entry: std::sync::Arc<RouteEntry>,
181) -> Response {
182 let channel = proxy_state.grpc_channel();
183
184 let input_desc = entry.method.input();
185 let request_msg = DynamicMessage::new(input_desc);
186
187 let grpc_metadata =
188 metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
189 let mut grpc_request = tonic::Request::new(request_msg);
190 *grpc_request.metadata_mut() = grpc_metadata;
191 metadata::apply_request_deadline(&mut grpc_request, &headers);
192
193 let output_desc = entry.method.output();
194 let grpc_codec = codec::DynamicCodec::new(output_desc.clone());
195 let grpc_path = entry.grpc_path.clone();
196
197 let mut grpc_client = Grpc::new(channel);
198 if let Err(e) = grpc_client.ready().await {
199 return (
200 StatusCode::SERVICE_UNAVAILABLE,
201 Json(serde_json::json!({
202 "error": "UNAVAILABLE",
203 "message": format!("gRPC upstream not ready: {e}"),
204 })),
205 )
206 .into_response();
207 }
208
209 match grpc_client
210 .server_streaming(grpc_request, grpc_path, grpc_codec)
211 .await
212 {
213 Ok(response) => {
214 let stream = response.into_inner();
215 let serialize_opts = SerializeOptions::new()
216 .skip_default_fields(false)
217 .stringify_64_bit_integers(true);
218
219 let byte_stream = stream.map(move |result| match result {
220 Ok(msg) => {
221 match msg.serialize_with_options(serde_json::value::Serializer, &serialize_opts)
222 {
223 Ok(json_value) => {
224 let mut bytes = serde_json::to_vec(&json_value).unwrap_or_default();
225 bytes.push(b'\n');
226 Ok::<axum::body::Bytes, std::io::Error>(axum::body::Bytes::from(bytes))
227 }
228 Err(e) => Err(std::io::Error::other(format!("serialization error: {e}"))),
229 }
230 }
231 Err(status) => Err(std::io::Error::other(format!(
232 "gRPC stream error: {status}"
233 ))),
234 });
235
236 let body = axum::body::Body::from_stream(byte_stream);
237 Response::builder()
238 .status(StatusCode::OK)
239 .header("content-type", "application/x-ndjson")
240 .header("transfer-encoding", "chunked")
241 .body(body)
242 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
243 }
244 Err(status) => error::status_to_response(status),
245 }
246}
247
248async fn transcode_handler<S: TranscodeState>(
250 State(proxy_state): State<S>,
251 headers: HeaderMap,
252 Path(path_params): Path<std::collections::HashMap<String, String>>,
253 RawQuery(raw_query): RawQuery,
254 body_bytes: axum::body::Bytes,
255 entry: std::sync::Arc<RouteEntry>,
256) -> Response {
257 let channel = proxy_state.grpc_channel();
258
259 let json_body = match entry.body {
261 request::BodyMapping::None => serde_json::Value::Null,
262 _ => {
263 let ct = body::content_type(&headers);
264 match body::parse_body(ct, &body_bytes) {
265 Ok(v) => v,
266 Err(e) => {
267 return (
268 StatusCode::BAD_REQUEST,
269 Json(serde_json::json!({
270 "error": "INVALID_ARGUMENT",
271 "message": format!("failed to parse request body: {e}"),
272 })),
273 )
274 .into_response();
275 }
276 }
277 }
278 };
279
280 let query_pairs = match request::parse_query(raw_query.as_deref()) {
284 Ok(pairs) => pairs,
285 Err(e) => {
286 return (
287 StatusCode::BAD_REQUEST,
288 Json(serde_json::json!({
289 "error": "INVALID_ARGUMENT",
290 "message": e,
291 })),
292 )
293 .into_response();
294 }
295 };
296
297 let input_desc = entry.method.input();
298 let request_json = match request::build_request_json(
299 &input_desc,
300 &entry.body,
301 json_body,
302 &path_params,
303 &query_pairs,
304 ) {
305 Ok(v) => v,
306 Err(e) => {
307 return (
308 StatusCode::BAD_REQUEST,
309 Json(serde_json::json!({
310 "error": "INVALID_ARGUMENT",
311 "message": e,
312 })),
313 )
314 .into_response();
315 }
316 };
317
318 let request_msg = match DynamicMessage::deserialize(input_desc, request_json) {
319 Ok(msg) => msg,
320 Err(e) => {
321 return (
322 StatusCode::BAD_REQUEST,
323 Json(serde_json::json!({
324 "error": "INVALID_ARGUMENT",
325 "message": format!("failed to decode request: {e}"),
326 })),
327 )
328 .into_response();
329 }
330 };
331
332 let grpc_metadata =
333 metadata::http_headers_to_grpc_metadata(&headers, proxy_state.forwarded_headers());
334 let mut grpc_request = tonic::Request::new(request_msg);
335 *grpc_request.metadata_mut() = grpc_metadata;
336 metadata::apply_request_deadline(&mut grpc_request, &headers);
337
338 let output_desc = entry.method.output();
339 let grpc_codec = codec::DynamicCodec::new(output_desc.clone());
340 let grpc_path = entry.grpc_path.clone();
341
342 let mut grpc_client = Grpc::new(channel);
343 if let Err(e) = grpc_client.ready().await {
344 return (
345 StatusCode::SERVICE_UNAVAILABLE,
346 Json(serde_json::json!({
347 "error": "UNAVAILABLE",
348 "message": format!("gRPC upstream not ready: {e}"),
349 })),
350 )
351 .into_response();
352 }
353
354 match grpc_client.unary(grpc_request, grpc_path, grpc_codec).await {
355 Ok(response) => {
356 let response_msg = response.into_inner();
357 let serialize_opts = SerializeOptions::new()
358 .skip_default_fields(false)
359 .stringify_64_bit_integers(true);
360 match response_msg
361 .serialize_with_options(serde_json::value::Serializer, &serialize_opts)
362 {
363 Ok(json_value) => {
364 let out = match &entry.response_body {
366 Some(path) => request::extract_response_body(&json_value, path)
367 .unwrap_or_else(|| {
368 tracing::warn!(
369 response_body = %path,
370 "configured response_body path not found in response; \
371 returning null"
372 );
373 serde_json::Value::Null
374 }),
375 None => json_value,
376 };
377 (StatusCode::OK, Json(out)).into_response()
378 }
379 Err(e) => {
380 tracing::error!("Failed to serialize gRPC response: {e}");
381 (
382 StatusCode::INTERNAL_SERVER_ERROR,
383 Json(serde_json::json!({
384 "error": "INTERNAL",
385 "message": "failed to serialize response",
386 })),
387 )
388 .into_response()
389 }
390 }
391 }
392 Err(status) => error::status_to_response(status),
393 }
394}
395
396fn extract_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
398 let http_ext = match pool.get_extension_by_name("google.api.http") {
399 Some(ext) => ext,
400 None => {
401 tracing::warn!("google.api.http extension not found in descriptor pool");
402 return Vec::new();
403 }
404 };
405
406 let mut entries = Vec::new();
407
408 for service in pool.services() {
409 for method in service.methods() {
410 if method.is_client_streaming() || method.is_server_streaming() {
411 continue;
412 }
413
414 let grpc_path = format!("/{}/{}", service.full_name(), method.name());
415 let grpc_path: axum::http::uri::PathAndQuery = match grpc_path.parse() {
416 Ok(p) => p,
417 Err(e) => {
418 tracing::error!("skipping route with invalid gRPC path '{grpc_path}': {e}");
419 continue;
420 }
421 };
422
423 for binding in extract_http_bindings(&method, &http_ext) {
424 entries.push(RouteEntry {
425 http_path: binding.http_path,
426 http_method: binding.http_method,
427 grpc_path: grpc_path.clone(),
428 method: method.clone(),
429 body: binding.body,
430 response_body: binding.response_body,
431 });
432 }
433 }
434 }
435
436 entries
437}
438
439fn extract_streaming_routes(pool: &DescriptorPool) -> Vec<RouteEntry> {
441 let http_ext = match pool.get_extension_by_name("google.api.http") {
442 Some(ext) => ext,
443 None => return Vec::new(),
444 };
445
446 let mut entries = Vec::new();
447
448 for service in pool.services() {
449 for method in service.methods() {
450 if !method.is_server_streaming() || method.is_client_streaming() {
451 continue;
452 }
453
454 let grpc_path = format!("/{}/{}", service.full_name(), method.name());
455 let grpc_path: axum::http::uri::PathAndQuery = match grpc_path.parse() {
456 Ok(p) => p,
457 Err(e) => {
458 tracing::error!("skipping route with invalid gRPC path '{grpc_path}': {e}");
459 continue;
460 }
461 };
462
463 for binding in extract_http_bindings(&method, &http_ext) {
464 tracing::info!(
465 "Registering streaming route: {} {} → {}",
466 match binding.http_method {
467 HttpMethod::Get => "GET",
468 HttpMethod::Post => "POST",
469 _ => "OTHER",
470 },
471 binding.http_path,
472 grpc_path
473 );
474 entries.push(RouteEntry {
475 http_path: binding.http_path,
476 http_method: binding.http_method,
477 grpc_path: grpc_path.clone(),
478 method: method.clone(),
479 body: binding.body,
480 response_body: binding.response_body,
481 });
482 }
483 }
484 }
485
486 entries
487}
488
489struct HttpBinding {
491 http_method: HttpMethod,
492 http_path: String,
493 body: request::BodyMapping,
494 response_body: Option<String>,
495}
496
497fn extract_http_bindings(
500 method: &MethodDescriptor,
501 http_ext: &prost_reflect::ExtensionDescriptor,
502) -> Vec<HttpBinding> {
503 let options = method.options();
504 if !options.has_extension(http_ext) {
505 return Vec::new();
506 }
507
508 let prost_reflect::Value::Message(rule_msg) = options.get_extension(http_ext).into_owned()
509 else {
510 return Vec::new();
511 };
512
513 collect_bindings(&rule_msg)
514}
515
516fn collect_bindings(rule_msg: &DynamicMessage) -> Vec<HttpBinding> {
519 let mut bindings = Vec::new();
520 if let Some(binding) = parse_http_rule(rule_msg) {
521 bindings.push(binding);
522 }
523
524 if let Some(field) = rule_msg.get_field_by_name("additional_bindings") {
527 if let prost_reflect::Value::List(list) = field.into_owned() {
528 for item in list {
529 if let prost_reflect::Value::Message(sub) = item {
530 if let Some(binding) = parse_http_rule(&sub) {
531 bindings.push(binding);
532 }
533 }
534 }
535 }
536 }
537
538 bindings
539}
540
541fn parse_http_rule(rule_msg: &DynamicMessage) -> Option<HttpBinding> {
543 let (http_method, http_path) = [
544 ("get", HttpMethod::Get),
545 ("post", HttpMethod::Post),
546 ("put", HttpMethod::Put),
547 ("delete", HttpMethod::Delete),
548 ("patch", HttpMethod::Patch),
549 ]
550 .into_iter()
551 .find_map(
552 |(name, http_method)| match rule_msg.get_field_by_name(name)?.into_owned() {
553 prost_reflect::Value::String(path) if !path.is_empty() => Some((http_method, path)),
554 _ => None,
555 },
556 )?;
557
558 let body = rule_msg
559 .get_field_by_name("body")
560 .and_then(|v| match v.into_owned() {
561 prost_reflect::Value::String(s) => Some(request::BodyMapping::parse(&s)),
562 _ => None,
563 })
564 .unwrap_or(request::BodyMapping::None);
565
566 let response_body =
567 rule_msg
568 .get_field_by_name("response_body")
569 .and_then(|v| match v.into_owned() {
570 prost_reflect::Value::String(s) if !s.is_empty() => Some(s),
571 _ => None,
572 });
573
574 Some(HttpBinding {
575 http_method,
576 http_path,
577 body,
578 response_body,
579 })
580}
581
582pub fn proto_path_to_axum(path: &str) -> String {
593 let mut out = String::with_capacity(path.len());
594
595 let segments = split_top_level(path);
596 let last = segments.len().saturating_sub(1);
597 for (idx, segment) in segments.iter().enumerate() {
598 if idx > 0 {
599 out.push('/');
600 }
601 out.push_str(&convert_segment(segment, idx, idx == last));
602 }
603
604 out
605}
606
607fn split_top_level(path: &str) -> Vec<&str> {
614 let mut segments = Vec::new();
615 let mut depth = 0usize;
616 let mut start = 0usize;
617
618 for (i, ch) in path.char_indices() {
619 match ch {
620 '{' => depth += 1,
621 '}' if depth > 0 => depth -= 1,
624 '/' if depth == 0 => {
625 segments.push(&path[start..i]);
626 start = i + 1;
627 }
628 _ => {}
629 }
630 }
631 segments.push(&path[start..]);
632 segments
633}
634
635fn convert_segment(segment: &str, idx: usize, is_last: bool) -> String {
640 if let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
641 if let Some((name, template)) = inner.split_once('=') {
643 return match template {
644 "*" => format!("{{{name}}}"),
646 "**" => catch_all(name, is_last),
648 _ => {
654 tracing::warn!(
655 template = %inner,
656 "google.api.http multi-segment field template is not fully \
657 supported; routing it as a catch-all capture"
658 );
659 catch_all(name, is_last)
660 }
661 };
662 }
663 return format!("{{{inner}}}");
665 }
666
667 match segment {
669 "**" => catch_all(&format!("wildcard{idx}"), is_last),
670 "*" => format!("{{wildcard{idx}}}"),
671 literal => literal.to_string(),
672 }
673}
674
675fn catch_all(name: &str, is_last: bool) -> String {
683 if is_last {
684 format!("{{*{name}}}")
685 } else {
686 tracing::warn!(
687 capture = %name,
688 "catch-all in a non-terminal path segment is unrepresentable in axum; \
689 degrading to a single-segment capture"
690 );
691 format!("{{{name}}}")
692 }
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698
699 fn http_rule_descriptor() -> prost_reflect::MessageDescriptor {
703 use prost_reflect::prost::Message;
704 use prost_reflect::prost_types::{
705 field_descriptor_proto::{Label, Type},
706 DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet,
707 };
708
709 let str_field = |name: &str, num: i32| FieldDescriptorProto {
710 name: Some(name.to_string()),
711 number: Some(num),
712 label: Some(Label::Optional as i32),
713 r#type: Some(Type::String as i32),
714 ..Default::default()
715 };
716 let rule = DescriptorProto {
717 name: Some("HttpRule".to_string()),
718 field: vec![
719 str_field("get", 2),
720 str_field("put", 3),
721 str_field("post", 4),
722 str_field("delete", 5),
723 str_field("patch", 6),
724 str_field("body", 7),
725 str_field("response_body", 12),
726 FieldDescriptorProto {
727 name: Some("additional_bindings".to_string()),
728 number: Some(11),
729 label: Some(Label::Repeated as i32),
730 r#type: Some(Type::Message as i32),
731 type_name: Some(".gapi.HttpRule".to_string()),
732 ..Default::default()
733 },
734 ],
735 ..Default::default()
736 };
737 let file = FileDescriptorProto {
738 name: Some("http.proto".to_string()),
739 package: Some("gapi".to_string()),
740 message_type: vec![rule],
741 syntax: Some("proto3".to_string()),
742 ..Default::default()
743 };
744 let fds = FileDescriptorSet { file: vec![file] };
745 let pool = DescriptorPool::decode(fds.encode_to_vec().as_slice()).unwrap();
746 pool.get_message_by_name("gapi.HttpRule").unwrap()
747 }
748
749 #[test]
750 fn collect_bindings_reads_body_response_and_additional() {
751 let desc = http_rule_descriptor();
752
753 let mut extra = DynamicMessage::new(desc.clone());
755 extra.set_field_by_name("post", prost_reflect::Value::String("/v1/items".into()));
756 extra.set_field_by_name("body", prost_reflect::Value::String("*".into()));
757
758 let mut rule = DynamicMessage::new(desc);
760 rule.set_field_by_name("get", prost_reflect::Value::String("/v1/items/{id}".into()));
761 rule.set_field_by_name(
762 "response_body",
763 prost_reflect::Value::String("result".into()),
764 );
765 rule.set_field_by_name(
766 "additional_bindings",
767 prost_reflect::Value::List(vec![prost_reflect::Value::Message(extra)]),
768 );
769
770 let bindings = collect_bindings(&rule);
771 assert_eq!(bindings.len(), 2);
772
773 assert!(matches!(bindings[0].http_method, HttpMethod::Get));
775 assert_eq!(bindings[0].http_path, "/v1/items/{id}");
776 assert_eq!(bindings[0].body, request::BodyMapping::None);
777 assert_eq!(bindings[0].response_body.as_deref(), Some("result"));
778
779 assert!(matches!(bindings[1].http_method, HttpMethod::Post));
781 assert_eq!(bindings[1].http_path, "/v1/items");
782 assert_eq!(bindings[1].body, request::BodyMapping::Root);
783 assert_eq!(bindings[1].response_body, None);
784 }
785
786 #[test]
787 fn test_proto_path_to_axum() {
788 assert_eq!(proto_path_to_axum("/v1/profiles/{id}"), "/v1/profiles/{id}");
790 assert_eq!(
791 proto_path_to_axum("/v1/admin/profiles/{profile_id}/metadata/{key}"),
792 "/v1/admin/profiles/{profile_id}/metadata/{key}"
793 );
794 assert_eq!(proto_path_to_axum("/v1/auth/login"), "/v1/auth/login");
795 }
796
797 #[test]
798 fn test_proto_path_to_axum_wildcards() {
799 assert_eq!(proto_path_to_axum("/v1/{name=*}"), "/v1/{name}");
801 assert_eq!(
803 proto_path_to_axum("/v1/files/{path=**}"),
804 "/v1/files/{*path}"
805 );
806 assert_eq!(proto_path_to_axum("/v1/*/items"), "/v1/{wildcard2}/items");
809 assert_eq!(proto_path_to_axum("/v1/files/**"), "/v1/files/{*wildcard3}");
810 }
811
812 #[test]
813 fn non_terminal_catch_all_degrades_to_single_capture() {
814 assert_eq!(
820 proto_path_to_axum("/v1/{name=projects/*}/topics"),
821 "/v1/{name}/topics"
822 );
823 let path = proto_path_to_axum("/v1/{name=projects/*}/topics");
824 let _router: Router<()> = Router::new().route(&path, get(|| async { "ok" }));
825
826 assert_eq!(proto_path_to_axum("/v1/{rest=**}/tail"), "/v1/{rest}/tail");
829 assert_eq!(
830 proto_path_to_axum("/v1/files/{rest=**}"),
831 "/v1/files/{*rest}"
832 );
833 }
834
835 #[test]
836 fn multi_segment_field_template_does_not_fracture() {
837 assert_eq!(
843 proto_path_to_axum("/v1/{name=shelves/*/books/*}"),
844 "/v1/{*name}"
845 );
846 let path = proto_path_to_axum("/v1/{name=shelves/*/books/*}");
848 let _router: Router<()> = Router::new().route(&path, get(|| async { "ok" }));
849 }
850
851 #[test]
856 fn router_builds_with_brace_path_params_on_axum_0_8() {
857 let axum_path = proto_path_to_axum("/v1/profiles/{id}");
858 let _router: Router<()> = Router::new().route(&axum_path, get(|| async { "ok" }));
859
860 let nested = proto_path_to_axum("/v1/admin/profiles/{profile_id}/metadata/{key}");
862 let catch_all = proto_path_to_axum("/v1/files/{path=**}");
863 let _router: Router<()> = Router::new()
864 .route(&nested, get(|| async { "ok" }))
865 .route(&catch_all, get(|| async { "ok" }));
866 }
867}