Skip to main content

qubit_codec_misc/
form_urlencoded_codec.rs

1// =============================================================================
2//    Copyright (c) 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! `application/x-www-form-urlencoded` text codec.
9
10use qubit_codec::Codec;
11use qubit_codec::DecodeFailure;
12use qubit_codec::ValueDecoder;
13use qubit_codec::ValueEncoder;
14use qubit_utils::nonzero;
15
16use crate::MiscCodecError;
17use crate::MiscCodecResult;
18use crate::internal::ParseError;
19use crate::internal::PercentEncodingMode;
20use crate::misc_codec_error::map_misc_decode_failure_with_consumed;
21use crate::percent_codec::percent_decode_byte;
22use crate::percent_codec::percent_decode_bytes;
23use crate::percent_codec::percent_encode_byte;
24use crate::percent_codec::percent_encode_bytes;
25use crate::percent_codec::percent_encode_len;
26use crate::percent_codec::percent_invalid_consumed;
27
28/// Encodes and decodes `application/x-www-form-urlencoded` text fragments.
29///
30/// Its low-level [`Codec<Value = u8, Unit = u8>`] implementation converts one
31/// byte at a time, including the form-specific space and `+` mapping. UTF-8
32/// validation remains part of the owned [`decode`](Self::decode) helper.
33#[must_use]
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub struct FormUrlencodedCodec;
36
37impl FormUrlencodedCodec {
38    /// Creates a form-url-encoded codec.
39    ///
40    /// # Returns
41    /// Form URL encoded codec.
42    #[inline]
43    pub fn new() -> Self {
44        Self
45    }
46
47    /// Encodes text, using `+` for spaces.
48    ///
49    /// # Parameters
50    /// - `text`: Text to encode.
51    ///
52    /// # Returns
53    /// Form-url-encoded text.
54    #[inline]
55    #[must_use]
56    pub fn encode(&self, text: &str) -> String {
57        percent_encode_bytes(
58            text.as_bytes(),
59            PercentEncodingMode::FormUrlencoded,
60        )
61    }
62
63    /// Decodes text, treating `+` as space.
64    ///
65    /// # Parameters
66    /// - `text`: Text to decode.
67    ///
68    /// # Returns
69    /// Decoded UTF-8 text.
70    ///
71    /// # Errors
72    /// Returns [`MiscCodecError`] when an escape is malformed or decoded bytes
73    /// are not valid UTF-8.
74    #[inline]
75    pub fn decode(&self, text: &str) -> MiscCodecResult<String> {
76        String::from_utf8(percent_decode_bytes(text, true)?)
77            .map_err(MiscCodecError::from)
78    }
79}
80
81impl ValueEncoder<str> for FormUrlencodedCodec {
82    type Error = MiscCodecError;
83    type Output = String;
84
85    /// Encodes text, using `+` for spaces.
86    #[inline]
87    fn encode(&mut self, input: &str) -> Result<Self::Output, Self::Error> {
88        Ok(FormUrlencodedCodec::encode(self, input))
89    }
90}
91
92impl ValueDecoder<str> for FormUrlencodedCodec {
93    type Error = MiscCodecError;
94    type Output = String;
95
96    /// Decodes form-url-encoded text.
97    #[inline]
98    fn decode(&mut self, input: &str) -> Result<Self::Output, Self::Error> {
99        FormUrlencodedCodec::decode(self, input)
100    }
101}
102
103impl Codec for FormUrlencodedCodec {
104    type Value = u8;
105    type Unit = u8;
106    type DecodeError = MiscCodecError;
107    type EncodeError = MiscCodecError;
108
109    const MIN_UNITS_PER_VALUE: usize = 1;
110    const MAX_ENCODE_UNITS_PER_VALUE: usize = 3;
111
112    const MAX_DECODE_UNITS_PER_VALUE: usize = 3;
113
114    /// Returns the exact form-url-encoded width for one byte.
115    #[inline(always)]
116    fn encode_len(&self, value: &u8) -> usize {
117        percent_encode_len(*value, PercentEncodingMode::FormUrlencoded)
118    }
119
120    /// Decodes one raw byte, `+`, or `%XX` escape.
121    ///
122    /// # Safety
123    /// The caller must provide an input index with at least one readable unit
124    /// and a slice satisfying the codec trait's decode preconditions.
125    #[inline]
126    unsafe fn decode(
127        &mut self,
128        input: &[u8],
129        input_index: usize,
130    ) -> Result<(u8, core::num::NonZeroUsize), DecodeFailure<Self::DecodeError>>
131    {
132        debug_assert!(input_index < input.len());
133
134        let (value, consumed) = percent_decode_byte(input, input_index, true)
135            .map_err(|error| {
136            ParseError::into_decode_failure_with_consumed(error, nonzero(3))
137        })?;
138        debug_assert!(consumed > 0);
139        // SAFETY: `percent_decode_byte` returns a non-zero width for every
140        // successful raw byte, `+`, or escape.
141        let consumed = nonzero(consumed);
142        Ok((value, consumed))
143    }
144
145    /// Encodes one byte using form URL encoding.
146    ///
147    /// # Safety
148    /// The caller must provide enough writable output units for
149    /// [`Self::encode_len`].
150    #[inline]
151    unsafe fn encode(
152        &mut self,
153        value: &u8,
154        output: &mut [u8],
155        output_index: usize,
156    ) -> Result<usize, Self::EncodeError> {
157        let required =
158            percent_encode_len(*value, PercentEncodingMode::FormUrlencoded);
159        debug_assert!(output_index + required <= output.len());
160
161        let written = percent_encode_byte(
162            *value,
163            output,
164            output_index,
165            PercentEncodingMode::FormUrlencoded,
166        );
167        debug_assert_eq!(written, required);
168        Ok(required)
169    }
170
171    /// Decodes one value after end of input has been confirmed.
172    ///
173    /// # Safety
174    /// The caller must provide an input index with at least one readable unit
175    /// and a slice satisfying the codec trait's EOF decode preconditions.
176    #[inline]
177    unsafe fn decode_eof(
178        &mut self,
179        input: &[u8],
180        input_index: usize,
181    ) -> Result<(u8, core::num::NonZeroUsize), DecodeFailure<Self::DecodeError>>
182    {
183        debug_assert!(input_index < input.len());
184
185        let (value, consumed) = percent_decode_byte(input, input_index, true)
186            .map_err(|error| match error {
187            ParseError::Incomplete { .. } => {
188                map_misc_decode_failure_with_consumed(
189                    MiscCodecError::InvalidEscape {
190                        index: input_index,
191                        escape: String::from_utf8_lossy(&input[input_index..])
192                            .into_owned(),
193                        reason: "expected two hexadecimal digits".to_owned(),
194                    },
195                    percent_invalid_consumed(input, input_index),
196                )
197            }
198            ParseError::Invalid(error) => {
199                map_misc_decode_failure_with_consumed(
200                    error,
201                    percent_invalid_consumed(input, input_index),
202                )
203            }
204        })?;
205        debug_assert!(consumed > 0);
206        let consumed = nonzero(consumed);
207        Ok((value, consumed))
208    }
209}