Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add HttpServerRespondable trait #3088

Merged
merged 1 commit into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/breezy-walls-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@effect/platform": patch
---

add HttpServerRespondable trait

This trait allows you to define how a value should be responded to in an HTTP
server.

You can it for both errors and success values.

```ts
import { Schema } from "@effect/schema";
import {
HttpRouter,
HttpServerRespondable,
HttpServerResponse,
} from "@effect/platform";

class User extends Schema.Class<User>("User")({
name: Schema.String,
}) {
[HttpServerRespondable.symbol]() {
return HttpServerResponse.schemaJson(User)(this);
}
}

class MyError extends Schema.TaggedError<MyError>()("MyError", {
message: Schema.String,
}) {
[HttpServerRespondable.symbol]() {
return HttpServerResponse.schemaJson(MyError)(this, { status: 403 });
}
}

HttpRouter.empty.pipe(
// responds with `{ "name": "test" }`
HttpRouter.get("/user", Effect.succeed(new User({ name: "test" }))),
// responds with a 403 status, and `{ "_tag": "MyError", "message": "boom" }`
HttpRouter.get("/fail", new MyError({ message: "boom" })),
);
```
5 changes: 5 additions & 0 deletions .changeset/eighty-berries-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/platform": patch
---

swap type parameters for HttpRouter.Tag, so request context comes first
5 changes: 5 additions & 0 deletions .changeset/famous-radios-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/platform": patch
---

add HttpRouter.Default, a default instance of HttpRouter.Tag
10 changes: 10 additions & 0 deletions packages/platform-bun/examples/http-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
HttpRouter,
HttpServer,
HttpServerRequest,
HttpServerRespondable,
HttpServerResponse,
Multipart
} from "@effect/platform"
Expand All @@ -12,6 +13,14 @@ import { Effect, Layer, Schedule, Stream } from "effect"

const ServerLive = BunHttpServer.layer({ port: 3000 })

class MyError extends Schema.TaggedError<MyError>()("MyError", {
message: Schema.String
}) {
[HttpServerRespondable.symbol]() {
return HttpServerResponse.schemaJson(MyError)(this, { status: 403 })
}
}

const HttpLive = HttpRouter.empty.pipe(
HttpRouter.get(
"/",
Expand All @@ -21,6 +30,7 @@ const HttpLive = HttpRouter.empty.pipe(
)
),
HttpRouter.get("/package", HttpServerResponse.file("./package.json")),
HttpRouter.get("/fail", new MyError({ message: "failed" })),
HttpRouter.get("/sleep", Effect.as(Effect.sleep("10 seconds"), HttpServerResponse.empty())),
HttpRouter.post(
"/upload",
Expand Down
49 changes: 49 additions & 0 deletions packages/platform-node/test/HttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
HttpServer,
HttpServerError,
HttpServerRequest,
HttpServerRespondable,
HttpServerResponse,
Multipart,
UrlParams
Expand Down Expand Up @@ -672,4 +673,52 @@ describe("HttpServer", () => {
const res = yield* _(HttpClientRequest.get("/home"), client, Effect.scoped)
assert.strictEqual(res.status, 204)
}).pipe(Effect.scoped, runPromise))

describe("HttpServerRespondable", () => {
it("error/RouteNotFound", () =>
Effect.gen(function*() {
yield* HttpRouter.empty.pipe(HttpServer.serveEffect())
const client = yield* makeClient
const res = yield* HttpClientRequest.get("/home").pipe(client, Effect.scoped)
assert.strictEqual(res.status, 404)
}).pipe(Effect.scoped, runPromise))

it("error/schema", () =>
Effect.gen(function*() {
class CustomError extends Schema.TaggedError<CustomError>()("CustomError", {
name: Schema.String
}) {
[HttpServerRespondable.symbol]() {
return HttpServerResponse.schemaJson(CustomError)(this, { status: 599 })
}
}
yield* HttpRouter.empty.pipe(
HttpRouter.get("/home", new CustomError({ name: "test" })),
HttpServer.serveEffect()
)
const client = yield* makeClient
const res = yield* HttpClientRequest.get("/home").pipe(client)
assert.strictEqual(res.status, 599)
const err = yield* HttpClientResponse.schemaBodyJson(CustomError)(res)
assert.deepStrictEqual(err, new CustomError({ name: "test" }))
}).pipe(Effect.scoped, runPromise))

it("respondable schema", () =>
Effect.gen(function*() {
class User extends Schema.Class<User>("User")({
name: Schema.String
}) {
[HttpServerRespondable.symbol]() {
return HttpServerResponse.schemaJson(User)(this)
}
}
yield* HttpRouter.empty.pipe(
HttpRouter.get("/user", Effect.succeed(new User({ name: "test" }))),
HttpServer.serveEffect()
)
const client = yield* makeClient
const res = yield* HttpClientRequest.get("/user").pipe(client, HttpClientResponse.schemaBodyJsonScoped(User))
assert.deepStrictEqual(res, new User({ name: "test" }))
}).pipe(Effect.scoped, runPromise))
})
})
17 changes: 14 additions & 3 deletions packages/platform/src/HttpApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as Scope from "effect/Scope"
import type { HttpMiddleware } from "./HttpMiddleware.js"
import * as ServerError from "./HttpServerError.js"
import * as ServerRequest from "./HttpServerRequest.js"
import * as Respondable from "./HttpServerRespondable.js"
import * as ServerResponse from "./HttpServerResponse.js"
import * as internalMiddleware from "./internal/httpMiddleware.js"

