001/* 002 * Copyright © 2025 CUI-OpenSource-Software (info@cuioss.de) 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package de.cuioss.http.security.data; 017 018import de.cuioss.http.security.core.ValidationType; 019import org.jspecify.annotations.Nullable; 020 021import java.util.Optional; 022 023/** 024 * Immutable record representing an HTTP request or response body with content, content type, and encoding. 025 * 026 * <p>This record encapsulates the structure of HTTP message bodies, providing a type-safe way 027 * to handle body data in HTTP security validation. It supports various content types and 028 * encoding schemes commonly used in HTTP communications.</p> 029 * 030 * <h3>Design Principles</h3> 031 * <ul> 032 * <li><strong>Immutability</strong> - All fields are final and the record cannot be modified</li> 033 * <li><strong>Type Safety</strong> - Strongly typed representation of HTTP body data</li> 034 * <li><strong>Encoding Awareness</strong> - Explicit handling of content encoding</li> 035 * <li><strong>Content Type Support</strong> - Supports MIME type specification</li> 036 * </ul> 037 * 038 * <h3>Usage Examples</h3> 039 * <pre> 040 * // JSON body 041 * HTTPBody jsonBody = new HTTPBody( 042 * "{\"userId\": 123, \"name\": \"John\"}", 043 * "application/json", 044 * "" 045 * ); 046 * 047 * // Form data 048 * HTTPBody formBody = new HTTPBody( 049 * "username=admin&password=secret", 050 * "application/x-www-form-urlencoded", 051 * "" 052 * ); 053 * 054 * // Compressed content 055 * HTTPBody compressedBody = new HTTPBody( 056 * "...", // compressed content 057 * "text/html", 058 * "gzip" 059 * ); 060 * 061 * // Access components 062 * String content = body.content(); // The actual content 063 * String contentType = body.contentType(); // "application/json" 064 * String encoding = body.encoding(); // "gzip" 065 * 066 * // Check content characteristics 067 * boolean isJson = body.isJson(); // true for JSON content 068 * boolean hasContent = body.hasContent(); // true if content is not empty 069 * boolean isCompressed = body.isCompressed(); // true if encoding is specified 070 * 071 * // Use in validation 072 * validator.validate(body.content(), ValidationType.BODY); 073 * </pre> 074 * 075 * <h3>Content Types</h3> 076 * <p>The contentType field should contain a valid MIME type (e.g., "application/json", 077 * "text/html", "multipart/form-data"). An empty string indicates no content type is specified.</p> 078 * 079 * <h3>Encoding</h3> 080 * <p>The encoding field specifies content encoding such as "gzip", "deflate", "br" (Brotli), 081 * or "" for no encoding. This is distinct from character encoding, which is typically 082 * specified in the Content-Type header.</p> 083 * 084 * <h3>Security Considerations</h3> 085 * <p>This record is a simple data container. Security validation should be applied to 086 * the content using appropriate validators for {@link ValidationType#BODY}, taking into 087 * account the content type and encoding when determining validation strategies.</p> 088 * 089 * Implements: Task B3 from HTTP verification specification 090 * 091 * @param content The body content as a string 092 * @param contentType The MIME content type (e.g., "application/json", "text/html") 093 * @param encoding The content encoding (e.g., "gzip", "deflate", "" for none) 094 * 095 * @since 1.0 096 * @see ValidationType#BODY 097 */ 098public record HTTPBody(@Nullable String content, @Nullable String contentType, @Nullable String encoding) { 099 100 public static HTTPBody of(String content, String contentType) { 101 return new HTTPBody(content, contentType, ""); 102 } 103 104 public static HTTPBody text(String content) { 105 return new HTTPBody(content, "text/plain", ""); 106 } 107 108 public static HTTPBody json(String jsonContent) { 109 return new HTTPBody(jsonContent, "application/json", ""); 110 } 111 112 public static HTTPBody html(String htmlContent) { 113 return new HTTPBody(htmlContent, "text/html", ""); 114 } 115 116 public static HTTPBody form(String formContent) { 117 return new HTTPBody(formContent, "application/x-www-form-urlencoded", ""); 118 } 119 120 public boolean hasContent() { 121 return content != null && !content.isEmpty(); 122 } 123 124 public boolean hasContentType() { 125 return contentType != null && !contentType.isEmpty(); 126 } 127 128 public boolean hasEncoding() { 129 return encoding != null && !encoding.isEmpty(); 130 } 131 132 public boolean isCompressed() { 133 return hasEncoding(); 134 } 135 136 @SuppressWarnings("ConstantConditions") 137 public boolean isJson() { 138 return hasContentType() && contentType.toLowerCase().contains("json"); 139 } 140 141 /** 142 * Checks if the content type indicates XML content. 143 * 144 * @return true if the content type contains "xml" 145 */ 146 @SuppressWarnings("ConstantConditions") 147 public boolean isXml() { 148 return hasContentType() && contentType.toLowerCase().contains("xml"); 149 } 150 151 /** 152 * Checks if the content type indicates HTML content. 153 * 154 * @return true if the content type contains "html" 155 */ 156 @SuppressWarnings("ConstantConditions") 157 public boolean isHtml() { 158 return hasContentType() && contentType.toLowerCase().contains("html"); 159 } 160 161 /** 162 * Checks if the content type indicates plain text. 163 * 164 * @return true if the content type is "text/plain" 165 */ 166 public boolean isPlainText() { 167 return hasContentType() && "text/plain".equalsIgnoreCase(contentType); 168 } 169 170 /** 171 * Checks if the content type indicates form data. 172 * 173 * @return true if the content type is form-encoded 174 */ 175 @SuppressWarnings("ConstantConditions") 176 public boolean isFormData() { 177 return hasContentType() && 178 (contentType.toLowerCase().contains("application/x-www-form-urlencoded") || 179 contentType.toLowerCase().contains("multipart/form-data")); 180 } 181 182 /** 183 * Checks if the content type indicates binary content. 184 * 185 * @return true if the content type suggests binary data 186 */ 187 @SuppressWarnings("ConstantConditions") 188 public boolean isBinary() { 189 return hasContentType() && 190 (contentType.toLowerCase().contains("application/octet-stream") || 191 contentType.toLowerCase().contains("image/") || 192 contentType.toLowerCase().contains("video/") || 193 contentType.toLowerCase().contains("audio/")); 194 } 195 196 /** 197 * Returns the content length in characters. 198 * 199 * @return The length of the content string, or 0 if content is null 200 */ 201 public int contentLength() { 202 return content != null ? content.length() : 0; 203 } 204 205 /** 206 * Extracts the charset from the content type if specified. 207 * 208 * @return The charset name wrapped in Optional, or empty if not specified 209 */ 210 public Optional<String> getCharset() { 211 if (!hasContentType()) { 212 return Optional.empty(); 213 } 214 return AttributeParser.extractAttributeValue(contentType, "charset"); 215 } 216 217 /** 218 * Returns the content or a default value if content is null. 219 * 220 * @param defaultContent The default content to return if content is null 221 * @return The content or the default 222 */ 223 public String contentOrDefault(String defaultContent) { 224 return content != null ? content : defaultContent; 225 } 226 227 /** 228 * Returns the content type or a default value if content type is null. 229 * 230 * @param defaultContentType The default content type to return if contentType is null 231 * @return The content type or the default 232 */ 233 public String contentTypeOrDefault(String defaultContentType) { 234 return contentType != null ? contentType : defaultContentType; 235 } 236 237 /** 238 * Returns the encoding or a default value if encoding is null. 239 * 240 * @param defaultEncoding The default encoding to return if encoding is null 241 * @return The encoding or the default 242 */ 243 public String encodingOrDefault(String defaultEncoding) { 244 return encoding != null ? encoding : defaultEncoding; 245 } 246 247 /** 248 * Returns a copy of this body with new content. 249 * 250 * @param newContent The new content 251 * @return A new HTTPBody with the specified content and same contentType/encoding 252 */ 253 public HTTPBody withContent(String newContent) { 254 return new HTTPBody(newContent, contentType, encoding); 255 } 256 257 /** 258 * Returns a copy of this body with a new content type. 259 * 260 * @param newContentType The new content type 261 * @return A new HTTPBody with the same content/encoding and specified content type 262 */ 263 public HTTPBody withContentType(String newContentType) { 264 return new HTTPBody(content, newContentType, encoding); 265 } 266 267 /** 268 * Returns a copy of this body with a new encoding. 269 * 270 * @param newEncoding The new encoding 271 * @return A new HTTPBody with the same content/contentType and specified encoding 272 */ 273 public HTTPBody withEncoding(String newEncoding) { 274 return new HTTPBody(content, contentType, newEncoding); 275 } 276 277 /** 278 * Returns a truncated version of the content for safe logging. 279 * 280 * @param maxLength The maximum length for the truncated content 281 * @return The content truncated to the specified length with "..." if truncated 282 */ 283 public String contentTruncated(int maxLength) { 284 if (content == null) { 285 return "null"; 286 } 287 if (content.length() <= maxLength) { 288 return content; 289 } 290 return content.substring(0, maxLength) + "..."; 291 } 292}