diff --git a/_project/api/_src/Usecases.ts b/_project/api/_src/Usecases.ts index 2d32e1f88..0562d7a31 100644 --- a/_project/api/_src/Usecases.ts +++ b/_project/api/_src/Usecases.ts @@ -1,5 +1,6 @@ // codegen:start {preset: barrel, include: ./Usecases/*.ts, export} export * from "./Usecases/Blog.Controllers.js" +export * from "./Usecases/Graph.Controllers.js" export * from "./Usecases/HelloWorld.Controllers.js" export * from "./Usecases/Me.Controllers.js" export * from "./Usecases/Operations.Controllers.js" diff --git a/_project/api/_src/Usecases/Graph.Controllers.ts b/_project/api/_src/Usecases/Graph.Controllers.ts new file mode 100644 index 000000000..f4c4c896a --- /dev/null +++ b/_project/api/_src/Usecases/Graph.Controllers.ts @@ -0,0 +1,175 @@ +/** + * @experimental + */ + +import type { CTX } from "@/lib/routing.js" +import { BasicRequestEnv } from "@effect-app-boilerplate/messages/RequestLayers" +import { GraphRsc } from "@effect-app-boilerplate/resources" +import type { GraphMutationResponse } from "@effect-app-boilerplate/resources/Graph/Mutation" +import type { GraphQueryRequest, GraphQueryResponse } from "@effect-app-boilerplate/resources/Graph/Query" +import { dropUndefined } from "@effect-app/core/utils" +import { makeRequestId, RequestContext } from "@effect-app/infra/RequestContext" +import { BlogControllers } from "./Blog.Controllers.js" + +// TODO: Apply roles&rights to individual actions. + +const NoResponse = Effect(undefined) + +function request( + req: Partial>, + context: CTX +) { + return ( + name: RKey, + handler: (inp: any, ctx: CTX) => Effect + ) => { + const q = req[name] + return q + ? Effect.gen(function*($) { + const ctx = yield* $(RequestContext.Tag.access) + const childCtx = RequestContext.inherit(ctx, { + id: makeRequestId(), + locale: ctx.locale, + name: ReasonableString(name) // TODO: Use name from handler.Request + }) + + const r = yield* $( + handler(q.input ?? {}, { ...context, context: childCtx }).provideSomeContextEffect( + BasicRequestEnv( + childCtx + ) + ) + ) + return r + })["|>"](Effect.$.either) + : NoResponse + } +} + +const { controllers, matchWithEffect } = matchFor(GraphRsc) + +// TODO: Auto generate from the clients +const Query = matchWithEffect("Query")( + Effect.gen(function*($) { + const blogPostControllers = yield* $(BlogControllers) + return (req, ctx) => + Effect.gen(function*($) { + const handle = request(req, ctx) + const r: GraphQueryResponse = yield* $( + Effect.structPar({ + // TODO: AllMe currently has a temporal requirement; it must be executed first. (therefore atm requested first..) + // for two reasons: 1. create the user if it didnt exist yet. + // 2. because if the user changes locale, its stored on the user object, and put in cache for the follow-up handlers. + // AllMe: handle("AllMe", AllMe.h), + // AllMeCommentActivity: handle("AllMeCommentActivity", AllMeCommentActivity.h), + // AllMeChangeRequests: handle("AllMeChangeRequests", AllMeChangeRequests.h), + // AllMeEventlog: handle("AllMeEventlog", AllMeEventlog.h), + // AllMeTasks: handle("AllMeTasks", AllMeTasks.h), + + // FindPurchaseOrder: handle("FindPurchaseOrder", FindPurchaseOrder.h) + FindBlogPost: handle("FindBlogPost", blogPostControllers.FindPost.h) + }) + ) + return dropUndefined(r as any) // TODO: Fix optional encoder should ignore undefined values! + }) + }) +) + +const emptyResponse = Effect(undefined) + +function mutation( + req: Partial>, + ctx: CTX +) { + const f = request(req, ctx) + return ( + name: RKey, + handler: (inp: any, ctx: CTX) => Effect, + resultQuery?: (inp: A, ctx: CTX) => Effect + ) => { + const q = req[name] + return f(name, handler).flatMap(x => + !x + ? Effect(x) + : x.isLeft() + ? Effect(x) + : (q?.query + ? Effect.structPar({ + query: Query.flatMap(_ => _(q.query!, ctx)), + result: resultQuery ? resultQuery(x.right, ctx) : emptyResponse + }).map(({ query, result }) => ({ ...query, result })) // TODO: Replace $ variables in the query parameters baed on mutation output! + : emptyResponse).map(query => Either(query ? { query, response: x.right } : { response: x.right })) + ) + } +} + +const Mutation = matchWithEffect("Mutation")( + Effect.gen(function*($) { + const blogPostControllers = yield* $(BlogControllers) + return (req, ctx) => + Effect.gen(function*($) { + const handle = mutation(req, ctx) + const r: GraphMutationResponse = yield* $( + Effect.structPar({ + CreatePost: handle( + "CreatePost", + blogPostControllers.CreatePost.h, + (id, ctx) => + blogPostControllers.FindPost.h({ id }, ctx) + .flatMap(x => (!x ? Effect.die("Post went away?") : Effect(x))) + ) + // UpdatePurchaseOrder: handle("UpdatePurchaseOrder", UpdatePurchaseOrder.h, () => + // FindPurchaseOrder.h(req.UpdatePurchaseOrder!.input).flatMap(x => + // !x ? + // Effect.die("PO went away?") : + // Effect(x) + // )) + }) + ) + return dropUndefined(r as any) // TODO: Fix optional encoder should ignore undefined values! + }) + }) +) + +// TODO: Implement for mutations, with optional query on return +// function createAndRetrievePO() { +// const mutation = { +// "PurchaseOrders.Create": { +// input: { title: "dude" }, +// // optional +// query: { +// "PurchaseOrders.Get": { +// input: { +// id: "$id" /* $ prefix means it should be taken from response output */, +// }, +// }, +// }, +// }, +// } +// type Response = { +// "PurchaseOrders.Create": Either< +// MutationError, +// { +// result: { +// id: "some id" +// } +// query: { +// "PurchaseOrders.Get": Either< +// QueryError, +// { +// id: "some id" +// title: "dude" +// } +// > +// } +// } +// > +// } +// } + +export const GraphControllers = controllers(Effect.struct({ Query, Mutation })) + +// export const GraphControllers = Effect.struct({ +// GraphQuery: match(GraphQuery, defaultErrorHandler, handleRequestEnv), +// GraphMutation: match(GraphMutation, defaultErrorHandler, handleRequestEnv) +// }) diff --git a/_project/api/_src/lib/routing.ts b/_project/api/_src/lib/routing.ts index c0d342955..16834df23 100644 --- a/_project/api/_src/lib/routing.ts +++ b/_project/api/_src/lib/routing.ts @@ -136,7 +136,7 @@ export type RouteAllTest = { type ContextA = X extends Context ? A : never export type RequestEnv = ContextA>>> -function handleRequestEnv< +export function handleRequestEnv< R, PathA, CookieA, diff --git a/_project/api/openapi.json b/_project/api/openapi.json index 0de2002e5..b16ae79c5 100644 --- a/_project/api/openapi.json +++ b/_project/api/openapi.json @@ -216,6 +216,173 @@ } } }, + "/graph/mutate": { + "post": { + "operationId": "GraphMutationRequest", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "CreatePost": { + "properties": { + "input": { + "properties": { + "title": { + "minLength": 1, + "maxLength": 255, + "type": "string" + }, + "body": { + "minLength": 1, + "maxLength": 2047, + "type": "string" + } + }, + "required": [ + "title", + "body" + ], + "type": "object" + }, + "query": { + "properties": { + "FindBlogPost": { + "properties": { + "input": { + "properties": { + "id": { + "minLength": 6, + "maxLength": 50, + "title": "BlogPostId", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + "required": [ + "input" + ], + "type": "object" + }, + "GetAllBlogPosts": { + "properties": { + "input": { + "properties": {}, + "type": "object" + } + }, + "required": [ + "input" + ], + "type": "object" + }, + "result": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "required": [ + "input" + ], + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GraphMutationResponse" + } + } + } + }, + "400": { + "description": "ValidationError" + } + } + } + }, + "/graph/query": { + "post": { + "operationId": "GraphQueryRequest", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "FindBlogPost": { + "properties": { + "input": { + "properties": { + "id": { + "minLength": 6, + "maxLength": 50, + "title": "BlogPostId", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + "required": [ + "input" + ], + "type": "object" + }, + "GetAllBlogPosts": { + "properties": { + "input": { + "properties": {}, + "type": "object" + } + }, + "required": [ + "input" + ], + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GraphQueryResponse" + } + } + } + }, + "400": { + "description": "ValidationError" + } + } + } + }, "/hello-world": { "get": { "parameters": [], @@ -432,6 +599,421 @@ } }, "schemas": { + "InvalidStateError": { + "title": "InvalidStateError", + "properties": { + "_tag": { + "enum": [ + "InvalidStateError" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "type": "object" + }, + "OptimisticConcurrencyException": { + "title": "OptimisticConcurrencyException", + "properties": { + "_tag": { + "enum": [ + "OptimisticConcurrencyException" + ], + "type": "string" + } + }, + "required": [ + "_tag" + ], + "type": "object" + }, + "NotFoundError": { + "title": "NotFoundError", + "properties": { + "_tag": { + "enum": [ + "NotFoundError" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "type": "object" + }, + "NotLoggedInError": { + "title": "NotLoggedInError", + "properties": { + "_tag": { + "enum": [ + "NotLoggedInError" + ], + "type": "string" + } + }, + "required": [ + "_tag" + ], + "type": "object" + }, + "UnauthorizedError": { + "title": "UnauthorizedError", + "properties": { + "_tag": { + "enum": [ + "UnauthorizedError" + ], + "type": "string" + } + }, + "required": [ + "_tag" + ], + "type": "object" + }, + "ValidationError": { + "title": "ValidationError", + "properties": { + "_tag": { + "enum": [ + "ValidationError" + ], + "type": "string" + }, + "errors": { + "items": { + "properties": {}, + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "_tag", + "errors" + ], + "type": "object" + }, + "GraphMutationResponse": { + "title": "GraphMutationResponse", + "properties": { + "CreatePost": { + "oneOf": [ + { + "properties": { + "_tag": { + "enum": [ + "Left" + ] + }, + "left": { + "title": "SupportedErrors", + "oneOf": [ + { + "$ref": "#/components/schemas/InvalidStateError" + }, + { + "$ref": "#/components/schemas/OptimisticConcurrencyException" + }, + { + "$ref": "#/components/schemas/NotFoundError" + }, + { + "$ref": "#/components/schemas/NotLoggedInError" + }, + { + "$ref": "#/components/schemas/UnauthorizedError" + }, + { + "$ref": "#/components/schemas/ValidationError" + } + ], + "discriminator": { + "propertyName": "_tag" + } + } + }, + "required": [ + "_tag", + "left" + ], + "type": "object" + }, + { + "properties": { + "_tag": { + "enum": [ + "Right" + ] + }, + "right": { + "properties": { + "response": { + "minLength": 6, + "maxLength": 50, + "title": "BlogPostId", + "type": "string" + }, + "query": { + "properties": { + "FindBlogPost": { + "oneOf": [ + { + "properties": { + "_tag": { + "enum": [ + "Left" + ] + }, + "left": { + "title": "SupportedErrors", + "oneOf": [ + { + "$ref": "#/components/schemas/InvalidStateError" + }, + { + "$ref": "#/components/schemas/OptimisticConcurrencyException" + }, + { + "$ref": "#/components/schemas/NotFoundError" + }, + { + "$ref": "#/components/schemas/NotLoggedInError" + }, + { + "$ref": "#/components/schemas/UnauthorizedError" + }, + { + "$ref": "#/components/schemas/ValidationError" + } + ], + "discriminator": { + "propertyName": "_tag" + } + } + }, + "required": [ + "_tag", + "left" + ], + "type": "object" + }, + { + "properties": { + "_tag": { + "enum": [ + "Right" + ] + }, + "right": { + "properties": { + "id": { + "minLength": 6, + "maxLength": 50, + "title": "BlogPostId", + "type": "string" + }, + "title": { + "minLength": 1, + "maxLength": 255, + "type": "string" + }, + "body": { + "minLength": 1, + "maxLength": 2047, + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "id", + "title", + "body", + "createdAt" + ], + "type": "object", + "nullable": true + } + }, + "required": [ + "_tag", + "right" + ], + "type": "object" + } + ], + "discriminator": { + "propertyName": "_tag" + } + }, + "result": { + "properties": { + "id": { + "minLength": 6, + "maxLength": 50, + "title": "BlogPostId", + "type": "string" + }, + "title": { + "minLength": 1, + "maxLength": 255, + "type": "string" + }, + "body": { + "minLength": 1, + "maxLength": 2047, + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "id", + "title", + "body", + "createdAt" + ], + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "response" + ], + "type": "object" + } + }, + "required": [ + "_tag", + "right" + ], + "type": "object" + } + ], + "discriminator": { + "propertyName": "_tag" + } + } + }, + "type": "object" + }, + "GraphQueryResponse": { + "title": "GraphQueryResponse", + "properties": { + "FindBlogPost": { + "oneOf": [ + { + "properties": { + "_tag": { + "enum": [ + "Left" + ] + }, + "left": { + "title": "SupportedErrors", + "oneOf": [ + { + "$ref": "#/components/schemas/InvalidStateError" + }, + { + "$ref": "#/components/schemas/OptimisticConcurrencyException" + }, + { + "$ref": "#/components/schemas/NotFoundError" + }, + { + "$ref": "#/components/schemas/NotLoggedInError" + }, + { + "$ref": "#/components/schemas/UnauthorizedError" + }, + { + "$ref": "#/components/schemas/ValidationError" + } + ], + "discriminator": { + "propertyName": "_tag" + } + } + }, + "required": [ + "_tag", + "left" + ], + "type": "object" + }, + { + "properties": { + "_tag": { + "enum": [ + "Right" + ] + }, + "right": { + "properties": { + "id": { + "minLength": 6, + "maxLength": 50, + "title": "BlogPostId", + "type": "string" + }, + "title": { + "minLength": 1, + "maxLength": 255, + "type": "string" + }, + "body": { + "minLength": 1, + "maxLength": 2047, + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "id", + "title", + "body", + "createdAt" + ], + "type": "object", + "nullable": true + } + }, + "required": [ + "_tag", + "right" + ], + "type": "object" + } + ], + "discriminator": { + "propertyName": "_tag" + } + } + }, + "type": "object" + }, "User": { "title": "User", "properties": {