Skip to content

Commit a12c1d7

Browse files
committed
Updates
1 parent 6ef6db8 commit a12c1d7

38 files changed

Lines changed: 286 additions & 261 deletions

‎readme.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ JWT, OpenAPI, HPACK, QPACK — is built on what is in the box.
2828

2929
```csharp
3030
var server = new HttpServer(new HttpServerOptions { Port = 8080 });
31-
server.OnGet("/ping", ctx => ctx.Response.WriteAsync("pong"));
31+
server.MapGet("/ping", ctx => ctx.Response.WriteAsync("pong"));
3232
await server.RunAsync();
3333
```
3434

‎samples/Sample.Api/Program.cs‎

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Sample.Api;
55
using Shiny.Net.HttpServer;
66
using Shiny.Net.HttpServer.Cors;
7+
using Shiny.Net.HttpServer.FileBrowser;
78
using Shiny.Net.HttpServer.Jwt;
89
using Shiny.Net.HttpServer.OpenApi;
910
using Shiny.Net.HttpServer.RateLimiting;
@@ -15,7 +16,7 @@
1516
// The whole ramp, in one app. Every tier below is optional and they all compose:
1617
//
1718
// 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
1920
// Tier 2 Use middleware, ASP.NET Core shaped
2021
// Tier 3 [Route] generated typed endpoints — see WidgetEndpoints.cs
2122
// ---------------------------------------------------------------------------
@@ -129,73 +130,97 @@
129130
// --- Tier 1: routed raw handlers ---
130131
//
131132
// 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"))
133134
.Describe(o =>
134135
{
135136
o.Summary = "Liveness probe";
136137
o.Tags.Add("ops");
137138
o.Responses.Add(new ApiResponse { StatusCode = 200, Type = typeof(string), ContentType = "text/plain" });
138139
});
139140

140-
app.OnGet("/hello/{name}", ctx =>
141+
app.MapGet("/hello/{name}", ctx =>
141142
{
142143
var greeter = ctx.GetRequiredService<IGreeter>();
143144
return ctx.Response.WriteAsync(greeter.Greet(ctx.Request.RouteValues["name"]!));
144145
});
145146

146147
// 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"))
148149
.RequireCors("public")
149150
.DisableRateLimiting();
150151

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"))
152153
.RequireIpFilter("admin");
153154

154155
// 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 =>
156157
{
157158
ctx.Request.RouteValues.TryGetInt32("id", out var id);
158159
return id is > 0 and < 1000
159160
? Results.Ok(new User(id, $"user-{id}"), SampleJson.Default.User)
160161
: Results.NotFound();
161162
});
162163

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 =>
164166
Results.Text($"catch-all captured: {ctx.Request.RouteValues["path"]}"));
165167

