|
4 | 4 | using Sample.Api; |
5 | 5 | using Shiny.Net.HttpServer; |
6 | 6 | using Shiny.Net.HttpServer.Cors; |
| 7 | +using Shiny.Net.HttpServer.FileBrowser; |
7 | 8 | using Shiny.Net.HttpServer.Jwt; |
8 | 9 | using Shiny.Net.HttpServer.OpenApi; |
9 | 10 | using Shiny.Net.HttpServer.RateLimiting; |
|
15 | 16 | // The whole ramp, in one app. Every tier below is optional and they all compose: |
16 | 17 | // |
17 | 18 | // Tier 0 OnRequest one delegate, no routing |
18 | | -// Tier 1 OnGet/OnPost raw handlers behind a route template |
| 19 | +// Tier 1 MapGet/MapPost raw handlers behind a route template |
19 | 20 | // Tier 2 Use middleware, ASP.NET Core shaped |
20 | 21 | // Tier 3 [Route] generated typed endpoints — see WidgetEndpoints.cs |
21 | 22 | // --------------------------------------------------------------------------- |
|
129 | 130 | // --- Tier 1: routed raw handlers --- |
130 | 131 | // |
131 | 132 | // Describe() puts a raw route in the OpenAPI document with more than just its path. |
132 | | -app.OnGet("/ping", ctx => ctx.Response.WriteAsync("pong")) |
| 133 | +app.MapGet("/ping", ctx => ctx.Response.WriteAsync("pong")) |
133 | 134 | .Describe(o => |
134 | 135 | { |
135 | 136 | o.Summary = "Liveness probe"; |
136 | 137 | o.Tags.Add("ops"); |
137 | 138 | o.Responses.Add(new ApiResponse { StatusCode = 200, Type = typeof(string), ContentType = "text/plain" }); |
138 | 139 | }); |
139 | 140 |
|
140 | | -app.OnGet("/hello/{name}", ctx => |
| 141 | +app.MapGet("/hello/{name}", ctx => |
141 | 142 | { |
142 | 143 | var greeter = ctx.GetRequiredService<IGreeter>(); |
143 | 144 | return ctx.Response.WriteAsync(greeter.Greet(ctx.Request.RouteValues["name"]!)); |
144 | 145 | }); |
145 | 146 |
|
146 | 147 | // Per-route policies. Each applies to the route just mapped, the same way RequireAuthorization does. |
147 | | -app.OnGet("/status", ctx => ctx.Response.WriteAsync("ok")) |
| 148 | +app.MapGet("/status", ctx => ctx.Response.WriteAsync("ok")) |
148 | 149 | .RequireCors("public") |
149 | 150 | .DisableRateLimiting(); |
150 | 151 |
|
151 | | -app.OnGet("/admin/keys", ctx => ctx.Response.WriteAsync("nothing to see here")) |
| 152 | +app.MapGet("/admin/keys", ctx => ctx.Response.WriteAsync("nothing to see here")) |
152 | 153 | .RequireIpFilter("admin"); |
153 | 154 |
|
154 | 155 | // IResult with JsonTypeInfo passed directly — the most explicit AOT-safe JSON you can write. |
155 | | -app.OnGet("/users/{id:int}", ctx => |
| 156 | +app.MapGet("/users/{id:int}", ctx => |
156 | 157 | { |
157 | 158 | ctx.Request.RouteValues.TryGetInt32("id", out var id); |
158 | 159 | return id is > 0 and < 1000 |
159 | 160 | ? Results.Ok(new User(id, $"user-{id}"), SampleJson.Default.User) |
160 | 161 | : Results.NotFound(); |
161 | 162 | }); |
162 | 163 |
|
163 | | -app.OnGet("/files/{*path}", ctx => |
| 164 | +// A catch-all route parameter: everything after the prefix, slashes included, arrives as one value. |
| 165 | +app.MapGet("/catch-all/{*path}", ctx => |
164 | 166 | Results.Text($"catch-all captured: {ctx.Request.RouteValues["path"]}")); |
165 | 167 |
|
166 | 168 | // Proves the scope really is per request: both resolutions inside one request are the same |
167 | 169 | // instance, and a second request gets a different one. |
168 | | -app.OnGet("/scope", ctx => |
| 170 | +app.MapGet("/scope", ctx => |
169 | 171 | { |
170 | 172 | var a = ctx.GetRequiredService<RequestId>(); |
171 | 173 | var b = ctx.GetRequiredService<RequestId>(); |
172 | 174 | return ctx.Response.WriteAsync($"same-instance={ReferenceEquals(a, b)} id={a.Value}"); |
173 | 175 | }); |
174 | 176 |
|
175 | | -app.OnPost("/echo", async ctx => |
| 177 | +app.MapPost("/echo", async ctx => |
176 | 178 | { |
177 | 179 | var body = await ctx.Request.ReadBodyAsStringAsync(); |
178 | 180 | await ctx.Response.WriteAsync($"echo:{body}"); |
179 | 181 | }); |
180 | 182 |
|
181 | | -app.OnGet("/boom", _ => throw new InvalidOperationException("deliberate failure")); |
| 183 | +app.MapGet("/boom", _ => throw new InvalidOperationException("deliberate failure")); |
182 | 184 |
|
183 | | -// --- A directory, as a drive --- |
| 185 | +// --- One directory, served two ways --- |
| 186 | +// |
| 187 | +// The same folder is mapped below as a JSON API and as a drive. Which one a caller wants depends |
| 188 | +// entirely on what the caller is: a script speaks HTTP, a desktop speaks WebDAV. |
| 189 | +var filesRoot = Directory.CreateDirectory(Path.Combine(AppContext.BaseDirectory, "files-root")); |
| 190 | + |
| 191 | +if (!File.Exists(Path.Combine(filesRoot.FullName, "readme.txt"))) |
| 192 | + File.WriteAllText(Path.Combine(filesRoot.FullName, "readme.txt"), "Edit me from your file manager.\n"); |
| 193 | + |
| 194 | +// --- A directory, as an API --- |
| 195 | +// |
| 196 | +// curl http://localhost:8080/files # JSON listing |
| 197 | +// curl http://localhost:8080/files/readme.txt # the bytes |
| 198 | +// curl -X PUT --data 'hello' -H "Authorization: Bearer $TOKEN" http://localhost:8080/files/notes.txt |
| 199 | +// curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:8080/files/notes.txt |
| 200 | +// |
| 201 | +// The file browser is mapped as routes rather than middleware, and that is the whole reason it |
| 202 | +// hands its endpoints back: reads stay open here while anything that changes a file needs an admin |
| 203 | +// token — a distinction middleware could not express. Get a token from POST /api/auth/login as |
| 204 | +// ada/hunter2, or swap the call below for .RequireAuthorization() to close the reads too. |
| 205 | +app.MapFileBrowser("/files", o => |
| 206 | +{ |
| 207 | + o.RootPath = filesRoot.FullName; |
| 208 | + o.AllowWrite = true; |
| 209 | + o.AllowDelete = true; |
| 210 | +}) |
| 211 | +.RequireAuthorizationForChanges("admin"); |
| 212 | + |
| 213 | +// --- The same directory, as a drive --- |
184 | 214 | // |
185 | 215 | // One call maps the twenty-two routes of an RFC 4918 class 1 & 2 WebDAV mount. Point Finder (Go → |
186 | 216 | // Connect to Server) or Explorer (Map network drive) at http://localhost:8080/dav and the folder |
187 | | -// below opens as a drive — no client to write, and no client to install. |
| 217 | +// opens as a drive — no client to write, and no client to install. |
188 | 218 | // |
189 | 219 | // A browser GET of the same URL shows a plain HTML index, which is the quickest way to see it |
190 | 220 | // working without mounting anything. |
191 | | -var davRoot = Directory.CreateDirectory(Path.Combine(AppContext.BaseDirectory, "dav-root")); |
192 | | - |
193 | | -if (!File.Exists(Path.Combine(davRoot.FullName, "readme.txt"))) |
194 | | - File.WriteAllText(Path.Combine(davRoot.FullName, "readme.txt"), "Edit me from your file manager.\n"); |
195 | | - |
196 | 221 | app.MapWebDav("/dav", o => |
197 | 222 | { |
198 | | - o.RootPath = davRoot.FullName; |
| 223 | + o.RootPath = filesRoot.FullName; |
199 | 224 | o.AllowWrite = true; |
200 | 225 | o.AllowDelete = true; |
201 | 226 | o.DisplayName = "Sample"; |
|
213 | 238 | // sample exposes it over HTTP for demonstration, but the same two calls sit behind a toggle in a |
214 | 239 | // MAUI app. With AddHttpServer(..., autoStart: false) the server is registered and configured but |
215 | 240 | // never listening until something asks it to. |
216 | | -app.OnGet("/server/status", ctx => ctx.Response.WriteAsync($"{app.State} {app.ListenUrl}")); |
| 241 | +app.MapGet("/server/status", ctx => ctx.Response.WriteAsync($"{app.State} {app.ListenUrl}")); |
217 | 242 |
|
218 | | -app.OnPost("/server/restart", async ctx => |
| 243 | +app.MapPost("/server/restart", async ctx => |
219 | 244 | { |
220 | 245 | // Not awaited inline: restarting tears down the connection this request arrived on. |
221 | 246 | _ = Task.Run(async () => |
|
0 commit comments