Expand Down Expand Up @@ -58,9 +59,19 @@ export const toHandled = <E, R, _, RH>(
Effect.exit(preHandled),
(exit) => {
if (exit._tag === "Failure") {
const dieOption = Cause.dieOption(exit.cause)
if (dieOption._tag === "Some" && ServerResponse.isServerResponse(dieOption.value)) {
exit = Exit.succeed(dieOption.value)
const cause = exit.cause
const thing = Cause.squash(exit.cause)
if (ServerResponse.isServerResponse(thing)) {
exit = Exit.succeed(thing)
} else {
return Effect.matchCauseEffect(Respondable.toResponseError(thing), {
onFailure: (_) => Effect.zipRight(handleResponse(request, exit), exit),
onSuccess: (response) =>
Effect.zipRight(
handleResponse(request, Exit.succeed(response)),
Effect.failCause(Cause.sequential(cause, Cause.die(response)))
)
})
}
}
return Effect.zipRight(handleResponse(request, exit), exit)
Expand Down
19 changes: 15 additions & 4 deletions packages/platform/src/HttpRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type * as App from "./HttpApp.js"
import type * as Method from "./HttpMethod.js"
import type * as Error from "./HttpServerError.js"
import type * as ServerRequest from "./HttpServerRequest.js"
import type * as Respondable from "./HttpServerRespondable.js"
import * as internal from "./internal/httpRouter.js"

/**
Expand All @@ -42,7 +43,7 @@ export interface HttpRouter<E = never, R = never>
readonly mounts: Chunk.Chunk<
readonly [
prefix: string,
httpApp: App.Default<E, R>,
httpApp: App.HttpApp<Respondable.Respondable, E, R>,
options?: { readonly includePrefix?: boolean | undefined } | undefined
]
>
Expand Down Expand Up @@ -116,7 +117,7 @@ export declare namespace HttpRouter {
* @since 1.0.0
*/
export interface TagClass<Self, Name extends string, E, R> extends Context.Tag<Self, Service<E, R>> {
new(_: never): Context.TagClassShape<`@effect/platform/HttpRouter/${Name}`, Service<E, R>>
new(_: never): Context.TagClassShape<Name, Service<E, R>>
readonly Live: Layer.Layer<Self>
readonly router: Effect.Effect<HttpRouter<E, R>, never, Self>
readonly use: <XA, XE, XR>(f: (router: Service<E, R>) => Effect.Effect<XA, XE, XR>) => Layer.Layer<never, XE, XR>
Expand Down Expand Up @@ -165,7 +166,11 @@ export declare namespace Route {
/**
* @since 1.0.0
*/
export type Handler<E, R> = App.Default<E, R | RouteContext | ServerRequest.ParsedSearchParams>
export type Handler<E, R> = App.HttpApp<
Respondable.Respondable,
E,
R | RouteContext | ServerRequest.ParsedSearchParams
>
}

/**
Expand Down Expand Up @@ -729,4 +734,10 @@ export const provideServiceEffect: {
*/
export const Tag: <const Name extends string>(
id: Name
) => <Self, E = never, R = never>() => HttpRouter.TagClass<Self, Name, E, R> = internal.Tag
) => <Self, R = never, E = unknown>() => HttpRouter.TagClass<Self, Name, E, R> = internal.Tag

/**
* @since 1.0.0
* @category tags
*/
export class Default extends Tag("@effect/platform/HttpRouter/Default")<Default>() {}
42 changes: 37 additions & 5 deletions packages/platform/src/HttpServerError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
* @since 1.0.0
*/
import type * as Cause from "effect/Cause"
import type * as Exit from "effect/Exit"
import type * as FiberId from "effect/FiberId"
import type * as Option from "effect/Option"
import { RefailError, TypeIdError } from "./Error.js"
import type * as ServerRequest from "./HttpServerRequest.js"
import type * as ServerResponse from "./HttpServerResponse.js"
import * as Respondable from "./HttpServerRespondable.js"
import * as ServerResponse from "./HttpServerResponse.js"
import * as internal from "./internal/httpServerError.js"

/**
Expand Down Expand Up @@ -33,7 +36,14 @@ export type HttpServerError = RequestError | ResponseError | RouteNotFound | Ser
export class RequestError extends RefailError(TypeId, "RequestError")<{
readonly request: ServerRequest.HttpServerRequest
readonly reason: "Transport" | "Decode"
}> {
}> implements Respondable.Respondable {
/**
* @since 1.0.0
*/
[Respondable.symbol]() {
return ServerResponse.empty({ status: 400 })
}

get methodAndUrl() {
return `${this.request.method} ${this.request.url}`
}
Expand All @@ -56,6 +66,13 @@ export const isServerError: (u: unknown) => u is HttpServerError = internal.isSe
export class RouteNotFound extends TypeIdError(TypeId, "RouteNotFound")<{
readonly request: ServerRequest.HttpServerRequest
}> {
/**
* @since 1.0.0
*/
[Respondable.symbol]() {
return ServerResponse.empty({ status: 404 })
}

get message() {
return `${this.request.method} ${this.request.url} not found`
}
Expand All @@ -70,6 +87,13 @@ export class ResponseError extends RefailError(TypeId, "ResponseError")<{
readonly response: ServerResponse.HttpServerResponse
readonly reason: "Decode"
}> {
/**
* @since 1.0.0
*/
[Respondable.symbol]() {
return ServerResponse.empty({ status: 500 })
}

get methodAndUrl() {
return `${this.request.method} ${this.request.url}`
}
Expand All @@ -83,8 +107,7 @@ export class ResponseError extends RefailError(TypeId, "ResponseError")<{
* @since 1.0.0
* @category error
*/
export class ServeError extends RefailError(TypeId, "ServeError")<{}> {
}
export class ServeError extends RefailError(TypeId, "ServeError")<{}> {}

/**
* @since 1.0.0
Expand All @@ -99,4 +122,13 @@ export const isClientAbortCause: <E>(cause: Cause.Cause<E>) => boolean = interna
/**
* @since 1.0.0
*/
export const causeStatusCode: <E>(cause: Cause.Cause<E>) => number = internal.causeStatusCode
export const causeStatusStripped: <E>(
cause: Cause.Cause<E>
) => readonly [status: number, cause: Option.Option<Cause.Cause<E>>] = internal.causeStatusStripped

/**
* @since 1.0.0
*/
export const exitResponse: <E>(
exit: Exit.Exit<ServerResponse.HttpServerResponse, E>
) => ServerResponse.HttpServerResponse = internal.exitResponse
66 changes: 66 additions & 0 deletions packages/platform/src/HttpServerRespondable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* @since 1.0.0
*/
import * as ParseResult from "@effect/schema/ParseResult"
import * as Effect from "effect/Effect"
import { hasProperty } from "effect/Predicate"
import type { HttpServerResponse } from "./HttpServerResponse.js"
import * as ServerResponse from "./HttpServerResponse.js"

/**
* @since 1.0.0
* @category symbols
*/
export const symbol: unique symbol = Symbol.for("@effect/platform/HttpServerRespondable")

/**
* @since 1.0.0
* @category models
*/
export interface Respondable {
readonly [symbol]: () => Effect.Effect<HttpServerResponse, unknown>
}

/**
* @since 1.0.0
* @category guards
*/
export const isRespondable = (u: unknown): u is Respondable => hasProperty(u, symbol)

const badRequest = ServerResponse.empty({ status: 400 })
const internalServerError = () => ServerResponse.empty({ status: 500 })

/**
* @since 1.0.0
* @category accessors
*/
export const toResponse = (self: Respondable): Effect.Effect<HttpServerResponse> => {
if (ServerResponse.isServerResponse(self)) {
return Effect.succeed(self)
}
return Effect.orDie(self[symbol]())
}

/**
* @since 1.0.0
* @category accessors
*/
export const toResponseOrElse = (
u: unknown,
orElse: () => Effect.Effect<HttpServerResponse, unknown>
): Effect.Effect<HttpServerResponse, unknown> => {
if (isRespondable(u)) {
return u[symbol]()
// add support for some commmon types
} else if (ParseResult.isParseError(u)) {
return Effect.succeed(badRequest)
}
return orElse()
}

/**
* @since 1.0.0
* @category accessors
*/
export const toResponseError = (u: unknown): Effect.Effect<HttpServerResponse, unknown> =>
toResponseOrElse(u, internalServerError)
3 changes: 2 additions & 1 deletion packages/platform/src/HttpServerResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type * as FileSystem from "./FileSystem.js"
import type * as Headers from "./Headers.js"
import type * as Body from "./HttpBody.js"
import type * as Platform from "./HttpPlatform.js"
import type { Respondable } from "./HttpServerRespondable.js"
import * as internal from "./internal/httpServerResponse.js"
import type * as Template from "./Template.js"
import type * as UrlParams from "./UrlParams.js"
Expand All @@ -32,7 +33,7 @@ export type TypeId = typeof TypeId
* @since 1.0.0
* @category models
*/
export interface HttpServerResponse extends Effect.Effect<HttpServerResponse>, Inspectable {
export interface HttpServerResponse extends Effect.Effect<HttpServerResponse>, Inspectable, Respondable {
readonly [TypeId]: TypeId
readonly status: number
readonly statusText?: string | undefined
Expand Down
Loading