166168
// Proves the scope really is per request: both resolutions inside one request are the same
167169
// instance, and a second request gets a different one.
168-
app.OnGet("/scope", ctx =>
170+
app.MapGet("/scope", ctx =>
169171
{
170172
var a = ctx.GetRequiredService<RequestId>();
171173
var b = ctx.GetRequiredService<RequestId>();
172174
return ctx.Response.WriteAsync($"same-instance={ReferenceEquals(a, b)} id={a.Value}");
173175
});
174176

175-
app.OnPost("/echo", async ctx =>
177+
app.MapPost("/echo", async ctx =>
176178
{
177179
var body = await ctx.Request.ReadBodyAsStringAsync();
178180
await ctx.Response.WriteAsync($"echo:{body}");
179181
});
180182

181-
app.OnGet("/boom", _ => throw new InvalidOperationException("deliberate failure"));
183+
app.MapGet("/boom", _ => throw new InvalidOperationException("deliberate failure"));
182184

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 ---
184214
//
185215
// One call maps the twenty-two routes of an RFC 4918 class 1 & 2 WebDAV mount. Point Finder (Go →
186216
// 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.
188218
//
189219
// A browser GET of the same URL shows a plain HTML index, which is the quickest way to see it
190220
// 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-
196221
app.MapWebDav("/dav", o =>
197222
{
198-
o.RootPath = davRoot.FullName;
223+
o.RootPath = filesRoot.FullName;
199224
o.AllowWrite = true;
200225
o.AllowDelete = true;
201226
o.DisplayName = "Sample";
@@ -213,9 +238,9 @@
213238
// sample exposes it over HTTP for demonstration, but the same two calls sit behind a toggle in a
214239
// MAUI app. With AddHttpServer(..., autoStart: false) the server is registered and configured but
215240
// 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}"));
217242

218-
app.OnPost("/server/restart", async ctx =>
243+
app.MapPost("/server/restart", async ctx =>
219244
{
220245
// Not awaited inline: restarting tears down the connection this request arrived on.
221246
_ = Task.Run(async () =>

‎samples/Sample.Maui/Pages/ServerPage.xaml‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@
6767
<Label Text="{Binding McpUrl, TargetNullValue='Server not started'}"
6868
LineBreakMode="CharacterWrap" />
6969

70+
<!-- The file browser over the app's own storage. Tapping it opens the JSON
71+
listing of the root; every entry's path hangs off this address, so the
72+
browser walks the tree and curl scripts against the same URLs. The mount
73+
below is the same directory for a client that would rather have a drive. -->
74+
<Label Text="File browser — the app's storage as JSON" TextColor="Gray" Margin="0,6,0,0" />
75+
<Label Text="{Binding FilesUrl, TargetNullValue='Server not started'}"
76+
TextColor="{StaticResource Primary}"
77+
TextDecorations="Underline"
78+
LineBreakMode="CharacterWrap">
79+
<Label.GestureRecognizers>
80+
<TapGestureRecognizer Command="{Binding OpenUrlCommand}"
81+
CommandParameter="{Binding FilesUrl}" />
82+
</Label.GestureRecognizers>
83+
</Label>
84+
7085
<!-- The WebDAV mount, both ways to reach it. Finder: Go → Connect to Server.
7186
Explorer: Map network drive. Same server, same password — the app's storage
7287
as a drive. Tapping either one browses the mount here instead: GET on a

‎samples/Sample.Maui/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,9 @@ required. `Server/RequireAuthentication.cs` is the ten lines that close that.
120120

121121
## Browsing the device's files
122122

123-
`/files` maps the file browser over `FileSystem.AppDataDirectory`:
123+
`/files` maps the file browser over `FileSystem.AppDataDirectory`. The **Server** tab shows it as a
124+
tappable link — the address on this network, because the endpoint hands out the contents of the
125+
device's storage and the tunnel is cleartext HTTP through a shared host:
124126

125127
```bash
126128
curl -u admin:PASSWORD https://xxxx.lhr.life/files # JSON listing

‎samples/Sample.Maui/ViewModels/ServerViewModel.cs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,14 @@ IMainThread mainThread
4242
[NotifyPropertyChangedFor(nameof(HasPublicUrl))]
4343
[NotifyPropertyChangedFor(nameof(CanStopSharing))]
4444
[NotifyPropertyChangedFor(nameof(McpUrl))]
45+
[NotifyPropertyChangedFor(nameof(FilesUrl))]
4546
[NotifyPropertyChangedFor(nameof(PublicWebDavUrl))]
4647
string? publicUrl;
4748

4849
/// <summary>The address on this Wi-Fi network. Works with no internet at all.</summary>
4950
[ObservableProperty]
5051
[NotifyPropertyChangedFor(nameof(McpUrl))]
52+
[NotifyPropertyChangedFor(nameof(FilesUrl))]
5153
[NotifyPropertyChangedFor(nameof(LocalWebDavUrl))]
5254
string? localUrl;
5355

@@ -99,6 +101,19 @@ IMainThread mainThread
99101
? $"{url.TrimEnd('/')}/mcp"
100102
: null;
101103

104+
/// <summary>
105+
/// The file browser over the app's own storage — a JSON listing a script or a browser can walk,
106+
/// which is the same directory the WebDAV mount below exposes as a drive.
107+
/// <para>
108+
/// The address on this network comes first, the same way round as the mount and for the same
109+
/// reason: this hands out the contents of the device's storage, and the tunnel is cleartext HTTP
110+
/// through a shared host.
111+
/// </para>
112+
/// </summary>
113+
public string? FilesUrl => (this.LocalUrl ?? this.PublicUrl) is { Length: > 0 } url
114+
? $"{url.TrimEnd('/')}/files"
115+
: null;
116+
102117
/// <summary>
103118
/// The mount on this network — what to type into Finder's "Connect to Server" or Explorer's
104119
/// "Map network drive", and what to tap to browse it from the phone itself.

‎samples/Sample.Mcp/Program.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
app.MapMcp();
5151

5252
// The MCP endpoint is just another route: everything else the server does still works alongside it.
53-
app.OnGet("/health", ctx => ctx.Response.WriteAsync("ok"));
53+
app.MapGet("/health", ctx => ctx.Response.WriteAsync("ok"));
5454

5555
Console.WriteLine("MCP endpoint: http://localhost:8181/mcp");
5656
Console.WriteLine("Press Ctrl+C to stop.");

‎skills/shiny-httpserver/SKILL.md‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ triggers:
1111
- HttpServerOptions
1212
- HttpServerBuilder
1313
- AddHttpServer
14-
- OnGet
15-
- OnRequest
1614
- MapGet
15+
- MapPost
16+
- OnRequest
1717
- IHttpMiddleware
1818
- RequestDelegate
1919
- HttpContext
@@ -202,7 +202,7 @@ Every new API belongs to one of these. Say which when you introduce one. They co
202202
| Tier | What it is | Use when |
203203
| --- | --- | --- |
204204
| 0 | `OnRequest(ctx => …)` — one delegate, no routing | A single handler, a test fixture, a fallback |
205-
| 1 | `OnGet`/`OnPost`/… — raw handlers behind a route template | A handful of routes, no binding wanted |
205+
| 1 | `MapGet`/`MapPost`/… — raw handlers behind a route template | A handful of routes, no binding wanted |
206206
| 2 | `Use(...)` / `IHttpMiddleware` — the pipeline | Cross-cutting work |
207207
| 3 | `[Route]` classes + the source generator | Anything real: typed parameters, DI, OpenAPI |
208208

@@ -216,7 +216,7 @@ Choose the host shape from who owns the container:
216216
```csharp
217217
// (a) No container — smallest possible
218218
var server = new HttpServer(new HttpServerOptions { Port = 8080 });
219-
server.OnGet("/ping", ctx => ctx.Response.WriteAsync("pong"));
219+
server.MapGet("/ping", ctx => ctx.Response.WriteAsync("pong"));
220220
await server.RunAsync();
221221

222222
// (b) The builder — a console app or service that wants DI
@@ -552,7 +552,7 @@ app.MapWebDav("/dav", o =>
552552

553553
```csharp
554554
// WebSockets — the handler owns the socket; loop inside it
555-
app.OnGet("/ws", async ctx =>
555+
app.MapGet("/ws", async ctx =>
556556
{
557557
if (!ctx.Request.IsWebSocketRequest()) { ctx.Response.StatusCode = 400; return; }
558558

@@ -562,7 +562,7 @@ app.OnGet("/ws", async ctx =>
562562
});
563563

564564
// SSE
565-
app.OnGet("/events", ctx => ctx.SendEventsAsync(async stream =>
565+
app.MapGet("/events", ctx => ctx.SendEventsAsync(async stream =>
566566
{
567567
while (!stream.Aborted.IsCancellationRequested)
568568
{

‎src/Shiny.Net.HttpServer/Cors/CorsExtensions.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ public static HttpServer UseCors(this HttpServer server, CorsPolicy? policy)
8484
/// <summary>
8585
/// Applies a named CORS policy to the most recently mapped route.
8686
/// <code>
87-
/// app.OnGet("/status", Handler).RequireCors("public");
87+
/// app.MapGet("/status", Handler).RequireCors("public");
8888
/// </code>
8989
/// </summary>
9090
public static HttpServer RequireCors(this HttpServer server, string policyName)

‎src/Shiny.Net.HttpServer/Cors/CorsOptions.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ namespace Shiny.Net.HttpServer.Cors;
1010
/// });
1111
///
1212
/// app.UseCors();
13-
/// app.OnGet("/status", Handler).RequireCors("public");
13+
/// app.MapGet("/status", Handler).RequireCors("public");
1414
/// </code>
1515
/// </summary>
1616
public sealed class CorsOptions

‎src/Shiny.Net.HttpServer/Hosting/HttpServer.cs‎

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -176,19 +176,6 @@ public HttpServer ClearRoutes()
176176

177177
public HttpServer MapPatch(string pattern, RequestDelegate handler) => this.Map(HttpMethods.Patch, pattern, handler);
178178

179-
// On* reads better next to OnRequest — "when a GET for /hello arrives, do this" — and is the
180-
// spelling the tier-1 examples use. Map* is kept because it is what ASP.NET Core calls these.
181-
182-
public HttpServer OnGet(string pattern, RequestDelegate handler) => this.Map(HttpMethods.Get, pattern, handler);
183-
184-
public HttpServer OnPost(string pattern, RequestDelegate handler) => this.Map(HttpMethods.Post, pattern, handler);
185-
186-
public HttpServer OnPut(string pattern, RequestDelegate handler) => this.Map(HttpMethods.Put, pattern, handler);
187-
188-
public HttpServer OnDelete(string pattern, RequestDelegate handler) => this.Map(HttpMethods.Delete, pattern, handler);
189-
190-
public HttpServer OnPatch(string pattern, RequestDelegate handler) => this.Map(HttpMethods.Patch, pattern, handler);
191-
192179
// ---- Tier 2: middleware ----
193180

194181
/// <summary>

0 commit comments

Comments
 (0)