diff --git a/content/package.json b/content/package.json index 8527fa441..a92792c7a 100644 --- a/content/package.json +++ b/content/package.json @@ -46,6 +46,7 @@ "@radix-ui/react-tooltip": "^1.1.4", "@sentry/opentelemetry": "^8.50.0", "@tanstack/react-table": "^8.20.5", + "@types/bun": "^1.2.1", "@types/express": "^5.0.0", "@types/json-schema": "^7.0.15", "@types/node": "^22.9.3", @@ -101,6 +102,7 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-tabs": "^1.1.1", + "@types/pg": "^8.11.11", "cmdk": "1.0.4" } } diff --git a/content/pnpm-lock.yaml b/content/pnpm-lock.yaml index eee07fae7..9af6925a3 100644 --- a/content/pnpm-lock.yaml +++ b/content/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/pg': + specifier: ^8.11.11 + version: 8.11.11 cmdk: specifier: 1.0.4 version: 1.0.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -134,6 +137,9 @@ importers: '@tanstack/react-table': specifier: ^8.20.5 version: 8.20.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/bun': + specifier: ^1.2.1 + version: 1.2.1 '@types/express': specifier: ^5.0.0 version: 5.0.0 @@ -2278,6 +2284,9 @@ packages: '@types/body-parser@1.19.5': resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + '@types/bun@1.2.1': + resolution: {integrity: sha512-iiCeMAKMkft8EPQJxSbpVRD0DKqrh91w40zunNajce3nMNNFd/LnAquVisSZC+UpTMjDwtcdyzbWct08IvEqRA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -2329,6 +2338,9 @@ packages: '@types/node@22.9.3': resolution: {integrity: sha512-F3u1fs/fce3FFk+DAxbxc78DF8x0cY09RRL8GnXLmkJ1jvx3TtPdWoTT5/NiYfI5ASqXBmfqJi9dZ3gxMx4lzw==} + '@types/pg@8.11.11': + resolution: {integrity: sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==} + '@types/picomatch@2.3.3': resolution: {integrity: sha512-Yll76ZHikRFCyz/pffKGjrCwe/le2CDwOP5F210KQo27kpRE46U2rDnzikNlVn6/ezH3Mhn46bJMTfeVTtcYMg==} @@ -2365,6 +2377,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.5.14': + resolution: {integrity: sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==} + '@typescript/vfs@1.6.0': resolution: {integrity: sha512-hvJUjNVeBMp77qPINuUvYXj4FyWeeMMKZkxEATEU3hqBAQ7qdTBCUFT7Sp0Zu0faeEtFf+ldXxMEDr/bk73ISg==} peerDependencies: @@ -2652,6 +2667,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bun-types@1.2.1: + resolution: {integrity: sha512-p7bmXUWmrPWxhcbFVk7oUXM5jAGt94URaoa3qf4mz43MEhNAo/ot1urzBqctgvuq7y9YxkuN51u+/qm4BiIsHw==} + bundle-require@5.0.0: resolution: {integrity: sha512-GuziW3fSSmopcx4KRymQEJVbZUfqlCqcq7dvs6TYwKRZiegK/2buMxQTPs6MGlNv50wms1699qYO54R8XfRX4w==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4092,6 +4110,9 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4183,6 +4204,21 @@ packages: pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-numeric@1.0.2: + resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} + engines: {node: '>=4'} + + pg-protocol@1.7.1: + resolution: {integrity: sha512-gjTHWGYWsEgy9MsY0Gp6ZJxV24IjDqdpTW7Eh0x+WfJLFsm/TJx1MzL6T0D88mBvkpxotCQ6TwW6N+Kko7lhgQ==} + + pg-types@4.0.2: + resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} + engines: {node: '>=10'} + picocolors@1.1.0: resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} @@ -4295,6 +4331,25 @@ packages: resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} engines: {node: ^10 || ^12 || >=14} + postgres-array@3.0.2: + resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==} + engines: {node: '>=12'} + + postgres-bytea@3.0.0: + resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} + engines: {node: '>= 6'} + + postgres-date@2.1.0: + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} + + postgres-interval@3.0.0: + resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} + engines: {node: '>=12'} + + postgres-range@1.1.4: + resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} + preferred-pm@4.0.0: resolution: {integrity: sha512-gYBeFTZLu055D8Vv3cSPox/0iTPtkzxpLroSYYA7WXgRi31WCJ51Uyl8ZiPeUUjyvs2MBzK+S8v9JVUgHU/Sqw==} engines: {node: '>=18.12'} @@ -7072,6 +7127,10 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.9.3 + '@types/bun@1.2.1': + dependencies: + bun-types: 1.2.1 + '@types/connect@3.4.38': dependencies: '@types/node': 22.9.3 @@ -7130,6 +7189,12 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/pg@8.11.11': + dependencies: + '@types/node': 22.9.3 + pg-protocol: 1.7.1 + pg-types: 4.0.2 + '@types/picomatch@2.3.3': {} '@types/prop-types@15.7.13': {} @@ -7168,6 +7233,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.5.14': + dependencies: + '@types/node': 22.9.3 + '@typescript/vfs@1.6.0(typescript@5.7.2)': dependencies: debug: 4.3.7 @@ -7553,6 +7622,11 @@ snapshots: node-releases: 2.0.18 update-browserslist-db: 1.1.1(browserslist@4.24.2) + bun-types@1.2.1: + dependencies: + '@types/node': 22.9.3 + '@types/ws': 8.5.14 + bundle-require@5.0.0(esbuild@0.24.0): dependencies: esbuild: 0.24.0 @@ -9472,6 +9546,8 @@ snapshots: object-hash@3.0.0: {} + obuf@1.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -9582,6 +9658,22 @@ snapshots: pathe@1.1.2: {} + pg-int8@1.0.1: {} + + pg-numeric@1.0.2: {} + + pg-protocol@1.7.1: {} + + pg-types@4.0.2: + dependencies: + pg-int8: 1.0.1 + pg-numeric: 1.0.2 + postgres-array: 3.0.2 + postgres-bytea: 3.0.0 + postgres-date: 2.1.0 + postgres-interval: 3.0.0 + postgres-range: 1.1.4 + picocolors@1.1.0: {} picocolors@1.1.1: {} @@ -9680,6 +9772,18 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@3.0.2: {} + + postgres-bytea@3.0.0: + dependencies: + obuf: 1.1.2 + + postgres-date@2.1.0: {} + + postgres-interval@3.0.0: {} + + postgres-range@1.1.4: {} + preferred-pm@4.0.0: dependencies: find-up-simple: 1.0.0 diff --git a/content/src/content/docs/learn/tutorials/calling-an-api.mdx b/content/src/content/docs/learn/tutorials/calling-an-api.mdx new file mode 100644 index 000000000..f441fcf57 --- /dev/null +++ b/content/src/content/docs/learn/tutorials/calling-an-api.mdx @@ -0,0 +1,199 @@ +--- +type: Tutorial +title: Calling an API +tags: + - some + - tags +sidebar: + order: 4 +--- + +Next up we're going to take a look at an endpoint that makes an API call to +another service. Here's the code: + +```ts twoslash title=index.ts {14-24} +import express from "express"; + +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { HttpRouter } from "@effect/platform"; +import { Effect } from "effect"; +import { Client } from "pg"; + +const db = new Client({ user: "postgres" }); + +const app = express(); + +const BASE_URL = "https://owen-wilson-wow-api.onrender.com"; + +app.get("/wow", async (req, res) => { + const url = new URL(`${BASE_URL}/wows/random`); + + const year = req.query.year; + if (typeof year === "string") { + url.searchParams.set("year", year); + } + + const wow = await fetch(url.toString()); + res.json(await wow.json()); +}); + +const router = HttpRouter.empty + +const main = Effect.gen(function* () { + yield* Effect.promise(() => db.connect()); + app.use(yield* NodeHttpServer.makeHandler(router)); + app.listen(3000); + yield* Effect.never; +}); + +NodeRuntime.runMain(main); +``` + +For a bit of fun I've chosen the [Owen Wilson Wow +API](https://owen-wilson-wow-api.onrender.com/) as our API to call. I've also +decided to use a query parameter to filter the results by year, to show how this +works in Effect. + +## Using `Effect.tryPromise` + +If we were to reach straight away for `Effect.tryPromise`, we would quickly +run into a problem: how do we reference the HTTP request object? We need to +get the `year` from the query parameters, but if you look back at our +`/users` endpoint, you'll see that there's no request object. + +Here's one way to do it. + +```ts twoslash +import { + HttpMiddleware, + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect } from "effect"; +//---cut--- +const query = HttpServerRequest.HttpServerRequest.pipe( + Effect.map((req) => ({ url: req.url })), + Effect.flatMap(HttpServerResponse.json) +); + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/query", query), +); +``` + +We can pipe `HttpServerRequest` into a new `Effect` that extracts the URL from +it and displays the result in JSON. Requesting this endpoint works as you might +expect: + +```shell +http get "localhost:3000/query?year=2010" +``` + +```http +HTTP/1.1 200 OK +Content-Length: 16 +Content-Type: application/json +Date: Fri, 21 Feb 2025 16:41:03 GMT +X-Powered-By: Express + +{ + "url": "/query?year=2010" +} +``` + +But a simpler and more idiomatic way to do this is with `Effect.gen`: + +```ts twoslash +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect } from "effect"; +//---cut--- +const query = Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + return yield* HttpServerResponse.json({ url: req.url }); +}); +``` + +## Using `Effect.gen` + +You've actually seen `Effect.gen` before this section of the tutorial. Right +at the very beginning, we defined our main function using `Effect.gen`: + +```ts twoslash +import express from "express"; +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect } from "effect"; +import { Client } from "pg"; +import { NodeHttpServer } from "@effect/platform-node"; +const db = new Client({ user: "postgres" }); +const app = express(); +const router = HttpRouter.empty +//---cut--- +const main = Effect.gen(function* () { + yield* Effect.promise(() => db.connect()); + app.use(yield* NodeHttpServer.makeHandler(router)); + app.listen(3000); + yield* Effect.never; +}); +``` + +The power of `Effect.gen` is that it allows you to use Effects in a very similar +way to how you use Promises. You can think of `function*` as defining an async +function, and `yield*` as `await`. + +In reality, `function*` defines a [generator +function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*), +and `yield*` [yields +control](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/yield*) +to another generator function. This syntax allows Effect to create an +async/await like experience that can communicate with the runtime and propagate +types correctly. + +Speaking of types, let's take a look at the type returned from our `Effect.gen` +call: + +```ts twoslash +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect } from "effect"; +//---cut--- +const query = Effect.gen(function* () { + // ^? + const req = yield* HttpServerRequest.HttpServerRequest; + return yield* HttpServerResponse.json({ url: req.url }); +}); +``` + +An Effect that reutrns an `HttpServerResponse`, may throw an `HttpBodyError`, +but also, for the first time, has something other than `never` in that third +spot. As a reminder, this is the `Requirement` type. What it's saying here is +that, to execute, this Effect requires an `HttpServerRequest`. + +If we were to try and run this Effect directly, we would get a type error: + +```ts twoslash +// @errors: 2379 +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect } from "effect"; +const query = Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + return yield* HttpServerResponse.json({ url: req.url }); +}); +//---cut--- +Effect.runPromise(query); +``` diff --git a/content/src/content/docs/learn/tutorials/first-endpoint.mdx b/content/src/content/docs/learn/tutorials/first-endpoint.mdx new file mode 100644 index 000000000..a51545787 --- /dev/null +++ b/content/src/content/docs/learn/tutorials/first-endpoint.mdx @@ -0,0 +1,307 @@ +--- +type: Tutorial +title: Migrating our first endpoint +tags: + - some + - tags +sidebar: + order: 2 +--- + +The first step to migrating our Express.js app to Effect is to add some +boilerplate that will allow us to write Effect endpoints that live alongside +Express.js. + +```ts twoslash {3-5,13-21} title=index.ts +import express from "express" + +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { HttpRouter } from "@effect/platform" +import { Effect } from "effect" + +const app = express() + +app.get("/health", (req, res) => { + res.type("text/plain").send("ok") +}) + +const router = HttpRouter.empty + +const main = Effect.gen(function* () { + app.use(yield* NodeHttpServer.makeHandler(router)) + app.listen(3000) + yield* Effect.never +}) + +NodeRuntime.runMain(main) +``` + +And we can run the resulting code like so: + +```shell +bun add @effect/platform @effect/platform-node +bun index.ts +``` + +Our Express.js `/health` handler still works as expected, but we've wrapped the +Express.js app in a way that will allow us to define endpoints using Effect +while still responding to our existing endpoints in Express.js. + +There's a lot of new things happening at the bottom of our file. What is +`Effect.gen`, what are those `yield*`s doing, what is `Effect.never`. For the +time being we're going to set these questions aside. It looks intimidating now, +but if you stick with this tutorial to the end, it will not only make sense, but +start to become second nature. + +## Our new `/health` handler + +The Effect equivalent of our Express.js `/health` handler looks like this: + +```ts twoslash +import { HttpServerResponse } from "@effect/platform" +// ---cut--- +function health() { + return HttpServerResponse.text("ok") +} +``` + +`HttpServerResponse` is Effect's class for generating HTTP responses, we need to +use it for any responses returned from Effect. + +The way we wire this into our Effect `router` is going to look a little bit +strange, but please stay with me: + +```ts twoslash +import { HttpServerResponse, HttpRouter } from "@effect/platform" +import { Effect } from "effect" +// ---cut--- +function health() { + return HttpServerResponse.text("ok") +} + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", Effect.sync(health)), +) +``` + +What on earth are `Effect.sync` and `.pipe`? + +## The Effect Type + +At the core of Effect is... well, the `Effect`. You can think of an `Effect` as +a lazy computation, similar to a function that hasn't been called yet. + +Here's an example of an `Effect` that returns the string `"Hello, world!"`: + +```ts twoslash +import { Effect } from "effect" +// ---cut--- +Effect.promise(async () => "Hello, world!") +``` + +`Effect.promise` takes a function that returns a `Promise` and creates an +`Effect` out of it. `Effect.sync` works exactly the same, except it takes a +synchronous function instead of an asynchronous one. + +If we want to run this Effect, we can use `Effect.runPromise`: + +```ts twoslash +import { Effect } from "effect" +// ---cut--- +const effect = Effect.promise(async () => "Hello, world!") +const result = await Effect.runPromise(effect) +console.log(result) +// => "Hello, world!" +``` + +What if you want to run an Effect that calls another Effect? You can use +`Effect.gen` to do that: + +```ts twoslash {2-5,8} +import { Effect } from "effect" +// ---cut--- +const effect = Effect.promise(async () => "Hello, world!") +const gen = Effect.gen(function* () { + const str = yield* effect + return str.toUpperCase() +}) +const result = await Effect.runPromise(gen) +console.log(result) +// => "HELLO, WORLD!" +``` + +This makes use of JavaScript's [generator functions][1]. You can think of the +`yield*` keyword as being very similar to `await`ing a `Promise`. Under the +hood, all `Effect`s are generators, and `yield*`ing one passes it to the Effect +runtime and waits for the result. + +Given that all we want to do above is call `.toUpperCase()` on the result +of `effect`, the scaffolding of `Effect.gen` may feel heavy. Effect gives us +a suite of tools to work with `Effect`s, and one of those tools is `pipe`: + +```ts twoslash {2} +import { Effect } from "effect" +// ---cut--- +const effect = Effect.promise(async () => "Hello, world!") +const upper = effect.pipe(Effect.map((s) => s.toUpperCase())) +const result = await Effect.runPromise(upper) +console.log(result) +// => "HELLO, WORLD!" +``` + +`pipe` passes the result of one computation as input to another. Here, +`Effect.map` transforms the result of the first `Effect` with the function +provided. + +We can `pipe` as many `Effect`s together as we like: + +```ts twoslash {2-7,10} +import { Effect } from "effect" +// ---cut--- +const effect = Effect.promise(async () => "Hello, world!") +const upper = effect.pipe( + Effect.map((s) => s.toUpperCase()), + Effect.map((s) => s.split("")), + Effect.map((s) => s.reverse()), + Effect.map((s) => s.join("")), +) +const result = await Effect.runPromise(upper) +console.log(result) +// => "!DLROW ,OLLEH" +``` + +This should give us just enough to carry on with migrating our first endpoint +to Effect. As we continue through this tutorial, we'll introduce more and more +things you can do with the `Effect` type. + +## Understanding `HttpRouter` + +Looking back at our `HttpRouter`, we used `pipe` to add a new endpoint to our +app: + +```ts twoslash +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +function health() { + return HttpServerResponse.text("ok") +} +// ---cut--- +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", Effect.sync(health)), +) +``` + +`HttpRouter` is a data structure that represents a collection of routes. The +simplest router is one with no routes at all, and Effect exposes that to us as +the `HttpRouter.empty` value. Under the hood, this is itself an `Effect`: + +```ts twoslash +import { HttpRouter } from "@effect/platform" +import { Effect } from "effect" +// ---cut--- +console.log(Effect.isEffect(HttpRouter.empty)) +// => true +``` + +The helper `HttpRouter.get` takes an `HttpRouter` as an argument and returns a +new `HttpRouter` with the given route added. If we wanted to, we could have done +this much more directly: + +```ts twoslash +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +function health() { + return HttpServerResponse.text("ok") +} +// ---cut--- +const router = HttpRouter.get("/health", Effect.sync(health))(HttpRouter.empty) +``` + +This is exactly the same as the `pipe` version, except that if we wanted to add +multiple routes it gets unwieldy quickly: + +```ts twoslash +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +function health() { + return HttpServerResponse.text("ok") +} +function status() { + return HttpServerResponse.text("ok") +} +function version() { + return HttpServerResponse.text("ok") +} +// ---cut--- +HttpRouter.get("/health", Effect.sync(health))( + HttpRouter.get("/status", Effect.sync(status))( + HttpRouter.get("/version", Effect.sync(version))( + HttpRouter.empty, + ), + ), +) + +// vs + +HttpRouter.empty.pipe( + HttpRouter.get("/version", Effect.sync(version)), + HttpRouter.get("/status", Effect.sync(status)), + HttpRouter.get("/health", Effect.sync(health)), +) +``` + +## The end result + +Here's the full code of our app with our `/health` endpoint being served by +Effect: + +```ts twoslash {9-15} +import express from "express" + +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" + +const app = express() + +function health() { + return HttpServerResponse.text("ok") +} + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", Effect.sync(health)) +) + +const main = Effect.gen(function* () { + app.use(yield* NodeHttpServer.makeHandler(router)) + app.listen(3000) + yield* Effect.never +}) + +NodeRuntime.runMain(main) +``` + +And the output of the endpoint: + +```shell +http get localhost:3000/health +``` + +```http +HTTP/1.1 200 OK +Content-Length: 2 +Content-Type: text/plain +Date: Fri, 14 Feb 2025 13:51:39 GMT +X-Powered-By: Express + +ok +``` + +This output has 2 small differences from the Express.js version: + +1. The `Content-Type` header is `text/plain` instead of `text/plain; charset=utf-8`. +2. The `ETag` header is missing. + +TODO(samwho): explain how to close this gap. + +[1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function* diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx new file mode 100644 index 000000000..769c5dba9 --- /dev/null +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -0,0 +1,515 @@ +--- +type: Tutorial +title: Hooking up a database +tags: + - some + - tags +sidebar: + order: 3 +--- + +The next endpoint we're going to migrate is one that lists all of the users in a +database. For this tutorial I'm going to use Postgres running in Docker as our +database. If you don't have Docker installed, I recommend following the +[official getting started guide](https://www.docker.com/get-started/). + +When you're ready, you can start a Postgres container with: + +```shell +docker run -e POSTGRES_HOST_AUTH_METHOD=trust -p 5432:5432 postgres +``` + +## Creating our data + +To create our `users` table and sample data, we're going to use the `psql` +tool that's present inside of the Docker container we created. Open a new +terminal and run: + +```shell +docker exec -it $(docker ps -qf ancestor=postgres) psql -U postgres +``` + +This will drop you into a `psql` shell, with a prompt that starts `postgres=#`. +From here you can run the following SQL commands: + +```sql +CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT); +INSERT INTO users (name) VALUES ('Alice'); +INSERT INTO users (name) VALUES ('Bob'); +``` + +## The `/users` endpoint + +Let's take a look at our app in full with new Express.js `/users` endpoint added +to it: + +```ts twoslash {6,8,11-14,25} title=index.ts +import express from "express" + +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +import { Client } from "pg" + +const db = new Client({ user: "postgres" }) +const app = express() + +app.get("/users", async (req, res) => { + const { rows } = await db.query("SELECT * FROM users") + res.send({ users: rows }) +}) + +function health() { + return HttpServerResponse.text("ok") +} + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", Effect.sync(health)) +) + +const main = Effect.gen(function* () { + yield* Effect.promise(() => db.connect()) + app.use(yield* NodeHttpServer.makeHandler(router)) + app.listen(3000) + yield* Effect.never +}) + +NodeRuntime.runMain(main) +``` + +We'll also need to add some new dependencies to our project: + +```shell +bun add pg @types/pg +``` + +Then you can run the server with: + +```shell +bun index.ts +``` + +And in another shell we can test the endpoint. + +```shell +http get localhost:3000/users +``` + +```http +HTTP/1.1 200 OK +Content-Length: 57 +Content-Type: application/json; charset=utf-8 +Date: Fri, 14 Feb 2025 14:28:02 GMT +ETag: W/"39-6Qu85qIU12mhgGYWwHErQVfJRxI" +X-Powered-By: Express + +{ + "users": [ + { + "id": 1, + "name": "Alice" + }, + { + "id": 2, + "name": "Bob" + } + ] +} +``` + +## The direct approach + +If we were to copy what we did with the `/health` endpoint, we'd end up with +code that looked like this: + +```ts twoslash +import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform" +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" +import { Client } from "pg" +const db = new Client({ user: "postgres" }) +const health = Effect.sync(() => { + return HttpServerResponse.text("ok") +}) +//---cut--- +// @errors: 2379 +const users = Effect.promise(async () => { + const { rows } = await db.query("SELECT * FROM users") + return HttpServerResponse.json({ users: rows }) +}) + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", health), + HttpRouter.get("/users", users), +) +``` + +This doesn't work, as we can see from the error. The reason is that +`HttpServerResponse.json` is an operation that might fail. Not all JavaScript +objects can be converted to JSON. + +```ts twoslash +JSON.stringify({ big: 10n }) +//=> TypeError: JSON.stringify cannot serialize BigInt. +``` + +For this reason, `HttpServerResponse.json` returns an `Effect` that can fail. +We didn't see this earlier because `HttpServerResponse.text` can't fail, and +so just returns an `HttpServerResponse` directly. + +## Error handling in Effect + +When we introduced the `Effect` type in the previous section, we dealt +exclusively with Effects that couldn't fail. That won't get us very far if +we're building anything non-trivial. + +`Effect` is a generic type that takes 3 arguments: + +```typescript +Effect +``` + +`Success` is the type the Effect returns when it succeeds, `Error` is the type +it returns when it fails, and we'll talk about `Requirements` a bit later. + +When we created our example Effects in the previous section, these all took on +the type `Effect`. + +```ts twoslash +import { Effect } from "effect" +//---cut--- +const _ = Effect.sync(() => "Hello, world!") +// ^? +``` + +This means they will return a `string` on success, and never fail. + +The Effect that `HttpServerResponse.json` returns is of type +`Effect`. So when we try to return +it from our `users` handler, we end up wrapping one Effect in another. + +```ts twoslash +import { HttpServerResponse } from "@effect/platform" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" +import { Client } from "pg" +const db = new Client({ user: "postgres" }) +//---cut--- +const users = Effect.promise(async () => { +// ^? + const { rows } = await db.query("SELECT * FROM users") + return HttpServerResponse.json({ users: rows }) +}) +``` + +This is what the type error from earlier was trying to tell us. Here's the +error again: + +```text wrap +Argument of type 'Effect, never, never>' is not assignable to parameter of type 'Handler' +``` + +Under the hood, a `Handler` is just a type alias for `Effect`. Because `Effect` is +not an `HttpServerResponse`, the type checker gets upset with us. + +## Fixing our nested Effects with `flatMap` + +It's possible to fix this with a call to `Effect.flatMap`: + +```ts twoslash +import { HttpServerResponse } from "@effect/platform" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" +import { Client } from "pg" +const db = new Client({ user: "postgres" }) +//---cut--- +const users = Effect.flatMap( + Effect.promise(async () => { + const { rows } = await db.query("SELECT * FROM users") + return { users: rows } + }), + HttpServerResponse.json +) +``` + +But it is more idiomatic in Effect to use `pipe`: + +```ts twoslash +import { HttpServerResponse } from "@effect/platform" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" +import { Client } from "pg" +const db = new Client({ user: "postgres" }) +//---cut--- +const users = Effect.promise( + () => db.query("SELECT * FROM users") +).pipe( + Effect.map(({ rows }) => ({ users: rows })), + Effect.flatMap(HttpServerResponse.json) +) +``` + +This no longer gives us a type error. We've split our computation into parts: +fetching the data with `Effect.promise`, manipulating its shape with +`Effect.map`, and serializing it with `HttpServerResponse.json`. The +`Effect.flatMap` at the end takes care of the accidental `Effect` nesting we had in our previous implementation. + +## An aside on pipes + +If `pipe` isn't quite clicking for you yet, one way to visualise it is that +you're passing some data through a sequence of functions. + +Take this example: + +```ts twoslash +import { pipe } from "effect" + +const add = (x: number) => (y: number) => y + x +const mul = (x: number) => (y: number) => y * x +const sub = (x: number) => (y: number) => y - x + +const result = pipe( + 1, + add(2), + mul(3), + sub(1) +) +console.log(result) +//=> 8 +``` + +`add`, `mul`, and `sub` are all functions that return functions. So `add(2)` +returns a function that can add 2 to a given number: `add(2)(1)` returns 3. +Our pipeline starts with 1, then passes it through `add(2)`, `mul(3)`, and +`sub(1)`, resulting in 8. + +One of the powers this gives you is the ability to add things easily into +the pipeline. + +```ts twoslash {6-9,14,16,18} +import { pipe } from "effect" + +const add = (x: number) => (y: number) => y + x +const mul = (x: number) => (y: number) => y * x +const sub = (x: number) => (y: number) => y - x +const log = (s: string) => (y: number) => { + console.log(s, y) + return y +} + +const result = pipe( + 1, + add(2), + log("1 + 2 ="), + mul(3), + log(" * 3 ="), + sub(1), + log(" - 1 ="), +) +``` + +This prints the following, with `result` still being 8: + +``` +1 + 2 = 3 + * 3 = 9 + - 1 = 8 +``` + +As we migrate more complex endpoints, we'll see how this ability to "tap into" +the pipeline is really useful. + +## Adding error handling to our database query + +All this talk of error handling might have gotten you thinking: can't our +database query fail? It can! If the query is malformed, `db.query` will throw an +error. When that happens, our endpoint will currently return a 500 error to +the client. Change the SQL query to `SELECT * FROM nonexistent` and try it: + +```shell +http get "localhost:3000/users" +``` + +```http +HTTP/1.1 500 Internal Server Error +Content-Length: 0 +Date: Fri, 14 Feb 2025 17:25:57 GMT +X-Powered-By: Express +``` + +Strictly speaking, a 500 error is correct here. Our server encountered an error +because of a server-side problem, which is what the 500-class errors are for. +But it's not good Effect to let exceptions sneak by like this. + +A simple thing we can do is changing `Effect.promise` to `Effect.tryPromise`: + +```ts twoslash +import { HttpServerResponse } from "@effect/platform" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" +import { Client } from "pg" +const db = new Client({ user: "postgres" }) +//---cut--- +const users = Effect.tryPromise( + () => db.query("SELECT * FROM users") +).pipe( + Effect.map(({ rows }) => ({ users: rows })), + Effect.flatMap(HttpServerResponse.json) +) +``` + +This changes the return type from: + +```typescript +Effect +``` + +To: + +```typescript +Effect +``` + +`Effect.promise` is assumed to always succeed. `Effect.tryPromise` is assumed +to maybe throw an exception, but because TypeScript doesn't give us any way +to know _what_ exception might be thrown, Effect defaults to `UnknownException`. + +We can improve on this: + +```ts twoslash {1,4-5} +import { HttpServerResponse } from "@effect/platform" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" +import { Client} from "pg" +const db = new Client({ user: "postgres" }) +//---cut--- +class UserFetchError extends Error {} + +const users = Effect.tryPromise({ + try: () => db.query("SELECT * FROM users"), + catch: (cause) => new UserFetchError("failed to fetch users", { cause }), +}).pipe( + Effect.map(({ rows }) => ({ users: rows })), + Effect.flatMap(HttpServerResponse.json) +) +``` + +We're using a different variant of `Effect.tryPromise` to give us more control +over the type of the error that is returned. Becuase it's difficult to know what +errors may be thrown by third-party libraries, it's idiomatic to create new +error types that wrap the original error. + +Now our effect has the type: + +```typescript +Effect +``` + +Now our program is aware of what can go wrong _at the type level_. This +information is completely missing if you're using exceptions in TypeScript. +With this information we can ensure, and get TypeScript to verify for us, +that things do not go wrong in unexpected ways. + +```ts twoslash +// @errors: 2375 +import { Effect } from "effect" + +const effect = Effect.succeed(1).pipe( + Effect.tryMap({ + try: (n) => n + 1, + catch: () => new Error(), + }), +) + +const noError: Effect.Effect = effect +``` + +The above code fails because we try to assign an Effect that can fail to a +variable that expects an Effect that can't fail. We can fix this error in a +few ways. + +By providing a default value: + +```ts twoslash {8} +import { Effect } from "effect" + +const effect = Effect.succeed(1).pipe( + Effect.tryMap({ + try: (n) => n + 1, + catch: () => new Error(), + }), + Effect.orElseSucceed(() => 0), +) + +const noError: Effect.Effect = effect +``` + +Or by converting the error into what Effect calls a "defect": + +```ts twoslash {8} +import { Effect } from "effect" + +const effect = Effect.succeed(1).pipe( + Effect.tryMap({ + try: (n) => n + 1, + catch: () => new Error(), + }), + Effect.orDie +) + +const noError: Effect.Effect = effect +``` + +This effectively recreates the behaviour of throwing an exception. Defects are +critical errors that you don't want to handle. If you were to create a defect in +an Effect handler, the result would be a 500 error returned to the user. + +## The end result + +Here's our full code so far: + +```ts twoslash title=index.ts +import express from "express" + +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { HttpRouter, HttpServerResponse } from "@effect/platform"; +import { Effect } from "effect"; +import { Client } from "pg"; + +const db = new Client({ user: "postgres" }); + +const app = express(); + +class UserFetchError extends Error {} +const users = Effect.tryPromise({ + try: () => db.query("SELECT * FROM users"), + catch: (cause) => new UserFetchError("failed to fetch users", { cause }), +}).pipe( + Effect.map(({ rows }) => ({ users: rows })), + Effect.flatMap(HttpServerResponse.json), + Effect.orDie +); + +function health() { + return HttpServerResponse.text("ok"); +} + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", Effect.sync(health)), + HttpRouter.get("/users", users), +); + +const main = Effect.gen(function* () { + yield* Effect.promise(() => db.connect()); + app.use(yield* NodeHttpServer.makeHandler(router)); + app.listen(3000); + yield* Effect.never; +}); + +NodeRuntime.runMain(main); +``` + +Going forward, I'm going to trim older endpoints from the full code as we go so +that the length of these code samples doesn't get overwhelming. \ No newline at end of file diff --git a/content/src/content/docs/learn/tutorials/introduction.mdx b/content/src/content/docs/learn/tutorials/introduction.mdx index 888b1e2c5..3afaf03fa 100644 --- a/content/src/content/docs/learn/tutorials/introduction.mdx +++ b/content/src/content/docs/learn/tutorials/introduction.mdx @@ -4,6 +4,8 @@ title: Introduction tags: - some - tags +sidebar: + order: 1 --- In this tutorial we're going to migrate an Express.js app to Effect. We'll learn @@ -17,7 +19,7 @@ introduce new concepts as we migrate more complex endpoints. We'll start with a simple Express.js app that has a single health checking endpoint. -```ts twoslash +```ts twoslash title=index.ts import express from "express" const app = express() @@ -44,218 +46,24 @@ Save our Express.js app into `index.ts` and run it to make sure it works: ```shell bun add express -bun run index.ts +bun add -D @types/express +bun index.ts ``` You should see `Server is running on http://localhost:3000` in your terminal, and visiting `http://localhost:3000/health` should return `ok`. -## Migrating to Effect +```http +$ http get localhost:3000/health +HTTP/1.1 200 OK +Content-Length: 2 +Content-Type: text/plain; charset=utf-8 +Date: Fri, 14 Feb 2025 13:48:43 GMT +ETag: W/"2-eoX0dku9ba8cNUXvu/DyeabcC+s" +X-Powered-By: Express -The first step to migrating this to Effect is to launch the Express.js server -with Effect instead of the traditional `app.listen`. Because the Express.js -`app` is a function that returns a node `http.Server` under the hood, it slots -nicely into Effect's `HttpServer` abstraction. - -```typescript -import express from "express" - -// New imports -import { HttpRouter, HttpServer } from "@effect/platform" -import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" -import { Layer } from "effect" -import { createServer } from "node:http" - -const app = express() - -app.get("/health", (req, res) => { - res.type("text/plain").send("ok") -}) - -// New server runner -NodeRuntime.runMain( - Layer.launch( - Layer.provide( - HttpServer.serve(HttpRouter.empty), - NodeHttpServer.layer(() => createServer(app), { port: 3000 }), - ), - ), -) -``` - -And we can run the resulting code like so: - -```shell -bun add @effect/platform @effect/platform-node -bun run index.ts -``` - -We haven't changed anything about how the server behaves. It still exposes a -single endpoint, `http://localhost:3000/health`, and that still returns `ok`. -But we've wrapped the Express.js app in a way that will allow us to define new -endpoints using Express while still responding to our existing endpoints in -Express.js. - -There's a lot of new things happening at the bottom of our file. For the time -being we're going to ignore most of it and focus on migrating an endpoint to -Effect. I promise by the time we're done, you'll understand every line we added -above. - -First, let's break out our router, `HttpRouter.empty`, out into its own variable -so it's easier to add to. - -```typescript -const router = HttpRouter.empty -``` - -And let's define the function we want to run when we hit the `/health` endpoint: - -```typescript -function health() { - return HttpServerResponse.text("ok") -} -``` - -`HttpServerResponse` is Effect's class for generating HTTP responses, we need to -use it for any responses returned from our endpoints defined with Effect. - -The way we wire these things together is going to look a bit strange, but bear -with me. - -```typescript -function health() { - return HttpServerResponse.text("ok") -} - -const router = HttpRouter.empty.pipe( - HttpRouter.get("/health", Effect.sync(health)), -) -``` - -What on earth are `Effect.sync` and `.pipe`? - -## The Effect Type - -At the core of Effect is... well, the `Effect`. An `Effect` is what's called a -"thunk", which you can think of as a lazy computation. It doesn't do anything -until you run it. - -Here are some examples of simple `Effect`s: - -```typescript -const sync = Effect.sync(() => "Hello, world!") -const promise = Effect.promise(async () => "Hello, world!") -const succeed = Effect.succeed("Hello, world!") -``` - -Above we have examples of synchronous, asynchronous, and static Effects. None of -them will do anything as defined there, they need to be run. There are several -ways to run Effects. - -```typescript -const resultSync = Effect.runSync(sync) -const resultPromise = await Effect.runPromise(promise); -const resultSucceed = Effect.runSync(succeed) - -console.log(resultSync) -// "Hello, world!" -console.log(resultPromise) -// "Hello, world!" -console.log(resultSucceed) -// "Hello, world!" -``` - -This prints out `"Hello, world!"` three times. Note that you have to run -synchronous and asynchronous Effects differently, `runSync` and `runPromise` -respectively. - -What if you want to run an Effect that calls another Effect? You can use -`Effect.gen` to do that: - -```typescript -const gen = Effect.gen(function* () { - const result = yield* promise - return result.toUpperCase() -}) - -const resultGen = await Effect.runPromise(gen) -console.log(resultGen) -// "HELLO, WORLD!" -``` - -Given that all we really want to do above is call `.toUpperCase()` on the result -of `promise`, the scaffolding of `Effect.gen` feels a bit heavy. Effect gives us -plenty of tools to work with `Effect`s, one of which is `pipe`: - -```typescript -const upper = promise.pipe(Effect.map((s) => s.toUpperCase())) -const result = await Effect.runPromise(upper) -console.log(result) -// "HELLO, WORLD!" -``` - -`pipe` passes the result of one computation as input to another. Here, -`Effect.map` transforms the result of the first `Effect` with the function -provided. - -We can `pipe` as many `Effect`s together as we like: - -```typescript -const upper = promise.pipe( - Effect.map((s) => s.toUpperCase()), - Effect.map((s) => s.split("")), - Effect.map((s) => s.reverse()), - Effect.map((s) => s.join("")), -) -const result = await Effect.runPromise(upper) -console.log(result) -// "!DLROW ,OLLEH" -``` - -## Understanding `HttpRouter` - -Looking back at `HttpRouter`, we used `pipe` to add a new endpoint to our app: - -```typescript -const router = HttpRouter.empty.pipe( - HttpRouter.get("/health", Effect.sync(health)), -); -``` - -`HttpRouter` is a data structure that represents a collection of routes. The -simplest router is one with no routes at all, and Effect exposes that to us as -the `HttpRouter.empty` value. Under the hood, this is itself an `Effect`: - -```typescript -console.log(Effect.isEffect(HttpRouter.empty)) -// true -``` - -The helper `HttpRouter.get` takes as input an `HttpRouter` and returns a new -`HttpRouter` with the given route added. If we wanted to, we could have done -this much more directly: - -```typescript -const router = HttpRouter.get("/health", Effect.sync(health))(HttpRouter.empty) +ok ``` -This is exactly the same as the `pipe` version, except that if we wanted to add -multiple routes it gets unwieldy quickly: - -```typescript -HttpRouter.get("/health", Effect.sync(health))( - HttpRouter.get("/status", Effect.sync(status))( - HttpRouter.get("/version", Effect.sync(version))( - HttpRouter.empty, - ), - ), -) - -// vs - -HttpRouter.empty.pipe( - HttpRouter.get("/health", Effect.sync(health)), - HttpRouter.get("/status", Effect.sync(status)), - HttpRouter.get("/version", Effect.sync(version)), -) -``` +I'm using [HTTPie](https://httpie.io/cli) throughout this tutorial, because the +output is nicer than `curl`. \ No newline at end of file