| | | 1 | | using System.Collections; |
| | | 2 | | using System.Reflection; |
| | | 3 | | using System.Runtime.CompilerServices; |
| | | 4 | | using System.Xml.Linq; |
| | | 5 | | using Microsoft.OpenApi; |
| | | 6 | | using OpenApiXmlModel = Microsoft.OpenApi.OpenApiXml; |
| | | 7 | | using Serilog; |
| | | 8 | | |
| | | 9 | | namespace Kestrun.Utilities; |
| | | 10 | | |
| | | 11 | | /// <summary> |
| | | 12 | | /// Helpers for converting arbitrary objects into <see cref="XElement"/> instances. |
| | | 13 | | /// </summary> |
| | | 14 | | public static class XmlHelper |
| | | 15 | | { |
| | 1 | 16 | | private static readonly XNamespace xsi = "http://www.w3.org/2001/XMLSchema-instance"; |
| | | 17 | | /// <summary> |
| | | 18 | | /// Default maximum recursion depth for object-to-XML conversion. |
| | | 19 | | /// Chosen to balance performance and stack safety for typical object graphs. |
| | | 20 | | /// Adjust if deeper object graphs need to be serialized. |
| | | 21 | | /// </summary> |
| | | 22 | | public const int DefaultMaxDepth = 32; |
| | | 23 | | |
| | | 24 | | /// <summary> |
| | | 25 | | /// Maximum recursion depth for object-to-XML conversion. This value can be adjusted if deeper object graphs need to |
| | | 26 | | /// </summary> |
| | 119 | 27 | | public static int MaxDepth { get; set; } = DefaultMaxDepth; |
| | | 28 | | // Per-call cycle detection now passed explicitly (was ThreadStatic). Avoids potential thread reuse memory retention |
| | | 29 | | // Rationale: |
| | | 30 | | // * ThreadStatic HashSet could retain large object graphs across requests in thread pool threads causing memory b |
| | | 31 | | // * A per-call HashSet has a short lifetime and becomes eligible for GC immediately after serialization completes |
| | | 32 | | // * Passing the set by reference keeps allocation to a single HashSet per root ToXml call (lazy created on first |
| | | 33 | | // * No synchronization needed: the set is confined to the call stack; recursive calls share it by reference. |
| | | 34 | | |
| | | 35 | | /// <summary> |
| | | 36 | | /// Converts an object to an <see cref="XElement"/> with the specified name, handling nulls, primitives, dictionarie |
| | | 37 | | /// </summary> |
| | | 38 | | /// <param name="name">The name of the XML element.</param> |
| | | 39 | | /// <param name="value">The object to convert to XML.</param> |
| | | 40 | | /// <returns>An <see cref="XElement"/> representing the object.</returns> |
| | 14 | 41 | | public static XElement ToXml(string name, object? value) => ToXmlInternal(SanitizeName(name), value, 0, visited: nul |
| | | 42 | | |
| | | 43 | | /// <summary> |
| | | 44 | | /// Internal recursive method to convert an object to XML, with depth tracking and cycle detection. |
| | | 45 | | /// </summary> |
| | | 46 | | /// <param name="name">The name of the XML element.</param> |
| | | 47 | | /// <param name="value">The object to convert to XML.</param> |
| | | 48 | | /// <param name="depth">The current recursion depth.</param> |
| | | 49 | | /// <returns>An <see cref="XElement"/> representing the object.</returns> |
| | | 50 | | /// <param name="visited">Per-call set of already visited reference objects for cycle detection.</param> |
| | | 51 | | private static XElement ToXmlInternal(string name, object? value, int depth, HashSet<object>? visited) |
| | | 52 | | { |
| | | 53 | | // Fast path & terminal cases extracted to helpers for reduced branching complexity. |
| | 118 | 54 | | if (TryHandleTerminal(name, value, depth, ref visited, out var terminal)) |
| | | 55 | | { |
| | 73 | 56 | | return terminal; |
| | | 57 | | } |
| | | 58 | | |
| | | 59 | | // At this point value is non-null complex/reference or value-type object requiring reflection. |
| | 45 | 60 | | var type = value!.GetType(); |
| | | 61 | | |
| | | 62 | | // Check if the type has OpenAPI XML metadata. |
| | | 63 | | // Prefer a static XmlMetadata hashtable, otherwise build it from OpenApiXmlAttribute annotations. |
| | 45 | 64 | | var xmlMetadata = GetOpenApiXmlMetadataForType(type); |
| | | 65 | | |
| | 45 | 66 | | var needsCycleTracking = !type.IsValueType; |
| | 45 | 67 | | if (needsCycleTracking && !EnterCycle(value, ref visited, out var cycleElem)) |
| | | 68 | | { |
| | 1 | 69 | | return cycleElem!; // Cycle detected |
| | | 70 | | } |
| | | 71 | | |
| | | 72 | | try |
| | | 73 | | { |
| | 44 | 74 | | return ObjectToXml(name, value, depth, visited, xmlMetadata); |
| | | 75 | | } |
| | | 76 | | finally |
| | | 77 | | { |
| | 44 | 78 | | if (needsCycleTracking && visited is not null) |
| | | 79 | | { |
| | 44 | 80 | | _ = visited.Remove(value); |
| | | 81 | | } |
| | 44 | 82 | | } |
| | 44 | 83 | | } |
| | | 84 | | |
| | | 85 | | /// <summary> |
| | | 86 | | /// Handles depth guard, null, enums, primitives, simple temporal types, dictionaries & enumerables. |
| | | 87 | | /// </summary> |
| | | 88 | | /// <param name="name">The name of the XML element.</param> |
| | | 89 | | /// <param name="value">The object to convert to XML.</param> |
| | | 90 | | /// <param name="depth">The current recursion depth.</param> |
| | | 91 | | /// <param name="element">The resulting XML element if handled; otherwise, null.</param> |
| | | 92 | | /// <param name="visited">Per-call set used for cycle detection (reference types only).</param> |
| | | 93 | | /// <returns><c>true</c> if the value was handled; otherwise, <c>false</c>.</returns> |
| | | 94 | | private static bool TryHandleTerminal(string name, object? value, int depth, ref HashSet<object>? visited, out XElem |
| | | 95 | | { |
| | | 96 | | // Depth guard handled below. |
| | 118 | 97 | | if (depth >= MaxDepth) |
| | | 98 | | { |
| | 2 | 99 | | element = new XElement(name, new XAttribute("warning", "MaxDepthExceeded")); |
| | 2 | 100 | | return true; |
| | | 101 | | } |
| | | 102 | | |
| | | 103 | | // Null |
| | 116 | 104 | | if (value is null) |
| | | 105 | | { |
| | 6 | 106 | | element = new XElement(name, new XAttribute(xsi + "nil", true)); |
| | 6 | 107 | | return true; |
| | | 108 | | } |
| | | 109 | | |
| | 110 | 110 | | var type = value.GetType(); |
| | | 111 | | |
| | | 112 | | // Enum |
| | 110 | 113 | | if (type.IsEnum) |
| | | 114 | | { |
| | 0 | 115 | | element = new XElement(name, value.ToString()); |
| | 0 | 116 | | return true; |
| | | 117 | | } |
| | | 118 | | |
| | | 119 | | // Primitive / simple |
| | 110 | 120 | | if (IsSimple(value)) |
| | | 121 | | { |
| | 63 | 122 | | element = new XElement(name, value); |
| | 63 | 123 | | return true; |
| | | 124 | | } |
| | | 125 | | |
| | | 126 | | // DateTimeOffset / TimeSpan (already covered by IsSimple for DateTimeOffset/TimeSpan? DateTimeOffset yes, TimeS |
| | 47 | 127 | | if (value is DateTimeOffset dto) |
| | | 128 | | { |
| | 0 | 129 | | element = new XElement(name, dto.ToString("O")); |
| | 0 | 130 | | return true; |
| | | 131 | | } |
| | 47 | 132 | | if (value is TimeSpan ts) |
| | | 133 | | { |
| | 0 | 134 | | element = new XElement(name, ts.ToString()); |
| | 0 | 135 | | return true; |
| | | 136 | | } |
| | | 137 | | |
| | | 138 | | // IDictionary |
| | 47 | 139 | | if (value is IDictionary dict) |
| | | 140 | | { |
| | 1 | 141 | | element = DictionaryToXml(name, dict, depth, visited); |
| | 1 | 142 | | return true; |
| | | 143 | | } |
| | | 144 | | |
| | | 145 | | // IEnumerable |
| | 46 | 146 | | if (value is IEnumerable enumerable) |
| | | 147 | | { |
| | 1 | 148 | | element = EnumerableToXml(name, enumerable, depth, visited); |
| | 1 | 149 | | return true; |
| | | 150 | | } |
| | | 151 | | |
| | 45 | 152 | | element = null!; |
| | 45 | 153 | | return false; |
| | | 154 | | } |
| | | 155 | | |
| | | 156 | | /// <summary> |
| | | 157 | | /// Enters cycle tracking for the specified object. Returns false if a cycle is detected. |
| | | 158 | | /// </summary> |
| | | 159 | | /// <param name="value">The object to track.</param> |
| | | 160 | | /// <param name="cycleElement">The resulting XML element if a cycle is detected; otherwise, null.</param> |
| | | 161 | | /// <returns><c>true</c> if the object is successfully tracked; otherwise, <c>false</c>.</returns> |
| | | 162 | | /// <param name="visited">Per-call set of visited objects (created lazily).</param> |
| | | 163 | | private static bool EnterCycle(object value, ref HashSet<object>? visited, out XElement? cycleElement) |
| | | 164 | | { |
| | 45 | 165 | | visited ??= new HashSet<object>(ReferenceEqualityComparer.Instance); |
| | 45 | 166 | | if (!visited.Add(value)) |
| | | 167 | | { |
| | 1 | 168 | | cycleElement = new XElement("Object", new XAttribute("warning", "CycleDetected")); |
| | 1 | 169 | | return false; |
| | | 170 | | } |
| | 44 | 171 | | cycleElement = null; |
| | 44 | 172 | | return true; |
| | | 173 | | } |
| | | 174 | | |
| | | 175 | | /// <summary> |
| | | 176 | | /// Determines whether the specified object is a simple type (primitive, string, DateTime, Guid, or decimal). |
| | | 177 | | /// </summary> |
| | | 178 | | /// <param name="value">The object to check.</param> |
| | | 179 | | /// <returns><c>true</c> if the object is a simple type; otherwise, <c>false</c>.</returns> |
| | | 180 | | private static bool IsSimple(object value) |
| | | 181 | | { |
| | 110 | 182 | | var type = value.GetType(); |
| | 110 | 183 | | return type.IsPrimitive |
| | 110 | 184 | | || value is string |
| | 110 | 185 | | || value is DateTime or DateTimeOffset |
| | 110 | 186 | | || value is Guid |
| | 110 | 187 | | || value is decimal |
| | 110 | 188 | | || value is TimeSpan; |
| | | 189 | | } |
| | | 190 | | |
| | | 191 | | /// <summary>Converts a dictionary to an XML element (recursive).</summary> |
| | | 192 | | /// <param name="name">Element name for the dictionary.</param> |
| | | 193 | | /// <param name="dict">Dictionary to serialize.</param> |
| | | 194 | | /// <param name="depth">Current recursion depth (guarded).</param> |
| | | 195 | | /// <param name="visited">Per-call set used for cycle detection.</param> |
| | | 196 | | private static XElement DictionaryToXml(string name, IDictionary dict, int depth, HashSet<object>? visited) |
| | | 197 | | { |
| | 1 | 198 | | var elem = new XElement(name); |
| | 6 | 199 | | foreach (DictionaryEntry entry in dict) |
| | | 200 | | { |
| | 2 | 201 | | var key = SanitizeName(entry.Key?.ToString() ?? "Key"); |
| | 2 | 202 | | elem.Add(ToXmlInternal(key, entry.Value, depth + 1, visited)); |
| | | 203 | | } |
| | 1 | 204 | | return elem; |
| | | 205 | | } |
| | | 206 | | /// <summary>Converts an enumerable to an XML element; each item becomes <Item/>.</summary> |
| | | 207 | | /// <param name="name">Element name for the collection.</param> |
| | | 208 | | /// <param name="enumerable">Sequence to serialize.</param> |
| | | 209 | | /// <param name="depth">Current recursion depth (guarded).</param> |
| | | 210 | | /// <param name="visited">Per-call set used for cycle detection.</param> |
| | | 211 | | private static XElement EnumerableToXml(string name, IEnumerable enumerable, int depth, HashSet<object>? visited) |
| | | 212 | | { |
| | 1 | 213 | | var elem = new XElement(name); |
| | 6 | 214 | | foreach (var item in enumerable) |
| | | 215 | | { |
| | 2 | 216 | | elem.Add(ToXmlInternal("Item", item, depth + 1, visited)); |
| | | 217 | | } |
| | 1 | 218 | | return elem; |
| | | 219 | | } |
| | | 220 | | |
| | | 221 | | /// <summary>Reflects an object's public instance properties into XML.</summary> |
| | | 222 | | /// <param name="name">Element name for the object.</param> |
| | | 223 | | /// <param name="value">Object instance to serialize.</param> |
| | | 224 | | /// <param name="depth">Current recursion depth (guarded).</param> |
| | | 225 | | /// <param name="visited">Per-call set used for cycle detection.</param> |
| | | 226 | | /// <param name="xmlMetadata">Optional OpenAPI XML metadata hashtable (typically from a static XmlMetadata property) |
| | | 227 | | private static XElement ObjectToXml(string name, object value, int depth, HashSet<object>? visited, Hashtable? xmlMe |
| | | 228 | | { |
| | 44 | 229 | | var elementName = ResolveObjectElementName(name, xmlMetadata); |
| | 44 | 230 | | var objElem = new XElement(elementName); |
| | 44 | 231 | | var type = value.GetType(); |
| | | 232 | | |
| | 44 | 233 | | var propertyMetadata = BuildPropertyMetadataLookup(xmlMetadata); |
| | | 234 | | |
| | 286 | 235 | | foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) |
| | | 236 | | { |
| | 99 | 237 | | if (prop.GetIndexParameters().Length > 0) |
| | | 238 | | { |
| | | 239 | | continue; // skip indexers |
| | | 240 | | } |
| | 99 | 241 | | if (!TryGetPropertyValue(prop, value, out var propVal)) |
| | | 242 | | { |
| | | 243 | | continue; // skip unreadable props |
| | | 244 | | } |
| | | 245 | | |
| | | 246 | | // Check if property has OpenAPI XML metadata |
| | 99 | 247 | | var propName = prop.Name; |
| | 99 | 248 | | var childName = SanitizeName(propName); |
| | | 249 | | |
| | 99 | 250 | | if (propertyMetadata != null && propertyMetadata.TryGetValue(propName, out var propXml)) |
| | | 251 | | { |
| | 4 | 252 | | AddPropertyWithMetadata(objElem, propName, propVal, propXml, depth, visited); |
| | 4 | 253 | | continue; |
| | | 254 | | } |
| | | 255 | | |
| | 95 | 256 | | objElem.Add(ToXmlInternal(childName, propVal, depth + 1, visited)); |
| | | 257 | | } |
| | 44 | 258 | | return objElem; |
| | | 259 | | } |
| | | 260 | | |
| | | 261 | | /// <summary> |
| | | 262 | | /// Resolves the element name used when serializing an object instance. |
| | | 263 | | /// </summary> |
| | | 264 | | /// <param name="defaultName">Default element name (typically the requested root name).</param> |
| | | 265 | | /// <param name="xmlMetadata">Optional OpenAPI XML metadata hashtable.</param> |
| | | 266 | | /// <returns>The resolved element name.</returns> |
| | | 267 | | private static string ResolveObjectElementName(string defaultName, Hashtable? xmlMetadata) |
| | | 268 | | { |
| | | 269 | | // Use class-level XML name if available. |
| | 44 | 270 | | if (xmlMetadata?["ClassXml"] is Hashtable classXmlHash && classXmlHash["Name"] is string className) |
| | | 271 | | { |
| | 0 | 272 | | return className; |
| | | 273 | | } |
| | | 274 | | |
| | 44 | 275 | | if (xmlMetadata?["ClassName"] is string fallbackClassName && !string.IsNullOrWhiteSpace(fallbackClassName)) |
| | | 276 | | { |
| | | 277 | | // When responses are written with the default root ("Response"), prefer the model's class name. |
| | | 278 | | // This aligns runtime XML output with OpenAPI schema component names. |
| | 1 | 279 | | return fallbackClassName; |
| | | 280 | | } |
| | | 281 | | |
| | 43 | 282 | | return defaultName; |
| | | 283 | | } |
| | | 284 | | |
| | | 285 | | /// <summary> |
| | | 286 | | /// Builds a property metadata lookup from a metadata hashtable. |
| | | 287 | | /// </summary> |
| | | 288 | | /// <param name="xmlMetadata">Optional OpenAPI XML metadata hashtable.</param> |
| | | 289 | | /// <returns> |
| | | 290 | | /// A dictionary mapping CLR property names to per-property metadata, or <c>null</c> when no property metadata is av |
| | | 291 | | /// </returns> |
| | | 292 | | private static Dictionary<string, Hashtable>? BuildPropertyMetadataLookup(Hashtable? xmlMetadata) |
| | | 293 | | { |
| | 44 | 294 | | if (xmlMetadata?["Properties"] is not Hashtable propsHash) |
| | | 295 | | { |
| | 43 | 296 | | return null; |
| | | 297 | | } |
| | | 298 | | |
| | 1 | 299 | | var propertyMetadata = new Dictionary<string, Hashtable>(StringComparer.OrdinalIgnoreCase); |
| | 10 | 300 | | foreach (DictionaryEntry entry in propsHash) |
| | | 301 | | { |
| | 4 | 302 | | if (entry.Key is string propName && entry.Value is Hashtable propMeta) |
| | | 303 | | { |
| | 4 | 304 | | propertyMetadata[propName] = propMeta; |
| | | 305 | | } |
| | | 306 | | } |
| | | 307 | | |
| | 1 | 308 | | return propertyMetadata; |
| | | 309 | | } |
| | | 310 | | |
| | | 311 | | /// <summary> |
| | | 312 | | /// Attempts to read a property value via reflection. |
| | | 313 | | /// </summary> |
| | | 314 | | /// <param name="prop">Property info.</param> |
| | | 315 | | /// <param name="instance">Object instance.</param> |
| | | 316 | | /// <param name="value">Property value when readable.</param> |
| | | 317 | | /// <returns><c>true</c> when the property value was read; otherwise <c>false</c>.</returns> |
| | | 318 | | private static bool TryGetPropertyValue(PropertyInfo prop, object instance, out object? value) |
| | | 319 | | { |
| | | 320 | | try |
| | | 321 | | { |
| | 99 | 322 | | value = prop.GetValue(instance); |
| | 99 | 323 | | return true; |
| | | 324 | | } |
| | 0 | 325 | | catch |
| | | 326 | | { |
| | 0 | 327 | | value = null; |
| | 0 | 328 | | return false; |
| | | 329 | | } |
| | 99 | 330 | | } |
| | | 331 | | |
| | | 332 | | /// <summary> |
| | | 333 | | /// Adds a property to an object element using OpenAPI XML metadata rules. |
| | | 334 | | /// </summary> |
| | | 335 | | /// <param name="parent">Parent element representing the object.</param> |
| | | 336 | | /// <param name="propName">CLR property name.</param> |
| | | 337 | | /// <param name="propVal">Property value.</param> |
| | | 338 | | /// <param name="propXml">OpenAPI XML metadata hashtable for the property.</param> |
| | | 339 | | /// <param name="depth">Current recursion depth.</param> |
| | | 340 | | /// <param name="visited">Per-call set used for cycle detection.</param> |
| | | 341 | | private static void AddPropertyWithMetadata(XElement parent, string propName, object? propVal, Hashtable propXml, in |
| | | 342 | | { |
| | 4 | 343 | | var childName = SanitizeName(propName); |
| | | 344 | | |
| | | 345 | | // Use custom element name if specified. |
| | 4 | 346 | | if (propXml["Name"] is string customName) |
| | | 347 | | { |
| | 4 | 348 | | childName = customName; |
| | | 349 | | } |
| | | 350 | | |
| | | 351 | | // Check if this property should be an XML attribute. |
| | 4 | 352 | | if (propXml["Attribute"] is bool isAttribute && isAttribute && propVal != null) |
| | | 353 | | { |
| | 1 | 354 | | parent.Add(new XAttribute(childName, propVal)); |
| | 1 | 355 | | return; |
| | | 356 | | } |
| | | 357 | | |
| | | 358 | | // Special handling for array/list properties when XML metadata is present. |
| | | 359 | | // - If Wrapped=true, add a wrapper element and put items under it. |
| | | 360 | | // - If Wrapped=false, emit repeated sibling elements for each item. |
| | 3 | 361 | | if (propVal is IEnumerable enumerable and not string) |
| | | 362 | | { |
| | 1 | 363 | | var wrapped = propXml["Wrapped"] is bool w && w; |
| | 1 | 364 | | var nsUri = propXml["Namespace"] as string; |
| | 1 | 365 | | var prefix = propXml["Prefix"] as string; |
| | 1 | 366 | | AppendEnumerableProperty(parent, childName, enumerable, wrapped, nsUri, prefix, depth + 1, visited); |
| | 1 | 367 | | return; |
| | | 368 | | } |
| | | 369 | | |
| | | 370 | | // Apply namespace/prefix to the element representing this property (non-attribute, non-collection). |
| | 2 | 371 | | var nsUriForElement = propXml["Namespace"] as string; |
| | 2 | 372 | | var prefixForElement = propXml["Prefix"] as string; |
| | 2 | 373 | | var childElem = ToXmlInternal(childName, propVal, depth + 1, visited); |
| | 2 | 374 | | ApplyNamespaceAndPrefix(childElem, nsUriForElement, prefixForElement); |
| | 2 | 375 | | parent.Add(childElem); |
| | 2 | 376 | | } |
| | | 377 | | |
| | | 378 | | /// <summary> |
| | | 379 | | /// Appends an enumerable property to an existing element, honoring OpenAPI XML metadata options. |
| | | 380 | | /// </summary> |
| | | 381 | | /// <param name="parent">Parent element to append to.</param> |
| | | 382 | | /// <param name="itemName">Element name to use for items (and wrapper when <paramref name="wrapped"/> is true).</par |
| | | 383 | | /// <param name="enumerable">Enumerable to serialize.</param> |
| | | 384 | | /// <param name="wrapped">If true, wrap items in a container element.</param> |
| | | 385 | | /// <param name="nsUri">Optional namespace URI for the element(s).</param> |
| | | 386 | | /// <param name="prefix">Optional prefix to declare for <paramref name="nsUri"/>.</param> |
| | | 387 | | /// <param name="depth">Current recursion depth.</param> |
| | | 388 | | /// <param name="visited">Per-call set used for cycle detection.</param> |
| | | 389 | | private static void AppendEnumerableProperty(XElement parent, string itemName, IEnumerable enumerable, bool wrapped, |
| | | 390 | | { |
| | 1 | 391 | | if (wrapped) |
| | | 392 | | { |
| | 1 | 393 | | var wrapper = new XElement(itemName); |
| | 1 | 394 | | ApplyNamespaceAndPrefix(wrapper, nsUri, prefix); |
| | 8 | 395 | | foreach (var item in enumerable) |
| | | 396 | | { |
| | 3 | 397 | | var itemElem = ToXmlInternal(itemName, item, depth + 1, visited); |
| | 3 | 398 | | ApplyNamespaceAndPrefix(itemElem, nsUri, prefix); |
| | 3 | 399 | | wrapper.Add(itemElem); |
| | | 400 | | } |
| | 1 | 401 | | parent.Add(wrapper); |
| | 1 | 402 | | return; |
| | | 403 | | } |
| | | 404 | | |
| | 0 | 405 | | foreach (var item in enumerable) |
| | | 406 | | { |
| | 0 | 407 | | var itemElem = ToXmlInternal(itemName, item, depth + 1, visited); |
| | 0 | 408 | | ApplyNamespaceAndPrefix(itemElem, nsUri, prefix); |
| | 0 | 409 | | parent.Add(itemElem); |
| | | 410 | | } |
| | 0 | 411 | | } |
| | | 412 | | |
| | | 413 | | /// <summary> |
| | | 414 | | /// Applies namespace and prefix settings to an element, ensuring the requested prefix is declared. |
| | | 415 | | /// </summary> |
| | | 416 | | /// <param name="element">The element to update.</param> |
| | | 417 | | /// <param name="nsUri">Namespace URI to apply.</param> |
| | | 418 | | /// <param name="prefix">Prefix to declare for the namespace.</param> |
| | | 419 | | private static void ApplyNamespaceAndPrefix(XElement element, string? nsUri, string? prefix) |
| | | 420 | | { |
| | 6 | 421 | | if (string.IsNullOrWhiteSpace(nsUri)) |
| | | 422 | | { |
| | 5 | 423 | | return; |
| | | 424 | | } |
| | | 425 | | |
| | 1 | 426 | | var ns = XNamespace.Get(nsUri); |
| | 1 | 427 | | element.Name = ns + element.Name.LocalName; |
| | | 428 | | |
| | 1 | 429 | | if (string.IsNullOrWhiteSpace(prefix)) |
| | | 430 | | { |
| | 0 | 431 | | return; |
| | | 432 | | } |
| | | 433 | | |
| | | 434 | | // Ensure xmlns:prefix="nsUri" is present so the serializer can use the desired prefix. |
| | | 435 | | // Avoid adding duplicates if the declaration already exists. |
| | 1 | 436 | | var xmlnsName = XNamespace.Xmlns + prefix; |
| | 1 | 437 | | var existing = element.Attribute(xmlnsName); |
| | 1 | 438 | | if (existing == null) |
| | | 439 | | { |
| | 1 | 440 | | element.Add(new XAttribute(xmlnsName, nsUri)); |
| | | 441 | | } |
| | 1 | 442 | | } |
| | | 443 | | |
| | | 444 | | /// <summary> |
| | | 445 | | /// Attempts to retrieve OpenAPI XML metadata from a type. |
| | | 446 | | /// </summary> |
| | | 447 | | /// <param name="type">The type to check for metadata.</param> |
| | | 448 | | /// <returns>A Hashtable containing XML metadata, or null if none is available.</returns> |
| | | 449 | | private static Hashtable? TryGetXmlMetadata(Type type) |
| | | 450 | | { |
| | | 451 | | try |
| | | 452 | | { |
| | | 453 | | // Preferred: a static hashtable property/field (safe to read via reflection). |
| | | 454 | | // PowerShell class *methods* require an engine context on the current thread and may throw when invoked |
| | | 455 | | // from C# (even if the type was defined in a runspace). |
| | 48 | 456 | | var flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.FlattenHierarc |
| | | 457 | | |
| | 97 | 458 | | foreach (var value in EnumerateStaticMemberValues(type, flags, onlyNamedXmlMetadata: true)) |
| | | 459 | | { |
| | 1 | 460 | | var byName = AsMetadataHashtable(value); |
| | 1 | 461 | | if (byName is not null) |
| | | 462 | | { |
| | 1 | 463 | | return byName; |
| | | 464 | | } |
| | | 465 | | } |
| | | 466 | | |
| | | 467 | | // Fallback: scan static members and return the first value that looks like XML metadata. |
| | 94 | 468 | | foreach (var value in EnumerateStaticMemberValues(type, flags, onlyNamedXmlMetadata: false)) |
| | | 469 | | { |
| | 0 | 470 | | var candidate = AsMetadataHashtable(value); |
| | 0 | 471 | | if (candidate is not null) |
| | | 472 | | { |
| | 0 | 473 | | return candidate; |
| | | 474 | | } |
| | | 475 | | } |
| | 47 | 476 | | } |
| | 0 | 477 | | catch (Exception ex) |
| | | 478 | | { |
| | 0 | 479 | | Log.Logger.Error(ex, "Error retrieving XML metadata for type {TypeName}", type.FullName); |
| | 0 | 480 | | } |
| | 47 | 481 | | return null; |
| | 1 | 482 | | } |
| | | 483 | | |
| | | 484 | | /// <summary> |
| | | 485 | | /// Enumerates values of static properties and fields on a type. |
| | | 486 | | /// </summary> |
| | | 487 | | /// <param name="type">Type to reflect.</param> |
| | | 488 | | /// <param name="flags">Binding flags used for reflection.</param> |
| | | 489 | | /// <param name="onlyNamedXmlMetadata"> |
| | | 490 | | /// When <c>true</c>, only members named <c>XmlMetadata</c> (ignoring case and an optional leading <c>$</c>) are ret |
| | | 491 | | /// </param> |
| | | 492 | | /// <returns>An enumerable of static member values (may include nulls).</returns> |
| | | 493 | | private static IEnumerable<object?> EnumerateStaticMemberValues(Type type, BindingFlags flags, bool onlyNamedXmlMeta |
| | | 494 | | { |
| | 191 | 495 | | foreach (var prop in type.GetProperties(flags)) |
| | | 496 | | { |
| | 1 | 497 | | if (onlyNamedXmlMetadata && !NameMatchesXmlMetadata(prop.Name)) |
| | | 498 | | { |
| | | 499 | | continue; |
| | | 500 | | } |
| | | 501 | | |
| | 1 | 502 | | if (prop.GetIndexParameters().Length != 0) |
| | | 503 | | { |
| | | 504 | | continue; |
| | | 505 | | } |
| | | 506 | | |
| | 1 | 507 | | yield return prop.GetValue(null); |
| | | 508 | | } |
| | | 509 | | |
| | 188 | 510 | | foreach (var field in type.GetFields(flags)) |
| | | 511 | | { |
| | 0 | 512 | | if (onlyNamedXmlMetadata && !NameMatchesXmlMetadata(field.Name)) |
| | | 513 | | { |
| | | 514 | | continue; |
| | | 515 | | } |
| | | 516 | | |
| | 0 | 517 | | yield return field.GetValue(null); |
| | | 518 | | } |
| | 94 | 519 | | } |
| | | 520 | | |
| | | 521 | | /// <summary> |
| | | 522 | | /// Determines whether a hashtable resembles the expected XML metadata shape. |
| | | 523 | | /// </summary> |
| | | 524 | | /// <param name="ht">Hashtable to inspect.</param> |
| | | 525 | | /// <returns><c>true</c> if the hashtable has the expected keys; otherwise <c>false</c>.</returns> |
| | | 526 | | private static bool LooksLikeXmlMetadata(Hashtable ht) |
| | | 527 | | { |
| | 1 | 528 | | return ht.Count > 0 |
| | 1 | 529 | | && (ht.ContainsKey("ClassXml") || ht.ContainsKey("ClassName")) |
| | 1 | 530 | | && ht.ContainsKey("Properties"); |
| | | 531 | | } |
| | | 532 | | |
| | | 533 | | /// <summary> |
| | | 534 | | /// Attempts to normalize an object into an XML metadata hashtable. |
| | | 535 | | /// </summary> |
| | | 536 | | /// <param name="value">Candidate value (hashtable or dictionary).</param> |
| | | 537 | | /// <returns>A metadata hashtable when the candidate matches; otherwise <c>null</c>.</returns> |
| | | 538 | | private static Hashtable? AsMetadataHashtable(object? value) |
| | | 539 | | { |
| | 1 | 540 | | if (value is Hashtable ht) |
| | | 541 | | { |
| | 1 | 542 | | return LooksLikeXmlMetadata(ht) ? ht : null; |
| | | 543 | | } |
| | | 544 | | |
| | 0 | 545 | | if (value is IDictionary dict) |
| | | 546 | | { |
| | 0 | 547 | | var copied = new Hashtable(StringComparer.OrdinalIgnoreCase); |
| | 0 | 548 | | foreach (DictionaryEntry entry in dict) |
| | | 549 | | { |
| | 0 | 550 | | if (entry.Key is string k) |
| | | 551 | | { |
| | 0 | 552 | | copied[k] = entry.Value; |
| | | 553 | | } |
| | | 554 | | } |
| | 0 | 555 | | return LooksLikeXmlMetadata(copied) ? copied : null; |
| | | 556 | | } |
| | | 557 | | |
| | 0 | 558 | | return null; |
| | | 559 | | } |
| | | 560 | | |
| | | 561 | | /// <summary> |
| | | 562 | | /// Checks whether a reflected member name corresponds to an <c>XmlMetadata</c> property/field. |
| | | 563 | | /// </summary> |
| | | 564 | | /// <param name="name">Member name.</param> |
| | | 565 | | /// <returns><c>true</c> when the name matches; otherwise <c>false</c>.</returns> |
| | | 566 | | private static bool NameMatchesXmlMetadata(string name) |
| | | 567 | | { |
| | 1 | 568 | | var normalized = name.TrimStart('$'); |
| | 1 | 569 | | return string.Equals(normalized, "XmlMetadata", StringComparison.OrdinalIgnoreCase); |
| | | 570 | | } |
| | | 571 | | |
| | | 572 | | /// <summary> |
| | | 573 | | /// Gets OpenAPI XML metadata for a CLR type. |
| | | 574 | | /// </summary> |
| | | 575 | | /// <param name="type">The type to inspect.</param> |
| | | 576 | | /// <returns> |
| | | 577 | | /// A hashtable containing XML metadata (ClassName/ClassXml/Properties), or <c>null</c> when the type has no XML ann |
| | | 578 | | /// </returns> |
| | | 579 | | internal static Hashtable? GetOpenApiXmlMetadataForType(Type type) |
| | | 580 | | { |
| | 48 | 581 | | ArgumentNullException.ThrowIfNull(type); |
| | | 582 | | |
| | | 583 | | // First, try the static hashtable member (used by generated classes). |
| | 48 | 584 | | var fromStatic = TryGetXmlMetadata(type); |
| | 48 | 585 | | if (fromStatic is not null) |
| | | 586 | | { |
| | 1 | 587 | | return fromStatic; |
| | | 588 | | } |
| | | 589 | | |
| | | 590 | | // Next, build metadata from attributes on the type (used by user-defined PowerShell classes). |
| | 47 | 591 | | return BuildXmlMetadataFromAttributes(type); |
| | | 592 | | } |
| | | 593 | | |
| | | 594 | | /// <summary> |
| | | 595 | | /// Builds an XmlMetadata hashtable from <see cref="OpenApiXmlAttribute"/> annotations on a type and its members. |
| | | 596 | | /// This supports user-defined PowerShell classes that are not rewritten by the exporter. |
| | | 597 | | /// </summary> |
| | | 598 | | /// <param name="type">The annotated type.</param> |
| | | 599 | | /// <returns>An XmlMetadata hashtable, or <c>null</c> when the type has no XML annotations.</returns> |
| | | 600 | | private static Hashtable? BuildXmlMetadataFromAttributes(Type type) |
| | | 601 | | { |
| | 47 | 602 | | var classXmlAttr = FindOpenApiXmlAttribute(type); |
| | 47 | 603 | | var propsHash = BuildOpenApiXmlMemberMetadata(type); |
| | | 604 | | |
| | 47 | 605 | | if (classXmlAttr is null && propsHash.Count == 0) |
| | | 606 | | { |
| | 45 | 607 | | return null; |
| | | 608 | | } |
| | | 609 | | |
| | 2 | 610 | | var meta = new Hashtable(StringComparer.OrdinalIgnoreCase) |
| | 2 | 611 | | { |
| | 2 | 612 | | ["ClassName"] = type.Name, |
| | 2 | 613 | | ["Properties"] = propsHash, |
| | 2 | 614 | | }; |
| | | 615 | | |
| | 2 | 616 | | if (classXmlAttr is not null) |
| | | 617 | | { |
| | 0 | 618 | | var classXml = BuildOpenApiXmlClassMetadata(classXmlAttr); |
| | 0 | 619 | | if (classXml.Count > 0) |
| | | 620 | | { |
| | 0 | 621 | | meta["ClassXml"] = classXml; |
| | | 622 | | } |
| | | 623 | | } |
| | | 624 | | |
| | 2 | 625 | | return meta; |
| | | 626 | | } |
| | | 627 | | |
| | | 628 | | /// <summary> |
| | | 629 | | /// Finds an <c>OpenApiXmlAttribute</c>-like attribute on the provided member or type. |
| | | 630 | | /// </summary> |
| | | 631 | | /// <param name="provider">The attribute provider to inspect.</param> |
| | | 632 | | /// <returns>The attribute instance when present; otherwise <c>null</c>.</returns> |
| | | 633 | | private static object? FindOpenApiXmlAttribute(ICustomAttributeProvider provider) |
| | | 634 | | { |
| | 152 | 635 | | return provider |
| | 152 | 636 | | .GetCustomAttributes(inherit: false) |
| | 258 | 637 | | .FirstOrDefault(a => a?.GetType().Name == "OpenApiXmlAttribute"); |
| | | 638 | | } |
| | | 639 | | |
| | | 640 | | /// <summary> |
| | | 641 | | /// Reads a string-valued property from an attribute instance by reflection. |
| | | 642 | | /// </summary> |
| | | 643 | | /// <param name="attr">Attribute instance.</param> |
| | | 644 | | /// <param name="propName">Property name to read.</param> |
| | | 645 | | /// <returns>The string value when present; otherwise <c>null</c>.</returns> |
| | | 646 | | private static string? GetStringProperty(object attr, string propName) |
| | 24 | 647 | | => attr.GetType().GetProperty(propName, BindingFlags.Public | BindingFlags.Instance)?.GetValue(attr) as string; |
| | | 648 | | |
| | | 649 | | /// <summary> |
| | | 650 | | /// Reads a bool-valued property from an attribute instance by reflection. |
| | | 651 | | /// </summary> |
| | | 652 | | /// <param name="attr">Attribute instance.</param> |
| | | 653 | | /// <param name="propName">Property name to read.</param> |
| | | 654 | | /// <returns><c>true</c> when the property exists and evaluates to true; otherwise <c>false</c>.</returns> |
| | | 655 | | private static bool GetBoolProperty(object attr, string propName) |
| | | 656 | | { |
| | 16 | 657 | | var value = attr.GetType().GetProperty(propName, BindingFlags.Public | BindingFlags.Instance)?.GetValue(attr); |
| | 16 | 658 | | return value is bool b && b; |
| | | 659 | | } |
| | | 660 | | |
| | | 661 | | /// <summary> |
| | | 662 | | /// Builds a metadata entry hashtable from an <c>OpenApiXmlAttribute</c>-like attribute instance. |
| | | 663 | | /// </summary> |
| | | 664 | | /// <param name="xmlAttr">Attribute instance.</param> |
| | | 665 | | /// <returns>A hashtable containing any provided XML metadata fields.</returns> |
| | | 666 | | private static Hashtable BuildEntryFromAttribute(object xmlAttr) |
| | | 667 | | { |
| | 8 | 668 | | var entry = new Hashtable(StringComparer.OrdinalIgnoreCase); |
| | | 669 | | |
| | 8 | 670 | | var name = GetStringProperty(xmlAttr, "Name"); |
| | 8 | 671 | | if (!string.IsNullOrWhiteSpace(name)) |
| | | 672 | | { |
| | 8 | 673 | | entry["Name"] = name; |
| | | 674 | | } |
| | | 675 | | |
| | 8 | 676 | | var ns = GetStringProperty(xmlAttr, "Namespace"); |
| | 8 | 677 | | if (!string.IsNullOrWhiteSpace(ns)) |
| | | 678 | | { |
| | 2 | 679 | | entry["Namespace"] = ns; |
| | | 680 | | } |
| | | 681 | | |
| | 8 | 682 | | var prefix = GetStringProperty(xmlAttr, "Prefix"); |
| | 8 | 683 | | if (!string.IsNullOrWhiteSpace(prefix)) |
| | | 684 | | { |
| | 2 | 685 | | entry["Prefix"] = prefix; |
| | | 686 | | } |
| | | 687 | | |
| | 8 | 688 | | if (GetBoolProperty(xmlAttr, "Attribute")) |
| | | 689 | | { |
| | 2 | 690 | | entry["Attribute"] = true; |
| | | 691 | | } |
| | | 692 | | |
| | 8 | 693 | | if (GetBoolProperty(xmlAttr, "Wrapped")) |
| | | 694 | | { |
| | 2 | 695 | | entry["Wrapped"] = true; |
| | | 696 | | } |
| | | 697 | | |
| | 8 | 698 | | return entry; |
| | | 699 | | } |
| | | 700 | | |
| | | 701 | | /// <summary> |
| | | 702 | | /// Builds a property/field metadata hashtable by scanning public instance members for XML attributes. |
| | | 703 | | /// </summary> |
| | | 704 | | /// <param name="type">Type to scan.</param> |
| | | 705 | | /// <returns>A hashtable mapping member names to per-member metadata entries.</returns> |
| | | 706 | | private static Hashtable BuildOpenApiXmlMemberMetadata(Type type) |
| | | 707 | | { |
| | 47 | 708 | | var propsHash = new Hashtable(StringComparer.OrdinalIgnoreCase); |
| | 47 | 709 | | PopulateMemberMetadata(type.GetProperties(BindingFlags.Public | BindingFlags.Instance), propsHash); |
| | 47 | 710 | | PopulateMemberMetadata(type.GetFields(BindingFlags.Public | BindingFlags.Instance), propsHash); |
| | 47 | 711 | | return propsHash; |
| | | 712 | | } |
| | | 713 | | |
| | | 714 | | /// <summary> |
| | | 715 | | /// Populates a metadata hashtable by scanning members for XML attribute annotations. |
| | | 716 | | /// </summary> |
| | | 717 | | /// <param name="members">Members to scan.</param> |
| | | 718 | | /// <param name="propsHash">Target metadata hashtable to populate.</param> |
| | | 719 | | private static void PopulateMemberMetadata(IEnumerable<MemberInfo> members, Hashtable propsHash) |
| | | 720 | | { |
| | 398 | 721 | | foreach (var member in members) |
| | | 722 | | { |
| | 105 | 723 | | var xmlAttr = FindOpenApiXmlAttribute(member); |
| | 105 | 724 | | if (xmlAttr is null) |
| | | 725 | | { |
| | | 726 | | continue; |
| | | 727 | | } |
| | | 728 | | |
| | 8 | 729 | | var entry = BuildEntryFromAttribute(xmlAttr); |
| | 8 | 730 | | if (entry.Count > 0) |
| | | 731 | | { |
| | 8 | 732 | | propsHash[member.Name] = entry; |
| | | 733 | | } |
| | | 734 | | } |
| | 94 | 735 | | } |
| | | 736 | | |
| | | 737 | | /// <summary> |
| | | 738 | | /// Builds class-level XML metadata from an <c>OpenApiXmlAttribute</c>-like attribute instance. |
| | | 739 | | /// </summary> |
| | | 740 | | /// <param name="classXmlAttr">Class-level XML attribute instance.</param> |
| | | 741 | | /// <returns>A hashtable with class-level XML metadata.</returns> |
| | | 742 | | private static Hashtable BuildOpenApiXmlClassMetadata(object classXmlAttr) |
| | | 743 | | { |
| | 0 | 744 | | var classXml = new Hashtable(StringComparer.OrdinalIgnoreCase); |
| | | 745 | | |
| | 0 | 746 | | var classEntry = BuildEntryFromAttribute(classXmlAttr); |
| | 0 | 747 | | foreach (DictionaryEntry entry in classEntry) |
| | | 748 | | { |
| | 0 | 749 | | classXml[entry.Key] = entry.Value; |
| | | 750 | | } |
| | | 751 | | |
| | | 752 | | // Class-level metadata should not include member-only flags. |
| | 0 | 753 | | classXml.Remove("Attribute"); |
| | 0 | 754 | | classXml.Remove("Wrapped"); |
| | | 755 | | |
| | 0 | 756 | | return classXml; |
| | | 757 | | } |
| | | 758 | | |
| | | 759 | | /// <summary> |
| | | 760 | | /// Sanitizes a raw string to be a valid XML element name by replacing invalid characters with underscores. |
| | | 761 | | /// </summary> |
| | | 762 | | /// <param name="raw">The raw string to sanitize.</param> |
| | | 763 | | /// <returns>A sanitized XML element name.</returns> |
| | | 764 | | private static string SanitizeName(string raw) |
| | | 765 | | { |
| | 119 | 766 | | if (string.IsNullOrWhiteSpace(raw)) |
| | | 767 | | { |
| | 0 | 768 | | return "Element"; |
| | | 769 | | } |
| | | 770 | | // XML element names must start with letter or underscore; replace invalid chars with '_' |
| | 119 | 771 | | var sb = new System.Text.StringBuilder(raw.Length); |
| | 1310 | 772 | | for (var i = 0; i < raw.Length; i++) |
| | | 773 | | { |
| | 536 | 774 | | var ch = raw[i]; |
| | 536 | 775 | | var valid = i == 0 |
| | 536 | 776 | | ? (char.IsLetter(ch) || ch == '_') |
| | 536 | 777 | | : (char.IsLetterOrDigit(ch) || ch == '_' || ch == '-' || ch == '.'); |
| | 536 | 778 | | _ = sb.Append(valid ? ch : '_'); |
| | | 779 | | } |
| | 119 | 780 | | return sb.ToString(); |
| | | 781 | | } |
| | | 782 | | |
| | | 783 | | private sealed class ReferenceEqualityComparer : IEqualityComparer<object> |
| | | 784 | | { |
| | 1 | 785 | | public static readonly ReferenceEqualityComparer Instance = new(); |
| | 45 | 786 | | public new bool Equals(object? x, object? y) => ReferenceEquals(x, y); |
| | 89 | 787 | | public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj); |
| | | 788 | | } |
| | | 789 | | |
| | | 790 | | /// <summary> |
| | | 791 | | /// Converts an <see cref="XElement"/> into a <see cref="Hashtable"/>, optionally using OpenAPI XML metadata hashtab |
| | | 792 | | /// Nested elements become nested Hashtables; repeated elements become lists. |
| | | 793 | | /// Attributes are stored as keys prefixed with "@" unless guided by OpenAPI metadata, xsi:nil="true" becomes <c>nul |
| | | 794 | | /// </summary> |
| | | 795 | | /// <param name="element">The XML element to convert.</param> |
| | | 796 | | /// <param name="xmlMetadata">Optional OpenAPI XML metadata hashtable (as returned by GetOpenApiXmlMetadata()). Shou |
| | | 797 | | /// <returns>A Hashtable representation of the XML element.</returns> |
| | | 798 | | public static Hashtable ToHashtable(XElement element, Hashtable? xmlMetadata = null) |
| | | 799 | | { |
| | 11 | 800 | | if (xmlMetadata == null) |
| | | 801 | | { |
| | 9 | 802 | | return ToHashtableInternal(element, null, []); |
| | | 803 | | } |
| | | 804 | | |
| | | 805 | | // Convert hashtable metadata to OpenApiXml object for class-level metadata |
| | 2 | 806 | | OpenApiXmlModel? classXml = null; |
| | 2 | 807 | | if (xmlMetadata["ClassXml"] is Hashtable classXmlHash) |
| | | 808 | | { |
| | 0 | 809 | | classXml = HashtableToOpenApiXml(classXmlHash); |
| | | 810 | | } |
| | | 811 | | |
| | | 812 | | // Convert property metadata hashtables to OpenApiXml objects |
| | 2 | 813 | | var propertyModels = new Dictionary<string, OpenApiXmlModel>(); |
| | 2 | 814 | | if (xmlMetadata["Properties"] is Hashtable propsHash) |
| | | 815 | | { |
| | 20 | 816 | | foreach (DictionaryEntry entry in propsHash) |
| | | 817 | | { |
| | 8 | 818 | | if (entry.Key is string propName && entry.Value is Hashtable propXmlHash) |
| | | 819 | | { |
| | 8 | 820 | | var propXml = HashtableToOpenApiXml(propXmlHash); |
| | 8 | 821 | | if (propXml != null) |
| | | 822 | | { |
| | 8 | 823 | | propertyModels[propName] = propXml; |
| | | 824 | | } |
| | | 825 | | } |
| | | 826 | | } |
| | | 827 | | } |
| | | 828 | | |
| | 2 | 829 | | return ToHashtableInternal(element, classXml, propertyModels); |
| | | 830 | | } |
| | | 831 | | |
| | | 832 | | /// <summary> |
| | | 833 | | /// Converts a hashtable representation of OpenAPI XML metadata to an OpenApiXml object. |
| | | 834 | | /// </summary> |
| | | 835 | | /// <param name="hash">Hashtable containing Name, Namespace, Prefix, Attribute, and/or Wrapped keys.</param> |
| | | 836 | | /// <returns>An OpenApiXml object with the specified properties, or null if the hashtable is empty/invalid.</returns |
| | | 837 | | private static OpenApiXmlModel? HashtableToOpenApiXml(Hashtable hash) |
| | | 838 | | { |
| | 8 | 839 | | ArgumentNullException.ThrowIfNull(hash); |
| | | 840 | | |
| | 8 | 841 | | if (hash.Count == 0) |
| | | 842 | | { |
| | 0 | 843 | | return null; |
| | | 844 | | } |
| | | 845 | | |
| | 8 | 846 | | var xml = new OpenApiXmlModel(); |
| | 8 | 847 | | ApplyOpenApiXmlName(hash, xml); |
| | 8 | 848 | | ApplyOpenApiXmlNamespace(hash, xml); |
| | 8 | 849 | | ApplyOpenApiXmlPrefix(hash, xml); |
| | 8 | 850 | | ApplyOpenApiXmlNodeType(hash, xml); |
| | 8 | 851 | | return xml; |
| | | 852 | | } |
| | | 853 | | |
| | | 854 | | /// <summary> |
| | | 855 | | /// Applies the OpenAPI XML <c>name</c> value from a metadata hashtable. |
| | | 856 | | /// </summary> |
| | | 857 | | /// <param name="hash">Metadata hashtable.</param> |
| | | 858 | | /// <param name="xml">Target OpenAPI XML object.</param> |
| | | 859 | | private static void ApplyOpenApiXmlName(Hashtable hash, OpenApiXmlModel xml) |
| | | 860 | | { |
| | 8 | 861 | | if (hash["Name"] is string name) |
| | | 862 | | { |
| | 8 | 863 | | xml.Name = name; |
| | | 864 | | } |
| | 8 | 865 | | } |
| | | 866 | | |
| | | 867 | | /// <summary> |
| | | 868 | | /// Applies the OpenAPI XML <c>namespace</c> value from a metadata hashtable. |
| | | 869 | | /// </summary> |
| | | 870 | | /// <param name="hash">Metadata hashtable.</param> |
| | | 871 | | /// <param name="xml">Target OpenAPI XML object.</param> |
| | | 872 | | private static void ApplyOpenApiXmlNamespace(Hashtable hash, OpenApiXmlModel xml) |
| | | 873 | | { |
| | 8 | 874 | | if (hash["Namespace"] is string ns && !string.IsNullOrWhiteSpace(ns)) |
| | | 875 | | { |
| | 2 | 876 | | xml.Namespace = new Uri(ns); |
| | | 877 | | } |
| | 8 | 878 | | } |
| | | 879 | | |
| | | 880 | | /// <summary> |
| | | 881 | | /// Applies the OpenAPI XML <c>prefix</c> value from a metadata hashtable. |
| | | 882 | | /// </summary> |
| | | 883 | | /// <param name="hash">Metadata hashtable.</param> |
| | | 884 | | /// <param name="xml">Target OpenAPI XML object.</param> |
| | | 885 | | private static void ApplyOpenApiXmlPrefix(Hashtable hash, OpenApiXmlModel xml) |
| | | 886 | | { |
| | 8 | 887 | | if (hash["Prefix"] is string prefix) |
| | | 888 | | { |
| | 2 | 889 | | xml.Prefix = prefix; |
| | | 890 | | } |
| | 8 | 891 | | } |
| | | 892 | | |
| | | 893 | | /// <summary> |
| | | 894 | | /// Applies the OpenAPI XML node type (<c>attribute</c> vs element) based on metadata flags. |
| | | 895 | | /// </summary> |
| | | 896 | | /// <param name="hash">Metadata hashtable.</param> |
| | | 897 | | /// <param name="xml">Target OpenAPI XML object.</param> |
| | | 898 | | private static void ApplyOpenApiXmlNodeType(Hashtable hash, OpenApiXmlModel xml) |
| | | 899 | | { |
| | 8 | 900 | | if (hash["Attribute"] is bool isAttribute && isAttribute) |
| | | 901 | | { |
| | 2 | 902 | | xml.NodeType = OpenApiXmlNodeType.Attribute; |
| | 2 | 903 | | return; |
| | | 904 | | } |
| | | 905 | | |
| | 6 | 906 | | if (hash["Wrapped"] is bool isWrapped && isWrapped) |
| | | 907 | | { |
| | 2 | 908 | | xml.NodeType = OpenApiXmlNodeType.Element; |
| | | 909 | | } |
| | 6 | 910 | | } |
| | | 911 | | |
| | | 912 | | /// <summary> |
| | | 913 | | /// Internal recursive method to convert an <see cref="XElement"/> into a <see cref="Hashtable"/> with OpenAPI XML m |
| | | 914 | | /// </summary> |
| | | 915 | | /// <param name="element">The XML element to convert.</param> |
| | | 916 | | /// <param name="xmlModel">OpenAPI XML model metadata for the current element.</param> |
| | | 917 | | /// <param name="propertyModels">Dictionary of property-specific XML models for child elements.</param> |
| | | 918 | | /// <returns>A Hashtable representation of the XML element.</returns> |
| | | 919 | | private static Hashtable ToHashtableInternal(XElement element, OpenApiXmlModel? xmlModel, Dictionary<string, OpenApi |
| | | 920 | | { |
| | 34 | 921 | | var elementName = GetEffectiveElementName(element, xmlModel); |
| | | 922 | | |
| | 34 | 923 | | if (TryConvertNilElement(element, elementName, out var nilResult)) |
| | | 924 | | { |
| | 2 | 925 | | return nilResult; |
| | | 926 | | } |
| | | 927 | | |
| | 32 | 928 | | var table = new Hashtable(); |
| | 32 | 929 | | AddAttributesToTable(element, table, propertyModels); |
| | | 930 | | |
| | 32 | 931 | | if (TryAddLeafValue(element, elementName, table)) |
| | | 932 | | { |
| | 22 | 933 | | return table; |
| | | 934 | | } |
| | | 935 | | |
| | 10 | 936 | | table[elementName] = ConvertChildElements(element, propertyModels); |
| | 10 | 937 | | return table; |
| | | 938 | | } |
| | | 939 | | |
| | | 940 | | /// <summary> |
| | | 941 | | /// Gets the effective name for an element, honoring OpenAPI <c>xml.name</c> overrides. |
| | | 942 | | /// </summary> |
| | | 943 | | /// <param name="element">The element whose name is being resolved.</param> |
| | | 944 | | /// <param name="xmlModel">The OpenAPI XML model for the current element.</param> |
| | | 945 | | /// <returns>The resolved element name.</returns> |
| | | 946 | | private static string GetEffectiveElementName(XElement element, OpenApiXmlModel? xmlModel) |
| | 34 | 947 | | => xmlModel?.Name ?? element.Name.LocalName; |
| | | 948 | | |
| | | 949 | | /// <summary> |
| | | 950 | | /// Detects <c>xsi:nil="true"</c> and returns the null-valued hashtable representation. |
| | | 951 | | /// </summary> |
| | | 952 | | /// <param name="element">The element to inspect for <c>xsi:nil</c>.</param> |
| | | 953 | | /// <param name="elementName">The effective element name to use as the hashtable key.</param> |
| | | 954 | | /// <param name="result">The null-valued hashtable when <c>xsi:nil</c> is present; otherwise a default value.</param |
| | | 955 | | /// <returns><c>true</c> when <c>xsi:nil="true"</c> is present; otherwise <c>false</c>.</returns> |
| | | 956 | | private static bool TryConvertNilElement(XElement element, string elementName, out Hashtable result) |
| | | 957 | | { |
| | 82 | 958 | | foreach (var attr in element.Attributes()) |
| | | 959 | | { |
| | 8 | 960 | | if (attr.Name.NamespaceName == xsi.NamespaceName |
| | 8 | 961 | | && attr.Name.LocalName == "nil" |
| | 8 | 962 | | && string.Equals(attr.Value, "true", StringComparison.OrdinalIgnoreCase)) |
| | | 963 | | { |
| | 2 | 964 | | result = new Hashtable { [elementName] = null }; |
| | 2 | 965 | | return true; |
| | | 966 | | } |
| | | 967 | | } |
| | | 968 | | |
| | 32 | 969 | | result = default!; |
| | 32 | 970 | | return false; |
| | 2 | 971 | | } |
| | | 972 | | |
| | | 973 | | /// <summary> |
| | | 974 | | /// Adds element attributes to the target hashtable, honoring OpenAPI <c>xml.attribute</c> mappings. |
| | | 975 | | /// </summary> |
| | | 976 | | /// <param name="element">The element whose attributes are being read.</param> |
| | | 977 | | /// <param name="table">The hashtable to populate.</param> |
| | | 978 | | /// <param name="propertyModels">Property-level OpenAPI XML models used to map attribute names to property keys.</pa |
| | | 979 | | private static void AddAttributesToTable(XElement element, Hashtable table, Dictionary<string, OpenApiXmlModel> prop |
| | | 980 | | { |
| | 76 | 981 | | foreach (var attr in element.Attributes()) |
| | | 982 | | { |
| | 6 | 983 | | var attrKey = FindPropertyKeyForAttribute(attr.Name.LocalName, propertyModels); |
| | 6 | 984 | | if (attrKey is not null) |
| | | 985 | | { |
| | 2 | 986 | | table[attrKey] = attr.Value; |
| | 2 | 987 | | continue; |
| | | 988 | | } |
| | | 989 | | |
| | 4 | 990 | | table["@" + attr.Name.LocalName] = attr.Value; |
| | | 991 | | } |
| | 32 | 992 | | } |
| | | 993 | | |
| | | 994 | | /// <summary> |
| | | 995 | | /// Converts a leaf element (no child elements) by storing its string value under the effective element name. |
| | | 996 | | /// </summary> |
| | | 997 | | /// <param name="element">The element to evaluate.</param> |
| | | 998 | | /// <param name="elementName">The effective element name.</param> |
| | | 999 | | /// <param name="table">The hashtable to populate.</param> |
| | | 1000 | | /// <returns><c>true</c> when the element is a leaf; otherwise <c>false</c>.</returns> |
| | | 1001 | | private static bool TryAddLeafValue(XElement element, string elementName, Hashtable table) |
| | | 1002 | | { |
| | 32 | 1003 | | if (element.HasElements) |
| | | 1004 | | { |
| | 10 | 1005 | | return false; |
| | | 1006 | | } |
| | | 1007 | | |
| | 22 | 1008 | | if (!string.IsNullOrWhiteSpace(element.Value)) |
| | | 1009 | | { |
| | 21 | 1010 | | table[elementName] = element.Value; |
| | | 1011 | | } |
| | | 1012 | | |
| | 22 | 1013 | | return true; |
| | | 1014 | | } |
| | | 1015 | | |
| | | 1016 | | /// <summary> |
| | | 1017 | | /// Converts child elements into a nested hashtable, merging repeated keys into a list. |
| | | 1018 | | /// </summary> |
| | | 1019 | | /// <param name="element">The parent element whose children are being converted.</param> |
| | | 1020 | | /// <param name="propertyModels">Property-level OpenAPI XML models used to map element names to property keys.</para |
| | | 1021 | | /// <returns>A hashtable of child values.</returns> |
| | | 1022 | | private static Hashtable ConvertChildElements(XElement element, Dictionary<string, OpenApiXmlModel> propertyModels) |
| | | 1023 | | { |
| | 10 | 1024 | | var childMap = new Hashtable(); |
| | | 1025 | | |
| | 66 | 1026 | | foreach (var child in element.Elements()) |
| | | 1027 | | { |
| | 23 | 1028 | | var childLocalName = child.Name.LocalName; |
| | | 1029 | | |
| | | 1030 | | // Map the XML element name back to the *property key* (like attributes do) so callers can bind |
| | | 1031 | | // the resulting hashtable to CLR/PowerShell classes. |
| | 23 | 1032 | | var childPropertyKey = FindPropertyKeyForElement(childLocalName, propertyModels); |
| | 23 | 1033 | | var childModel = childPropertyKey != null ? propertyModels[childPropertyKey] : null; |
| | 23 | 1034 | | var childElementName = childModel?.Name ?? childLocalName; |
| | | 1035 | | |
| | 23 | 1036 | | var childValue = ToHashtableInternal(child, childModel, []); |
| | 23 | 1037 | | var valueToStore = childValue[childElementName]; |
| | | 1038 | | |
| | 23 | 1039 | | var keyToStore = childPropertyKey ?? childElementName; |
| | 23 | 1040 | | AddOrAppendChildValue(childMap, keyToStore, valueToStore); |
| | | 1041 | | } |
| | | 1042 | | |
| | 10 | 1043 | | return childMap; |
| | | 1044 | | } |
| | | 1045 | | |
| | | 1046 | | /// <summary> |
| | | 1047 | | /// Adds a child value to the map, converting repeated keys into a list (allowing null values). |
| | | 1048 | | /// </summary> |
| | | 1049 | | /// <param name="childMap">Target map of child values.</param> |
| | | 1050 | | /// <param name="key">Key to store under.</param> |
| | | 1051 | | /// <param name="value">Value to store.</param> |
| | | 1052 | | private static void AddOrAppendChildValue(Hashtable childMap, string key, object? value) |
| | | 1053 | | { |
| | 23 | 1054 | | if (!childMap.ContainsKey(key)) |
| | | 1055 | | { |
| | 16 | 1056 | | childMap[key] = value; |
| | 16 | 1057 | | return; |
| | | 1058 | | } |
| | | 1059 | | |
| | 7 | 1060 | | if (childMap[key] is List<object?> list) |
| | | 1061 | | { |
| | 3 | 1062 | | list.Add(value); |
| | 3 | 1063 | | return; |
| | | 1064 | | } |
| | | 1065 | | |
| | 4 | 1066 | | childMap[key] = new List<object?> |
| | 4 | 1067 | | { |
| | 4 | 1068 | | childMap[key], |
| | 4 | 1069 | | value |
| | 4 | 1070 | | }; |
| | 4 | 1071 | | } |
| | | 1072 | | |
| | | 1073 | | /// <summary> |
| | | 1074 | | /// Finds the property key that corresponds to an XML attribute based on OpenAPI XML models. |
| | | 1075 | | /// </summary> |
| | | 1076 | | /// <param name="attributeName">The XML attribute name.</param> |
| | | 1077 | | /// <param name="propertyModels">Dictionary of property-specific XML models.</param> |
| | | 1078 | | /// <returns>The property key if found; otherwise, null.</returns> |
| | | 1079 | | private static string? FindPropertyKeyForAttribute(string attributeName, Dictionary<string, OpenApiXmlModel> propert |
| | | 1080 | | { |
| | 14 | 1081 | | foreach (var kvp in propertyModels) |
| | | 1082 | | { |
| | 2 | 1083 | | var model = kvp.Value; |
| | | 1084 | | // Check if this property is marked as an attribute and matches the name |
| | 2 | 1085 | | if (model.NodeType == OpenApiXmlNodeType.Attribute) |
| | | 1086 | | { |
| | 2 | 1087 | | var expectedName = model.Name ?? kvp.Key; |
| | 2 | 1088 | | if (string.Equals(expectedName, attributeName, StringComparison.OrdinalIgnoreCase)) |
| | | 1089 | | { |
| | 2 | 1090 | | return kvp.Key; // Return the original property name |
| | | 1091 | | } |
| | | 1092 | | } |
| | | 1093 | | } |
| | 4 | 1094 | | return null; |
| | 2 | 1095 | | } |
| | | 1096 | | |
| | | 1097 | | /// <summary> |
| | | 1098 | | /// Finds the property key that corresponds to an XML element name based on OpenAPI XML models. |
| | | 1099 | | /// </summary> |
| | | 1100 | | /// <param name="elementName">The XML element local name.</param> |
| | | 1101 | | /// <param name="propertyModels">Dictionary of property-specific XML models.</param> |
| | | 1102 | | /// <returns>The property key if found; otherwise, null.</returns> |
| | | 1103 | | private static string? FindPropertyKeyForElement(string elementName, Dictionary<string, OpenApiXmlModel> propertyMod |
| | | 1104 | | { |
| | | 1105 | | // Fast path: property name matches element name. |
| | 88 | 1106 | | foreach (var kvp in propertyModels) |
| | | 1107 | | { |
| | 22 | 1108 | | if (string.Equals(kvp.Key, elementName, StringComparison.OrdinalIgnoreCase)) |
| | | 1109 | | { |
| | 2 | 1110 | | return kvp.Key; |
| | | 1111 | | } |
| | | 1112 | | } |
| | | 1113 | | |
| | | 1114 | | // Match using model.Name override. |
| | 62 | 1115 | | foreach (var kvp in propertyModels) |
| | | 1116 | | { |
| | 12 | 1117 | | var model = kvp.Value; |
| | 12 | 1118 | | var expectedName = model.Name ?? kvp.Key; |
| | 12 | 1119 | | if (string.Equals(expectedName, elementName, StringComparison.OrdinalIgnoreCase)) |
| | | 1120 | | { |
| | 4 | 1121 | | return kvp.Key; |
| | | 1122 | | } |
| | | 1123 | | } |
| | | 1124 | | |
| | 17 | 1125 | | return null; |
| | 6 | 1126 | | } |
| | | 1127 | | } |