|
| 1 | +namespace Oxpecker.OpenApi |
| 2 | + |
| 3 | +open System |
| 4 | +open System.Collections.Generic |
| 5 | +open System.Threading |
| 6 | +open System.Threading.Tasks |
| 7 | +open Microsoft.AspNetCore.OpenApi |
| 8 | +open Microsoft.OpenApi |
| 9 | +open FSharp.Control |
| 10 | +open type Microsoft.AspNetCore.Http.TypedResults |
| 11 | + |
| 12 | + |
| 13 | +// ---------- F# helpers ---------- |
| 14 | +module private FSharpTypeChecks = |
| 15 | + let (|FSharpOptionKind|_|) (t: Type) = |
| 16 | + if t.IsGenericType then |
| 17 | + let gtd = t.GetGenericTypeDefinition() |
| 18 | + if gtd = typedefof<option<_>> || gtd = typedefof<ValueOption<_>> then |
| 19 | + Some (t.GetGenericArguments()[0]) |
| 20 | + else None |
| 21 | + else None |
| 22 | + |
| 23 | +module private SchemaCopy = |
| 24 | + |
| 25 | + /// Shallowly "adopt" the public, settable surface from `src` into `dst`. |
| 26 | + /// We intentionally avoid touching internals; only copy what's publicly available. |
| 27 | + let copyTo (dst: OpenApiSchema) (src: IOpenApiSchema) = |
| 28 | + // Json-Schema identity & metadata |
| 29 | + dst.Title <- src.Title |
| 30 | + dst.Schema <- src.Schema |
| 31 | + dst.Id <- src.Id |
| 32 | + dst.Comment <- src.Comment |
| 33 | + dst.Vocabulary <- |
| 34 | + match src.Vocabulary with |
| 35 | + | null -> null |
| 36 | + | v -> Dictionary(v) |
| 37 | + dst.DynamicRef <- src.DynamicRef |
| 38 | + dst.DynamicAnchor <- src.DynamicAnchor |
| 39 | + dst.Definitions <- |
| 40 | + match src.Definitions with |
| 41 | + | null -> null |
| 42 | + | d -> Dictionary(d) |
| 43 | + // Numeric/string constraints |
| 44 | + dst.ExclusiveMaximum <- src.ExclusiveMaximum |
| 45 | + dst.ExclusiveMinimum <- src.ExclusiveMinimum |
| 46 | + dst.Maximum <- src.Maximum |
| 47 | + dst.Minimum <- src.Minimum |
| 48 | + dst.MultipleOf <- src.MultipleOf |
| 49 | + dst.MaxLength <- src.MaxLength |
| 50 | + dst.MinLength <- src.MinLength |
| 51 | + dst.Pattern <- src.Pattern |
| 52 | + |
| 53 | + // Type/format & const/default |
| 54 | + dst.Type <- src.Type |
| 55 | + dst.Format <- src.Format |
| 56 | + dst.Const <- src.Const |
| 57 | + dst.Default <- src.Default |
| 58 | + |
| 59 | + // Read/Write/Deprecated |
| 60 | + dst.ReadOnly <- src.ReadOnly |
| 61 | + dst.WriteOnly <- src.WriteOnly |
| 62 | + dst.Deprecated <- src.Deprecated |
| 63 | + |
| 64 | + // Compositions & negation |
| 65 | + dst.AllOf <- |
| 66 | + match src.AllOf with |
| 67 | + | null -> null |
| 68 | + | a -> ResizeArray(a) |
| 69 | + dst.AnyOf <- |
| 70 | + match src.AnyOf with |
| 71 | + | null -> null |
| 72 | + | a -> ResizeArray(a) |
| 73 | + dst.OneOf <- |
| 74 | + match src.OneOf with |
| 75 | + | null -> null |
| 76 | + | a -> ResizeArray(a) |
| 77 | + dst.Not <- src.Not |
| 78 | + |
| 79 | + // Array/object facets |
| 80 | + dst.Items <- src.Items |
| 81 | + dst.MaxItems <- src.MaxItems |
| 82 | + dst.MinItems <- src.MinItems |
| 83 | + dst.UniqueItems <- src.UniqueItems |
| 84 | + |
| 85 | + dst.Properties <- |
| 86 | + match src.Properties with |
| 87 | + | null -> null |
| 88 | + | p -> Dictionary(p) |
| 89 | + dst.PatternProperties <- |
| 90 | + match src.PatternProperties with |
| 91 | + | null -> null |
| 92 | + | p -> Dictionary(p) |
| 93 | + dst.MaxProperties <- src.MaxProperties |
| 94 | + dst.MinProperties <- src.MinProperties |
| 95 | + dst.Required <- |
| 96 | + match src.Required with |
| 97 | + | null -> null |
| 98 | + | r -> HashSet(r) |
| 99 | + dst.AdditionalPropertiesAllowed <- src.AdditionalPropertiesAllowed |
| 100 | + dst.AdditionalProperties <- src.AdditionalProperties |
| 101 | + |
| 102 | + // Misc |
| 103 | + dst.Discriminator <- src.Discriminator |
| 104 | + dst.Description <- src.Description |
| 105 | + dst.Example <- src.Example |
| 106 | + dst.Examples <- |
| 107 | + match src.Examples with |
| 108 | + | null -> null |
| 109 | + | e -> ResizeArray(e) |
| 110 | + dst.Enum <- |
| 111 | + match src.Enum with |
| 112 | + | null -> null |
| 113 | + | e -> ResizeArray(e) |
| 114 | + dst.UnevaluatedProperties <- src.UnevaluatedProperties |
| 115 | + dst.ExternalDocs <- src.ExternalDocs |
| 116 | + dst.Xml <- src.Xml |
| 117 | + dst.Extensions <- |
| 118 | + match src.Extensions with |
| 119 | + | null -> null |
| 120 | + | e -> Dictionary(e) |
| 121 | + dst.UnrecognizedKeywords <- |
| 122 | + match src.UnrecognizedKeywords with |
| 123 | + | null -> null |
| 124 | + | e -> Dictionary(e) |
| 125 | + dst.Metadata <- if src :? IMetadataContainer then (src :?> IMetadataContainer).Metadata else null |
| 126 | + dst.DependentRequired <- |
| 127 | + match src.DependentRequired with |
| 128 | + | null -> null |
| 129 | + | d -> Dictionary(d) |
| 130 | + |
| 131 | + /// Union an existing JsonSchemaType with `null` (OpenAPI 3.1), and also drives `nullable: true` for 3.0. |
| 132 | + let unionWithNull (t: Nullable<JsonSchemaType>) : Nullable<JsonSchemaType> = |
| 133 | + if t.HasValue then |
| 134 | + let combined = |
| 135 | + LanguagePrimitives.EnumOfValue( (int t.Value) ||| (int JsonSchemaType.Null) ) |
| 136 | + Nullable<JsonSchemaType>(combined) |
| 137 | + else |
| 138 | + // Leave as null; writer will omit 'type'. (Optional: add x-nullable via Extensions if you need 3.0 on typeless schemas.) |
| 139 | + Nullable() |
| 140 | + |
| 141 | +// ---------- Transformers ---------- |
| 142 | + |
| 143 | +/// 1) Map F# option/valueoption to the **inner T** and mark it nullable. |
| 144 | +/// Works with the vNext `Microsoft.OpenApi.OpenApiSchema` model you pasted. |
| 145 | +type FSharpOptionSchemaTransformer() = |
| 146 | + interface IOpenApiSchemaTransformer with |
| 147 | + member _.TransformAsync(schema: OpenApiSchema, ctx: OpenApiSchemaTransformerContext, ct: CancellationToken) : Task = |
| 148 | + task { |
| 149 | + match ctx.JsonTypeInfo.Type with |
| 150 | + | FSharpTypeChecks.FSharpOptionKind innerT -> |
| 151 | + // Ask pipeline for T's schema … |
| 152 | + let! inner = ctx.GetOrCreateSchemaAsync(innerT, null, ct) |
| 153 | + // … copy its shape … |
| 154 | + inner |> SchemaCopy.copyTo schema |
| 155 | + // … and mark nullable (OAS 3.0 => "nullable: true"; OAS 3.1 => type union with "null") |
| 156 | + schema.Type <- SchemaCopy.unionWithNull schema.Type |
| 157 | + | _ -> () |
| 158 | + } :> Task |
| 159 | + |
| 160 | +/// 2) On object schemas, ensure option-backed properties are **NOT in `required`**. |
| 161 | +/// We use CLR metadata only — no dependency on child schema concrete types. |
| 162 | +// type FSharpOptionRequiredPruner() = |
| 163 | +// interface IOpenApiSchemaTransformer with |
| 164 | +// member _.TransformAsync(schema: OpenApiSchema, ctx: OpenApiSchemaTransformerContext, _ct: CancellationToken) : Task = |
| 165 | +// task { |
| 166 | +// match ctx.JsonTypeInfo.Kind, schema.Required with |
| 167 | +// | JsonTypeInfoKind.Object, NonNull s when s.Count > 0 -> |
| 168 | +// for p in ctx.JsonTypeInfo.Properties do |
| 169 | +// match p.PropertyType with |
| 170 | +// | FSharpTypeChecks.FSharpOptionKind _ -> |
| 171 | +// s.Remove(p.Name) |> ignore |
| 172 | +// | _ -> |
| 173 | +// () |
| 174 | +// | _ -> |
| 175 | +// () |
| 176 | +// } :> Task |
0 commit comments