1use bytes::BytesMut;
16use chrono::format::SecondsFormat;
17use chrono::{Local, Utc};
18use pingap_core::{Ctx, HOST_NAME_TAG, format_duration, get_hostname};
19use pingap_util::format_byte_size;
20use pingora::http::ResponseHeader;
21use pingora::proxy::Session;
22use regex::Regex;
23use std::sync::LazyLock;
24use std::time::Instant;
25use substring::Substring;
26
27#[derive(Debug, Clone, PartialEq)]
29pub enum TagCategory {
30 Fill, Host, Method, Path, Proto, Query, Remote, ClientIp, Scheme,
39 Uri,
40 Referrer,
41 UserAgent,
42 When,
43 WhenUtcIso,
44 WhenUnix,
45 Size,
46 SizeHuman,
47 Status,
48 Latency,
49 LatencyHuman,
50 Cookie,
51 RequestHeader,
52 ResponseHeader,
53 Context,
54 PayloadSize,
55 PayloadSizeHuman,
56 RequestId,
57}
58
59#[derive(Debug, Clone)]
61pub struct Tag {
62 pub category: TagCategory,
63 pub data: Option<String>, }
65
66#[derive(Debug, Default, Clone)]
67pub struct Parser {
68 pub needs_timestamp: bool,
69 pub capacity: usize,
70 pub tags: Vec<Tag>,
71}
72
73fn format_extra_tag(key: &str) -> Option<Tag> {
75 if key.len() < 2 {
77 return None;
78 }
79 let key = key.substring(1, key.len() - 1);
80 let ch = key.substring(0, 1);
81 let value = key.substring(1, key.len());
82 match ch {
83 "~" => Some(Tag {
84 category: TagCategory::Cookie,
86 data: Some(value.to_string()),
87 }),
88 ">" => Some(Tag {
89 category: TagCategory::RequestHeader,
91 data: Some(value.to_string()),
92 }),
93 "<" => Some(Tag {
94 category: TagCategory::ResponseHeader,
96 data: Some(value.to_string()),
97 }),
98 ":" => Some(Tag {
99 category: TagCategory::Context,
100 data: Some(value.to_string()),
101 }),
102 "$" => {
103 if key.as_bytes() == HOST_NAME_TAG {
104 Some(Tag {
105 category: TagCategory::Fill,
106 data: Some(get_hostname().to_string()),
107 })
108 } else {
109 Some(Tag {
110 category: TagCategory::Fill,
111 data: Some(std::env::var(value).unwrap_or_default()),
112 })
113 }
114 },
115 _ => None,
116 }
117}
118
119static COMBINED: &str = r###"{remote} "{method} {uri} {proto}" {status} {size_human} "{referer}" "{user_agent}""###;
121static COMMON: &str =
122 r###"{remote} "{method} {uri} {proto}" {status} {size_human}""###;
123static SHORT: &str = r###"{remote} {method} {uri} {proto} {status} {size_human} - {latency}ms"###;
124static TINY: &str = r###"{method} {uri} {status} {size_human} - {latency}ms"###;
125
126impl From<&str> for Parser {
127 fn from(value: &str) -> Self {
128 let value = match value {
129 "combined" => COMBINED,
130 "common" => COMMON,
131 "short" => SHORT,
132 "tiny" => TINY,
133 _ => value,
134 };
135 let Ok(reg) = Regex::new(r"(\{[a-zA-Z_<>\-~:$]+*\})") else {
136 return Parser {
137 needs_timestamp: false,
138 capacity: 0,
139 tags: vec![Tag {
140 category: TagCategory::Fill,
141 data: Some(value.to_string()),
142 }],
143 };
144 };
145 let mut current = 0;
146 let mut end = 0;
147 let mut tags = vec![];
148
149 while let Some(result) = reg.find_at(value, current) {
150 if end < result.start() {
151 tags.push(Tag {
152 category: TagCategory::Fill,
153 data: Some(
154 value.substring(end, result.start()).to_string(),
155 ),
156 });
157 }
158 let key = result.as_str();
159
160 match key {
161 "{host}" => tags.push(Tag {
162 category: TagCategory::Host,
163 data: None,
164 }),
165 "{method}" => tags.push(Tag {
166 category: TagCategory::Method,
167 data: None,
168 }),
169 "{path}" => tags.push(Tag {
170 category: TagCategory::Path,
171 data: None,
172 }),
173 "{proto}" => tags.push(Tag {
174 category: TagCategory::Proto,
175 data: None,
176 }),
177 "{query}" => tags.push(Tag {
178 category: TagCategory::Query,
179 data: None,
180 }),
181 "{remote}" => tags.push(Tag {
182 category: TagCategory::Remote,
183 data: None,
184 }),
185 "{client_ip}" => tags.push(Tag {
186 category: TagCategory::ClientIp,
187 data: None,
188 }),
189 "{scheme}" => tags.push(Tag {
190 category: TagCategory::Scheme,
191 data: None,
192 }),
193 "{uri}" => tags.push(Tag {
194 category: TagCategory::Uri,
195 data: None,
196 }),
197 "{referer}" => tags.push(Tag {
198 category: TagCategory::Referrer,
199 data: None,
200 }),
201 "{user_agent}" => tags.push(Tag {
202 category: TagCategory::UserAgent,
203 data: None,
204 }),
205 "{when}" => tags.push(Tag {
206 category: TagCategory::When,
207 data: None,
208 }),
209 "{when_utc_iso}" => tags.push(Tag {
210 category: TagCategory::WhenUtcIso,
211 data: None,
212 }),
213 "{when_unix}" => tags.push(Tag {
214 category: TagCategory::WhenUnix,
215 data: None,
216 }),
217 "{size}" => tags.push(Tag {
218 category: TagCategory::Size,
219 data: None,
220 }),
221 "{size_human}" => tags.push(Tag {
222 category: TagCategory::SizeHuman,
223 data: None,
224 }),
225 "{status}" => tags.push(Tag {
226 category: TagCategory::Status,
227 data: None,
228 }),
229 "{latency}" => tags.push(Tag {
230 category: TagCategory::Latency,
231 data: None,
232 }),
233 "{latency_human}" => tags.push(Tag {
234 category: TagCategory::LatencyHuman,
235 data: None,
236 }),
237 "{payload_size}" => tags.push(Tag {
238 category: TagCategory::PayloadSize,
239 data: None,
240 }),
241 "{payload_size_human}" => tags.push(Tag {
242 category: TagCategory::PayloadSizeHuman,
243 data: None,
244 }),
245 "{request_id}" => tags.push(Tag {
246 category: TagCategory::RequestId,
247 data: None,
248 }),
249 _ => {
250 if let Some(tag) = format_extra_tag(key) {
251 tags.push(tag);
252 }
253 },
254 }
255
256 end = result.end();
257 current = result.start() + 1;
258 }
259 if end < value.len() {
260 tags.push(Tag {
261 category: TagCategory::Fill,
262 data: Some(value.substring(end, value.len()).to_string()),
263 });
264 }
265 let needs_timestamp = tags.iter().any(|t| {
266 matches!(
267 t.category,
268 TagCategory::When
269 | TagCategory::WhenUtcIso
270 | TagCategory::WhenUnix
271 | TagCategory::Latency
272 | TagCategory::LatencyHuman
273 )
274 });
275 let capacity = if *LOG_CAPACITY > 0 {
276 *LOG_CAPACITY
277 } else {
278 Parser::estimate_capacity(&tags)
279 };
280 Parser {
281 capacity,
282 tags,
283 needs_timestamp,
284 }
285 }
286}
287
288fn get_resp_header_value<'a>(
289 resp_header: &'a ResponseHeader,
290 key: &str,
291) -> Option<&'a [u8]> {
292 resp_header.headers.get(key).map(|v| v.as_bytes())
293}
294
295static LOG_CAPACITY: LazyLock<usize> = LazyLock::new(|| {
296 std::env::var("PINGAP_ACCESS_LOG_CAPACITY")
297 .unwrap_or_default()
298 .parse::<usize>()
299 .unwrap_or_default()
300});
301
302impl Parser {
303 fn estimate_capacity(tags: &[Tag]) -> usize {
305 let mut size = 128; for tag in tags {
308 size += match tag.category {
309 TagCategory::Fill => tag.data.as_ref().map_or(0, |s| s.len()),
310 TagCategory::Uri | TagCategory::Path => 64, TagCategory::UserAgent => 100, _ => 16, };
315 }
316 size
317 }
318 pub fn format(&self, session: &Session, ctx: &Ctx) -> BytesMut {
320 let mut buf = BytesMut::with_capacity(self.capacity);
322 let req_header = session.req_header();
323
324 let (now, instant) = if self.needs_timestamp {
326 let n = Utc::now();
327 (Some(n), Some(Instant::now()))
328 } else {
329 (None, None)
330 };
331
332 const EMPTY_FIELD: &[u8] = b"-";
333
334 for tag in self.tags.iter() {
336 match tag.category {
337 TagCategory::Fill => {
338 if let Some(data) = &tag.data {
340 buf.extend_from_slice(data.as_bytes());
341 }
342 },
343 TagCategory::Host => {
344 match pingap_core::get_host(req_header) {
346 Some(host) if !host.is_empty() => {
347 buf.extend_from_slice(host.as_bytes())
348 },
349 _ => buf.extend_from_slice(EMPTY_FIELD),
350 }
351 },
352 TagCategory::Method => {
353 let method = req_header.method.as_str();
354 if method.is_empty() {
355 buf.extend_from_slice(EMPTY_FIELD);
356 } else {
357 buf.extend_from_slice(method.as_bytes());
358 }
359 },
360 TagCategory::Path => {
361 let path = req_header.uri.path();
362 if path.is_empty() {
363 buf.extend_from_slice(EMPTY_FIELD);
364 } else {
365 buf.extend_from_slice(path.as_bytes());
366 }
367 },
368 TagCategory::Proto => {
369 if session.is_http2() {
370 buf.extend_from_slice(b"HTTP/2.0");
371 } else {
372 buf.extend_from_slice(b"HTTP/1.1");
373 }
374 },
375 TagCategory::Query => match req_header.uri.query() {
376 Some(query) if !query.is_empty() => {
377 buf.extend_from_slice(query.as_bytes())
378 },
379 _ => buf.extend_from_slice(EMPTY_FIELD),
380 },
381 TagCategory::Remote => match &ctx.conn.remote_addr {
382 Some(addr) if !addr.is_empty() => {
383 buf.extend_from_slice(addr.as_bytes())
384 },
385 _ => buf.extend_from_slice(EMPTY_FIELD),
386 },
387 TagCategory::ClientIp => {
388 if let Some(client_ip) = &ctx.conn.client_ip {
389 if client_ip.is_empty() {
390 buf.extend_from_slice(EMPTY_FIELD);
391 } else {
392 buf.extend_from_slice(client_ip.as_bytes());
393 }
394 } else {
395 let client_ip = pingap_core::get_client_ip(session);
396 if client_ip.is_empty() {
397 buf.extend_from_slice(EMPTY_FIELD);
398 } else {
399 buf.extend_from_slice(client_ip.as_bytes());
400 }
401 }
402 },
403 TagCategory::Scheme => {
404 if ctx.conn.tls_version.is_some() {
405 buf.extend_from_slice(b"https");
406 } else {
407 buf.extend_from_slice(b"http");
408 }
409 },
410 TagCategory::Uri => match req_header.uri.path_and_query() {
411 Some(value) if !value.as_str().is_empty() => {
412 buf.extend_from_slice(value.as_str().as_bytes())
413 },
414 _ => buf.extend_from_slice(EMPTY_FIELD),
415 },
416 TagCategory::Referrer => {
417 let value = session.get_header_bytes("referer");
418 if value.is_empty() {
419 buf.extend_from_slice(EMPTY_FIELD);
420 } else {
421 buf.extend_from_slice(value);
422 }
423 },
424 TagCategory::UserAgent => {
425 let value = session.get_header_bytes("user-agent");
426 if value.is_empty() {
427 buf.extend_from_slice(EMPTY_FIELD);
428 } else {
429 buf.extend_from_slice(value);
430 }
431 },
432 TagCategory::When => {
433 if let Some(now) = &now {
434 buf.extend_from_slice(
435 now.with_timezone(&Local)
436 .to_rfc3339_opts(SecondsFormat::Millis, false)
437 .as_bytes(),
438 );
439 } else {
440 buf.extend_from_slice(EMPTY_FIELD);
441 }
442 },
443 TagCategory::WhenUtcIso => {
444 if let Some(now) = &now {
445 buf.extend_from_slice(
446 now.to_rfc3339_opts(SecondsFormat::Millis, true)
447 .as_bytes(),
448 );
449 } else {
450 buf.extend_from_slice(EMPTY_FIELD);
451 }
452 },
453 TagCategory::WhenUnix => {
454 if let Some(now) = &now {
455 buf.extend_from_slice(
456 itoa::Buffer::new()
457 .format(now.timestamp_millis())
458 .as_bytes(),
459 );
460 } else {
461 buf.extend_from_slice(EMPTY_FIELD);
462 }
463 },
464 TagCategory::Size => {
465 buf.extend_from_slice(
466 itoa::Buffer::new()
467 .format(session.body_bytes_sent())
468 .as_bytes(),
469 );
470 },
471 TagCategory::SizeHuman => {
472 format_byte_size(&mut buf, session.body_bytes_sent());
473 },
474 TagCategory::Status => {
475 if let Some(status) = &ctx.state.status {
476 buf.extend_from_slice(status.as_str().as_bytes());
477 } else {
478 buf.extend_from_slice(b"-");
479 }
480 },
481 TagCategory::Latency => {
482 if let Some(instant) = instant {
483 let ms = (instant - ctx.timing.created_at).as_millis();
484 buf.extend_from_slice(
485 itoa::Buffer::new().format(ms).as_bytes(),
486 );
487 } else {
488 buf.extend_from_slice(EMPTY_FIELD);
489 }
490 },
491 TagCategory::LatencyHuman => {
492 if let Some(instant) = instant {
493 let ms = (instant - ctx.timing.created_at).as_millis();
494 format_duration(&mut buf, ms as u64);
495 } else {
496 buf.extend_from_slice(EMPTY_FIELD);
497 }
498 },
499 TagCategory::Cookie => {
500 if let Some(cookie) = &tag.data {
501 if let Some(value) =
502 pingap_core::get_cookie_value(req_header, cookie)
503 {
504 buf.extend_from_slice(value.as_bytes());
505 }
506 } else {
507 buf.extend_from_slice(EMPTY_FIELD);
508 }
509 },
510 TagCategory::RequestHeader => {
511 if let Some(key) = &tag.data {
512 match req_header.headers.get(key) {
513 Some(value) if !value.is_empty() => {
514 buf.extend_from_slice(value.as_bytes())
515 },
516 _ => buf.extend_from_slice(EMPTY_FIELD),
517 }
518 } else {
519 buf.extend_from_slice(EMPTY_FIELD);
520 }
521 },
522 TagCategory::ResponseHeader => {
523 if let Some(resp_header) = session.response_written() {
524 if let Some(key) = &tag.data {
525 match get_resp_header_value(resp_header, key) {
526 Some(value) if !value.is_empty() => {
527 buf.extend_from_slice(value)
528 },
529 _ => buf.extend_from_slice(EMPTY_FIELD),
530 }
531 } else {
532 buf.extend_from_slice(EMPTY_FIELD);
533 }
534 } else {
535 buf.extend_from_slice(EMPTY_FIELD);
536 }
537 },
538 TagCategory::PayloadSize => {
539 buf.extend_from_slice(
540 itoa::Buffer::new()
541 .format(ctx.state.payload_size)
542 .as_bytes(),
543 );
544 },
545 TagCategory::PayloadSizeHuman => {
546 format_byte_size(&mut buf, ctx.state.payload_size);
547 },
548 TagCategory::RequestId => {
549 if let Some(key) = &ctx.state.request_id {
550 buf.extend_from_slice(key.as_bytes());
551 } else {
552 buf.extend_from_slice(EMPTY_FIELD);
553 }
554 },
555 TagCategory::Context => {
556 if let Some(key) = &tag.data {
557 ctx.append_log_value(&mut buf, key.as_str());
558 } else {
559 buf.extend_from_slice(EMPTY_FIELD);
560 }
561 },
562 };
563 }
564
565 buf
566 }
567}
568
569pub fn parse_access_log_directive(
599 access_log: Option<&String>,
600) -> (Option<String>, Option<String>) {
601 let default_value = (access_log.cloned(), None);
602 let Some(access_log) = access_log else {
603 return default_value;
604 };
605 if access_log.starts_with('{') {
606 return default_value;
607 }
608 let Some((path, access)) = access_log.split_once(' ') else {
609 return default_value;
610 };
611
612 if !["combined", "common", "short", "tiny"].contains(&access)
613 && !access.starts_with('{')
614 {
615 return default_value;
616 }
617
618 (Some(access.to_string()), Some(path.to_string()))
619}
620
621#[cfg(test)]
622mod tests {
623 use super::{
624 Parser, Tag, TagCategory, format_extra_tag, get_resp_header_value,
625 parse_access_log_directive,
626 };
627 use http::Method;
628 use pingap_core::{
629 ConnectionInfo, Ctx, RequestState, Timing, UpstreamInfo,
630 };
631 use pingora::{http::ResponseHeader, proxy::Session};
632 use pretty_assertions::assert_eq;
633 use tokio_test::io::Builder;
634
635 #[test]
636 fn test_parse_access_log_directive() {
637 let (access, path) = parse_access_log_directive(Some(
638 &"{when} {host} {method} {path}".to_string(),
639 ));
640 assert_eq!(Some("{when} {host} {method} {path}".to_string()), access);
641 assert_eq!(None, path);
642
643 let (access, path) = parse_access_log_directive(Some(
644 &"/var/log/pingap.log {when} {host} {method} {path}".to_string(),
645 ));
646 assert_eq!(Some("{when} {host} {method} {path}".to_string()), access);
647 assert_eq!(Some("/var/log/pingap.log".to_string()), path);
648 }
649
650 #[test]
651 fn test_format_extra_tag() {
652 assert_eq!(true, format_extra_tag(":").is_none());
653
654 let cookie = format_extra_tag("{~deviceId}").unwrap();
655 assert_eq!(TagCategory::Cookie, cookie.category);
656 assert_eq!("deviceId", cookie.data.unwrap());
657
658 let req_header = format_extra_tag("{>X-User}").unwrap();
659 assert_eq!(TagCategory::RequestHeader, req_header.category);
660 assert_eq!("X-User", req_header.data.unwrap());
661
662 let resp_header = format_extra_tag("{<X-Response-Id}").unwrap();
663 assert_eq!(TagCategory::ResponseHeader, resp_header.category);
664 assert_eq!("X-Response-Id", resp_header.data.unwrap());
665
666 let hostname = format_extra_tag("{$hostname}").unwrap();
667 assert_eq!(TagCategory::Fill, hostname.category);
668 assert_eq!(false, hostname.data.unwrap().is_empty());
669
670 let env = format_extra_tag("{$HOME}").unwrap();
671 assert_eq!(TagCategory::Fill, env.category);
672 assert_eq!(false, env.data.unwrap().is_empty());
673 }
674 #[test]
675 fn test_parse_format() {
676 let tests = [
677 (
678 "{host}",
679 Tag {
680 category: TagCategory::Host,
681 data: None,
682 },
683 ),
684 (
685 "{method}",
686 Tag {
687 category: TagCategory::Method,
688 data: None,
689 },
690 ),
691 (
692 "{path}",
693 Tag {
694 category: TagCategory::Path,
695 data: None,
696 },
697 ),
698 (
699 "{proto}",
700 Tag {
701 category: TagCategory::Proto,
702 data: None,
703 },
704 ),
705 (
706 "{query}",
707 Tag {
708 category: TagCategory::Query,
709 data: None,
710 },
711 ),
712 (
713 "{remote}",
714 Tag {
715 category: TagCategory::Remote,
716 data: None,
717 },
718 ),
719 (
720 "{client_ip}",
721 Tag {
722 category: TagCategory::ClientIp,
723 data: None,
724 },
725 ),
726 (
727 "{scheme}",
728 Tag {
729 category: TagCategory::Scheme,
730 data: None,
731 },
732 ),
733 (
734 "{uri}",
735 Tag {
736 category: TagCategory::Uri,
737 data: None,
738 },
739 ),
740 (
741 "{referer}",
742 Tag {
743 category: TagCategory::Referrer,
744 data: None,
745 },
746 ),
747 (
748 "{user_agent}",
749 Tag {
750 category: TagCategory::UserAgent,
751 data: None,
752 },
753 ),
754 (
755 "{when}",
756 Tag {
757 category: TagCategory::When,
758 data: None,
759 },
760 ),
761 (
762 "{when_utc_iso}",
763 Tag {
764 category: TagCategory::WhenUtcIso,
765 data: None,
766 },
767 ),
768 (
769 "{when_unix}",
770 Tag {
771 category: TagCategory::WhenUnix,
772 data: None,
773 },
774 ),
775 (
776 "{size}",
777 Tag {
778 category: TagCategory::Size,
779 data: None,
780 },
781 ),
782 (
783 "{size_human}",
784 Tag {
785 category: TagCategory::SizeHuman,
786 data: None,
787 },
788 ),
789 (
790 "{status}",
791 Tag {
792 category: TagCategory::Status,
793 data: None,
794 },
795 ),
796 (
797 "{latency}",
798 Tag {
799 category: TagCategory::Latency,
800 data: None,
801 },
802 ),
803 (
804 "{latency_human}",
805 Tag {
806 category: TagCategory::LatencyHuman,
807 data: None,
808 },
809 ),
810 (
811 "{payload_size}",
812 Tag {
813 category: TagCategory::PayloadSize,
814 data: None,
815 },
816 ),
817 (
818 "{payload_size_human}",
819 Tag {
820 category: TagCategory::PayloadSizeHuman,
821 data: None,
822 },
823 ),
824 (
825 "{request_id}",
826 Tag {
827 category: TagCategory::RequestId,
828 data: None,
829 },
830 ),
831 ];
832
833 for (value, tag) in tests {
834 let p = Parser::from(value);
835 assert_eq!(tag.category, p.tags[0].category);
836 }
837 }
838
839 #[tokio::test]
840 async fn test_logger() {
841 let p: Parser =
842 "{host} {method} {path} {proto} {query} {remote} {client_ip} \
843{scheme} {uri} {referer} {user_agent} {size} \
844{size_human} {status} {payload_size} {payload_size_human} \
845{~deviceId} {>accept} {:upstream_reused} {:upstream_addr} \
846{:processing} {:upstream_connect_time_human} {:location} \
847{:connection_time_human} {:tls_version} {request_id}"
848 .into();
849 let headers = [
850 "Host: github.com",
851 "User-Agent: pingap/0.1.1",
852 "Cookie: deviceId=abc",
853 "Accept: application/json",
854 ]
855 .join("\r\n");
856 let input_header =
857 format!("GET /vicanso/pingap?size=1 HTTP/1.1\r\n{headers}\r\n\r\n");
858 let mock_io = Builder::new().read(input_header.as_bytes()).build();
859
860 let mut session = Session::new_h1(Box::new(mock_io));
861 session.read_request().await.unwrap();
862 assert_eq!(Method::GET, session.req_header().method);
863
864 let ctx = Ctx {
865 conn: ConnectionInfo {
866 remote_addr: Some("10.1.1.1".to_string()),
867 client_ip: Some("1.1.1.1".to_string()),
868 tls_version: Some("1.2".to_string()),
869 ..Default::default()
870 },
871 upstream: UpstreamInfo {
872 reused: true,
873 address: "192.186.1.1:6188".to_string(),
874 location: "test".to_string().into(),
875 ..Default::default()
876 },
877 timing: Timing {
878 connection_duration: 300,
879 upstream_connect: Some(100),
880 ..Default::default()
881 },
882 state: RequestState {
883 request_id: Some("nanoid".to_string()),
884 processing_count: 1,
885 ..Default::default()
886 },
887 ..Default::default()
888 };
889 let log = p.format(&session, &ctx);
890 assert_eq!(
891 "github.com GET /vicanso/pingap HTTP/1.1 size=1 10.1.1.1 1.1.1.1 https /vicanso/pingap?size=1 - pingap/0.1.1 0 0B - 0 0B abc application/json true 192.186.1.1:6188 1 100ms test 300ms 1.2 nanoid",
892 log
893 );
894
895 let p: Parser = "{when_utc_iso}".into();
896 let log = p.format(&session, &ctx);
897 assert_eq!(true, log.len() > 20);
898
899 let p: Parser = "{when}".into();
900 let log = p.format(&session, &ctx);
901 assert_eq!(true, log.len() > 20);
902
903 let p: Parser = "{when_unix}".into();
904 let log = p.format(&session, &ctx);
905 assert_eq!(true, log.len() == 13);
906 }
907
908 #[test]
909 fn test_get_resp_header_value() {
910 let mut header =
911 ResponseHeader::build_no_case(200, Some(1024)).unwrap();
912 header
913 .append_header("Content-Type", "application/json")
914 .unwrap();
915 let value = get_resp_header_value(&header, "content-type");
916 assert_eq!(
917 "application/json",
918 std::str::from_utf8(value.unwrap()).unwrap()
919 );
920
921 let value = get_resp_header_value(&header, "content-type-not-exists");
922 assert_eq!(None, value);
923 }
924}