using System.Text; using System.Xml; using System.Xml.Linq; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(DemoAlert.Load()); builder.Services.AddControllers(options => { options.RespectBrowserAcceptHeader = true; options.ReturnHttpNotAcceptable = true; // This standalone app serves only these two representations. options.OutputFormatters.Clear(); options.OutputFormatters.Add(new AlertFormatter("application/cap+xml")); options.OutputFormatters.Add(new AlertFormatter("text/html")); }); var app = builder.Build(); app.Use(async (context, next) => { context.Response.Headers.Append("Vary", "Accept"); context.Response.Headers.Append("X-Content-Type-Options", "nosniff"); context.Response.Headers.Append("Cache-Control", "no-store"); // Normalize supported candidates using the framework parser. This prevents // MVC fallback from reviving q=0 types, including exclusions under */*. if (context.Request.Path.StartsWithSegments("/alerts") && !context.Request.Path.Value!.EndsWith("/cap", StringComparison.Ordinal)) { IList ranges; try { ranges = context.Request.GetTypedHeaders().Accept ?? []; } catch (FormatException) { context.Response.StatusCode = 400; return; } if (ranges.Count == 0) ranges = [MediaTypeHeaderValue.Parse("*/*")]; var candidates = new[] { "application/cap+xml", "text/html" } .Select(type => (Type: type, Q: EffectiveQuality(type, ranges))) .Where(c => c.Q > 0).OrderByDescending(c => c.Q).ToArray(); if (candidates.Length == 0) { context.Response.StatusCode = 406; return; } context.Request.Headers.Accept = string.Join(",", candidates.Select(c => new MediaTypeHeaderValue(c.Type, c.Q).ToString())); } await next(); }); app.MapControllers(); app.Run(); static double EffectiveQuality(string type, IList ranges) { var candidate = MediaTypeHeaderValue.Parse(type + "; charset=utf-8"); var matched = ranges.Where(range => { var withoutQuality = MediaTypeHeaderValue.Parse(range.ToString()); withoutQuality.Quality = null; return candidate.IsSubsetOf(withoutQuality); }).OrderByDescending(r => r.MatchesAllTypes ? 0 : r.SubType.Value == "*" ? 1 : 2) .ThenByDescending(r => r.Parameters.Count(p => !p.Name.Equals("q", StringComparison.OrdinalIgnoreCase))).FirstOrDefault(); return matched?.Quality ?? (matched is null ? 0 : 1); } public sealed record DemoAlert(string Cap, string Headline, string Instruction) { public static DemoAlert Load() { string cap = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "alert.xml"), Encoding.UTF8); using var reader = XmlReader.Create(new StringReader(cap), new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); var xml = XDocument.Load(reader); XNamespace ns = "urn:oasis:names:tc:emergency:cap:1.2"; var info = xml.Root!.Element(ns + "info")!; return new(cap, info.Element(ns + "headline")!.Value, info.Element(ns + "instruction")!.Value); } public string Html() { var e = HtmlEncoder.Default; return $"Fictional CAP example

{e.Encode(Headline)}

{e.Encode(Instruction)}

CAP XML
"; } } public sealed class AlertFormatter : TextOutputFormatter { private readonly bool html; public AlertFormatter(string mediaType) { html = mediaType == "text/html"; SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(mediaType)); SupportedEncodings.Add(new UTF8Encoding(false)); } protected override bool CanWriteType(Type? type) => type == typeof(DemoAlert); public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding encoding) { var alert = (DemoAlert)context.Object!; return context.HttpContext.Response.WriteAsync(html ? alert.Html() : alert.Cap, encoding, context.HttpContext.RequestAborted); } } [ApiController] public sealed class AlertsController(DemoAlert alert) : ControllerBase { [HttpGet("/alerts/{id}")] [HttpHead("/alerts/{id}")] [Produces("application/cap+xml", "text/html")] public IActionResult Get(string id) => id == "123" ? Ok(alert) : NotFound(); // Explicit link: a separate route, not header negotiation. [HttpGet("/alerts/123/cap")] [HttpHead("/alerts/123/cap")] public IActionResult Cap() => Content(alert.Cap, "application/cap+xml", Encoding.UTF8); }