Skip to content

Commit d07022b

Browse files
authored
Fix ReadBodyFromRequestAsync disposing ctx.Request.Body (#615)
* add leaveOpen=true to ReadBodyFromRequestAsync, so the ctx.Request.Body is not disposed * add helper test function getReqBody, and use the leaveOpen = true at the getBody * add more tests to assert that the body is being disposed correctly when using ReadBodyFromRequestAsync, and add a new ReadBodyFromRequestAsync function that let's the user define the value of the leaveOpen parameter (useful for tests)
1 parent bf435f7 commit d07022b

File tree

3 files changed

+131
-2
lines changed

3 files changed

+131
-2
lines changed

src/Giraffe/HttpContextExtensions.fs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,22 @@ type HttpContextExtensions() =
203203
[<Extension>]
204204
static member ReadBodyFromRequestAsync(ctx: HttpContext) =
205205
task {
206-
use reader = new StreamReader(ctx.Request.Body, Encoding.UTF8)
206+
use reader = new StreamReader(ctx.Request.Body, Encoding.UTF8, leaveOpen = true)
207+
return! reader.ReadToEndAsync()
208+
}
209+
210+
/// <summary>
211+
/// Reads the entire body of the <see cref="Microsoft.AspNetCore.Http.HttpRequest"/> asynchronously and returns it as a <see cref="System.String"/> value. This function let's you decide if you want to leave the ctx.Request.Body element open or not.
212+
/// </summary>
213+
/// <param name="ctx">The current http context object.</param>
214+
/// <param name="leaveOpen">`true` to leave the stream open after the StreamReader object is disposed; otherwise, `false`.</param>
215+
/// <returns>Returns the contents of the request body as a <see cref="System.Threading.Tasks.Task{System.String}"/>.</returns>
216+
[<Extension>]
217+
static member ReadBodyFromRequestAsync(ctx: HttpContext, leaveOpen: bool) =
218+
task {
219+
use reader =
220+
new StreamReader(ctx.Request.Body, Encoding.UTF8, leaveOpen = leaveOpen)
221+
207222
return! reader.ReadToEndAsync()
208223
}
209224

tests/Giraffe.Tests/Helpers.fs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,14 @@ let hasLastModified (lastModified: DateTimeOffset) (response: HttpResponseMessag
196196
Assert.Equal(lastModified, response.Content.Headers.LastModified.Value)
197197
response
198198

199+
let getReqBody (ctx: HttpContext) =
200+
ctx.Request.Body.Position <- 0L
201+
use reader = new StreamReader(ctx.Request.Body, Encoding.UTF8, leaveOpen = true)
202+
reader.ReadToEnd()
203+
199204
let getBody (ctx: HttpContext) =
200205
ctx.Response.Body.Position <- 0L
201-
use reader = new StreamReader(ctx.Response.Body, Encoding.UTF8)
206+
use reader = new StreamReader(ctx.Response.Body, Encoding.UTF8, leaveOpen = true)
202207
reader.ReadToEnd()
203208

204209
let readText (response: HttpResponseMessage) = response.Content.ReadAsStringAsync()

tests/Giraffe.Tests/HttpContextExtensionsTests.fs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,3 +289,112 @@ let ``WriteHtmlViewAsync should add html to the context`` () =
289289
| None -> assertFailf "Result was expected to be %s" expected
290290
| Some ctx -> Assert.Equal(expected, getBody ctx)
291291
}
292+
293+
[<Fact>]
294+
let ``ReadBodyFromRequestAsync is not incorrectly disposing the request body`` () =
295+
let ctx = Substitute.For<HttpContext>()
296+
297+
let bodyStr = "Hello world!"
298+
let mutable reqBodyDisposed = false
299+
300+
let testHandler: HttpHandler =
301+
fun (_: HttpFunc) (ctx: HttpContext) ->
302+
task {
303+
let! bodyFstRead = ctx.ReadBodyFromRequestAsync()
304+
Assert.Equal(bodyStr, bodyFstRead)
305+
Assert.False reqBodyDisposed
306+
307+
// By the second time, the request body was read, so the position will be at the end of
308+
// the stream. Due to it, we need to get back to the beginning of the stream:
309+
ctx.Request.Body.Position <- 0L
310+
let! bodySndRead = ctx.ReadBodyFromRequestAsync()
311+
Assert.Equal(bodyStr, bodySndRead)
312+
Assert.False reqBodyDisposed
313+
314+
// If we don't change the position, it will simply return an empty string.
315+
let! bodyThirdRead = ctx.ReadBodyFromRequestAsync()
316+
Assert.Equal("", bodyThirdRead)
317+
Assert.False reqBodyDisposed
318+
319+
return! Encoding.UTF8.GetBytes bodyFstRead |> ctx.WriteBytesAsync
320+
}
321+
322+
let app = route "/" >=> testHandler
323+
324+
ctx.Request.Method.ReturnsForAnyArgs "GET" |> ignore
325+
ctx.Request.Path.ReturnsForAnyArgs(PathString("/")) |> ignore
326+
327+
ctx.Request.Body <-
328+
{ new MemoryStream(buffer = Encoding.UTF8.GetBytes(bodyStr)) with
329+
member this.Dispose(disposing: bool) : unit =
330+
reqBodyDisposed <- true
331+
base.Dispose(disposing: bool)
332+
}
333+
334+
ctx.Response.Body <- new MemoryStream()
335+
336+
task {
337+
let! result = app (Some >> Task.FromResult) ctx
338+
339+
match result with
340+
| None -> assertFail "Result was expected to be Option.Some"
341+
| Some ctx ->
342+
let ctxReqBody = getReqBody ctx
343+
Assert.Equal(bodyStr, ctxReqBody)
344+
Assert.False reqBodyDisposed // not disposed yet
345+
346+
do ctx.Request.Body.Dispose() // "manually" disposed
347+
Assert.True reqBodyDisposed // now it's disposed
348+
}
349+
350+
[<Fact>]
351+
let ``Old ReadBodyFromRequestAsync implementation was incorrectly disposing the request body`` () =
352+
let ctx = Substitute.For<HttpContext>()
353+
354+
let bodyStr = "Hello world!"
355+
let mutable reqBodyDisposed = false
356+
let leaveOpen = false // the old default
357+
358+
let testHandler: HttpHandler =
359+
fun (_: HttpFunc) (ctx: HttpContext) ->
360+
task {
361+
Assert.False reqBodyDisposed
362+
363+
let! bodyFstRead = ctx.ReadBodyFromRequestAsync(leaveOpen)
364+
Assert.Equal(bodyStr, bodyFstRead)
365+
Assert.True reqBodyDisposed
366+
367+
let! capturedException =
368+
Assert.ThrowsAsync<ArgumentException>(fun () -> ctx.ReadBodyFromRequestAsync(leaveOpen))
369+
370+
Assert.Equal("Stream was not readable.", capturedException.Message)
371+
372+
return! Encoding.UTF8.GetBytes bodyFstRead |> ctx.WriteBytesAsync
373+
}
374+
375+
let app = route "/" >=> testHandler
376+
377+
ctx.Request.Method.ReturnsForAnyArgs "GET" |> ignore
378+
ctx.Request.Path.ReturnsForAnyArgs(PathString("/")) |> ignore
379+
380+
ctx.Request.Body <-
381+
{ new MemoryStream(buffer = Encoding.UTF8.GetBytes(bodyStr)) with
382+
member this.Dispose(disposing: bool) : unit =
383+
reqBodyDisposed <- true
384+
base.Dispose(disposing: bool)
385+
}
386+
387+
ctx.Response.Body <- new MemoryStream()
388+
389+
task {
390+
let! result = app (Some >> Task.FromResult) ctx
391+
392+
match result with
393+
| None -> assertFail "Result was expected to be Option.Some"
394+
| Some ctx ->
395+
let capturedException =
396+
Assert.Throws<ObjectDisposedException>(fun () -> getReqBody ctx |> ignore)
397+
398+
Assert.Equal("Cannot access a closed Stream.", capturedException.Message)
399+
Assert.True reqBodyDisposed
400+
}

0 commit comments

Comments
 (0)