├── .gitignore ├── LICENSE ├── README.md ├── api ├── Api.csproj ├── Program.cs ├── Properties │ └── launchSettings.json ├── appsettings.Development.json ├── appsettings.json └── requests.http ├── global.json ├── minapi-validation-support.sln ├── models ├── Models.csproj ├── Product.cs └── Store.cs └── nuget.config /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | # but not Directory.Build.rsp, as it configures directory-level build defaults 86 | !Directory.Build.rsp 87 | *.sbr 88 | *.tlb 89 | *.tli 90 | *.tlh 91 | *.tmp 92 | *.tmp_proj 93 | *_wpftmp.csproj 94 | *.log 95 | *.tlog 96 | *.vspscc 97 | *.vssscc 98 | .builds 99 | *.pidb 100 | *.svclog 101 | *.scc 102 | 103 | # Chutzpah Test files 104 | _Chutzpah* 105 | 106 | # Visual C++ cache files 107 | ipch/ 108 | *.aps 109 | *.ncb 110 | *.opendb 111 | *.opensdf 112 | *.sdf 113 | *.cachefile 114 | *.VC.db 115 | *.VC.VC.opendb 116 | 117 | # Visual Studio profiler 118 | *.psess 119 | *.vsp 120 | *.vspx 121 | *.sap 122 | 123 | # Visual Studio Trace Files 124 | *.e2e 125 | 126 | # TFS 2012 Local Workspace 127 | $tf/ 128 | 129 | # Guidance Automation Toolkit 130 | *.gpState 131 | 132 | # ReSharper is a .NET coding add-in 133 | _ReSharper*/ 134 | *.[Rr]e[Ss]harper 135 | *.DotSettings.user 136 | 137 | # TeamCity is a build add-in 138 | _TeamCity* 139 | 140 | # DotCover is a Code Coverage Tool 141 | *.dotCover 142 | 143 | # AxoCover is a Code Coverage Tool 144 | .axoCover/* 145 | !.axoCover/settings.json 146 | 147 | # Coverlet is a free, cross platform Code Coverage Tool 148 | coverage*.json 149 | coverage*.xml 150 | coverage*.info 151 | 152 | # Visual Studio code coverage results 153 | *.coverage 154 | *.coveragexml 155 | 156 | # NCrunch 157 | _NCrunch_* 158 | .*crunch*.local.xml 159 | nCrunchTemp_* 160 | 161 | # MightyMoose 162 | *.mm.* 163 | AutoTest.Net/ 164 | 165 | # Web workbench (sass) 166 | .sass-cache/ 167 | 168 | # Installshield output folder 169 | [Ee]xpress/ 170 | 171 | # DocProject is a documentation generator add-in 172 | DocProject/buildhelp/ 173 | DocProject/Help/*.HxT 174 | DocProject/Help/*.HxC 175 | DocProject/Help/*.hhc 176 | DocProject/Help/*.hhk 177 | DocProject/Help/*.hhp 178 | DocProject/Help/Html2 179 | DocProject/Help/html 180 | 181 | # Click-Once directory 182 | publish/ 183 | 184 | # Publish Web Output 185 | *.[Pp]ublish.xml 186 | *.azurePubxml 187 | # Note: Comment the next line if you want to checkin your web deploy settings, 188 | # but database connection strings (with potential passwords) will be unencrypted 189 | *.pubxml 190 | *.publishproj 191 | 192 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 193 | # checkin your Azure Web App publish settings, but sensitive information contained 194 | # in these scripts will be unencrypted 195 | PublishScripts/ 196 | 197 | # NuGet Packages 198 | *.nupkg 199 | # NuGet Symbol Packages 200 | *.snupkg 201 | # The packages folder can be ignored because of Package Restore 202 | **/[Pp]ackages/* 203 | # except build/, which is used as an MSBuild target. 204 | !**/[Pp]ackages/build/ 205 | # Uncomment if necessary however generally it will be regenerated when needed 206 | #!**/[Pp]ackages/repositories.config 207 | # NuGet v3's project.json files produces more ignorable files 208 | *.nuget.props 209 | *.nuget.targets 210 | 211 | # Microsoft Azure Build Output 212 | csx/ 213 | *.build.csdef 214 | 215 | # Microsoft Azure Emulator 216 | ecf/ 217 | rcf/ 218 | 219 | # Windows Store app package directories and files 220 | AppPackages/ 221 | BundleArtifacts/ 222 | Package.StoreAssociation.xml 223 | _pkginfo.txt 224 | *.appx 225 | *.appxbundle 226 | *.appxupload 227 | 228 | # Visual Studio cache files 229 | # files ending in .cache can be ignored 230 | *.[Cc]ache 231 | # but keep track of directories ending in .cache 232 | !?*.[Cc]ache/ 233 | 234 | # Others 235 | ClientBin/ 236 | ~$* 237 | *~ 238 | *.dbmdl 239 | *.dbproj.schemaview 240 | *.jfm 241 | *.pfx 242 | *.publishsettings 243 | orleans.codegen.cs 244 | 245 | # Including strong name files can present a security risk 246 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 247 | #*.snk 248 | 249 | # Since there are multiple workflows, uncomment next line to ignore bower_components 250 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 251 | #bower_components/ 252 | 253 | # RIA/Silverlight projects 254 | Generated_Code/ 255 | 256 | # Backup & report files from converting an old project file 257 | # to a newer Visual Studio version. Backup files are not needed, 258 | # because we have git ;-) 259 | _UpgradeReport_Files/ 260 | Backup*/ 261 | UpgradeLog*.XML 262 | UpgradeLog*.htm 263 | ServiceFabricBackup/ 264 | *.rptproj.bak 265 | 266 | # SQL Server files 267 | *.mdf 268 | *.ldf 269 | *.ndf 270 | 271 | # Business Intelligence projects 272 | *.rdl.data 273 | *.bim.layout 274 | *.bim_*.settings 275 | *.rptproj.rsuser 276 | *- [Bb]ackup.rdl 277 | *- [Bb]ackup ([0-9]).rdl 278 | *- [Bb]ackup ([0-9][0-9]).rdl 279 | 280 | # Microsoft Fakes 281 | FakesAssemblies/ 282 | 283 | # GhostDoc plugin setting file 284 | *.GhostDoc.xml 285 | 286 | # Node.js Tools for Visual Studio 287 | .ntvs_analysis.dat 288 | node_modules/ 289 | 290 | # Visual Studio 6 build log 291 | *.plg 292 | 293 | # Visual Studio 6 workspace options file 294 | *.opt 295 | 296 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 297 | *.vbw 298 | 299 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 300 | *.vbp 301 | 302 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 303 | *.dsw 304 | *.dsp 305 | 306 | # Visual Studio 6 technical files 307 | *.ncb 308 | *.aps 309 | 310 | # Visual Studio LightSwitch build output 311 | **/*.HTMLClient/GeneratedArtifacts 312 | **/*.DesktopClient/GeneratedArtifacts 313 | **/*.DesktopClient/ModelManifest.xml 314 | **/*.Server/GeneratedArtifacts 315 | **/*.Server/ModelManifest.xml 316 | _Pvt_Extensions 317 | 318 | # Paket dependency manager 319 | .paket/paket.exe 320 | paket-files/ 321 | 322 | # FAKE - F# Make 323 | .fake/ 324 | 325 | # CodeRush personal settings 326 | .cr/personal 327 | 328 | # Python Tools for Visual Studio (PTVS) 329 | __pycache__/ 330 | *.pyc 331 | 332 | # Cake - Uncomment if you are using it 333 | # tools/** 334 | # !tools/packages.config 335 | 336 | # Tabs Studio 337 | *.tss 338 | 339 | # Telerik's JustMock configuration file 340 | *.jmconfig 341 | 342 | # BizTalk build output 343 | *.btp.cs 344 | *.btm.cs 345 | *.odx.cs 346 | *.xsd.cs 347 | 348 | # OpenCover UI analysis results 349 | OpenCover/ 350 | 351 | # Azure Stream Analytics local run output 352 | ASALocalRun/ 353 | 354 | # MSBuild Binary and Structured Log 355 | *.binlog 356 | 357 | # NVidia Nsight GPU debugger configuration file 358 | *.nvuser 359 | 360 | # MFractors (Xamarin productivity tool) working folder 361 | .mfractor/ 362 | 363 | # Local History for Visual Studio 364 | .localhistory/ 365 | 366 | # Visual Studio History (VSHistory) files 367 | .vshistory/ 368 | 369 | # BeatPulse healthcheck temp database 370 | healthchecksdb 371 | 372 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 373 | MigrationBackup/ 374 | 375 | # Ionide (cross platform F# VS Code tools) working folder 376 | .ionide/ 377 | 378 | # Fody - auto-generated XML schema 379 | FodyWeavers.xsd 380 | 381 | # VS Code files for those working on multiple tools 382 | .vscode/* 383 | !.vscode/settings.json 384 | !.vscode/tasks.json 385 | !.vscode/launch.json 386 | !.vscode/extensions.json 387 | *.code-workspace 388 | 389 | # Local History for Visual Studio Code 390 | .history/ 391 | 392 | # Windows Installer files from build outputs 393 | *.cab 394 | *.msi 395 | *.msix 396 | *.msm 397 | *.msp 398 | 399 | # JetBrains Rider 400 | *.sln.iml 401 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Safia Abdalla 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Built-in Validation Support in Minimal APIs 2 | 3 | This app demonstrates built-in support for System.ComponentModel.DataAnnotations-based validations in minimal APIs. 4 | 5 | ## Run Sample App 6 | 7 | To run the API, navigate to the `api` directory and execute `dotnet run`. 8 | 9 | ``` 10 | $ cd api 11 | $ dotnet run 12 | Building... 13 | info: Microsoft.Hosting.Lifetime[14] 14 | Now listening on: http://localhost:5040 15 | info: Microsoft.Hosting.Lifetime[0] 16 | Application started. Press Ctrl+C to shut down. 17 | info: Microsoft.Hosting.Lifetime[0] 18 | Hosting environment: Development 19 | info: Microsoft.Hosting.Lifetime[0] 20 | Content root path: ~/git/minapi-validation-support/api 21 | ``` 22 | 23 | Use the `requests.http` file located in the `api` directory with your favorite HTTP client of choice to test out the end-to-end experience. 24 | 25 | ## Using built-in Validation Support 26 | 27 | To enable built-in validation support for minimal APIs, call the `AddValidation` extension method to register the required services into the service container for your application. 28 | 29 | ```csharp 30 | builder.Services.AddValidation(); 31 | ``` 32 | 33 | The implementation automatically discovers types that are defined in minimal API handlers or as base types of types defined in minimal API handlers. To explicitly opt-in a type to validation, add the `[ValidatableType]` attribute to the type definition. 34 | 35 | ```csharp 36 | [ValidatableType] 37 | public class Todo 38 | { 39 | [Required] 40 | [Range(1, 10)] 41 | public int Id { get; set; } 42 | 43 | [StringLength(10)] 44 | public string Title { get; set; } 45 | } 46 | ``` 47 | 48 | ## Implementation Details 49 | 50 | ### Default Validation Behavior of Validatable Type Info 51 | 52 | The `ValidatableTypeInfo.Validate` method follows these steps when validating an object: 53 | 54 | 1. **Null check**: If the value being validated is null, it immediately returns without validation unless the type is marked as required. 55 | 56 | 2. **RequiredAttribute handling**: `RequiredAttribute`s are validated before other attributes. If the requiredness check fails, remaining validation attributes are not applied. 57 | 58 | 3. **Depth limit check**: Before processing nested objects, it checks if the current validation depth exceeds `MaxDepth` (default 32) to prevent stack overflows from circular references or extremely deep object graphs. 59 | 60 | 4. **Property validation**: Iterates through each property defined in `Members` collection: 61 | - Gets the property value from the object 62 | - Applies validation attributes defined on that property 63 | - For nullable properties, skips validation if the value is null (unless marked required) 64 | - Handles collections by validating each item in the collection if the property is enumerable 65 | 66 | 5. **IValidatableObject support**: If the type implements `IValidatableObject`, it calls the `Validate` method after validating individual properties, collecting any additional validation results. 67 | 68 | 6. **Error aggregation**: Validation errors are added to the `ValidationErrors` dictionary in the context with property names as keys (prefixed if nested) and error messages as values. 69 | 70 | 7. **Recursive validation**: For properties with complex types that have their own validation requirements, it recursively validates those objects with an updated context prefix to maintain the property path. 71 | 72 | ### Validation Error Handling 73 | 74 | Validation errors are collected in a `Dictionary` where: 75 | - Keys are property names (including paths for nested properties like `Customer.HomeAddress.Street`) 76 | - Values are arrays of error messages for each property 77 | 78 | This format is compatible with ASP.NET Core's `ValidationProblemDetails` for consistent error responses. 79 | 80 | ### Parameter Validation 81 | 82 | The `ValidatableParameterInfo` class provides similar validation for method parameters: 83 | 84 | 1. Validates attributes applied directly to parameters 85 | 2. For complex types, delegates to the appropriate `ValidatableTypeInfo` 86 | 3. Supports special handling for common parameter types (primitives, strings, collections) 87 | 88 | The validation endpoint filter demonstrates integration with minimal APIs, automatically validating all parameters before the endpoint handler executes. 89 | 90 | ### Source Generation 91 | 92 | The validation system leverages a source generator to: 93 | 94 | 1. Analyze types marked with `[ValidatableType]` at build time 95 | 2. Analyze minimal API endpoints at build-time to automatically discover validatable types without an attribute 96 | 3. Generate concrete implementations of `ValidatableTypeInfo` and `ValidatablePropertyInfo` 97 | 4. Intercept the `AddValidation` call in user code and add the generated `IValidatableInfoResolver` to the list of resolvers available in the `ValidationOptions` 98 | 5. Pre-compiles and caches instances of ValidationAttributes uniquely hashed by their type and initialization arguments 99 | 100 | The source generator creates a specialized `IValidatableInfoResolver` implementation that can handle all your validatable types and parameters without runtime reflection overhead. 101 | 102 | ```csharp 103 | file class GeneratedValidatableInfoResolver : IValidatableInfoResolver 104 | { 105 | public ValidatableTypeInfo? GetValidatableTypeInfo(Type type) 106 | { 107 | // Fast type lookups with no reflection 108 | if (type == typeof(Customer)) 109 | { 110 | return CreateCustomerType(); 111 | } 112 | if (type == typeof(Address)) 113 | { 114 | return CreateAddressType(); 115 | } 116 | // Other types... 117 | 118 | return null; 119 | } 120 | 121 | public ValidatableParameterInfo? GetValidatableParameterInfo(ParameterInfo parameterInfo) 122 | { 123 | // ParameterInfo-based validations are resolved at runtime 124 | return null; 125 | } 126 | 127 | // Pre-generated factory methods for each type 128 | private ValidatableTypeInfo CreateCustomerType() 129 | { 130 | return new GeneratedValidatableTypeInfo( 131 | type: typeof(Customer), 132 | members: [ 133 | // Pre-compiled property validation info 134 | new GeneratedValidatablePropertyInfo( 135 | containingType: typeof(Customer), 136 | propertyType: typeof(string), 137 | name: "Name", 138 | displayName: "Name"), 139 | // Other properties... 140 | ]); 141 | } 142 | 143 | // Other factory methods... 144 | } 145 | ``` 146 | 147 | The generator emits a `ValidationAttributeCache` to support compiling and caching `ValidationAttributes` by their type and arguments. 148 | 149 | ```csharp 150 | // Generated ValidationAttribute storage and creation 151 | [GeneratedCode("Microsoft.AspNetCore.Http.ValidationsGenerator", "42.42.42.42")] 152 | file static class ValidationAttributeCache 153 | { 154 | private sealed record CacheKey(global::System.Type ContainingType, string PropertyName); 155 | private static readonly global::System.Collections.Concurrent.ConcurrentDictionary _cache = new(); 156 | 157 | public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes( 158 | global::System.Type containingType, 159 | string propertyName) 160 | { 161 | var key = new CacheKey(containingType, propertyName); 162 | return _cache.GetOrAdd(key, static k => 163 | { 164 | var property = k.ContainingType.GetProperty(k.PropertyName); 165 | if (property == null) 166 | { 167 | return []; 168 | } 169 | 170 | return [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes(property, inherit: true)]; 171 | }); 172 | } 173 | } 174 | ``` 175 | 176 | The generator also creates strongly-typed implementations of the abstract validation classes: 177 | 178 | ```csharp 179 | file sealed class GeneratedValidatablePropertyInfo : ValidatablePropertyInfo 180 | { 181 | private readonly ValidationAttribute[] _validationAttributes; 182 | 183 | public GeneratedValidatablePropertyInfo( 184 | Type containingType, 185 | Type propertyType, 186 | string name, 187 | string displayName, 188 | bool isEnumerable, 189 | bool isNullable, 190 | bool isRequired, 191 | bool hasValidatableType, 192 | ValidationAttribute[] validationAttributes) 193 | : base(containingType, propertyType, name, displayName, 194 | isEnumerable, isNullable, isRequired, hasValidatableType) 195 | { 196 | _validationAttributes = validationAttributes; 197 | } 198 | 199 | protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes; 200 | } 201 | ``` 202 | 203 | The generator emits an interceptor to the `AddValidation` method that injects the generated `ITypeInfoResolver` into the options object. 204 | 205 | ```csharp 206 | file static class GeneratedServiceCollectionExtensions 207 | { 208 | public static IServiceCollection AddValidation( 209 | this IServiceCollection services, 210 | Action? configureOptions) 211 | { 212 | return ValidationServiceCollectionExtensions.AddValidation(services, options => 213 | { 214 | options.Resolvers.Insert(0, new GeneratedValidatableInfoResolver()); 215 | if (configureOptions is not null) 216 | { 217 | configureOptions(options); 218 | } 219 | }); 220 | } 221 | } 222 | ``` 223 | 224 | ### Validation Extensibility 225 | 226 | Similar to existing validation options solutions, users can customize the behavior of the validation system by: 227 | 228 | - Custom `ValidationAttribute` implementations 229 | - `IValidatableObject` implementations for complex validation logic 230 | 231 | In addition to this, this implementation supports defining vustom validation behavior by defining custom `IValidatableInfoResolver` implementations and inserting them into the `ValidationOptions.Resolvers` property. 232 | 233 | ```csharp 234 | var builder = WebApplication.CreateBuilder(args); 235 | 236 | builder.Services.AddValidation(options => 237 | { 238 | // Add custom resolver before the generated one to give it higher priority 239 | options.Resolvers.Insert(0, new CustomValidatableInfoResolver()); 240 | }); 241 | 242 | 243 | var app = builder.Build(); 244 | 245 | app.MapPost("/payments", (PaymentInfo payment, [FromQuery] decimal amount) => 246 | { 247 | // Both payment and amount will be validated using the custom validators 248 | return TypedResults.Ok(new { PaymentAccepted = true }); 249 | }); 250 | 251 | app.Run(); 252 | 253 | public class PaymentInfo 254 | { 255 | public string CreditCardNumber { get; set; } = string.Empty; 256 | public string CardholderName { get; set; } = string.Empty; 257 | public DateTime ExpirationDate { get; set; } 258 | public string CVV { get; set; } = string.Empty; 259 | } 260 | 261 | public class CustomValidatableInfoResolver : IValidatableInfoResolver 262 | { 263 | // Provide validation info for specific types 264 | public ValidatableTypeInfo? GetValidatableTypeInfo(Type type) 265 | { 266 | // Example: Special handling for a specific type 267 | if (type == typeof(PaymentInfo)) 268 | { 269 | // Create custom validation rules for PaymentInfo type 270 | return new CustomPaymentInfoTypeInfo(); 271 | } 272 | 273 | return null; // Return null to let other resolvers handle other types 274 | } 275 | 276 | // Provide validation info for parameters 277 | public ValidatableParameterInfo? GetValidatableParameterInfo(ParameterInfo parameterInfo) 278 | { 279 | // Example: Special validation for payment amount parameters 280 | if (parameterInfo.Name == "amount" && parameterInfo.ParameterType == typeof(decimal)) 281 | { 282 | return new CustomAmountParameterInfo(); 283 | } 284 | 285 | return null; // Return null to let other resolvers handle other parameters 286 | } 287 | 288 | // Example of custom ValidatableTypeInfo implementation 289 | private class CustomPaymentInfoTypeInfo : ValidatableTypeInfo 290 | { 291 | public CustomPaymentInfoTypeInfo() 292 | : base(typeof(PaymentInfo), CreateValidatableProperties(), implementsIValidatableObject: false) 293 | { 294 | } 295 | 296 | private static IEnumerable CreateValidatableProperties() 297 | { 298 | // Define custom validation logic for properties 299 | yield return new CustomPropertyInfo( 300 | typeof(PaymentInfo), 301 | typeof(string), 302 | "CreditCardNumber", 303 | "Credit Card Number", 304 | isEnumerable: false, 305 | isNullable: false, 306 | isRequired: true, 307 | hasValidatableType: false); 308 | 309 | // Add more properties as needed 310 | } 311 | } 312 | 313 | // Example of custom ValidatableParameterInfo implementation 314 | private class CustomAmountParameterInfo : ValidatableParameterInfo 315 | { 316 | private static readonly ValidationAttribute[] _attributes = new ValidationAttribute[] 317 | { 318 | new RangeAttribute(0.01, 10000.00) { ErrorMessage = "Amount must be between $0.01 and $10,000.00" } 319 | }; 320 | 321 | public CustomAmountParameterInfo() 322 | : base("amount", "Payment Amount", isNullable: false, isRequired: true, 323 | hasValidatableType: false, isEnumerable: false) 324 | { 325 | } 326 | 327 | protected override ValidationAttribute[] GetValidationAttributes() => _attributes; 328 | } 329 | 330 | // Example of custom property info implementation 331 | private class CustomPropertyInfo : ValidatablePropertyInfo 332 | { 333 | private static readonly ValidationAttribute[] _ccAttributes = new ValidationAttribute[] 334 | { 335 | new CreditCardAttribute(), 336 | new RequiredAttribute(), 337 | new StringLengthAttribute(19) { MinimumLength = 13, ErrorMessage = "Credit card number must be between 13 and 19 digits" } 338 | }; 339 | 340 | public CustomPropertyInfo( 341 | Type containingType, Type propertyType, string name, string displayName, 342 | bool isEnumerable, bool isNullable, bool isRequired, bool hasValidatableType) 343 | : base(containingType, propertyType, name, displayName, 344 | isEnumerable, isNullable, isRequired, hasValidatableType) 345 | { 346 | } 347 | 348 | protected override ValidationAttribute[] GetValidationAttributes() => _ccAttributes; 349 | } 350 | } 351 | ``` 352 | 353 | ## Frequently Asked Questions 354 | 355 | ## Architecture 356 | 357 | ### What is an `IValidatableInfoResolver`? 358 | `IValidatableInfoResolver` is an interface that resolves validation information for types and parameters. It defines two methods: 359 | - `TryGetValidatableTypeInfo`: Resolves validation info for a given type 360 | - `TryGetValidatableParameterInfo`: Resolves validation info for method parameters 361 | 362 | ### What's the difference between compile-time and runtime validation? 363 | - **Compile-time**: Uses source generators to create strongly-typed validation logic during build 364 | - **Runtime**: Uses reflection to discover validation attributes dynamically at execution time 365 | 366 | ### How do the resolvers work together? 367 | Resolvers are registered in a chain within `ValidationOptions`. When validating an object, the system queries each resolver in sequence until one returns successful validation information. 368 | 369 | ## Validation Types 370 | 371 | ### What types of validation are supported? 372 | - Data annotation attributes (e.g., `[Required]`, `[Range]`, `[EmailAddress]`) 373 | - `IValidatableObject` implementation for custom validation 374 | - Complex type validation (nested object graph validation) 375 | - Polymorphic type validation (inheritance hierarchies) 376 | - Recursive type validation 377 | 378 | ### What is a `ValidatableTypeInfo`? 379 | `ValidatableTypeInfo` represents validation metadata about a type, including its properties and their validation requirements. It's used to validate instances of that type. 380 | 381 | ### What is a `ValidatableParameterInfo`? 382 | `ValidatableParameterInfo` represents validation metadata for parameters in methods, including validation attributes and type information. 383 | 384 | ## Configuration 385 | 386 | ### How do I configure the validation system? 387 | Configure validation by calling the `AddValidation` extension method on `IServiceCollection`, optionally providing a configuration delegate for `ValidationOptions`. 388 | 389 | ### How do I add custom validation logic? 390 | You can: 391 | 1. Implement `IValidatableObject` on your models 392 | 2. Create custom validation attributes 393 | 3. Implement custom `IValidatableInfoResolver`s 394 | 395 | ## Advanced Features 396 | 397 | ### Does the validation system support polymorphic types? 398 | Yes, it can validate properties with base types that might hold derived instances at runtime, ensuring proper validation regardless of the actual concrete type. This validation depends on the validations-source generator and builds on top of the `JsonDerivedType` attribute. 399 | 400 | ### How does validation work with complex object graphs? 401 | The system validates deeply nested objects, reporting path-based validation errors (e.g., "PropertyWithInheritance.EmailString"). 402 | 403 | ### How does the system handle recursive types? 404 | It supports recursive types (types that contain themselves either directly or indirectly) by safely traversing the object graph to avoid infinite loops. 405 | 406 | ### What happens when validation fails? 407 | The system collects validation errors in a `ValidateContext`, mapping property paths to error messages, which can be used to report errors back to users. The minimal APIs implementation captures these errors into a HTTP Validation Problem Details response. 408 | 409 | ## License 410 | [MIT](https://choosealicense.com/licenses/mit/) 411 | 412 | -------------------------------------------------------------------------------- /api/Api.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | $(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Validation.Generated 8 | true 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /api/Program.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | using System.ComponentModel.DataAnnotations; 5 | using System.Text.Json; 6 | using System.Text.Json.Serialization; 7 | using System.Text.Json.Serialization.Metadata; 8 | using Microsoft.AspNetCore.Http.Validation; 9 | using Models; 10 | 11 | var builder = WebApplication.CreateBuilder(args); 12 | 13 | builder.Services.ConfigureHttpJsonOptions(options => 14 | { 15 | options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); 16 | }); 17 | builder.Services.AddValidation(); 18 | 19 | var app = builder.Build(); 20 | 21 | // ValidationEndpointFilterFactory is implicitly enabled on all endpoints 22 | app.MapGet("/customers/{id}", ([Range(1, int.MaxValue)] int id) => 23 | $"Getting customer with ID: {id}"); 24 | 25 | app.MapPost("/customers", (Customer customer) => TypedResults.Created($"/customers/{customer.Name}", customer)); 26 | 27 | app.MapPost("/orders", (Order order) => TypedResults.Created($"/orders/{order.OrderId}", order)); 28 | 29 | app.MapPost("/products", 30 | ([EvenNumber(ErrorMessage = "Product ID must be even")] int productId, [Required] string name) 31 | => TypedResults.Ok(productId)) 32 | .DisableValidation(); 33 | 34 | app.MapPost("/product", ([Required] Product product) => 35 | { 36 | return TypedResults.Created($"/products/{product.Id}", product); 37 | }); 38 | 39 | app.MapPost("/stores", ([Required] Store store) => 40 | { 41 | return TypedResults.Created($"/stores/{store.Id}", store); 42 | }); 43 | 44 | app.Run(); 45 | 46 | // Define validatable types with the ValidatableType attribute 47 | [ValidatableType] 48 | public class Customer 49 | { 50 | [Required] 51 | public required string Name { get; set; } 52 | 53 | [EmailAddress] 54 | public required string Email { get; set; } 55 | 56 | [Range(18, 120)] 57 | [Display(Name = "Customer Age")] 58 | public int Age { get; set; } 59 | 60 | // Complex property with nested validation 61 | public Address HomeAddress { get; set; } = new Address 62 | { 63 | Street = "123 Main St", 64 | City = "Anytown", 65 | ZipCode = "12345" 66 | }; 67 | } 68 | 69 | public class Address 70 | { 71 | [Required] 72 | public required string Street { get; set; } 73 | 74 | [Required] 75 | public required string City { get; set; } 76 | 77 | [StringLength(5)] 78 | public required string ZipCode { get; set; } 79 | } 80 | 81 | // Define a type implementing IValidatableObject for custom validation 82 | public class Order : IValidatableObject 83 | { 84 | [Range(1, int.MaxValue)] 85 | public int OrderId { get; set; } 86 | 87 | [Required] 88 | public required string ProductName { get; set; } 89 | 90 | public int Quantity { get; set; } 91 | 92 | // Custom validation logic using IValidatableObject 93 | public IEnumerable Validate(ValidationContext validationContext) 94 | { 95 | if (Quantity <= 0) 96 | { 97 | yield return new ValidationResult( 98 | "Quantity must be greater than zero", 99 | [nameof(Quantity)]); 100 | } 101 | } 102 | } 103 | 104 | // Use a custom validation attribute 105 | public class EvenNumberAttribute : ValidationAttribute 106 | { 107 | public override bool IsValid(object? value) 108 | { 109 | if (value is int number) 110 | { 111 | return number % 2 == 0; 112 | } 113 | return false; 114 | } 115 | } 116 | 117 | [JsonSerializable(typeof(Customer))] 118 | [JsonSerializable(typeof(Order))] 119 | [JsonSerializable(typeof(Address))] 120 | [JsonSerializable(typeof(Store))] 121 | [JsonSerializable(typeof(Product))] 122 | [JsonSerializable(typeof(HttpValidationProblemDetails))] 123 | internal partial class AppJsonSerializerContext : JsonSerializerContext 124 | { 125 | } -------------------------------------------------------------------------------- /api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": true, 8 | "applicationUrl": "http://localhost:5040", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | }, 13 | "https": { 14 | "commandName": "Project", 15 | "dotnetRunMessages": true, 16 | "launchBrowser": true, 17 | "applicationUrl": "https://localhost:7159;http://localhost:5040", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /api/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /api/requests.http: -------------------------------------------------------------------------------- 1 | ### Valid customer ID request 2 | GET http://localhost:5000/customers/42 3 | Accept: application/json 4 | 5 | ### Invalid customer ID request (ID must be >= 1) 6 | GET http://localhost:5000/customers/0 7 | Accept: application/json 8 | 9 | ### Valid customer POST request 10 | POST http://localhost:5000/customers 11 | Content-Type: application/json 12 | 13 | { 14 | "name": "John Doe", 15 | "email": "john.doe@example.com", 16 | "age": 30, 17 | "homeAddress": { 18 | "street": "123 Main St", 19 | "city": "Anytown", 20 | "zipCode": "12345" 21 | } 22 | } 23 | 24 | ### Invalid customer POST request (missing required fields) 25 | POST http://localhost:5000/customers 26 | Content-Type: application/json 27 | 28 | { 29 | "age": 15 30 | } 31 | 32 | ### Invalid customer POST request (invalid email format) 33 | POST http://localhost:5000/customers 34 | Content-Type: application/json 35 | 36 | { 37 | "name": "John Doe", 38 | "email": "not-an-email", 39 | "age": 30 40 | } 41 | 42 | ### Invalid customer POST request (age out of range) 43 | POST http://localhost:5000/customers 44 | Content-Type: application/json 45 | 46 | { 47 | "name": "John Doe", 48 | "email": "john.doe@example.com", 49 | "age": 15 50 | } 51 | 52 | ### Invalid customer POST request (invalid zipCode length) 53 | POST http://localhost:5000/customers 54 | Content-Type: application/json 55 | 56 | { 57 | "name": "John Doe", 58 | "email": "john.doe@example.com", 59 | "age": 30, 60 | "homeAddress": { 61 | "street": "123 Main St", 62 | "city": "Anytown", 63 | "zipCode": "1234567" 64 | } 65 | } 66 | 67 | ### Valid order POST request 68 | POST http://localhost:5000/orders 69 | Content-Type: application/json 70 | 71 | { 72 | "orderId": 12345, 73 | "productName": "Sample Product", 74 | "quantity": 5 75 | } 76 | 77 | ### Invalid order POST request (missing required field) 78 | POST http://localhost:5000/orders 79 | Content-Type: application/json 80 | 81 | { 82 | "orderId": 12345, 83 | "quantity": 5 84 | } 85 | 86 | ### Invalid order POST request (IValidatableObject validation failure) 87 | POST http://localhost:5000/orders 88 | Content-Type: application/json 89 | 90 | { 91 | "orderId": 12345, 92 | "productName": "Sample Product", 93 | "quantity": 0 94 | } 95 | 96 | ### Invalid order POST request (negative orderId) 97 | POST http://localhost:5000/orders 98 | Content-Type: application/json 99 | 100 | { 101 | "orderId": -1, 102 | "productName": "Sample Product", 103 | "quantity": 5 104 | } 105 | 106 | ### Valid product POST request (validation disabled) 107 | # This endpoint has DisableValidation() applied, so even invalid data should be accepted 108 | POST http://localhost:5000/products?productId=2&name=TestProduct 109 | Content-Type: application/json 110 | 111 | ### Invalid product POST request (validation disabled) 112 | # This has an odd productId but should still work because validation is disabled 113 | POST http://localhost:5000/products?productId=3&name=TestProduct 114 | Content-Type: application/json 115 | 116 | ### Valid product POST request 117 | POST http://localhost:5000/product 118 | Content-Type: application/json 119 | 120 | { 121 | "name": "Gaming Laptop", 122 | "description": "High-performance gaming laptop with RTX graphics", 123 | "price": 1299.99, 124 | "category": "Electronics", 125 | "inventoryCount": 50 126 | } 127 | 128 | ### Invalid product POST request (name too short) 129 | POST http://localhost:5000/product 130 | Content-Type: application/json 131 | 132 | { 133 | "name": "A", 134 | "description": "Description too short", 135 | "price": 1299.99, 136 | "category": "Electronics", 137 | "inventoryCount": 50 138 | } 139 | 140 | ### Invalid product POST request (price out of range) 141 | POST http://localhost:5000/product 142 | Content-Type: application/json 143 | 144 | { 145 | "name": "Expensive Item", 146 | "description": "This item is way too expensive", 147 | "price": 15000, 148 | "category": "Electronics", 149 | "inventoryCount": 50 150 | } 151 | 152 | ### Valid store POST request 153 | POST http://localhost:5000/stores 154 | Content-Type: application/json 155 | 156 | { 157 | "name": "Downtown Tech Store", 158 | "address": "123 Main Street", 159 | "city": "Seattle", 160 | "state": "WA", 161 | "zipCode": "98101", 162 | "phoneNumber": "206-555-1234", 163 | "email": "contact@downtowntech.com", 164 | "openingHour": 8, 165 | "closingHour": 20, 166 | "establishedDate": "2020-01-15", 167 | "storeAmenities": ["Wi-Fi", "Coffee Bar", "Tech Support"] 168 | } 169 | 170 | ### Invalid store POST request (invalid state format) 171 | POST http://localhost:5000/stores 172 | Content-Type: application/json 173 | 174 | { 175 | "name": "Downtown Tech Store", 176 | "address": "123 Main Street", 177 | "city": "Seattle", 178 | "state": "Wash", 179 | "zipCode": "98101", 180 | "phoneNumber": "206-555-1234", 181 | "email": "contact@downtowntech.com" 182 | } 183 | 184 | ### Invalid store POST request (invalid zip code) 185 | POST http://localhost:5000/stores 186 | Content-Type: application/json 187 | 188 | { 189 | "name": "Downtown Tech Store", 190 | "address": "123 Main Street", 191 | "city": "Seattle", 192 | "state": "WA", 193 | "zipCode": "981", 194 | "phoneNumber": "206-555-1234", 195 | "email": "contact@downtowntech.com" 196 | } 197 | 198 | ### Invalid store POST request (invalid phone number) 199 | POST http://localhost:5000/stores 200 | Content-Type: application/json 201 | 202 | { 203 | "name": "Downtown Tech Store", 204 | "address": "123 Main Street", 205 | "city": "Seattle", 206 | "state": "WA", 207 | "zipCode": "98101", 208 | "phoneNumber": "not-a-phone", 209 | "email": "contact@downtowntech.com" 210 | } -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "10.0.100-preview.3.25167.17" 4 | } 5 | } -------------------------------------------------------------------------------- /minapi-validation-support.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "api", "api", "{D82C56D6-B2CD-F611-9E31-04638F7EC30E}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api", "api\Api.csproj", "{244FC259-96C3-4996-9F99-567C8B3BD063}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "models", "models", "{7E5E3D99-03B0-3438-6A3D-D9E8D97DA366}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Models", "models\Models.csproj", "{1ECAA028-2E09-45A9-AB4F-365F3A7A069E}" 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Debug|x64 = Debug|x64 18 | Debug|x86 = Debug|x86 19 | Release|Any CPU = Release|Any CPU 20 | Release|x64 = Release|x64 21 | Release|x86 = Release|x86 22 | EndGlobalSection 23 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 24 | {244FC259-96C3-4996-9F99-567C8B3BD063}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {244FC259-96C3-4996-9F99-567C8B3BD063}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {244FC259-96C3-4996-9F99-567C8B3BD063}.Debug|x64.ActiveCfg = Debug|Any CPU 27 | {244FC259-96C3-4996-9F99-567C8B3BD063}.Debug|x64.Build.0 = Debug|Any CPU 28 | {244FC259-96C3-4996-9F99-567C8B3BD063}.Debug|x86.ActiveCfg = Debug|Any CPU 29 | {244FC259-96C3-4996-9F99-567C8B3BD063}.Debug|x86.Build.0 = Debug|Any CPU 30 | {244FC259-96C3-4996-9F99-567C8B3BD063}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {244FC259-96C3-4996-9F99-567C8B3BD063}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {244FC259-96C3-4996-9F99-567C8B3BD063}.Release|x64.ActiveCfg = Release|Any CPU 33 | {244FC259-96C3-4996-9F99-567C8B3BD063}.Release|x64.Build.0 = Release|Any CPU 34 | {244FC259-96C3-4996-9F99-567C8B3BD063}.Release|x86.ActiveCfg = Release|Any CPU 35 | {244FC259-96C3-4996-9F99-567C8B3BD063}.Release|x86.Build.0 = Release|Any CPU 36 | {1ECAA028-2E09-45A9-AB4F-365F3A7A069E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 37 | {1ECAA028-2E09-45A9-AB4F-365F3A7A069E}.Debug|Any CPU.Build.0 = Debug|Any CPU 38 | {1ECAA028-2E09-45A9-AB4F-365F3A7A069E}.Debug|x64.ActiveCfg = Debug|Any CPU 39 | {1ECAA028-2E09-45A9-AB4F-365F3A7A069E}.Debug|x64.Build.0 = Debug|Any CPU 40 | {1ECAA028-2E09-45A9-AB4F-365F3A7A069E}.Debug|x86.ActiveCfg = Debug|Any CPU 41 | {1ECAA028-2E09-45A9-AB4F-365F3A7A069E}.Debug|x86.Build.0 = Debug|Any CPU 42 | {1ECAA028-2E09-45A9-AB4F-365F3A7A069E}.Release|Any CPU.ActiveCfg = Release|Any CPU 43 | {1ECAA028-2E09-45A9-AB4F-365F3A7A069E}.Release|Any CPU.Build.0 = Release|Any CPU 44 | {1ECAA028-2E09-45A9-AB4F-365F3A7A069E}.Release|x64.ActiveCfg = Release|Any CPU 45 | {1ECAA028-2E09-45A9-AB4F-365F3A7A069E}.Release|x64.Build.0 = Release|Any CPU 46 | {1ECAA028-2E09-45A9-AB4F-365F3A7A069E}.Release|x86.ActiveCfg = Release|Any CPU 47 | {1ECAA028-2E09-45A9-AB4F-365F3A7A069E}.Release|x86.Build.0 = Release|Any CPU 48 | EndGlobalSection 49 | GlobalSection(SolutionProperties) = preSolution 50 | HideSolutionNode = FALSE 51 | EndGlobalSection 52 | GlobalSection(NestedProjects) = preSolution 53 | {244FC259-96C3-4996-9F99-567C8B3BD063} = {D82C56D6-B2CD-F611-9E31-04638F7EC30E} 54 | {1ECAA028-2E09-45A9-AB4F-365F3A7A069E} = {7E5E3D99-03B0-3438-6A3D-D9E8D97DA366} 55 | EndGlobalSection 56 | EndGlobal 57 | -------------------------------------------------------------------------------- /models/Models.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /models/Product.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Models; 4 | 5 | public class Product 6 | { 7 | public int Id { get; set; } 8 | 9 | [Required(ErrorMessage = "Name is required")] 10 | [StringLength(100, MinimumLength = 2, ErrorMessage = "Name must be between 2 and 100 characters")] 11 | public string Name { get; set; } = string.Empty; 12 | 13 | [Required(ErrorMessage = "Description is required")] 14 | [StringLength(500, ErrorMessage = "Description cannot exceed 500 characters")] 15 | public string Description { get; set; } = string.Empty; 16 | 17 | [Required(ErrorMessage = "Price is required")] 18 | [Range(0.01, 10000, ErrorMessage = "Price must be between 0.01 and 10000")] 19 | public decimal Price { get; set; } 20 | 21 | [Required(ErrorMessage = "Category is required")] 22 | [StringLength(50, ErrorMessage = "Category cannot exceed 50 characters")] 23 | public string Category { get; set; } = string.Empty; 24 | 25 | [Range(0, 10000, ErrorMessage = "Inventory must be between 0 and 10000")] 26 | public int InventoryCount { get; set; } 27 | 28 | [DataType(DataType.Date)] 29 | public DateTime CreatedAt { get; set; } = DateTime.UtcNow; 30 | 31 | [Display(Name = "In Stock")] 32 | public bool IsInStock => InventoryCount > 0; 33 | } 34 | -------------------------------------------------------------------------------- /models/Store.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Models; 4 | 5 | public class Store 6 | { 7 | public int Id { get; set; } 8 | 9 | [Required(ErrorMessage = "Store name is required")] 10 | [StringLength(100, MinimumLength = 2, ErrorMessage = "Name must be between 2 and 100 characters")] 11 | public string Name { get; set; } = string.Empty; 12 | 13 | [Required(ErrorMessage = "Address is required")] 14 | [StringLength(200, ErrorMessage = "Address cannot exceed 200 characters")] 15 | public string Address { get; set; } = string.Empty; 16 | 17 | [Required(ErrorMessage = "City is required")] 18 | [StringLength(50, ErrorMessage = "City cannot exceed 50 characters")] 19 | public string City { get; set; } = string.Empty; 20 | 21 | [Required(ErrorMessage = "State is required")] 22 | [StringLength(2, MinimumLength = 2, ErrorMessage = "State must be a 2-character code")] 23 | [RegularExpression(@"^[A-Z]{2}$", ErrorMessage = "State must be a 2-letter uppercase code")] 24 | public string State { get; set; } = string.Empty; 25 | 26 | [Required(ErrorMessage = "Zip code is required")] 27 | [StringLength(10, ErrorMessage = "Zip code cannot exceed 10 characters")] 28 | [RegularExpression(@"^\d{5}(-\d{4})?$", ErrorMessage = "Zip code must be in format 12345 or 12345-6789")] 29 | public string ZipCode { get; set; } = string.Empty; 30 | 31 | [Phone(ErrorMessage = "Invalid phone number format")] 32 | [Required(ErrorMessage = "Phone number is required")] 33 | public string PhoneNumber { get; set; } = string.Empty; 34 | 35 | [EmailAddress(ErrorMessage = "Invalid email address format")] 36 | public string? Email { get; set; } 37 | 38 | [Range(0, 24, ErrorMessage = "Opening hours must be between 0 and 24")] 39 | public int OpeningHour { get; set; } = 9; 40 | 41 | [Range(0, 24, ErrorMessage = "Closing hours must be between 0 and 24")] 42 | public int ClosingHour { get; set; } = 17; 43 | 44 | [DataType(DataType.Date)] 45 | public DateTime EstablishedDate { get; set; } 46 | 47 | public bool IsActive { get; set; } = true; 48 | 49 | public List StoreAmenities { get; set; } = new(); 50 | } -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | --------------------------------------------------------------------------------