From c732aff8b774abd013a017133a12ca0ac0ec0f36 Mon Sep 17 00:00:00 2001 From: moosebay Date: Fri, 13 Feb 2026 15:32:11 +0300 Subject: [PATCH 1/2] feat: add GraphQL request support with dark theme tokens Add full-stack GraphQL request support including schema, API, flow node integration, and UI pages. UI components use semantic design tokens (neutral, accent, success) for dark mode compatibility. --- apps/cli/cmd/flow.go | 3 + apps/cli/internal/runner/runner.go | 13 + apps/cli/internal/runner/runner_test.go | 3 + .../client/src/app/router/route-tree.gen.ts | 62 ++ .../client/src/features/file-system/index.tsx | 107 +++ packages/client/src/pages/flow/add-node.tsx | 74 +- packages/client/src/pages/flow/edit.tsx | 22 + .../client/src/pages/flow/nodes/graphql.tsx | 134 ++++ packages/client/src/pages/graphql/@x/flow.tsx | 2 + .../client/src/pages/graphql/@x/workspace.tsx | 3 + packages/client/src/pages/graphql/page.tsx | 53 ++ .../src/pages/graphql/request/header.tsx | 114 +++ .../src/pages/graphql/request/index.tsx | 2 + .../src/pages/graphql/request/panel.tsx | 77 ++ .../pages/graphql/request/query-editor.tsx | 24 + .../src/pages/graphql/request/top-bar.tsx | 114 +++ .../graphql/request/variables-editor.tsx | 29 + .../src/pages/graphql/response/body.tsx | 42 + .../src/pages/graphql/response/header.tsx | 37 + .../src/pages/graphql/response/index.tsx | 129 +++ .../routes/graphql/$graphqlIdCan/index.tsx | 19 + .../routes/graphql/$graphqlIdCan/route.tsx | 11 + packages/client/src/pages/graphql/tab.tsx | 37 + .../$workspaceIdCan/(graphql)/__virtual.ts | 3 + packages/client/src/shared/routes.tsx | 4 + packages/db/pkg/sqlc/gen/db.go | 260 ++++++ packages/db/pkg/sqlc/gen/flow.sql.go | 106 ++- packages/db/pkg/sqlc/gen/graphql.sql.go | 746 ++++++++++++++++++ packages/db/pkg/sqlc/gen/models.go | 51 ++ packages/db/pkg/sqlc/queries/flow.sql | 31 +- packages/db/pkg/sqlc/queries/graphql.sql | 168 ++++ packages/db/pkg/sqlc/schema/05_flow.sql | 10 +- packages/db/pkg/sqlc/schema/08_graphql.sql | 75 ++ packages/db/pkg/sqlc/sqlc.yaml | 79 ++ packages/server/cmd/server/server.go | 95 ++- packages/server/docs/specs/GRAPHQL.md | 357 +++++++++ .../internal/api/rflowv2/logging_test.go | 3 + .../server/internal/api/rflowv2/rflowv2.go | 69 +- .../internal/api/rflowv2/rflowv2_common.go | 15 + .../internal/api/rflowv2/rflowv2_exec.go | 108 ++- .../internal/api/rflowv2/rflowv2_exec_test.go | 3 + .../rflowv2/rflowv2_node_condition_test.go | 3 + .../api/rflowv2/rflowv2_node_exec_test.go | 6 + .../api/rflowv2/rflowv2_node_graphql.go | 446 +++++++++++ .../api/rflowv2/rflowv2_testutil_test.go | 3 + .../server/internal/api/rgraphql/rgraphql.go | 470 +++++++++++ .../api/rgraphql/rgraphql_converter.go | 262 ++++++ .../internal/api/rgraphql/rgraphql_crud.go | 207 +++++ .../api/rgraphql/rgraphql_crud_header.go | 212 +++++ .../api/rgraphql/rgraphql_crud_response.go | 62 ++ .../internal/api/rgraphql/rgraphql_exec.go | 482 +++++++++++ .../server/internal/converter/converter.go | 2 + .../server/pkg/flow/flowbuilder/builder.go | 39 + .../server/pkg/flow/node/ngraphql/ngraphql.go | 294 +++++++ packages/server/pkg/model/mflow/execution.go | 1 + packages/server/pkg/model/mflow/node.go | 1 + packages/server/pkg/model/mflow/node_types.go | 7 + .../server/pkg/model/mgraphql/mgraphql.go | 50 ++ packages/server/pkg/mutation/event.go | 7 + .../service/sflow/node_execution_mapper.go | 2 + .../service/sflow/node_execution_writer.go | 3 + .../server/pkg/service/sflow/node_graphql.go | 60 ++ .../pkg/service/sflow/node_graphql_mapper.go | 26 + .../pkg/service/sflow/node_graphql_reader.go | 34 + .../pkg/service/sflow/node_graphql_writer.go | 47 ++ .../server/pkg/service/sgraphql/header.go | 88 +++ .../server/pkg/service/sgraphql/mapper.go | 111 +++ .../server/pkg/service/sgraphql/reader.go | 73 ++ .../server/pkg/service/sgraphql/response.go | 122 +++ .../server/pkg/service/sgraphql/sgraphql.go | 62 ++ .../server/pkg/service/sgraphql/writer.go | 50 ++ packages/server/test/e2e_har_to_cli_test.go | 15 + packages/spec/api/file-system.tsp | 1 + packages/spec/api/flow.tsp | 8 + packages/spec/api/graphql.tsp | 66 ++ packages/spec/api/main.tsp | 1 + 76 files changed, 6572 insertions(+), 45 deletions(-) create mode 100644 packages/client/src/pages/flow/nodes/graphql.tsx create mode 100644 packages/client/src/pages/graphql/@x/flow.tsx create mode 100644 packages/client/src/pages/graphql/@x/workspace.tsx create mode 100644 packages/client/src/pages/graphql/page.tsx create mode 100644 packages/client/src/pages/graphql/request/header.tsx create mode 100644 packages/client/src/pages/graphql/request/index.tsx create mode 100644 packages/client/src/pages/graphql/request/panel.tsx create mode 100644 packages/client/src/pages/graphql/request/query-editor.tsx create mode 100644 packages/client/src/pages/graphql/request/top-bar.tsx create mode 100644 packages/client/src/pages/graphql/request/variables-editor.tsx create mode 100644 packages/client/src/pages/graphql/response/body.tsx create mode 100644 packages/client/src/pages/graphql/response/header.tsx create mode 100644 packages/client/src/pages/graphql/response/index.tsx create mode 100644 packages/client/src/pages/graphql/routes/graphql/$graphqlIdCan/index.tsx create mode 100644 packages/client/src/pages/graphql/routes/graphql/$graphqlIdCan/route.tsx create mode 100644 packages/client/src/pages/graphql/tab.tsx create mode 100644 packages/client/src/pages/workspace/routes/workspace/$workspaceIdCan/(graphql)/__virtual.ts create mode 100644 packages/db/pkg/sqlc/gen/graphql.sql.go create mode 100644 packages/db/pkg/sqlc/queries/graphql.sql create mode 100644 packages/db/pkg/sqlc/schema/08_graphql.sql create mode 100644 packages/server/docs/specs/GRAPHQL.md create mode 100644 packages/server/internal/api/rflowv2/rflowv2_node_graphql.go create mode 100644 packages/server/internal/api/rgraphql/rgraphql.go create mode 100644 packages/server/internal/api/rgraphql/rgraphql_converter.go create mode 100644 packages/server/internal/api/rgraphql/rgraphql_crud.go create mode 100644 packages/server/internal/api/rgraphql/rgraphql_crud_header.go create mode 100644 packages/server/internal/api/rgraphql/rgraphql_crud_response.go create mode 100644 packages/server/internal/api/rgraphql/rgraphql_exec.go create mode 100644 packages/server/pkg/flow/node/ngraphql/ngraphql.go create mode 100644 packages/server/pkg/model/mgraphql/mgraphql.go create mode 100644 packages/server/pkg/service/sflow/node_graphql.go create mode 100644 packages/server/pkg/service/sflow/node_graphql_mapper.go create mode 100644 packages/server/pkg/service/sflow/node_graphql_reader.go create mode 100644 packages/server/pkg/service/sflow/node_graphql_writer.go create mode 100644 packages/server/pkg/service/sgraphql/header.go create mode 100644 packages/server/pkg/service/sgraphql/mapper.go create mode 100644 packages/server/pkg/service/sgraphql/reader.go create mode 100644 packages/server/pkg/service/sgraphql/response.go create mode 100644 packages/server/pkg/service/sgraphql/sgraphql.go create mode 100644 packages/server/pkg/service/sgraphql/writer.go create mode 100644 packages/spec/api/graphql.tsp diff --git a/apps/cli/cmd/flow.go b/apps/cli/cmd/flow.go index 9a5908061..f622604ad 100644 --- a/apps/cli/cmd/flow.go +++ b/apps/cli/cmd/flow.go @@ -176,6 +176,9 @@ var yamlflowRunCmd = &cobra.Command{ &services.NodeAI, &services.NodeAiProvider, &services.NodeMemory, + nil, // NodeGraphQLService - not yet supported in CLI + nil, // GraphQLService - not yet supported in CLI + nil, // GraphQLHeaderService - not yet supported in CLI &services.Workspace, &services.Variable, &services.FlowVariable, diff --git a/apps/cli/internal/runner/runner.go b/apps/cli/internal/runner/runner.go index dd83376d7..247ea6d62 100644 --- a/apps/cli/internal/runner/runner.go +++ b/apps/cli/internal/runner/runner.go @@ -13,6 +13,7 @@ import ( "github.com/the-dev-tools/dev-tools/apps/cli/internal/reporter" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/node" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/node/ngraphql" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/node/nrequest" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/runner" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/runner/flowlocalrunner" @@ -251,6 +252,17 @@ func RunFlow(ctx context.Context, flowPtr *mflow.Flow, services RunnerServices, }() defer close(requestRespChan) + // Initialize GraphQL response channel + gqlRespChan := make(chan ngraphql.NodeGraphQLSideResp, requestBufferSize) + go func() { + for resp := range gqlRespChan { + if resp.Done != nil { + close(resp.Done) + } + } + }() + defer close(gqlRespChan) + // Build flow node map using flowbuilder flowNodeMap, startNodeID, err := services.Builder.BuildNodes( ctx, @@ -259,6 +271,7 @@ func RunFlow(ctx context.Context, flowPtr *mflow.Flow, services RunnerServices, nodeTimeout, httpClient, requestRespChan, + gqlRespChan, services.JSClient, ) if err != nil { diff --git a/apps/cli/internal/runner/runner_test.go b/apps/cli/internal/runner/runner_test.go index 5dcc0f06c..ba6a6faea 100644 --- a/apps/cli/internal/runner/runner_test.go +++ b/apps/cli/internal/runner/runner_test.go @@ -113,6 +113,9 @@ func newFlowTestFixture(t *testing.T) *flowTestFixture { nil, // NodeAIService - not needed for CLI tests nil, // NodeAiProviderService - not needed for CLI tests nil, // NodeMemoryService - not needed for CLI tests + nil, // NodeGraphQLService - not needed for CLI tests + nil, // GraphQLService - not needed for CLI tests + nil, // GraphQLHeaderService - not needed for CLI tests &workspaceService, &varService, &flowVariableService, diff --git a/packages/client/src/app/router/route-tree.gen.ts b/packages/client/src/app/router/route-tree.gen.ts index dbbe4df20..baff4983b 100644 --- a/packages/client/src/app/router/route-tree.gen.ts +++ b/packages/client/src/app/router/route-tree.gen.ts @@ -13,8 +13,10 @@ import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesIndexRout import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteImport } from './../../pages/workspace/routes/workspace/$workspaceIdCan/route' import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRouteImport } from './../../pages/workspace/routes/workspace/$workspaceIdCan/index' import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteImport } from './../../pages/http/routes/http/$httpIdCan/route' +import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteImport } from './../../pages/graphql/routes/graphql/$graphqlIdCan/route' import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteImport } from './../../pages/flow/routes/flow/$flowIdCan/route' import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRouteImport } from './../../pages/http/routes/http/$httpIdCan/index' +import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRouteImport } from './../../pages/graphql/routes/graphql/$graphqlIdCan/index' import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRouteImport } from './../../pages/flow/routes/flow/$flowIdCan/index' import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRouteImport } from './../../pages/credential/routes/credential/$credentialIdCan/index' import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRouteImport } from './../../pages/flow/routes/flow/$flowIdCan/history' @@ -52,6 +54,15 @@ const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoute dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute, } as any, ) +const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRoute = + dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteImport.update( + { + id: '/(graphql)/graphql/$graphqlIdCan', + path: '/graphql/$graphqlIdCan', + getParentRoute: () => + dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute, + } as any, + ) const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute = dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteImport.update( { @@ -70,6 +81,15 @@ const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoute dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute, } as any, ) +const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRoute = + dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRouteImport.update( + { + id: '/', + path: '/', + getParentRoute: () => + dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRoute, + } as any, + ) const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute = dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRouteImport.update( { @@ -112,10 +132,12 @@ export interface FileRoutesByFullPath { '/workspace/$workspaceIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteWithChildren '/workspace/$workspaceIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute '/workspace/$workspaceIdCan/flow/$flowIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteWithChildren + '/workspace/$workspaceIdCan/graphql/$graphqlIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteWithChildren '/workspace/$workspaceIdCan/http/$httpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteWithChildren '/workspace/$workspaceIdCan/flow/$flowIdCan/history': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute '/workspace/$workspaceIdCan/credential/$credentialIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute '/workspace/$workspaceIdCan/flow/$flowIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute + '/workspace/$workspaceIdCan/graphql/$graphqlIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRoute '/workspace/$workspaceIdCan/http/$httpIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute '/workspace/$workspaceIdCan/http/$httpIdCan/delta/$deltaHttpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute } @@ -125,6 +147,7 @@ export interface FileRoutesByTo { '/workspace/$workspaceIdCan/flow/$flowIdCan/history': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute '/workspace/$workspaceIdCan/credential/$credentialIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute '/workspace/$workspaceIdCan/flow/$flowIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute + '/workspace/$workspaceIdCan/graphql/$graphqlIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRoute '/workspace/$workspaceIdCan/http/$httpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute '/workspace/$workspaceIdCan/http/$httpIdCan/delta/$deltaHttpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute } @@ -134,10 +157,12 @@ export interface FileRoutesById { '/(dashboard)/(workspace)/workspace/$workspaceIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteWithChildren '/(dashboard)/(workspace)/workspace/$workspaceIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteWithChildren + '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteWithChildren '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteWithChildren '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/history': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(credential)/credential/$credentialIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute + '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRoute '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/delta/$deltaHttpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute } @@ -148,10 +173,12 @@ export interface FileRouteTypes { | '/workspace/$workspaceIdCan' | '/workspace/$workspaceIdCan/' | '/workspace/$workspaceIdCan/flow/$flowIdCan' + | '/workspace/$workspaceIdCan/graphql/$graphqlIdCan' | '/workspace/$workspaceIdCan/http/$httpIdCan' | '/workspace/$workspaceIdCan/flow/$flowIdCan/history' | '/workspace/$workspaceIdCan/credential/$credentialIdCan' | '/workspace/$workspaceIdCan/flow/$flowIdCan/' + | '/workspace/$workspaceIdCan/graphql/$graphqlIdCan/' | '/workspace/$workspaceIdCan/http/$httpIdCan/' | '/workspace/$workspaceIdCan/http/$httpIdCan/delta/$deltaHttpIdCan' fileRoutesByTo: FileRoutesByTo @@ -161,6 +188,7 @@ export interface FileRouteTypes { | '/workspace/$workspaceIdCan/flow/$flowIdCan/history' | '/workspace/$workspaceIdCan/credential/$credentialIdCan' | '/workspace/$workspaceIdCan/flow/$flowIdCan' + | '/workspace/$workspaceIdCan/graphql/$graphqlIdCan' | '/workspace/$workspaceIdCan/http/$httpIdCan' | '/workspace/$workspaceIdCan/http/$httpIdCan/delta/$deltaHttpIdCan' id: @@ -169,10 +197,12 @@ export interface FileRouteTypes { | '/(dashboard)/(workspace)/workspace/$workspaceIdCan' | '/(dashboard)/(workspace)/workspace/$workspaceIdCan/' | '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan' + | '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan' | '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan' | '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/history' | '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(credential)/credential/$credentialIdCan/' | '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/' + | '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan/' | '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/' | '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/delta/$deltaHttpIdCan' fileRoutesById: FileRoutesById @@ -212,6 +242,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteImport parentRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute } + '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan': { + id: '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan' + path: '/graphql/$graphqlIdCan' + fullPath: '/workspace/$workspaceIdCan/graphql/$graphqlIdCan' + preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteImport + parentRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute + } '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan': { id: '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan' path: '/flow/$flowIdCan' @@ -226,6 +263,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRouteImport parentRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute } + '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan/': { + id: '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan/' + path: '/' + fullPath: '/workspace/$workspaceIdCan/graphql/$graphqlIdCan/' + preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRouteImport + parentRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRoute + } '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/': { id: '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/' path: '/' @@ -275,6 +319,21 @@ const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoute dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteChildren, ) +interface dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteChildren { + dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRoute +} + +const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteChildren: dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteChildren = + { + dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRoute: + dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRoute, + } + +const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteWithChildren = + dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRoute._addFileChildren( + dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteChildren, + ) + interface dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteChildren { dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute @@ -296,6 +355,7 @@ const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoute interface dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteChildren { dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteWithChildren + dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteWithChildren dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteWithChildren dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute } @@ -306,6 +366,8 @@ const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspace dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute, dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute: dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteWithChildren, + dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRoute: + dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteWithChildren, dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute: dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteWithChildren, dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute: diff --git a/packages/client/src/features/file-system/index.tsx b/packages/client/src/features/file-system/index.tsx index d52daeccd..72bb20001 100644 --- a/packages/client/src/features/file-system/index.tsx +++ b/packages/client/src/features/file-system/index.tsx @@ -29,6 +29,7 @@ import { FolderSchema, } from '@the-dev-tools/spec/buf/api/file_system/v1/file_system_pb'; import { FlowSchema, FlowService } from '@the-dev-tools/spec/buf/api/flow/v1/flow_pb'; +import { GraphQLSchema as GraphQLItemSchema } from '@the-dev-tools/spec/buf/api/graph_q_l/v1/graph_q_l_pb'; import { HttpDeltaSchema, HttpMethod, HttpSchema, HttpService } from '@the-dev-tools/spec/buf/api/http/v1/http_pb'; import { CredentialAnthropicCollectionSchema, @@ -38,6 +39,7 @@ import { } from '@the-dev-tools/spec/tanstack-db/v1/api/credential'; import { FileCollectionSchema, FolderCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/file_system'; import { FlowCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/flow'; +import { GraphQLCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/graph_q_l'; import { HttpCollectionSchema, HttpDeltaCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/http'; import { Button } from '@the-dev-tools/ui/button'; import { FlowsIcon, FolderOpenedIcon } from '@the-dev-tools/ui/icons'; @@ -84,6 +86,7 @@ export const FileCreateMenu = ({ parentFolderId, ...props }: FileCreateMenuProps const { workspaceId } = routes.dashboard.workspace.route.useLoaderData(); const folderCollection = useApiCollection(FolderCollectionSchema); + const graphqlCollection = useApiCollection(GraphQLCollectionSchema); const httpCollection = useApiCollection(HttpCollectionSchema); const flowCollection = useApiCollection(FlowCollectionSchema); @@ -116,6 +119,22 @@ export const FileCreateMenu = ({ parentFolderId, ...props }: FileCreateMenuProps HTTP request + { + const graphqlUlid = Ulid.generate(); + graphqlCollection.utils.insert({ graphqlId: graphqlUlid.bytes, name: 'New GraphQL request' }); + await insertFile({ fileId: graphqlUlid.bytes, kind: FileKind.GRAPH_Q_L }); + if (toNavigate) + await navigate({ + from: router.routesById[routes.dashboard.workspace.route.id].fullPath, + params: { graphqlIdCan: graphqlUlid.toCanonical() }, + to: router.routesById[routes.dashboard.workspace.graphql.route.id].fullPath, + }); + }} + > + GraphQL request + + { const flowUlid = Ulid.generate(); @@ -332,6 +351,7 @@ const FileItem = ({ id }: FileItemProps) => { Match.when(FileKind.HTTP, () => ), Match.when(FileKind.HTTP_DELTA, () => ), Match.when(FileKind.FLOW, () => ), + Match.when(FileKind.GRAPH_Q_L, () => ), Match.when(FileKind.CREDENTIAL, () => ), Match.orElse(() => null), ); @@ -884,6 +904,93 @@ const FlowFile = ({ id }: FileItemProps) => { return toNavigate ? : ; }; +const GraphQLFile = ({ id }: FileItemProps) => { + const router = useRouter(); + const matchRoute = useMatchRoute(); + + const fileCollection = useApiCollection(FileCollectionSchema); + + const { fileId: graphqlId } = useMemo(() => fileCollection.utils.parseKeyUnsafe(id), [fileCollection.utils, id]); + + const graphqlCollection = useApiCollection(GraphQLCollectionSchema); + + const { name } = + useLiveQuery( + (_) => + _.from({ item: graphqlCollection }) + .where((_) => eq(_.item.graphqlId, graphqlId)) + .select((_) => pick(_.item, 'name')) + .findOne(), + [graphqlCollection, graphqlId], + ).data ?? create(GraphQLItemSchema); + + const { containerRef, navigate: toNavigate = false, showControls } = useContext(FileTreeContext); + + const { escapeRef, escapeRender } = useEscapePortal(containerRef); + + const { edit, isEditing, textFieldProps } = useEditableTextState({ + onSuccess: (_) => graphqlCollection.utils.update({ graphqlId, name: _ }), + value: name, + }); + + const { menuProps, menuTriggerProps, onContextMenu } = useContextMenuState(); + + const route = { + from: router.routesById[routes.dashboard.workspace.route.id].fullPath, + params: { graphqlIdCan: Ulid.construct(graphqlId).toCanonical() }, + to: router.routesById[routes.dashboard.workspace.graphql.route.id].fullPath, + } satisfies ToOptions; + + const content = ( + <> + GQL + + + {name} + + + {isEditing && + escapeRender( + , + )} + + {showControls && ( + + + + + void edit()}>Rename + + pipe(fileCollection.utils.parseKeyUnsafe(id), (_) => fileCollection.utils.delete(_))} + variant='danger' + > + Delete + + + + )} + + ); + + const props = { + children: content, + className: toNavigate && matchRoute(route) !== false ? tw`bg-neutral` : '', + id, + onContextMenu, + textValue: name, + } satisfies TreeItemProps; + + return toNavigate ? : ; +}; + const CredentialFile = ({ id }: FileItemProps) => { const router = useRouter(); const matchRoute = useMatchRoute(); diff --git a/packages/client/src/pages/flow/add-node.tsx b/packages/client/src/pages/flow/add-node.tsx index 70d70db11..ed2f9fcda 100644 --- a/packages/client/src/pages/flow/add-node.tsx +++ b/packages/client/src/pages/flow/add-node.tsx @@ -6,7 +6,7 @@ import * as RAC from 'react-aria-components'; import { FiArrowLeft, FiBriefcase, FiChevronRight, FiTerminal, FiX } from 'react-icons/fi'; import { TbRobotFace } from 'react-icons/tb'; import { FileKind } from '@the-dev-tools/spec/buf/api/file_system/v1/file_system_pb'; -import { HandleKind, NodeHttpInsertSchema, NodeKind } from '@the-dev-tools/spec/buf/api/flow/v1/flow_pb'; +import { HandleKind, NodeGraphQLInsertSchema, NodeHttpInsertSchema, NodeKind } from '@the-dev-tools/spec/buf/api/flow/v1/flow_pb'; import { HttpMethod } from '@the-dev-tools/spec/buf/api/http/v1/http_pb'; import { FileCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/file_system'; import { @@ -16,9 +16,11 @@ import { NodeConditionCollectionSchema, NodeForCollectionSchema, NodeForEachCollectionSchema, + NodeGraphQLCollectionSchema, NodeHttpCollectionSchema, NodeJsCollectionSchema, } from '@the-dev-tools/spec/tanstack-db/v1/api/flow'; +import { GraphQLCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/graph_q_l'; import { HttpCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/http'; import { Button } from '@the-dev-tools/ui/button'; import { FlowsIcon, ForIcon, IfIcon, SendRequestIcon } from '@the-dev-tools/ui/icons'; @@ -247,6 +249,13 @@ const AddCoreNodeSidebar = (props: AddNodeSidebarProps) => { onAction={() => void setSidebar?.((_) => )} title='HTTP Request' /> + + } + onAction={() => void setSidebar?.((_) => )} + title='GraphQL Request' + /> ); @@ -318,6 +327,69 @@ const AddHttpRequestNodeSidebar = ({ handleKind, position, previous, sourceId, t ); }; +const AddGraphQLRequestNodeSidebar = ({ handleKind, position, previous, sourceId, targetId }: AddNodeSidebarProps) => { + const { workspaceId } = routes.dashboard.workspace.route.useLoaderData(); + + const insertNode = useInsertNode(); + + const fileCollection = useApiCollection(FileCollectionSchema); + const graphqlCollection = useApiCollection(GraphQLCollectionSchema); + const nodeGraphQLCollection = useApiCollection(NodeGraphQLCollectionSchema); + + return ( + <> + + +
+ +
+ + { + const nodeId = Ulid.generate().bytes; + const data: MessageInitShape = { nodeId }; + + const file = fileCollection.get(key.toString())!; + + if (file.kind === FileKind.GRAPH_Q_L) { + data.graphqlId = file.fileId; + } else { + return; + } + + nodeGraphQLCollection.utils.insert(data); + insertNode({ handleKind, kind: NodeKind.GRAPH_Q_L, name: 'graphql', nodeId, position, sourceId, targetId }); + }} + showControls + /> + + ); +}; + const AddAiNode = ({ handleKind, position, sourceId, targetId }: AddNodeSidebarProps) => { const insertNode = useInsertNode(); diff --git a/packages/client/src/pages/flow/edit.tsx b/packages/client/src/pages/flow/edit.tsx index 094ccbb72..c9cc947d5 100644 --- a/packages/client/src/pages/flow/edit.tsx +++ b/packages/client/src/pages/flow/edit.tsx @@ -18,6 +18,7 @@ import { FlowCollectionSchema, FlowVariableCollectionSchema, NodeCollectionSchema, + NodeGraphQLCollectionSchema, NodeHttpCollectionSchema, } from '@the-dev-tools/spec/tanstack-db/v1/api/flow'; import { Button, ButtonAsRouteLink } from '@the-dev-tools/ui/button'; @@ -60,6 +61,7 @@ import { import { ConditionNode, ConditionSettings } from './nodes/condition'; import { ForNode, ForSettings } from './nodes/for'; import { ForEachNode, ForEachSettings } from './nodes/for-each'; +import { GraphQLNode, GraphQLSettings } from './nodes/graphql'; import { HttpNode, HttpSettings } from './nodes/http'; import { JavaScriptNode, JavaScriptSettings } from './nodes/javascript'; import { ManualStartNode } from './nodes/manual-start'; @@ -72,6 +74,7 @@ export const nodeTypes: XF.NodeTypes = { [NodeKind.CONDITION]: ConditionNode, [NodeKind.FOR]: ForNode, [NodeKind.FOR_EACH]: ForEachNode, + [NodeKind.GRAPH_Q_L]: GraphQLNode, [NodeKind.HTTP]: HttpNode, [NodeKind.JS]: JavaScriptNode, [NodeKind.MANUAL_START]: ManualStartNode, @@ -108,6 +111,7 @@ export const Flow = ({ children }: PropsWithChildren) => { const flowCollection = useApiCollection(FlowCollectionSchema); const edgeCollection = useApiCollection(EdgeCollectionSchema); const nodeCollection = useApiCollection(NodeCollectionSchema); + const nodeGraphQLCollection = useApiCollection(NodeGraphQLCollectionSchema); const nodeHttpCollection = useApiCollection(NodeHttpCollectionSchema); const nodeEditDialog = useNodeEditDialog(); @@ -214,6 +218,23 @@ export const Flow = ({ children }: PropsWithChildren) => { position, }); } + + if (file?.kind === FileKind.GRAPH_Q_L) { + const nodeId = Ulid.generate().bytes; + + nodeGraphQLCollection.utils.insert({ + graphqlId: file.fileId, + nodeId, + }); + + nodeCollection.utils.insert({ + flowId, + kind: NodeKind.GRAPH_Q_L, + name: `graphql_${getNodes().length}`, + nodeId, + position, + }); + } }, ref, }); @@ -610,6 +631,7 @@ const useNodeEditDialog = () => { Match.when({ kind: NodeKind.FOR }, (_) => ), Match.when({ kind: NodeKind.JS }, (_) => ), Match.when({ kind: NodeKind.HTTP }, (_) => ), + Match.when({ kind: NodeKind.GRAPH_Q_L }, (_) => ), Match.when({ kind: NodeKind.AI }, (_) => ), Match.when({ kind: NodeKind.AI_PROVIDER }, (_) => ), Match.when({ kind: NodeKind.AI_MEMORY }, (_) => ), diff --git a/packages/client/src/pages/flow/nodes/graphql.tsx b/packages/client/src/pages/flow/nodes/graphql.tsx new file mode 100644 index 000000000..08afeeba9 --- /dev/null +++ b/packages/client/src/pages/flow/nodes/graphql.tsx @@ -0,0 +1,134 @@ +import { create } from '@bufbuild/protobuf'; +import { eq, useLiveQuery } from '@tanstack/react-db'; +import { useRouter } from '@tanstack/react-router'; +import * as XF from '@xyflow/react'; +import { Ulid } from 'id128'; +import { FiExternalLink } from 'react-icons/fi'; +import { NodeGraphQLSchema } from '@the-dev-tools/spec/buf/api/flow/v1/flow_pb'; +import { + NodeExecutionCollectionSchema, + NodeGraphQLCollectionSchema, +} from '@the-dev-tools/spec/tanstack-db/v1/api/flow'; +import { GraphQLCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/graph_q_l'; +import { ButtonAsLink } from '@the-dev-tools/ui/button'; +import { SendRequestIcon } from '@the-dev-tools/ui/icons'; +import { tw } from '@the-dev-tools/ui/tailwind-literal'; +import { GraphQLRequestPanel, GraphQLResponsePanel } from '~/pages/graphql/@x/flow'; +import { useApiCollection } from '~/shared/api'; +import { pick } from '~/shared/lib'; +import { routes } from '~/shared/routes'; +import { Handle } from '../handle'; +import { NodeSettingsBody, NodeSettingsOutputProps, NodeSettingsProps, SimpleNode } from '../node'; + +export const GraphQLNode = ({ id, selected }: XF.NodeProps) => { + const nodeId = Ulid.fromCanonical(id).bytes; + + const nodeGraphQLCollection = useApiCollection(NodeGraphQLCollectionSchema); + + const { graphqlId } = + useLiveQuery( + (_) => + _.from({ item: nodeGraphQLCollection }) + .where((_) => eq(_.item.nodeId, nodeId)) + .select((_) => pick(_.item, 'graphqlId')) + .findOne(), + [nodeGraphQLCollection, nodeId], + ).data ?? create(NodeGraphQLSchema); + + const graphqlCollection = useApiCollection(GraphQLCollectionSchema); + + const { name, url } = + useLiveQuery( + (_) => + _.from({ item: graphqlCollection }) + .where((_) => eq(_.item.graphqlId, graphqlId)) + .select((_) => pick(_.item, 'name', 'url')) + .findOne(), + [graphqlCollection, graphqlId], + ).data ?? {}; + + return ( + + + + + } + icon={} + nodeId={nodeId} + selected={selected} + title='GraphQL' + > +
+
GQL
+
{name ?? url}
+
+
+ ); +}; + +export const GraphQLSettings = ({ nodeId }: NodeSettingsProps) => { + const router = useRouter(); + + const { workspaceIdCan } = routes.dashboard.workspace.route.useParams(); + + const nodeGraphQLCollection = useApiCollection(NodeGraphQLCollectionSchema); + + const { graphqlId } = + useLiveQuery( + (_) => + _.from({ item: nodeGraphQLCollection }) + .where((_) => eq(_.item.nodeId, nodeId)) + .select((_) => pick(_.item, 'graphqlId')) + .findOne(), + [nodeGraphQLCollection, nodeId], + ).data ?? create(NodeGraphQLSchema); + + return ( + } + settingsHeader={ + + + Open GraphQL + + } + title='GraphQL request' + > + + + ); +}; + +const Output = ({ nodeExecutionId }: NodeSettingsOutputProps) => { + const collection = useApiCollection(NodeExecutionCollectionSchema); + + const { graphqlResponseId } = + useLiveQuery( + (_) => + _.from({ item: collection }) + .where((_) => eq(_.item.nodeExecutionId, nodeExecutionId)) + .select((_) => pick(_.item, 'graphqlResponseId')) + .findOne(), + [collection, nodeExecutionId], + ).data ?? {}; + + if (!graphqlResponseId) return null; + + return ( +
+ +
+ ); +}; diff --git a/packages/client/src/pages/graphql/@x/flow.tsx b/packages/client/src/pages/graphql/@x/flow.tsx new file mode 100644 index 000000000..df4df11f6 --- /dev/null +++ b/packages/client/src/pages/graphql/@x/flow.tsx @@ -0,0 +1,2 @@ +export { GraphQLRequestPanel } from '../request/panel'; +export { GraphQLResponsePanel } from '../response'; diff --git a/packages/client/src/pages/graphql/@x/workspace.tsx b/packages/client/src/pages/graphql/@x/workspace.tsx new file mode 100644 index 000000000..13825d610 --- /dev/null +++ b/packages/client/src/pages/graphql/@x/workspace.tsx @@ -0,0 +1,3 @@ +import { resolveRoutesTo } from '../../../shared/lib/router'; + +export const resolveRoutesFrom = resolveRoutesTo(import.meta.dirname, '../routes'); diff --git a/packages/client/src/pages/graphql/page.tsx b/packages/client/src/pages/graphql/page.tsx new file mode 100644 index 000000000..8d919bb53 --- /dev/null +++ b/packages/client/src/pages/graphql/page.tsx @@ -0,0 +1,53 @@ +import { eq, useLiveQuery } from '@tanstack/react-db'; +import { Panel, Group as PanelGroup, useDefaultLayout } from 'react-resizable-panels'; +import { GraphQLResponseCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/graph_q_l'; +import { PanelResizeHandle } from '@the-dev-tools/ui/resizable-panel'; +import { ReferenceContext } from '~/features/expression'; +import { useApiCollection } from '~/shared/api'; +import { pick } from '~/shared/lib'; +import { routes } from '~/shared/routes'; +import { GraphQLRequestPanel } from './request/panel'; +import { GraphQLTopBar } from './request/top-bar'; +import { GraphQLResponsePanel } from './response'; + +export const GraphQLPage = () => { + const { graphqlId } = routes.dashboard.workspace.graphql.route.useRouteContext(); + const { workspaceId } = routes.dashboard.workspace.route.useLoaderData(); + + const responseCollection = useApiCollection(GraphQLResponseCollectionSchema); + + const { graphqlResponseId } = + useLiveQuery( + (_) => + _.from({ item: responseCollection }) + .where((_) => eq(_.item.graphqlId, graphqlId)) + .select((_) => pick(_.item, 'graphqlResponseId')) + .orderBy((_) => _.item.graphqlResponseId, 'desc') + .limit(1) + .findOne(), + [responseCollection, graphqlId], + ).data ?? {}; + + const endpointLayout = useDefaultLayout({ id: 'graphql-endpoint' }); + + return ( + + + + + + + + + {graphqlResponseId && ( + <> + + + + + + + )} + + ); +}; diff --git a/packages/client/src/pages/graphql/request/header.tsx b/packages/client/src/pages/graphql/request/header.tsx new file mode 100644 index 000000000..08830f2ad --- /dev/null +++ b/packages/client/src/pages/graphql/request/header.tsx @@ -0,0 +1,114 @@ +import { eq, useLiveQuery } from '@tanstack/react-db'; +import { Ulid } from 'id128'; +import { useDragAndDrop } from 'react-aria-components'; +import { GraphQLHeaderCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/graph_q_l'; +import { DataTable } from '@the-dev-tools/ui/data-table'; +import { DropIndicatorHorizontal } from '@the-dev-tools/ui/reorder'; +import { + columnActionsCommon, + columnCheckboxField, + columnReferenceField, + columnTextField, + ReactTableNoMemo, + useFormTableAddRow, +} from '~/features/form-table'; +import { useApiCollection } from '~/shared/api'; +import { getNextOrder, handleCollectionReorder, pick } from '~/shared/lib'; + +export interface GraphQLHeaderTableProps { + graphqlId: Uint8Array; +} + +export const GraphQLHeaderTable = ({ graphqlId }: GraphQLHeaderTableProps) => { + const collection = useApiCollection(GraphQLHeaderCollectionSchema); + + const items = useLiveQuery( + (_) => + _.from({ item: collection }) + .where((_) => eq(_.item.graphqlId, graphqlId)) + .orderBy((_) => _.item.order) + .select((_) => pick(_.item, 'graphqlHeaderId', 'order')), + [collection, graphqlId], + ).data.map((_) => pick(_, 'graphqlHeaderId')); + + const addRow = useFormTableAddRow({ + createLabel: 'New header', + items, + onCreate: async () => + void collection.utils.insert({ + enabled: true, + graphqlHeaderId: Ulid.generate().bytes, + graphqlId, + order: await getNextOrder(collection), + }), + }); + + const { dragAndDropHooks } = useDragAndDrop({ + getItems: (keys) => [...keys].map((key) => ({ key: key.toString() })), + onReorder: handleCollectionReorder(collection), + renderDropIndicator: () => , + }); + + const getItem = (row: (typeof items)[number]) => + collection.get(collection.utils.getKey({ graphqlHeaderId: row.graphqlHeaderId })); + + return ( + { + const item = getItem(context.row.original); + if (item) collection.utils.update({ enabled: value, graphqlHeaderId: item.graphqlHeaderId }); + }, + value: (provide, context) => { + const item = getItem(context.row.original); + return provide(item?.enabled ?? false); + }, + }), + columnReferenceField( + 'key', + { + onChange: (value, context) => { + const item = getItem(context.row.original); + if (item) collection.utils.update({ graphqlHeaderId: item.graphqlHeaderId, key: value }); + }, + value: (provide, context) => { + const item = getItem(context.row.original); + return provide(item?.key ?? ''); + }, + }, + { meta: { isRowHeader: true } }, + ), + columnReferenceField('value', { + onChange: (value, context) => { + const item = getItem(context.row.original); + if (item) collection.utils.update({ graphqlHeaderId: item.graphqlHeaderId, value }); + }, + value: (provide, context) => { + const item = getItem(context.row.original); + return provide(item?.value ?? ''); + }, + }), + columnTextField('description', { + onChange: (value, context) => { + const item = getItem(context.row.original); + if (item) collection.utils.update({ description: value, graphqlHeaderId: item.graphqlHeaderId }); + }, + value: (provide, context) => { + const item = getItem(context.row.original); + return provide(item?.description ?? ''); + }, + }), + columnActionsCommon({ + onDelete: (item) => collection.utils.delete({ graphqlHeaderId: item.graphqlHeaderId! }), + }), + ]} + data={items} + getRowId={(_) => collection.utils.getKey({ graphqlHeaderId: _.graphqlHeaderId! })} + > + {(table) => ( + + )} + + ); +}; diff --git a/packages/client/src/pages/graphql/request/index.tsx b/packages/client/src/pages/graphql/request/index.tsx new file mode 100644 index 000000000..0dcfe6553 --- /dev/null +++ b/packages/client/src/pages/graphql/request/index.tsx @@ -0,0 +1,2 @@ +export { GraphQLRequestPanel, type GraphQLRequestPanelProps } from './panel'; +export { GraphQLTopBar, type GraphQLTopBarProps } from './top-bar'; diff --git a/packages/client/src/pages/graphql/request/panel.tsx b/packages/client/src/pages/graphql/request/panel.tsx new file mode 100644 index 000000000..515cce687 --- /dev/null +++ b/packages/client/src/pages/graphql/request/panel.tsx @@ -0,0 +1,77 @@ +import { count, eq, useLiveQuery } from '@tanstack/react-db'; +import { Suspense } from 'react'; +import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'; +import { twMerge } from 'tailwind-merge'; +import { GraphQLHeaderCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/graph_q_l'; +import { Spinner } from '@the-dev-tools/ui/spinner'; +import { tw } from '@the-dev-tools/ui/tailwind-literal'; +import { useApiCollection } from '~/shared/api'; +import { GraphQLHeaderTable } from './header'; +import { GraphQLQueryEditor } from './query-editor'; +import { GraphQLVariablesEditor } from './variables-editor'; + +export interface GraphQLRequestPanelProps { + graphqlId: Uint8Array; +} + +export const GraphQLRequestPanel = ({ graphqlId }: GraphQLRequestPanelProps) => { + const headerCollection = useApiCollection(GraphQLHeaderCollectionSchema); + + const { headerCount = 0 } = + useLiveQuery( + (_) => + _.from({ item: headerCollection }) + .where((_) => eq(_.item.graphqlId, graphqlId)) + .select((_) => ({ headerCount: count(_.item.graphqlId) })) + .findOne(), + [headerCollection, graphqlId], + ).data ?? {}; + + const tabClass = ({ isSelected }: { isSelected: boolean }) => + twMerge( + tw` + -mb-px cursor-pointer border-b-2 border-transparent py-1.5 text-md leading-5 font-medium tracking-tight + text-on-neutral-low transition-colors + `, + isSelected && tw`border-b-accent text-on-neutral`, + ); + + return ( + + + + Query + + + + Variables + + + + Headers + {headerCount > 0 && ({headerCount})} + + + + + + + } + > + + + + + + + + + + + + + + ); +}; diff --git a/packages/client/src/pages/graphql/request/query-editor.tsx b/packages/client/src/pages/graphql/request/query-editor.tsx new file mode 100644 index 000000000..dc04327d9 --- /dev/null +++ b/packages/client/src/pages/graphql/request/query-editor.tsx @@ -0,0 +1,24 @@ +import CodeMirror from '@uiw/react-codemirror'; +import { GraphQLCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/graph_q_l'; +import { tw } from '@the-dev-tools/ui/tailwind-literal'; +import { useApiCollection } from '~/shared/api'; + +export interface GraphQLQueryEditorProps { + graphqlId: Uint8Array; +} + +export const GraphQLQueryEditor = ({ graphqlId }: GraphQLQueryEditorProps) => { + const collection = useApiCollection(GraphQLCollectionSchema); + const item = collection.get(collection.utils.getKey({ graphqlId })); + + return ( + collection.utils.update({ graphqlId, query: value })} + placeholder='Enter your GraphQL query...' + value={item?.query ?? ''} + /> + ); +}; diff --git a/packages/client/src/pages/graphql/request/top-bar.tsx b/packages/client/src/pages/graphql/request/top-bar.tsx new file mode 100644 index 000000000..42a704b5e --- /dev/null +++ b/packages/client/src/pages/graphql/request/top-bar.tsx @@ -0,0 +1,114 @@ +import { Array, pipe } from 'effect'; +import { useTransition } from 'react'; +import { Button as AriaButton, MenuTrigger } from 'react-aria-components'; +import { FiMoreHorizontal } from 'react-icons/fi'; +import { GraphQLService } from '@the-dev-tools/spec/buf/api/graph_q_l/v1/graph_q_l_pb'; +import { GraphQLCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/graph_q_l'; +import { Button } from '@the-dev-tools/ui/button'; +import { Menu, MenuItem, useContextMenuState } from '@the-dev-tools/ui/menu'; +import { tw } from '@the-dev-tools/ui/tailwind-literal'; +import { TextInputField, useEditableTextState } from '@the-dev-tools/ui/text-field'; +import { request, useApiCollection } from '~/shared/api'; +import { routes } from '~/shared/routes'; + +export interface GraphQLTopBarProps { + graphqlId: Uint8Array; +} + +export const GraphQLTopBar = ({ graphqlId }: GraphQLTopBarProps) => { + const { transport } = routes.root.useRouteContext(); + + const collection = useApiCollection(GraphQLCollectionSchema); + + const item = collection.get(collection.utils.getKey({ graphqlId })); + + const { menuProps, menuTriggerProps, onContextMenu } = useContextMenuState(); + + const { edit, isEditing, textFieldProps } = useEditableTextState({ + onSuccess: (_) => { + if (_ === item?.name) return; + collection.utils.update({ graphqlId, name: _ }); + }, + value: item?.name ?? '', + }); + + const [isSending, startTransition] = useTransition(); + + return ( + <> +
+
+ {isEditing ? ( + + ) : ( + void edit()} + > + {item?.name} + + )} +
+ + + + + + void edit()}>Rename + + collection.utils.delete({ graphqlId })} variant='danger'> + Delete + + + +
+ +
+ collection.utils.update({ graphqlId, url: _ })} + placeholder='Enter GraphQL endpoint URL' + value={item?.url ?? ''} + /> + + +
+ + ); +}; diff --git a/packages/client/src/pages/graphql/request/variables-editor.tsx b/packages/client/src/pages/graphql/request/variables-editor.tsx new file mode 100644 index 000000000..63baa0f84 --- /dev/null +++ b/packages/client/src/pages/graphql/request/variables-editor.tsx @@ -0,0 +1,29 @@ +import { json } from '@codemirror/lang-json'; +import CodeMirror from '@uiw/react-codemirror'; +import { useMemo } from 'react'; +import { GraphQLCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/graph_q_l'; +import { tw } from '@the-dev-tools/ui/tailwind-literal'; +import { useApiCollection } from '~/shared/api'; + +export interface GraphQLVariablesEditorProps { + graphqlId: Uint8Array; +} + +export const GraphQLVariablesEditor = ({ graphqlId }: GraphQLVariablesEditorProps) => { + const collection = useApiCollection(GraphQLCollectionSchema); + const item = collection.get(collection.utils.getKey({ graphqlId })); + + const extensions = useMemo(() => [json()], []); + + return ( + collection.utils.update({ graphqlId, variables: value })} + placeholder='{"key": "value"}' + value={item?.variables ?? ''} + /> + ); +}; diff --git a/packages/client/src/pages/graphql/response/body.tsx b/packages/client/src/pages/graphql/response/body.tsx new file mode 100644 index 000000000..5526a3697 --- /dev/null +++ b/packages/client/src/pages/graphql/response/body.tsx @@ -0,0 +1,42 @@ +import { create } from '@bufbuild/protobuf'; +import { eq, useLiveQuery } from '@tanstack/react-db'; +import { useQuery } from '@tanstack/react-query'; +import CodeMirror from '@uiw/react-codemirror'; +import { GraphQLResponseSchema } from '@the-dev-tools/spec/buf/api/graph_q_l/v1/graph_q_l_pb'; +import { GraphQLResponseCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/graph_q_l'; +import { tw } from '@the-dev-tools/ui/tailwind-literal'; +import { prettierFormatQueryOptions, useCodeMirrorLanguageExtensions } from '~/features/expression'; +import { useApiCollection } from '~/shared/api'; +import { pick } from '~/shared/lib'; + +export interface GraphQLResponseBodyProps { + graphqlResponseId: Uint8Array; +} + +export const GraphQLResponseBody = ({ graphqlResponseId }: GraphQLResponseBodyProps) => { + const collection = useApiCollection(GraphQLResponseCollectionSchema); + + const { body } = + useLiveQuery( + (_) => + _.from({ item: collection }) + .where((_) => eq(_.item.graphqlResponseId, graphqlResponseId)) + .select((_) => pick(_.item, 'body')) + .findOne(), + [collection, graphqlResponseId], + ).data ?? create(GraphQLResponseSchema); + + const { data: prettierBody } = useQuery(prettierFormatQueryOptions({ language: 'json', text: body })); + const extensions = useCodeMirrorLanguageExtensions('json'); + + return ( + + ); +}; diff --git a/packages/client/src/pages/graphql/response/header.tsx b/packages/client/src/pages/graphql/response/header.tsx new file mode 100644 index 000000000..000be9371 --- /dev/null +++ b/packages/client/src/pages/graphql/response/header.tsx @@ -0,0 +1,37 @@ +import { eq, useLiveQuery } from '@tanstack/react-db'; +import { createColumnHelper } from '@tanstack/react-table'; +import { GraphQLResponseHeaderCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/graph_q_l'; +import { DataTable, useReactTable } from '@the-dev-tools/ui/data-table'; +import { tw } from '@the-dev-tools/ui/tailwind-literal'; +import { useApiCollection } from '~/shared/api'; +import { pick } from '~/shared/lib'; + +export interface GraphQLResponseHeaderTableProps { + graphqlResponseId: Uint8Array; +} + +export const GraphQLResponseHeaderTable = ({ graphqlResponseId }: GraphQLResponseHeaderTableProps) => { + const collection = useApiCollection(GraphQLResponseHeaderCollectionSchema); + + const { data: items } = useLiveQuery( + (_) => + _.from({ item: collection }) + .where((_) => eq(_.item.graphqlResponseId, graphqlResponseId)) + .select((_) => pick(_.item, 'key', 'value')), + [collection, graphqlResponseId], + ); + + const { accessor } = createColumnHelper<(typeof items)[number]>(); + + const columns = [ + accessor('key', { cell: ({ cell }) =>
{cell.renderValue()}
}), + accessor('value', { cell: ({ cell }) =>
{cell.renderValue()}
}), + ]; + + const table = useReactTable({ + columns, + data: items, + }); + + return ; +}; diff --git a/packages/client/src/pages/graphql/response/index.tsx b/packages/client/src/pages/graphql/response/index.tsx new file mode 100644 index 000000000..ae1352b9b --- /dev/null +++ b/packages/client/src/pages/graphql/response/index.tsx @@ -0,0 +1,129 @@ +import { create } from '@bufbuild/protobuf'; +import { count, eq, useLiveQuery } from '@tanstack/react-db'; +import { Duration, pipe } from 'effect'; +import { Suspense } from 'react'; +import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'; +import { twJoin, twMerge } from 'tailwind-merge'; +import { GraphQLResponseSchema } from '@the-dev-tools/spec/buf/api/graph_q_l/v1/graph_q_l_pb'; +import { + GraphQLResponseCollectionSchema, + GraphQLResponseHeaderCollectionSchema, +} from '@the-dev-tools/spec/tanstack-db/v1/api/graph_q_l'; +import { Separator } from '@the-dev-tools/ui/separator'; +import { Spinner } from '@the-dev-tools/ui/spinner'; +import { tw } from '@the-dev-tools/ui/tailwind-literal'; +import { formatSize } from '@the-dev-tools/ui/utils'; +import { useApiCollection } from '~/shared/api'; +import { pick } from '~/shared/lib'; +import { GraphQLResponseBody } from './body'; +import { GraphQLResponseHeaderTable } from './header'; + +interface GraphQLResponsePanelProps { + graphqlResponseId: Uint8Array; +} + +export const GraphQLResponsePanel = ({ graphqlResponseId }: GraphQLResponsePanelProps) => { + const responseCollection = useApiCollection(GraphQLResponseCollectionSchema); + + const { duration, size, status } = + useLiveQuery( + (_) => + _.from({ item: responseCollection }) + .where((_) => eq(_.item.graphqlResponseId, graphqlResponseId)) + .select((_) => pick(_.item, 'duration', 'size', 'status')) + .findOne(), + [responseCollection, graphqlResponseId], + ).data ?? create(GraphQLResponseSchema); + + const headerCollection = useApiCollection(GraphQLResponseHeaderCollectionSchema); + + const { headerCount = 0 } = + useLiveQuery( + (_) => + _.from({ item: headerCollection }) + .where((_) => eq(_.item.graphqlResponseId, graphqlResponseId)) + .select((_) => ({ headerCount: count(_.item.graphqlResponseHeaderId) })) + .findOne(), + [headerCollection, graphqlResponseId], + ).data ?? {}; + + return ( + +
+ + + twMerge( + tw` + -mb-px cursor-pointer border-b-2 border-transparent py-2 text-md leading-5 font-medium tracking-tight + text-on-neutral-low transition-colors + `, + isSelected && tw`border-b-accent text-on-neutral`, + ) + } + id='body' + > + Body + + + + twMerge( + tw` + -mb-px cursor-pointer border-b-2 border-transparent py-2 text-md leading-5 font-medium tracking-tight + text-on-neutral-low transition-colors + `, + isSelected && tw`border-b-accent text-on-neutral`, + ) + } + id='headers' + > + Headers + {headerCount > 0 && ({headerCount})} + + + +
+ +
+
+ Status: + {status} +
+ + + +
+ Time: + {pipe(duration, Duration.millis, Duration.format)} +
+ + + +
+ Size: + {formatSize(size)} +
+
+
+ +
+ + +
+ } + > + + + + + + + + +
+
+ ); +}; diff --git a/packages/client/src/pages/graphql/routes/graphql/$graphqlIdCan/index.tsx b/packages/client/src/pages/graphql/routes/graphql/$graphqlIdCan/index.tsx new file mode 100644 index 000000000..1053d3aca --- /dev/null +++ b/packages/client/src/pages/graphql/routes/graphql/$graphqlIdCan/index.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { openTab } from '~/widgets/tabs'; +import { GraphQLPage } from '../../../page'; +import { GraphQLTab, graphqlTabId } from '../../../tab'; + +export const Route = createFileRoute( + '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan/', +)({ + component: GraphQLPage, + onEnter: async (match) => { + const { graphqlId } = match.context; + + await openTab({ + id: graphqlTabId({ graphqlId }), + match, + node: , + }); + }, +}); diff --git a/packages/client/src/pages/graphql/routes/graphql/$graphqlIdCan/route.tsx b/packages/client/src/pages/graphql/routes/graphql/$graphqlIdCan/route.tsx new file mode 100644 index 000000000..e61f74b47 --- /dev/null +++ b/packages/client/src/pages/graphql/routes/graphql/$graphqlIdCan/route.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { Ulid } from 'id128'; + +export const Route = createFileRoute( + '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan', +)({ + context: ({ params: { graphqlIdCan } }) => { + const graphqlId = Ulid.fromCanonical(graphqlIdCan).bytes; + return { graphqlId }; + }, +}); diff --git a/packages/client/src/pages/graphql/tab.tsx b/packages/client/src/pages/graphql/tab.tsx new file mode 100644 index 000000000..4a9769040 --- /dev/null +++ b/packages/client/src/pages/graphql/tab.tsx @@ -0,0 +1,37 @@ +import { useLiveQuery } from '@tanstack/react-db'; +import { useEffect } from 'react'; +import { GraphQLCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/graph_q_l'; +import { tw } from '@the-dev-tools/ui/tailwind-literal'; +import { useApiCollection } from '~/shared/api'; +import { eqStruct } from '~/shared/lib'; +import { routes } from '~/shared/routes'; +import { useCloseTab } from '~/widgets/tabs'; + +export interface GraphQLTabProps { + graphqlId: Uint8Array; +} + +export const graphqlTabId = ({ graphqlId }: GraphQLTabProps) => + JSON.stringify({ graphqlId, route: routes.dashboard.workspace.graphql.route.id }); + +export const GraphQLTab = ({ graphqlId }: GraphQLTabProps) => { + const closeTab = useCloseTab(); + + const collection = useApiCollection(GraphQLCollectionSchema); + + const item = useLiveQuery( + (_) => _.from({ item: collection }).where(eqStruct({ graphqlId })).findOne(), + [collection, graphqlId], + ).data; + + useEffect(() => { + if (!item) void closeTab(graphqlTabId({ graphqlId })); + }, [item, graphqlId, closeTab]); + + return ( + <> + GQL + {item?.name} + + ); +}; diff --git a/packages/client/src/pages/workspace/routes/workspace/$workspaceIdCan/(graphql)/__virtual.ts b/packages/client/src/pages/workspace/routes/workspace/$workspaceIdCan/(graphql)/__virtual.ts new file mode 100644 index 000000000..5de4c49bd --- /dev/null +++ b/packages/client/src/pages/workspace/routes/workspace/$workspaceIdCan/(graphql)/__virtual.ts @@ -0,0 +1,3 @@ +import { resolveRoutesFrom } from '../../../../../graphql/@x/workspace'; + +export default resolveRoutesFrom(import.meta.dirname); diff --git a/packages/client/src/shared/routes.tsx b/packages/client/src/shared/routes.tsx index 1072e6b2a..935fddcc7 100644 --- a/packages/client/src/shared/routes.tsx +++ b/packages/client/src/shared/routes.tsx @@ -16,6 +16,10 @@ export const routes = { index: getRouteApi('/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/'), history: getRouteApi('/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/history'), }, + graphql: { + route: getRouteApi('/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan'), + index: getRouteApi('/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan/'), + }, http: { route: getRouteApi('/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan'), index: getRouteApi('/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/'), diff --git a/packages/db/pkg/sqlc/gen/db.go b/packages/db/pkg/sqlc/gen/db.go index a109944ac..8eb3ed88e 100644 --- a/packages/db/pkg/sqlc/gen/db.go +++ b/packages/db/pkg/sqlc/gen/db.go @@ -39,6 +39,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.cleanupOrphanedFlowNodeForEachStmt, err = db.PrepareContext(ctx, cleanupOrphanedFlowNodeForEach); err != nil { return nil, fmt.Errorf("error preparing query CleanupOrphanedFlowNodeForEach: %w", err) } + if q.cleanupOrphanedFlowNodeGraphQLStmt, err = db.PrepareContext(ctx, cleanupOrphanedFlowNodeGraphQL); err != nil { + return nil, fmt.Errorf("error preparing query CleanupOrphanedFlowNodeGraphQL: %w", err) + } if q.cleanupOrphanedFlowNodeHttpStmt, err = db.PrepareContext(ctx, cleanupOrphanedFlowNodeHttp); err != nil { return nil, fmt.Errorf("error preparing query CleanupOrphanedFlowNodeHttp: %w", err) } @@ -90,6 +93,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.createFlowNodeForEachStmt, err = db.PrepareContext(ctx, createFlowNodeForEach); err != nil { return nil, fmt.Errorf("error preparing query CreateFlowNodeForEach: %w", err) } + if q.createFlowNodeGraphQLStmt, err = db.PrepareContext(ctx, createFlowNodeGraphQL); err != nil { + return nil, fmt.Errorf("error preparing query CreateFlowNodeGraphQL: %w", err) + } if q.createFlowNodeHTTPStmt, err = db.PrepareContext(ctx, createFlowNodeHTTP); err != nil { return nil, fmt.Errorf("error preparing query CreateFlowNodeHTTP: %w", err) } @@ -117,6 +123,21 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.createFlowsBulkStmt, err = db.PrepareContext(ctx, createFlowsBulk); err != nil { return nil, fmt.Errorf("error preparing query CreateFlowsBulk: %w", err) } + if q.createGraphQLStmt, err = db.PrepareContext(ctx, createGraphQL); err != nil { + return nil, fmt.Errorf("error preparing query CreateGraphQL: %w", err) + } + if q.createGraphQLHeaderStmt, err = db.PrepareContext(ctx, createGraphQLHeader); err != nil { + return nil, fmt.Errorf("error preparing query CreateGraphQLHeader: %w", err) + } + if q.createGraphQLResponseStmt, err = db.PrepareContext(ctx, createGraphQLResponse); err != nil { + return nil, fmt.Errorf("error preparing query CreateGraphQLResponse: %w", err) + } + if q.createGraphQLResponseHeaderStmt, err = db.PrepareContext(ctx, createGraphQLResponseHeader); err != nil { + return nil, fmt.Errorf("error preparing query CreateGraphQLResponseHeader: %w", err) + } + if q.createGraphQLResponseHeaderBulkStmt, err = db.PrepareContext(ctx, createGraphQLResponseHeaderBulk); err != nil { + return nil, fmt.Errorf("error preparing query CreateGraphQLResponseHeaderBulk: %w", err) + } if q.createHTTPStmt, err = db.PrepareContext(ctx, createHTTP); err != nil { return nil, fmt.Errorf("error preparing query CreateHTTP: %w", err) } @@ -231,6 +252,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteFlowNodeForEachStmt, err = db.PrepareContext(ctx, deleteFlowNodeForEach); err != nil { return nil, fmt.Errorf("error preparing query DeleteFlowNodeForEach: %w", err) } + if q.deleteFlowNodeGraphQLStmt, err = db.PrepareContext(ctx, deleteFlowNodeGraphQL); err != nil { + return nil, fmt.Errorf("error preparing query DeleteFlowNodeGraphQL: %w", err) + } if q.deleteFlowNodeHTTPStmt, err = db.PrepareContext(ctx, deleteFlowNodeHTTP); err != nil { return nil, fmt.Errorf("error preparing query DeleteFlowNodeHTTP: %w", err) } @@ -246,6 +270,18 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteFlowVariableStmt, err = db.PrepareContext(ctx, deleteFlowVariable); err != nil { return nil, fmt.Errorf("error preparing query DeleteFlowVariable: %w", err) } + if q.deleteGraphQLStmt, err = db.PrepareContext(ctx, deleteGraphQL); err != nil { + return nil, fmt.Errorf("error preparing query DeleteGraphQL: %w", err) + } + if q.deleteGraphQLHeaderStmt, err = db.PrepareContext(ctx, deleteGraphQLHeader); err != nil { + return nil, fmt.Errorf("error preparing query DeleteGraphQLHeader: %w", err) + } + if q.deleteGraphQLResponseStmt, err = db.PrepareContext(ctx, deleteGraphQLResponse); err != nil { + return nil, fmt.Errorf("error preparing query DeleteGraphQLResponse: %w", err) + } + if q.deleteGraphQLResponseHeaderStmt, err = db.PrepareContext(ctx, deleteGraphQLResponseHeader); err != nil { + return nil, fmt.Errorf("error preparing query DeleteGraphQLResponseHeader: %w", err) + } if q.deleteHTTPStmt, err = db.PrepareContext(ctx, deleteHTTP); err != nil { return nil, fmt.Errorf("error preparing query DeleteHTTP: %w", err) } @@ -402,6 +438,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getFlowNodeForEachStmt, err = db.PrepareContext(ctx, getFlowNodeForEach); err != nil { return nil, fmt.Errorf("error preparing query GetFlowNodeForEach: %w", err) } + if q.getFlowNodeGraphQLStmt, err = db.PrepareContext(ctx, getFlowNodeGraphQL); err != nil { + return nil, fmt.Errorf("error preparing query GetFlowNodeGraphQL: %w", err) + } if q.getFlowNodeHTTPStmt, err = db.PrepareContext(ctx, getFlowNodeHTTP); err != nil { return nil, fmt.Errorf("error preparing query GetFlowNodeHTTP: %w", err) } @@ -444,6 +483,36 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getFlowsByWorkspaceIDStmt, err = db.PrepareContext(ctx, getFlowsByWorkspaceID); err != nil { return nil, fmt.Errorf("error preparing query GetFlowsByWorkspaceID: %w", err) } + if q.getGraphQLStmt, err = db.PrepareContext(ctx, getGraphQL); err != nil { + return nil, fmt.Errorf("error preparing query GetGraphQL: %w", err) + } + if q.getGraphQLHeadersStmt, err = db.PrepareContext(ctx, getGraphQLHeaders); err != nil { + return nil, fmt.Errorf("error preparing query GetGraphQLHeaders: %w", err) + } + if q.getGraphQLHeadersByIDsStmt, err = db.PrepareContext(ctx, getGraphQLHeadersByIDs); err != nil { + return nil, fmt.Errorf("error preparing query GetGraphQLHeadersByIDs: %w", err) + } + if q.getGraphQLResponseStmt, err = db.PrepareContext(ctx, getGraphQLResponse); err != nil { + return nil, fmt.Errorf("error preparing query GetGraphQLResponse: %w", err) + } + if q.getGraphQLResponseHeadersByResponseIDStmt, err = db.PrepareContext(ctx, getGraphQLResponseHeadersByResponseID); err != nil { + return nil, fmt.Errorf("error preparing query GetGraphQLResponseHeadersByResponseID: %w", err) + } + if q.getGraphQLResponseHeadersByWorkspaceIDStmt, err = db.PrepareContext(ctx, getGraphQLResponseHeadersByWorkspaceID); err != nil { + return nil, fmt.Errorf("error preparing query GetGraphQLResponseHeadersByWorkspaceID: %w", err) + } + if q.getGraphQLResponsesByGraphQLIDStmt, err = db.PrepareContext(ctx, getGraphQLResponsesByGraphQLID); err != nil { + return nil, fmt.Errorf("error preparing query GetGraphQLResponsesByGraphQLID: %w", err) + } + if q.getGraphQLResponsesByWorkspaceIDStmt, err = db.PrepareContext(ctx, getGraphQLResponsesByWorkspaceID); err != nil { + return nil, fmt.Errorf("error preparing query GetGraphQLResponsesByWorkspaceID: %w", err) + } + if q.getGraphQLWorkspaceIDStmt, err = db.PrepareContext(ctx, getGraphQLWorkspaceID); err != nil { + return nil, fmt.Errorf("error preparing query GetGraphQLWorkspaceID: %w", err) + } + if q.getGraphQLsByWorkspaceIDStmt, err = db.PrepareContext(ctx, getGraphQLsByWorkspaceID); err != nil { + return nil, fmt.Errorf("error preparing query GetGraphQLsByWorkspaceID: %w", err) + } if q.getHTTPStmt, err = db.PrepareContext(ctx, getHTTP); err != nil { return nil, fmt.Errorf("error preparing query GetHTTP: %w", err) } @@ -738,6 +807,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateFlowNodeForEachStmt, err = db.PrepareContext(ctx, updateFlowNodeForEach); err != nil { return nil, fmt.Errorf("error preparing query UpdateFlowNodeForEach: %w", err) } + if q.updateFlowNodeGraphQLStmt, err = db.PrepareContext(ctx, updateFlowNodeGraphQL); err != nil { + return nil, fmt.Errorf("error preparing query UpdateFlowNodeGraphQL: %w", err) + } if q.updateFlowNodeHTTPStmt, err = db.PrepareContext(ctx, updateFlowNodeHTTP); err != nil { return nil, fmt.Errorf("error preparing query UpdateFlowNodeHTTP: %w", err) } @@ -759,6 +831,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateFlowVariableOrderStmt, err = db.PrepareContext(ctx, updateFlowVariableOrder); err != nil { return nil, fmt.Errorf("error preparing query UpdateFlowVariableOrder: %w", err) } + if q.updateGraphQLStmt, err = db.PrepareContext(ctx, updateGraphQL); err != nil { + return nil, fmt.Errorf("error preparing query UpdateGraphQL: %w", err) + } + if q.updateGraphQLHeaderStmt, err = db.PrepareContext(ctx, updateGraphQLHeader); err != nil { + return nil, fmt.Errorf("error preparing query UpdateGraphQLHeader: %w", err) + } if q.updateHTTPStmt, err = db.PrepareContext(ctx, updateHTTP); err != nil { return nil, fmt.Errorf("error preparing query UpdateHTTP: %w", err) } @@ -879,6 +957,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing cleanupOrphanedFlowNodeForEachStmt: %w", cerr) } } + if q.cleanupOrphanedFlowNodeGraphQLStmt != nil { + if cerr := q.cleanupOrphanedFlowNodeGraphQLStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing cleanupOrphanedFlowNodeGraphQLStmt: %w", cerr) + } + } if q.cleanupOrphanedFlowNodeHttpStmt != nil { if cerr := q.cleanupOrphanedFlowNodeHttpStmt.Close(); cerr != nil { err = fmt.Errorf("error closing cleanupOrphanedFlowNodeHttpStmt: %w", cerr) @@ -964,6 +1047,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing createFlowNodeForEachStmt: %w", cerr) } } + if q.createFlowNodeGraphQLStmt != nil { + if cerr := q.createFlowNodeGraphQLStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createFlowNodeGraphQLStmt: %w", cerr) + } + } if q.createFlowNodeHTTPStmt != nil { if cerr := q.createFlowNodeHTTPStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createFlowNodeHTTPStmt: %w", cerr) @@ -1009,6 +1097,31 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing createFlowsBulkStmt: %w", cerr) } } + if q.createGraphQLStmt != nil { + if cerr := q.createGraphQLStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createGraphQLStmt: %w", cerr) + } + } + if q.createGraphQLHeaderStmt != nil { + if cerr := q.createGraphQLHeaderStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createGraphQLHeaderStmt: %w", cerr) + } + } + if q.createGraphQLResponseStmt != nil { + if cerr := q.createGraphQLResponseStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createGraphQLResponseStmt: %w", cerr) + } + } + if q.createGraphQLResponseHeaderStmt != nil { + if cerr := q.createGraphQLResponseHeaderStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createGraphQLResponseHeaderStmt: %w", cerr) + } + } + if q.createGraphQLResponseHeaderBulkStmt != nil { + if cerr := q.createGraphQLResponseHeaderBulkStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createGraphQLResponseHeaderBulkStmt: %w", cerr) + } + } if q.createHTTPStmt != nil { if cerr := q.createHTTPStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createHTTPStmt: %w", cerr) @@ -1199,6 +1312,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteFlowNodeForEachStmt: %w", cerr) } } + if q.deleteFlowNodeGraphQLStmt != nil { + if cerr := q.deleteFlowNodeGraphQLStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteFlowNodeGraphQLStmt: %w", cerr) + } + } if q.deleteFlowNodeHTTPStmt != nil { if cerr := q.deleteFlowNodeHTTPStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteFlowNodeHTTPStmt: %w", cerr) @@ -1224,6 +1342,26 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteFlowVariableStmt: %w", cerr) } } + if q.deleteGraphQLStmt != nil { + if cerr := q.deleteGraphQLStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteGraphQLStmt: %w", cerr) + } + } + if q.deleteGraphQLHeaderStmt != nil { + if cerr := q.deleteGraphQLHeaderStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteGraphQLHeaderStmt: %w", cerr) + } + } + if q.deleteGraphQLResponseStmt != nil { + if cerr := q.deleteGraphQLResponseStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteGraphQLResponseStmt: %w", cerr) + } + } + if q.deleteGraphQLResponseHeaderStmt != nil { + if cerr := q.deleteGraphQLResponseHeaderStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteGraphQLResponseHeaderStmt: %w", cerr) + } + } if q.deleteHTTPStmt != nil { if cerr := q.deleteHTTPStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteHTTPStmt: %w", cerr) @@ -1484,6 +1622,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getFlowNodeForEachStmt: %w", cerr) } } + if q.getFlowNodeGraphQLStmt != nil { + if cerr := q.getFlowNodeGraphQLStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getFlowNodeGraphQLStmt: %w", cerr) + } + } if q.getFlowNodeHTTPStmt != nil { if cerr := q.getFlowNodeHTTPStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getFlowNodeHTTPStmt: %w", cerr) @@ -1554,6 +1697,56 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getFlowsByWorkspaceIDStmt: %w", cerr) } } + if q.getGraphQLStmt != nil { + if cerr := q.getGraphQLStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getGraphQLStmt: %w", cerr) + } + } + if q.getGraphQLHeadersStmt != nil { + if cerr := q.getGraphQLHeadersStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getGraphQLHeadersStmt: %w", cerr) + } + } + if q.getGraphQLHeadersByIDsStmt != nil { + if cerr := q.getGraphQLHeadersByIDsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getGraphQLHeadersByIDsStmt: %w", cerr) + } + } + if q.getGraphQLResponseStmt != nil { + if cerr := q.getGraphQLResponseStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getGraphQLResponseStmt: %w", cerr) + } + } + if q.getGraphQLResponseHeadersByResponseIDStmt != nil { + if cerr := q.getGraphQLResponseHeadersByResponseIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getGraphQLResponseHeadersByResponseIDStmt: %w", cerr) + } + } + if q.getGraphQLResponseHeadersByWorkspaceIDStmt != nil { + if cerr := q.getGraphQLResponseHeadersByWorkspaceIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getGraphQLResponseHeadersByWorkspaceIDStmt: %w", cerr) + } + } + if q.getGraphQLResponsesByGraphQLIDStmt != nil { + if cerr := q.getGraphQLResponsesByGraphQLIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getGraphQLResponsesByGraphQLIDStmt: %w", cerr) + } + } + if q.getGraphQLResponsesByWorkspaceIDStmt != nil { + if cerr := q.getGraphQLResponsesByWorkspaceIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getGraphQLResponsesByWorkspaceIDStmt: %w", cerr) + } + } + if q.getGraphQLWorkspaceIDStmt != nil { + if cerr := q.getGraphQLWorkspaceIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getGraphQLWorkspaceIDStmt: %w", cerr) + } + } + if q.getGraphQLsByWorkspaceIDStmt != nil { + if cerr := q.getGraphQLsByWorkspaceIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getGraphQLsByWorkspaceIDStmt: %w", cerr) + } + } if q.getHTTPStmt != nil { if cerr := q.getHTTPStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getHTTPStmt: %w", cerr) @@ -2044,6 +2237,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateFlowNodeForEachStmt: %w", cerr) } } + if q.updateFlowNodeGraphQLStmt != nil { + if cerr := q.updateFlowNodeGraphQLStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateFlowNodeGraphQLStmt: %w", cerr) + } + } if q.updateFlowNodeHTTPStmt != nil { if cerr := q.updateFlowNodeHTTPStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateFlowNodeHTTPStmt: %w", cerr) @@ -2079,6 +2277,16 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateFlowVariableOrderStmt: %w", cerr) } } + if q.updateGraphQLStmt != nil { + if cerr := q.updateGraphQLStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateGraphQLStmt: %w", cerr) + } + } + if q.updateGraphQLHeaderStmt != nil { + if cerr := q.updateGraphQLHeaderStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateGraphQLHeaderStmt: %w", cerr) + } + } if q.updateHTTPStmt != nil { if cerr := q.updateHTTPStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateHTTPStmt: %w", cerr) @@ -2273,6 +2481,7 @@ type Queries struct { cleanupOrphanedFlowNodeConditionStmt *sql.Stmt cleanupOrphanedFlowNodeForStmt *sql.Stmt cleanupOrphanedFlowNodeForEachStmt *sql.Stmt + cleanupOrphanedFlowNodeGraphQLStmt *sql.Stmt cleanupOrphanedFlowNodeHttpStmt *sql.Stmt cleanupOrphanedFlowNodeJsStmt *sql.Stmt cleanupOrphanedNodeExecutionsStmt *sql.Stmt @@ -2290,6 +2499,7 @@ type Queries struct { createFlowNodeConditionStmt *sql.Stmt createFlowNodeForStmt *sql.Stmt createFlowNodeForEachStmt *sql.Stmt + createFlowNodeGraphQLStmt *sql.Stmt createFlowNodeHTTPStmt *sql.Stmt createFlowNodeJsStmt *sql.Stmt createFlowNodeMemoryStmt *sql.Stmt @@ -2299,6 +2509,11 @@ type Queries struct { createFlowVariableStmt *sql.Stmt createFlowVariableBulkStmt *sql.Stmt createFlowsBulkStmt *sql.Stmt + createGraphQLStmt *sql.Stmt + createGraphQLHeaderStmt *sql.Stmt + createGraphQLResponseStmt *sql.Stmt + createGraphQLResponseHeaderStmt *sql.Stmt + createGraphQLResponseHeaderBulkStmt *sql.Stmt createHTTPStmt *sql.Stmt createHTTPAssertStmt *sql.Stmt createHTTPAssertBulkStmt *sql.Stmt @@ -2337,11 +2552,16 @@ type Queries struct { deleteFlowNodeConditionStmt *sql.Stmt deleteFlowNodeForStmt *sql.Stmt deleteFlowNodeForEachStmt *sql.Stmt + deleteFlowNodeGraphQLStmt *sql.Stmt deleteFlowNodeHTTPStmt *sql.Stmt deleteFlowNodeJsStmt *sql.Stmt deleteFlowNodeMemoryStmt *sql.Stmt deleteFlowTagStmt *sql.Stmt deleteFlowVariableStmt *sql.Stmt + deleteGraphQLStmt *sql.Stmt + deleteGraphQLHeaderStmt *sql.Stmt + deleteGraphQLResponseStmt *sql.Stmt + deleteGraphQLResponseHeaderStmt *sql.Stmt deleteHTTPStmt *sql.Stmt deleteHTTPAssertStmt *sql.Stmt deleteHTTPBodyFormStmt *sql.Stmt @@ -2394,6 +2614,7 @@ type Queries struct { getFlowNodeConditionStmt *sql.Stmt getFlowNodeForStmt *sql.Stmt getFlowNodeForEachStmt *sql.Stmt + getFlowNodeGraphQLStmt *sql.Stmt getFlowNodeHTTPStmt *sql.Stmt getFlowNodeJsStmt *sql.Stmt getFlowNodeMemoryStmt *sql.Stmt @@ -2408,6 +2629,16 @@ type Queries struct { getFlowVariablesByFlowIDsStmt *sql.Stmt getFlowsByVersionParentIDStmt *sql.Stmt getFlowsByWorkspaceIDStmt *sql.Stmt + getGraphQLStmt *sql.Stmt + getGraphQLHeadersStmt *sql.Stmt + getGraphQLHeadersByIDsStmt *sql.Stmt + getGraphQLResponseStmt *sql.Stmt + getGraphQLResponseHeadersByResponseIDStmt *sql.Stmt + getGraphQLResponseHeadersByWorkspaceIDStmt *sql.Stmt + getGraphQLResponsesByGraphQLIDStmt *sql.Stmt + getGraphQLResponsesByWorkspaceIDStmt *sql.Stmt + getGraphQLWorkspaceIDStmt *sql.Stmt + getGraphQLsByWorkspaceIDStmt *sql.Stmt getHTTPStmt *sql.Stmt getHTTPAssertStmt *sql.Stmt getHTTPAssertsByHttpIDStmt *sql.Stmt @@ -2506,6 +2737,7 @@ type Queries struct { updateFlowNodeConditionStmt *sql.Stmt updateFlowNodeForStmt *sql.Stmt updateFlowNodeForEachStmt *sql.Stmt + updateFlowNodeGraphQLStmt *sql.Stmt updateFlowNodeHTTPStmt *sql.Stmt updateFlowNodeIDMappingStmt *sql.Stmt updateFlowNodeJsStmt *sql.Stmt @@ -2513,6 +2745,8 @@ type Queries struct { updateFlowNodeStateStmt *sql.Stmt updateFlowVariableStmt *sql.Stmt updateFlowVariableOrderStmt *sql.Stmt + updateGraphQLStmt *sql.Stmt + updateGraphQLHeaderStmt *sql.Stmt updateHTTPStmt *sql.Stmt updateHTTPAssertStmt *sql.Stmt updateHTTPAssertDeltaStmt *sql.Stmt @@ -2554,6 +2788,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { cleanupOrphanedFlowNodeConditionStmt: q.cleanupOrphanedFlowNodeConditionStmt, cleanupOrphanedFlowNodeForStmt: q.cleanupOrphanedFlowNodeForStmt, cleanupOrphanedFlowNodeForEachStmt: q.cleanupOrphanedFlowNodeForEachStmt, + cleanupOrphanedFlowNodeGraphQLStmt: q.cleanupOrphanedFlowNodeGraphQLStmt, cleanupOrphanedFlowNodeHttpStmt: q.cleanupOrphanedFlowNodeHttpStmt, cleanupOrphanedFlowNodeJsStmt: q.cleanupOrphanedFlowNodeJsStmt, cleanupOrphanedNodeExecutionsStmt: q.cleanupOrphanedNodeExecutionsStmt, @@ -2571,6 +2806,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { createFlowNodeConditionStmt: q.createFlowNodeConditionStmt, createFlowNodeForStmt: q.createFlowNodeForStmt, createFlowNodeForEachStmt: q.createFlowNodeForEachStmt, + createFlowNodeGraphQLStmt: q.createFlowNodeGraphQLStmt, createFlowNodeHTTPStmt: q.createFlowNodeHTTPStmt, createFlowNodeJsStmt: q.createFlowNodeJsStmt, createFlowNodeMemoryStmt: q.createFlowNodeMemoryStmt, @@ -2580,6 +2816,11 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { createFlowVariableStmt: q.createFlowVariableStmt, createFlowVariableBulkStmt: q.createFlowVariableBulkStmt, createFlowsBulkStmt: q.createFlowsBulkStmt, + createGraphQLStmt: q.createGraphQLStmt, + createGraphQLHeaderStmt: q.createGraphQLHeaderStmt, + createGraphQLResponseStmt: q.createGraphQLResponseStmt, + createGraphQLResponseHeaderStmt: q.createGraphQLResponseHeaderStmt, + createGraphQLResponseHeaderBulkStmt: q.createGraphQLResponseHeaderBulkStmt, createHTTPStmt: q.createHTTPStmt, createHTTPAssertStmt: q.createHTTPAssertStmt, createHTTPAssertBulkStmt: q.createHTTPAssertBulkStmt, @@ -2618,11 +2859,16 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { deleteFlowNodeConditionStmt: q.deleteFlowNodeConditionStmt, deleteFlowNodeForStmt: q.deleteFlowNodeForStmt, deleteFlowNodeForEachStmt: q.deleteFlowNodeForEachStmt, + deleteFlowNodeGraphQLStmt: q.deleteFlowNodeGraphQLStmt, deleteFlowNodeHTTPStmt: q.deleteFlowNodeHTTPStmt, deleteFlowNodeJsStmt: q.deleteFlowNodeJsStmt, deleteFlowNodeMemoryStmt: q.deleteFlowNodeMemoryStmt, deleteFlowTagStmt: q.deleteFlowTagStmt, deleteFlowVariableStmt: q.deleteFlowVariableStmt, + deleteGraphQLStmt: q.deleteGraphQLStmt, + deleteGraphQLHeaderStmt: q.deleteGraphQLHeaderStmt, + deleteGraphQLResponseStmt: q.deleteGraphQLResponseStmt, + deleteGraphQLResponseHeaderStmt: q.deleteGraphQLResponseHeaderStmt, deleteHTTPStmt: q.deleteHTTPStmt, deleteHTTPAssertStmt: q.deleteHTTPAssertStmt, deleteHTTPBodyFormStmt: q.deleteHTTPBodyFormStmt, @@ -2675,6 +2921,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getFlowNodeConditionStmt: q.getFlowNodeConditionStmt, getFlowNodeForStmt: q.getFlowNodeForStmt, getFlowNodeForEachStmt: q.getFlowNodeForEachStmt, + getFlowNodeGraphQLStmt: q.getFlowNodeGraphQLStmt, getFlowNodeHTTPStmt: q.getFlowNodeHTTPStmt, getFlowNodeJsStmt: q.getFlowNodeJsStmt, getFlowNodeMemoryStmt: q.getFlowNodeMemoryStmt, @@ -2689,6 +2936,16 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getFlowVariablesByFlowIDsStmt: q.getFlowVariablesByFlowIDsStmt, getFlowsByVersionParentIDStmt: q.getFlowsByVersionParentIDStmt, getFlowsByWorkspaceIDStmt: q.getFlowsByWorkspaceIDStmt, + getGraphQLStmt: q.getGraphQLStmt, + getGraphQLHeadersStmt: q.getGraphQLHeadersStmt, + getGraphQLHeadersByIDsStmt: q.getGraphQLHeadersByIDsStmt, + getGraphQLResponseStmt: q.getGraphQLResponseStmt, + getGraphQLResponseHeadersByResponseIDStmt: q.getGraphQLResponseHeadersByResponseIDStmt, + getGraphQLResponseHeadersByWorkspaceIDStmt: q.getGraphQLResponseHeadersByWorkspaceIDStmt, + getGraphQLResponsesByGraphQLIDStmt: q.getGraphQLResponsesByGraphQLIDStmt, + getGraphQLResponsesByWorkspaceIDStmt: q.getGraphQLResponsesByWorkspaceIDStmt, + getGraphQLWorkspaceIDStmt: q.getGraphQLWorkspaceIDStmt, + getGraphQLsByWorkspaceIDStmt: q.getGraphQLsByWorkspaceIDStmt, getHTTPStmt: q.getHTTPStmt, getHTTPAssertStmt: q.getHTTPAssertStmt, getHTTPAssertsByHttpIDStmt: q.getHTTPAssertsByHttpIDStmt, @@ -2787,6 +3044,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { updateFlowNodeConditionStmt: q.updateFlowNodeConditionStmt, updateFlowNodeForStmt: q.updateFlowNodeForStmt, updateFlowNodeForEachStmt: q.updateFlowNodeForEachStmt, + updateFlowNodeGraphQLStmt: q.updateFlowNodeGraphQLStmt, updateFlowNodeHTTPStmt: q.updateFlowNodeHTTPStmt, updateFlowNodeIDMappingStmt: q.updateFlowNodeIDMappingStmt, updateFlowNodeJsStmt: q.updateFlowNodeJsStmt, @@ -2794,6 +3052,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { updateFlowNodeStateStmt: q.updateFlowNodeStateStmt, updateFlowVariableStmt: q.updateFlowVariableStmt, updateFlowVariableOrderStmt: q.updateFlowVariableOrderStmt, + updateGraphQLStmt: q.updateGraphQLStmt, + updateGraphQLHeaderStmt: q.updateGraphQLHeaderStmt, updateHTTPStmt: q.updateHTTPStmt, updateHTTPAssertStmt: q.updateHTTPAssertStmt, updateHTTPAssertDeltaStmt: q.updateHTTPAssertDeltaStmt, diff --git a/packages/db/pkg/sqlc/gen/flow.sql.go b/packages/db/pkg/sqlc/gen/flow.sql.go index 335ae3c71..f547e2eb4 100644 --- a/packages/db/pkg/sqlc/gen/flow.sql.go +++ b/packages/db/pkg/sqlc/gen/flow.sql.go @@ -52,6 +52,15 @@ func (q *Queries) CleanupOrphanedFlowNodeForEach(ctx context.Context) error { return err } +const cleanupOrphanedFlowNodeGraphQL = `-- name: CleanupOrphanedFlowNodeGraphQL :exec +DELETE FROM flow_node_graphql WHERE flow_node_id NOT IN (SELECT id FROM flow_node) +` + +func (q *Queries) CleanupOrphanedFlowNodeGraphQL(ctx context.Context) error { + _, err := q.exec(ctx, q.cleanupOrphanedFlowNodeGraphQLStmt, cleanupOrphanedFlowNodeGraphQL) + return err +} + const cleanupOrphanedFlowNodeHttp = `-- name: CleanupOrphanedFlowNodeHttp :exec DELETE FROM flow_node_http WHERE flow_node_id NOT IN (SELECT id FROM flow_node) ` @@ -228,6 +237,20 @@ func (q *Queries) CreateFlowNodeForEach(ctx context.Context, arg CreateFlowNodeF return err } +const createFlowNodeGraphQL = `-- name: CreateFlowNodeGraphQL :exec +INSERT INTO flow_node_graphql (flow_node_id, graphql_id) VALUES (?, ?) +` + +type CreateFlowNodeGraphQLParams struct { + FlowNodeID idwrap.IDWrap + GraphqlID idwrap.IDWrap +} + +func (q *Queries) CreateFlowNodeGraphQL(ctx context.Context, arg CreateFlowNodeGraphQLParams) error { + _, err := q.exec(ctx, q.createFlowNodeGraphQLStmt, createFlowNodeGraphQL, arg.FlowNodeID, arg.GraphqlID) + return err +} + const createFlowNodeHTTP = `-- name: CreateFlowNodeHTTP :exec INSERT INTO flow_node_http ( @@ -848,10 +871,10 @@ func (q *Queries) CreateMigration(ctx context.Context, arg CreateMigrationParams const createNodeExecution = `-- name: CreateNodeExecution :one INSERT INTO node_execution ( id, node_id, name, state, error, input_data, input_data_compress_type, - output_data, output_data_compress_type, http_response_id, completed_at + output_data, output_data_compress_type, http_response_id, graphql_response_id, completed_at ) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -RETURNING id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, completed_at +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, graphql_response_id, completed_at ` type CreateNodeExecutionParams struct { @@ -865,6 +888,7 @@ type CreateNodeExecutionParams struct { OutputData []byte OutputDataCompressType int8 HttpResponseID *idwrap.IDWrap + GraphqlResponseID *idwrap.IDWrap CompletedAt sql.NullInt64 } @@ -880,6 +904,7 @@ func (q *Queries) CreateNodeExecution(ctx context.Context, arg CreateNodeExecuti arg.OutputData, arg.OutputDataCompressType, arg.HttpResponseID, + arg.GraphqlResponseID, arg.CompletedAt, ) var i NodeExecution @@ -894,6 +919,7 @@ func (q *Queries) CreateNodeExecution(ctx context.Context, arg CreateNodeExecuti &i.OutputData, &i.OutputDataCompressType, &i.HttpResponseID, + &i.GraphqlResponseID, &i.CompletedAt, ) return i, err @@ -990,6 +1016,15 @@ func (q *Queries) DeleteFlowNodeForEach(ctx context.Context, flowNodeID idwrap.I return err } +const deleteFlowNodeGraphQL = `-- name: DeleteFlowNodeGraphQL :exec +DELETE FROM flow_node_graphql WHERE flow_node_id = ? +` + +func (q *Queries) DeleteFlowNodeGraphQL(ctx context.Context, flowNodeID idwrap.IDWrap) error { + _, err := q.exec(ctx, q.deleteFlowNodeGraphQLStmt, deleteFlowNodeGraphQL, flowNodeID) + return err +} + const deleteFlowNodeHTTP = `-- name: DeleteFlowNodeHTTP :exec DELETE FROM flow_node_http WHERE @@ -1379,6 +1414,17 @@ func (q *Queries) GetFlowNodeForEach(ctx context.Context, flowNodeID idwrap.IDWr return i, err } +const getFlowNodeGraphQL = `-- name: GetFlowNodeGraphQL :one +SELECT flow_node_id, graphql_id FROM flow_node_graphql WHERE flow_node_id = ? LIMIT 1 +` + +func (q *Queries) GetFlowNodeGraphQL(ctx context.Context, flowNodeID idwrap.IDWrap) (FlowNodeGraphql, error) { + row := q.queryRow(ctx, q.getFlowNodeGraphQLStmt, getFlowNodeGraphQL, flowNodeID) + var i FlowNodeGraphql + err := row.Scan(&i.FlowNodeID, &i.GraphqlID) + return i, err +} + const getFlowNodeHTTP = `-- name: GetFlowNodeHTTP :one SELECT flow_node_id, @@ -1857,7 +1903,7 @@ func (q *Queries) GetFlowsByWorkspaceID(ctx context.Context, workspaceID idwrap. } const getLatestNodeExecutionByNodeID = `-- name: GetLatestNodeExecutionByNodeID :one -SELECT id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, completed_at +SELECT id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, graphql_response_id, completed_at FROM node_execution WHERE node_id = ? AND completed_at IS NOT NULL ORDER BY completed_at DESC, id DESC @@ -1878,6 +1924,7 @@ func (q *Queries) GetLatestNodeExecutionByNodeID(ctx context.Context, nodeID idw &i.OutputData, &i.OutputDataCompressType, &i.HttpResponseID, + &i.GraphqlResponseID, &i.CompletedAt, ) return i, err @@ -1979,7 +2026,7 @@ func (q *Queries) GetMigrations(ctx context.Context) ([]Migration, error) { } const getNodeExecution = `-- name: GetNodeExecution :one -SELECT id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, completed_at FROM node_execution +SELECT id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, graphql_response_id, completed_at FROM node_execution WHERE id = ? ` @@ -1998,13 +2045,14 @@ func (q *Queries) GetNodeExecution(ctx context.Context, id idwrap.IDWrap) (NodeE &i.OutputData, &i.OutputDataCompressType, &i.HttpResponseID, + &i.GraphqlResponseID, &i.CompletedAt, ) return i, err } const getNodeExecutionsByNodeID = `-- name: GetNodeExecutionsByNodeID :many -SELECT id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, completed_at +SELECT id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, graphql_response_id, completed_at FROM node_execution WHERE node_id = ? AND completed_at IS NOT NULL ORDER BY completed_at DESC, id DESC @@ -2030,6 +2078,7 @@ func (q *Queries) GetNodeExecutionsByNodeID(ctx context.Context, nodeID idwrap.I &i.OutputData, &i.OutputDataCompressType, &i.HttpResponseID, + &i.GraphqlResponseID, &i.CompletedAt, ); err != nil { return nil, err @@ -2111,7 +2160,7 @@ func (q *Queries) GetTagsByWorkspaceID(ctx context.Context, workspaceID idwrap.I } const listNodeExecutions = `-- name: ListNodeExecutions :many -SELECT id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, completed_at FROM node_execution +SELECT id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, graphql_response_id, completed_at FROM node_execution WHERE node_id = ? ORDER BY completed_at DESC, id DESC LIMIT ? OFFSET ? @@ -2143,6 +2192,7 @@ func (q *Queries) ListNodeExecutions(ctx context.Context, arg ListNodeExecutions &i.OutputData, &i.OutputDataCompressType, &i.HttpResponseID, + &i.GraphqlResponseID, &i.CompletedAt, ); err != nil { return nil, err @@ -2159,7 +2209,7 @@ func (q *Queries) ListNodeExecutions(ctx context.Context, arg ListNodeExecutions } const listNodeExecutionsByFlowRun = `-- name: ListNodeExecutionsByFlowRun :many -SELECT ne.id, ne.node_id, ne.name, ne.state, ne.error, ne.input_data, ne.input_data_compress_type, ne.output_data, ne.output_data_compress_type, ne.http_response_id, ne.completed_at FROM node_execution ne +SELECT ne.id, ne.node_id, ne.name, ne.state, ne.error, ne.input_data, ne.input_data_compress_type, ne.output_data, ne.output_data_compress_type, ne.http_response_id, ne.graphql_response_id, ne.completed_at FROM node_execution ne JOIN flow_node fn ON ne.node_id = fn.id WHERE fn.flow_id = ? ORDER BY ne.completed_at DESC, ne.id DESC @@ -2185,6 +2235,7 @@ func (q *Queries) ListNodeExecutionsByFlowRun(ctx context.Context, flowID idwrap &i.OutputData, &i.OutputDataCompressType, &i.HttpResponseID, + &i.GraphqlResponseID, &i.CompletedAt, ); err != nil { return nil, err @@ -2201,7 +2252,7 @@ func (q *Queries) ListNodeExecutionsByFlowRun(ctx context.Context, flowID idwrap } const listNodeExecutionsByState = `-- name: ListNodeExecutionsByState :many -SELECT id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, completed_at FROM node_execution +SELECT id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, graphql_response_id, completed_at FROM node_execution WHERE node_id = ? AND state = ? ORDER BY completed_at DESC, id DESC LIMIT ? OFFSET ? @@ -2239,6 +2290,7 @@ func (q *Queries) ListNodeExecutionsByState(ctx context.Context, arg ListNodeExe &i.OutputData, &i.OutputDataCompressType, &i.HttpResponseID, + &i.GraphqlResponseID, &i.CompletedAt, ); err != nil { return nil, err @@ -2425,6 +2477,21 @@ func (q *Queries) UpdateFlowNodeForEach(ctx context.Context, arg UpdateFlowNodeF return err } +const updateFlowNodeGraphQL = `-- name: UpdateFlowNodeGraphQL :exec +INSERT INTO flow_node_graphql (flow_node_id, graphql_id) VALUES (?, ?) +ON CONFLICT(flow_node_id) DO UPDATE SET graphql_id = excluded.graphql_id +` + +type UpdateFlowNodeGraphQLParams struct { + FlowNodeID idwrap.IDWrap + GraphqlID idwrap.IDWrap +} + +func (q *Queries) UpdateFlowNodeGraphQL(ctx context.Context, arg UpdateFlowNodeGraphQLParams) error { + _, err := q.exec(ctx, q.updateFlowNodeGraphQLStmt, updateFlowNodeGraphQL, arg.FlowNodeID, arg.GraphqlID) + return err +} + const updateFlowNodeHTTP = `-- name: UpdateFlowNodeHTTP :exec INSERT INTO flow_node_http ( flow_node_id, @@ -2553,10 +2620,10 @@ func (q *Queries) UpdateFlowVariableOrder(ctx context.Context, arg UpdateFlowVar const updateNodeExecution = `-- name: UpdateNodeExecution :one UPDATE node_execution -SET state = ?, error = ?, output_data = ?, - output_data_compress_type = ?, http_response_id = ?, completed_at = ? +SET state = ?, error = ?, output_data = ?, + output_data_compress_type = ?, http_response_id = ?, graphql_response_id = ?, completed_at = ? WHERE id = ? -RETURNING id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, completed_at +RETURNING id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, graphql_response_id, completed_at ` type UpdateNodeExecutionParams struct { @@ -2565,6 +2632,7 @@ type UpdateNodeExecutionParams struct { OutputData []byte OutputDataCompressType int8 HttpResponseID *idwrap.IDWrap + GraphqlResponseID *idwrap.IDWrap CompletedAt sql.NullInt64 ID idwrap.IDWrap } @@ -2576,6 +2644,7 @@ func (q *Queries) UpdateNodeExecution(ctx context.Context, arg UpdateNodeExecuti arg.OutputData, arg.OutputDataCompressType, arg.HttpResponseID, + arg.GraphqlResponseID, arg.CompletedAt, arg.ID, ) @@ -2591,6 +2660,7 @@ func (q *Queries) UpdateNodeExecution(ctx context.Context, arg UpdateNodeExecuti &i.OutputData, &i.OutputDataCompressType, &i.HttpResponseID, + &i.GraphqlResponseID, &i.CompletedAt, ) return i, err @@ -2635,19 +2705,20 @@ func (q *Queries) UpdateTag(ctx context.Context, arg UpdateTagParams) error { const upsertNodeExecution = `-- name: UpsertNodeExecution :one INSERT INTO node_execution ( id, node_id, name, state, error, input_data, input_data_compress_type, - output_data, output_data_compress_type, http_response_id, completed_at + output_data, output_data_compress_type, http_response_id, graphql_response_id, completed_at ) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET state = excluded.state, - error = excluded.error, + error = excluded.error, input_data = excluded.input_data, input_data_compress_type = excluded.input_data_compress_type, output_data = excluded.output_data, output_data_compress_type = excluded.output_data_compress_type, http_response_id = excluded.http_response_id, + graphql_response_id = excluded.graphql_response_id, completed_at = excluded.completed_at -RETURNING id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, completed_at +RETURNING id, node_id, name, state, error, input_data, input_data_compress_type, output_data, output_data_compress_type, http_response_id, graphql_response_id, completed_at ` type UpsertNodeExecutionParams struct { @@ -2661,6 +2732,7 @@ type UpsertNodeExecutionParams struct { OutputData []byte OutputDataCompressType int8 HttpResponseID *idwrap.IDWrap + GraphqlResponseID *idwrap.IDWrap CompletedAt sql.NullInt64 } @@ -2676,6 +2748,7 @@ func (q *Queries) UpsertNodeExecution(ctx context.Context, arg UpsertNodeExecuti arg.OutputData, arg.OutputDataCompressType, arg.HttpResponseID, + arg.GraphqlResponseID, arg.CompletedAt, ) var i NodeExecution @@ -2690,6 +2763,7 @@ func (q *Queries) UpsertNodeExecution(ctx context.Context, arg UpsertNodeExecuti &i.OutputData, &i.OutputDataCompressType, &i.HttpResponseID, + &i.GraphqlResponseID, &i.CompletedAt, ) return i, err diff --git a/packages/db/pkg/sqlc/gen/graphql.sql.go b/packages/db/pkg/sqlc/gen/graphql.sql.go new file mode 100644 index 000000000..9510569f3 --- /dev/null +++ b/packages/db/pkg/sqlc/gen/graphql.sql.go @@ -0,0 +1,746 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: graphql.sql + +package gen + +import ( + "context" + "strings" + "time" + + idwrap "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" +) + +const createGraphQL = `-- name: CreateGraphQL :exec +INSERT INTO graphql ( + id, workspace_id, folder_id, name, url, query, variables, + description, last_run_at, created_at, updated_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +` + +type CreateGraphQLParams struct { + ID idwrap.IDWrap + WorkspaceID idwrap.IDWrap + FolderID *idwrap.IDWrap + Name string + Url string + Query string + Variables string + Description string + LastRunAt interface{} + CreatedAt int64 + UpdatedAt int64 +} + +func (q *Queries) CreateGraphQL(ctx context.Context, arg CreateGraphQLParams) error { + _, err := q.exec(ctx, q.createGraphQLStmt, createGraphQL, + arg.ID, + arg.WorkspaceID, + arg.FolderID, + arg.Name, + arg.Url, + arg.Query, + arg.Variables, + arg.Description, + arg.LastRunAt, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const createGraphQLHeader = `-- name: CreateGraphQLHeader :exec +INSERT INTO graphql_header ( + id, graphql_id, header_key, header_value, description, + enabled, display_order, created_at, updated_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +` + +type CreateGraphQLHeaderParams struct { + ID idwrap.IDWrap + GraphqlID idwrap.IDWrap + HeaderKey string + HeaderValue string + Description string + Enabled bool + DisplayOrder float64 + CreatedAt int64 + UpdatedAt int64 +} + +func (q *Queries) CreateGraphQLHeader(ctx context.Context, arg CreateGraphQLHeaderParams) error { + _, err := q.exec(ctx, q.createGraphQLHeaderStmt, createGraphQLHeader, + arg.ID, + arg.GraphqlID, + arg.HeaderKey, + arg.HeaderValue, + arg.Description, + arg.Enabled, + arg.DisplayOrder, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const createGraphQLResponse = `-- name: CreateGraphQLResponse :exec +INSERT INTO graphql_response ( + id, graphql_id, status, body, time, duration, size, created_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +` + +type CreateGraphQLResponseParams struct { + ID idwrap.IDWrap + GraphqlID idwrap.IDWrap + Status interface{} + Body []byte + Time time.Time + Duration interface{} + Size interface{} + CreatedAt int64 +} + +func (q *Queries) CreateGraphQLResponse(ctx context.Context, arg CreateGraphQLResponseParams) error { + _, err := q.exec(ctx, q.createGraphQLResponseStmt, createGraphQLResponse, + arg.ID, + arg.GraphqlID, + arg.Status, + arg.Body, + arg.Time, + arg.Duration, + arg.Size, + arg.CreatedAt, + ) + return err +} + +const createGraphQLResponseHeader = `-- name: CreateGraphQLResponseHeader :exec +INSERT INTO graphql_response_header ( + id, response_id, key, value, created_at +) +VALUES (?, ?, ?, ?, ?) +` + +type CreateGraphQLResponseHeaderParams struct { + ID idwrap.IDWrap + ResponseID idwrap.IDWrap + Key string + Value string + CreatedAt int64 +} + +func (q *Queries) CreateGraphQLResponseHeader(ctx context.Context, arg CreateGraphQLResponseHeaderParams) error { + _, err := q.exec(ctx, q.createGraphQLResponseHeaderStmt, createGraphQLResponseHeader, + arg.ID, + arg.ResponseID, + arg.Key, + arg.Value, + arg.CreatedAt, + ) + return err +} + +const createGraphQLResponseHeaderBulk = `-- name: CreateGraphQLResponseHeaderBulk :exec +INSERT INTO graphql_response_header ( + id, response_id, key, value, created_at +) +VALUES + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?) +` + +type CreateGraphQLResponseHeaderBulkParams struct { + ID idwrap.IDWrap + ResponseID idwrap.IDWrap + Key string + Value string + CreatedAt int64 + ID_2 idwrap.IDWrap + ResponseID_2 idwrap.IDWrap + Key_2 string + Value_2 string + CreatedAt_2 int64 + ID_3 idwrap.IDWrap + ResponseID_3 idwrap.IDWrap + Key_3 string + Value_3 string + CreatedAt_3 int64 + ID_4 idwrap.IDWrap + ResponseID_4 idwrap.IDWrap + Key_4 string + Value_4 string + CreatedAt_4 int64 + ID_5 idwrap.IDWrap + ResponseID_5 idwrap.IDWrap + Key_5 string + Value_5 string + CreatedAt_5 int64 + ID_6 idwrap.IDWrap + ResponseID_6 idwrap.IDWrap + Key_6 string + Value_6 string + CreatedAt_6 int64 + ID_7 idwrap.IDWrap + ResponseID_7 idwrap.IDWrap + Key_7 string + Value_7 string + CreatedAt_7 int64 + ID_8 idwrap.IDWrap + ResponseID_8 idwrap.IDWrap + Key_8 string + Value_8 string + CreatedAt_8 int64 + ID_9 idwrap.IDWrap + ResponseID_9 idwrap.IDWrap + Key_9 string + Value_9 string + CreatedAt_9 int64 + ID_10 idwrap.IDWrap + ResponseID_10 idwrap.IDWrap + Key_10 string + Value_10 string + CreatedAt_10 int64 +} + +func (q *Queries) CreateGraphQLResponseHeaderBulk(ctx context.Context, arg CreateGraphQLResponseHeaderBulkParams) error { + _, err := q.exec(ctx, q.createGraphQLResponseHeaderBulkStmt, createGraphQLResponseHeaderBulk, + arg.ID, + arg.ResponseID, + arg.Key, + arg.Value, + arg.CreatedAt, + arg.ID_2, + arg.ResponseID_2, + arg.Key_2, + arg.Value_2, + arg.CreatedAt_2, + arg.ID_3, + arg.ResponseID_3, + arg.Key_3, + arg.Value_3, + arg.CreatedAt_3, + arg.ID_4, + arg.ResponseID_4, + arg.Key_4, + arg.Value_4, + arg.CreatedAt_4, + arg.ID_5, + arg.ResponseID_5, + arg.Key_5, + arg.Value_5, + arg.CreatedAt_5, + arg.ID_6, + arg.ResponseID_6, + arg.Key_6, + arg.Value_6, + arg.CreatedAt_6, + arg.ID_7, + arg.ResponseID_7, + arg.Key_7, + arg.Value_7, + arg.CreatedAt_7, + arg.ID_8, + arg.ResponseID_8, + arg.Key_8, + arg.Value_8, + arg.CreatedAt_8, + arg.ID_9, + arg.ResponseID_9, + arg.Key_9, + arg.Value_9, + arg.CreatedAt_9, + arg.ID_10, + arg.ResponseID_10, + arg.Key_10, + arg.Value_10, + arg.CreatedAt_10, + ) + return err +} + +const deleteGraphQL = `-- name: DeleteGraphQL :exec +DELETE FROM graphql +WHERE id = ? +` + +func (q *Queries) DeleteGraphQL(ctx context.Context, id idwrap.IDWrap) error { + _, err := q.exec(ctx, q.deleteGraphQLStmt, deleteGraphQL, id) + return err +} + +const deleteGraphQLHeader = `-- name: DeleteGraphQLHeader :exec +DELETE FROM graphql_header +WHERE id = ? +` + +func (q *Queries) DeleteGraphQLHeader(ctx context.Context, id idwrap.IDWrap) error { + _, err := q.exec(ctx, q.deleteGraphQLHeaderStmt, deleteGraphQLHeader, id) + return err +} + +const deleteGraphQLResponse = `-- name: DeleteGraphQLResponse :exec +DELETE FROM graphql_response WHERE id = ? +` + +func (q *Queries) DeleteGraphQLResponse(ctx context.Context, id idwrap.IDWrap) error { + _, err := q.exec(ctx, q.deleteGraphQLResponseStmt, deleteGraphQLResponse, id) + return err +} + +const deleteGraphQLResponseHeader = `-- name: DeleteGraphQLResponseHeader :exec +DELETE FROM graphql_response_header WHERE id = ? +` + +func (q *Queries) DeleteGraphQLResponseHeader(ctx context.Context, id idwrap.IDWrap) error { + _, err := q.exec(ctx, q.deleteGraphQLResponseHeaderStmt, deleteGraphQLResponseHeader, id) + return err +} + +const getGraphQL = `-- name: GetGraphQL :one + +SELECT + id, workspace_id, folder_id, name, url, query, variables, + description, last_run_at, created_at, updated_at +FROM graphql +WHERE id = ? LIMIT 1 +` + +// GraphQL Core Queries +func (q *Queries) GetGraphQL(ctx context.Context, id idwrap.IDWrap) (Graphql, error) { + row := q.queryRow(ctx, q.getGraphQLStmt, getGraphQL, id) + var i Graphql + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.FolderID, + &i.Name, + &i.Url, + &i.Query, + &i.Variables, + &i.Description, + &i.LastRunAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getGraphQLHeaders = `-- name: GetGraphQLHeaders :many + +SELECT + id, graphql_id, header_key, header_value, description, + enabled, display_order, created_at, updated_at +FROM graphql_header +WHERE graphql_id = ? +ORDER BY display_order +` + +// GraphQL Header Queries +func (q *Queries) GetGraphQLHeaders(ctx context.Context, graphqlID idwrap.IDWrap) ([]GraphqlHeader, error) { + rows, err := q.query(ctx, q.getGraphQLHeadersStmt, getGraphQLHeaders, graphqlID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GraphqlHeader{} + for rows.Next() { + var i GraphqlHeader + if err := rows.Scan( + &i.ID, + &i.GraphqlID, + &i.HeaderKey, + &i.HeaderValue, + &i.Description, + &i.Enabled, + &i.DisplayOrder, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getGraphQLHeadersByIDs = `-- name: GetGraphQLHeadersByIDs :many +SELECT + id, graphql_id, header_key, header_value, description, + enabled, display_order, created_at, updated_at +FROM graphql_header +WHERE id IN (/*SLICE:ids*/?) +` + +func (q *Queries) GetGraphQLHeadersByIDs(ctx context.Context, ids []idwrap.IDWrap) ([]GraphqlHeader, error) { + query := getGraphQLHeadersByIDs + var queryParams []interface{} + if len(ids) > 0 { + for _, v := range ids { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(ids))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1) + } + rows, err := q.query(ctx, nil, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GraphqlHeader{} + for rows.Next() { + var i GraphqlHeader + if err := rows.Scan( + &i.ID, + &i.GraphqlID, + &i.HeaderKey, + &i.HeaderValue, + &i.Description, + &i.Enabled, + &i.DisplayOrder, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getGraphQLResponse = `-- name: GetGraphQLResponse :one + +SELECT + id, graphql_id, status, body, time, duration, size, created_at +FROM graphql_response +WHERE id = ? LIMIT 1 +` + +// GraphQL Response Queries +func (q *Queries) GetGraphQLResponse(ctx context.Context, id idwrap.IDWrap) (GraphqlResponse, error) { + row := q.queryRow(ctx, q.getGraphQLResponseStmt, getGraphQLResponse, id) + var i GraphqlResponse + err := row.Scan( + &i.ID, + &i.GraphqlID, + &i.Status, + &i.Body, + &i.Time, + &i.Duration, + &i.Size, + &i.CreatedAt, + ) + return i, err +} + +const getGraphQLResponseHeadersByResponseID = `-- name: GetGraphQLResponseHeadersByResponseID :many + +SELECT + id, response_id, key, value, created_at +FROM graphql_response_header +WHERE response_id = ? +ORDER BY key +` + +// GraphQL Response Header Queries +func (q *Queries) GetGraphQLResponseHeadersByResponseID(ctx context.Context, responseID idwrap.IDWrap) ([]GraphqlResponseHeader, error) { + rows, err := q.query(ctx, q.getGraphQLResponseHeadersByResponseIDStmt, getGraphQLResponseHeadersByResponseID, responseID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GraphqlResponseHeader{} + for rows.Next() { + var i GraphqlResponseHeader + if err := rows.Scan( + &i.ID, + &i.ResponseID, + &i.Key, + &i.Value, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getGraphQLResponseHeadersByWorkspaceID = `-- name: GetGraphQLResponseHeadersByWorkspaceID :many +SELECT + grh.id, grh.response_id, grh.key, grh.value, grh.created_at +FROM graphql_response_header grh +INNER JOIN graphql_response gr ON grh.response_id = gr.id +INNER JOIN graphql g ON gr.graphql_id = g.id +WHERE g.workspace_id = ? +ORDER BY gr.time DESC, grh.key +` + +func (q *Queries) GetGraphQLResponseHeadersByWorkspaceID(ctx context.Context, workspaceID idwrap.IDWrap) ([]GraphqlResponseHeader, error) { + rows, err := q.query(ctx, q.getGraphQLResponseHeadersByWorkspaceIDStmt, getGraphQLResponseHeadersByWorkspaceID, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GraphqlResponseHeader{} + for rows.Next() { + var i GraphqlResponseHeader + if err := rows.Scan( + &i.ID, + &i.ResponseID, + &i.Key, + &i.Value, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getGraphQLResponsesByGraphQLID = `-- name: GetGraphQLResponsesByGraphQLID :many +SELECT + id, graphql_id, status, body, time, duration, size, created_at +FROM graphql_response +WHERE graphql_id = ? +ORDER BY time DESC +` + +func (q *Queries) GetGraphQLResponsesByGraphQLID(ctx context.Context, graphqlID idwrap.IDWrap) ([]GraphqlResponse, error) { + rows, err := q.query(ctx, q.getGraphQLResponsesByGraphQLIDStmt, getGraphQLResponsesByGraphQLID, graphqlID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GraphqlResponse{} + for rows.Next() { + var i GraphqlResponse + if err := rows.Scan( + &i.ID, + &i.GraphqlID, + &i.Status, + &i.Body, + &i.Time, + &i.Duration, + &i.Size, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getGraphQLResponsesByWorkspaceID = `-- name: GetGraphQLResponsesByWorkspaceID :many +SELECT + gr.id, gr.graphql_id, gr.status, gr.body, gr.time, + gr.duration, gr.size, gr.created_at +FROM graphql_response gr +INNER JOIN graphql g ON gr.graphql_id = g.id +WHERE g.workspace_id = ? +ORDER BY gr.time DESC +` + +func (q *Queries) GetGraphQLResponsesByWorkspaceID(ctx context.Context, workspaceID idwrap.IDWrap) ([]GraphqlResponse, error) { + rows, err := q.query(ctx, q.getGraphQLResponsesByWorkspaceIDStmt, getGraphQLResponsesByWorkspaceID, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GraphqlResponse{} + for rows.Next() { + var i GraphqlResponse + if err := rows.Scan( + &i.ID, + &i.GraphqlID, + &i.Status, + &i.Body, + &i.Time, + &i.Duration, + &i.Size, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getGraphQLWorkspaceID = `-- name: GetGraphQLWorkspaceID :one +SELECT workspace_id +FROM graphql +WHERE id = ? +LIMIT 1 +` + +func (q *Queries) GetGraphQLWorkspaceID(ctx context.Context, id idwrap.IDWrap) (idwrap.IDWrap, error) { + row := q.queryRow(ctx, q.getGraphQLWorkspaceIDStmt, getGraphQLWorkspaceID, id) + var workspace_id idwrap.IDWrap + err := row.Scan(&workspace_id) + return workspace_id, err +} + +const getGraphQLsByWorkspaceID = `-- name: GetGraphQLsByWorkspaceID :many +SELECT + id, workspace_id, folder_id, name, url, query, variables, + description, last_run_at, created_at, updated_at +FROM graphql +WHERE workspace_id = ? +ORDER BY updated_at DESC +` + +func (q *Queries) GetGraphQLsByWorkspaceID(ctx context.Context, workspaceID idwrap.IDWrap) ([]Graphql, error) { + rows, err := q.query(ctx, q.getGraphQLsByWorkspaceIDStmt, getGraphQLsByWorkspaceID, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Graphql{} + for rows.Next() { + var i Graphql + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.FolderID, + &i.Name, + &i.Url, + &i.Query, + &i.Variables, + &i.Description, + &i.LastRunAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateGraphQL = `-- name: UpdateGraphQL :exec +UPDATE graphql +SET + name = ?, + url = ?, + query = ?, + variables = ?, + description = ?, + last_run_at = COALESCE(?, last_run_at), + updated_at = unixepoch() +WHERE id = ? +` + +type UpdateGraphQLParams struct { + Name string + Url string + Query string + Variables string + Description string + LastRunAt interface{} + ID idwrap.IDWrap +} + +func (q *Queries) UpdateGraphQL(ctx context.Context, arg UpdateGraphQLParams) error { + _, err := q.exec(ctx, q.updateGraphQLStmt, updateGraphQL, + arg.Name, + arg.Url, + arg.Query, + arg.Variables, + arg.Description, + arg.LastRunAt, + arg.ID, + ) + return err +} + +const updateGraphQLHeader = `-- name: UpdateGraphQLHeader :exec +UPDATE graphql_header +SET + header_key = ?, + header_value = ?, + description = ?, + enabled = ?, + display_order = ?, + updated_at = unixepoch() +WHERE id = ? +` + +type UpdateGraphQLHeaderParams struct { + HeaderKey string + HeaderValue string + Description string + Enabled bool + DisplayOrder float64 + ID idwrap.IDWrap +} + +func (q *Queries) UpdateGraphQLHeader(ctx context.Context, arg UpdateGraphQLHeaderParams) error { + _, err := q.exec(ctx, q.updateGraphQLHeaderStmt, updateGraphQLHeader, + arg.HeaderKey, + arg.HeaderValue, + arg.Description, + arg.Enabled, + arg.DisplayOrder, + arg.ID, + ) + return err +} diff --git a/packages/db/pkg/sqlc/gen/models.go b/packages/db/pkg/sqlc/gen/models.go index a218fddd2..b3f243c0e 100644 --- a/packages/db/pkg/sqlc/gen/models.go +++ b/packages/db/pkg/sqlc/gen/models.go @@ -122,6 +122,11 @@ type FlowNodeForEach struct { Expression string } +type FlowNodeGraphql struct { + FlowNodeID idwrap.IDWrap + GraphqlID idwrap.IDWrap +} + type FlowNodeHttp struct { FlowNodeID idwrap.IDWrap HttpID idwrap.IDWrap @@ -156,6 +161,51 @@ type FlowVariable struct { DisplayOrder float64 } +type Graphql struct { + ID idwrap.IDWrap + WorkspaceID idwrap.IDWrap + FolderID *idwrap.IDWrap + Name string + Url string + Query string + Variables string + Description string + LastRunAt interface{} + CreatedAt int64 + UpdatedAt int64 +} + +type GraphqlHeader struct { + ID idwrap.IDWrap + GraphqlID idwrap.IDWrap + HeaderKey string + HeaderValue string + Description string + Enabled bool + DisplayOrder float64 + CreatedAt int64 + UpdatedAt int64 +} + +type GraphqlResponse struct { + ID idwrap.IDWrap + GraphqlID idwrap.IDWrap + Status interface{} + Body []byte + Time time.Time + Duration interface{} + Size interface{} + CreatedAt int64 +} + +type GraphqlResponseHeader struct { + ID idwrap.IDWrap + ResponseID idwrap.IDWrap + Key string + Value string + CreatedAt int64 +} + type Http struct { ID idwrap.IDWrap WorkspaceID idwrap.IDWrap @@ -340,6 +390,7 @@ type NodeExecution struct { OutputData []byte OutputDataCompressType int8 HttpResponseID *idwrap.IDWrap + GraphqlResponseID *idwrap.IDWrap CompletedAt sql.NullInt64 } diff --git a/packages/db/pkg/sqlc/queries/flow.sql b/packages/db/pkg/sqlc/queries/flow.sql index 8d919838d..f8b12088a 100644 --- a/packages/db/pkg/sqlc/queries/flow.sql +++ b/packages/db/pkg/sqlc/queries/flow.sql @@ -420,6 +420,22 @@ DELETE FROM flow_node_http WHERE flow_node_id = ?; +-- name: GetFlowNodeGraphQL :one +SELECT flow_node_id, graphql_id FROM flow_node_graphql WHERE flow_node_id = ? LIMIT 1; + +-- name: CreateFlowNodeGraphQL :exec +INSERT INTO flow_node_graphql (flow_node_id, graphql_id) VALUES (?, ?); + +-- name: UpdateFlowNodeGraphQL :exec +INSERT INTO flow_node_graphql (flow_node_id, graphql_id) VALUES (?, ?) +ON CONFLICT(flow_node_id) DO UPDATE SET graphql_id = excluded.graphql_id; + +-- name: DeleteFlowNodeGraphQL :exec +DELETE FROM flow_node_graphql WHERE flow_node_id = ?; + +-- name: CleanupOrphanedFlowNodeGraphQL :exec +DELETE FROM flow_node_graphql WHERE flow_node_id NOT IN (SELECT id FROM flow_node); + -- name: GetFlowNodeCondition :one SELECT flow_node_id, @@ -631,32 +647,33 @@ ORDER BY ne.completed_at DESC, ne.id DESC; -- name: CreateNodeExecution :one INSERT INTO node_execution ( id, node_id, name, state, error, input_data, input_data_compress_type, - output_data, output_data_compress_type, http_response_id, completed_at + output_data, output_data_compress_type, http_response_id, graphql_response_id, completed_at ) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; -- name: UpdateNodeExecution :one UPDATE node_execution -SET state = ?, error = ?, output_data = ?, - output_data_compress_type = ?, http_response_id = ?, completed_at = ? +SET state = ?, error = ?, output_data = ?, + output_data_compress_type = ?, http_response_id = ?, graphql_response_id = ?, completed_at = ? WHERE id = ? RETURNING *; -- name: UpsertNodeExecution :one INSERT INTO node_execution ( id, node_id, name, state, error, input_data, input_data_compress_type, - output_data, output_data_compress_type, http_response_id, completed_at + output_data, output_data_compress_type, http_response_id, graphql_response_id, completed_at ) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET state = excluded.state, - error = excluded.error, + error = excluded.error, input_data = excluded.input_data, input_data_compress_type = excluded.input_data_compress_type, output_data = excluded.output_data, output_data_compress_type = excluded.output_data_compress_type, http_response_id = excluded.http_response_id, + graphql_response_id = excluded.graphql_response_id, completed_at = excluded.completed_at RETURNING *; diff --git a/packages/db/pkg/sqlc/queries/graphql.sql b/packages/db/pkg/sqlc/queries/graphql.sql new file mode 100644 index 000000000..e5e531128 --- /dev/null +++ b/packages/db/pkg/sqlc/queries/graphql.sql @@ -0,0 +1,168 @@ +-- +-- GraphQL Core Queries +-- + +-- name: GetGraphQL :one +SELECT + id, workspace_id, folder_id, name, url, query, variables, + description, last_run_at, created_at, updated_at +FROM graphql +WHERE id = ? LIMIT 1; + +-- name: GetGraphQLsByWorkspaceID :many +SELECT + id, workspace_id, folder_id, name, url, query, variables, + description, last_run_at, created_at, updated_at +FROM graphql +WHERE workspace_id = ? +ORDER BY updated_at DESC; + +-- name: GetGraphQLWorkspaceID :one +SELECT workspace_id +FROM graphql +WHERE id = ? +LIMIT 1; + +-- name: CreateGraphQL :exec +INSERT INTO graphql ( + id, workspace_id, folder_id, name, url, query, variables, + description, last_run_at, created_at, updated_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + +-- name: UpdateGraphQL :exec +UPDATE graphql +SET + name = ?, + url = ?, + query = ?, + variables = ?, + description = ?, + last_run_at = COALESCE(?, last_run_at), + updated_at = unixepoch() +WHERE id = ?; + +-- name: DeleteGraphQL :exec +DELETE FROM graphql +WHERE id = ?; + +-- +-- GraphQL Header Queries +-- + +-- name: GetGraphQLHeaders :many +SELECT + id, graphql_id, header_key, header_value, description, + enabled, display_order, created_at, updated_at +FROM graphql_header +WHERE graphql_id = ? +ORDER BY display_order; + +-- name: GetGraphQLHeadersByIDs :many +SELECT + id, graphql_id, header_key, header_value, description, + enabled, display_order, created_at, updated_at +FROM graphql_header +WHERE id IN (sqlc.slice('ids')); + +-- name: CreateGraphQLHeader :exec +INSERT INTO graphql_header ( + id, graphql_id, header_key, header_value, description, + enabled, display_order, created_at, updated_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + +-- name: UpdateGraphQLHeader :exec +UPDATE graphql_header +SET + header_key = ?, + header_value = ?, + description = ?, + enabled = ?, + display_order = ?, + updated_at = unixepoch() +WHERE id = ?; + +-- name: DeleteGraphQLHeader :exec +DELETE FROM graphql_header +WHERE id = ?; + +-- +-- GraphQL Response Queries +-- + +-- name: GetGraphQLResponse :one +SELECT + id, graphql_id, status, body, time, duration, size, created_at +FROM graphql_response +WHERE id = ? LIMIT 1; + +-- name: GetGraphQLResponsesByGraphQLID :many +SELECT + id, graphql_id, status, body, time, duration, size, created_at +FROM graphql_response +WHERE graphql_id = ? +ORDER BY time DESC; + +-- name: GetGraphQLResponsesByWorkspaceID :many +SELECT + gr.id, gr.graphql_id, gr.status, gr.body, gr.time, + gr.duration, gr.size, gr.created_at +FROM graphql_response gr +INNER JOIN graphql g ON gr.graphql_id = g.id +WHERE g.workspace_id = ? +ORDER BY gr.time DESC; + +-- name: CreateGraphQLResponse :exec +INSERT INTO graphql_response ( + id, graphql_id, status, body, time, duration, size, created_at +) +VALUES (?, ?, ?, ?, ?, ?, ?, ?); + +-- name: DeleteGraphQLResponse :exec +DELETE FROM graphql_response WHERE id = ?; + +-- +-- GraphQL Response Header Queries +-- + +-- name: GetGraphQLResponseHeadersByResponseID :many +SELECT + id, response_id, key, value, created_at +FROM graphql_response_header +WHERE response_id = ? +ORDER BY key; + +-- name: GetGraphQLResponseHeadersByWorkspaceID :many +SELECT + grh.id, grh.response_id, grh.key, grh.value, grh.created_at +FROM graphql_response_header grh +INNER JOIN graphql_response gr ON grh.response_id = gr.id +INNER JOIN graphql g ON gr.graphql_id = g.id +WHERE g.workspace_id = ? +ORDER BY gr.time DESC, grh.key; + +-- name: CreateGraphQLResponseHeader :exec +INSERT INTO graphql_response_header ( + id, response_id, key, value, created_at +) +VALUES (?, ?, ?, ?, ?); + +-- name: CreateGraphQLResponseHeaderBulk :exec +INSERT INTO graphql_response_header ( + id, response_id, key, value, created_at +) +VALUES + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?); + +-- name: DeleteGraphQLResponseHeader :exec +DELETE FROM graphql_response_header WHERE id = ?; diff --git a/packages/db/pkg/sqlc/schema/05_flow.sql b/packages/db/pkg/sqlc/schema/05_flow.sql index a532f1f74..e6b4e11d7 100644 --- a/packages/db/pkg/sqlc/schema/05_flow.sql +++ b/packages/db/pkg/sqlc/schema/05_flow.sql @@ -83,6 +83,12 @@ CREATE TABLE flow_node_http ( ); +CREATE TABLE flow_node_graphql ( + flow_node_id BLOB NOT NULL PRIMARY KEY, + graphql_id BLOB NOT NULL, + FOREIGN KEY (graphql_id) REFERENCES graphql (id) ON DELETE CASCADE +); + CREATE TABLE flow_node_condition ( flow_node_id BLOB NOT NULL PRIMARY KEY, expression TEXT NOT NULL @@ -122,8 +128,10 @@ CREATE TABLE node_execution ( output_data_compress_type INT8 NOT NULL DEFAULT 0, -- Add new fields http_response_id BLOB, -- Response ID for HTTP request nodes (NULL for non-request nodes) + graphql_response_id BLOB, -- Response ID for GraphQL request nodes completed_at BIGINT, -- Unix timestamp in milliseconds - FOREIGN KEY (http_response_id) REFERENCES http_response (id) ON DELETE SET NULL + FOREIGN KEY (http_response_id) REFERENCES http_response (id) ON DELETE SET NULL, + FOREIGN KEY (graphql_response_id) REFERENCES graphql_response (id) ON DELETE SET NULL ); CREATE INDEX node_execution_idx1 ON node_execution (node_id); diff --git a/packages/db/pkg/sqlc/schema/08_graphql.sql b/packages/db/pkg/sqlc/schema/08_graphql.sql new file mode 100644 index 000000000..462808162 --- /dev/null +++ b/packages/db/pkg/sqlc/schema/08_graphql.sql @@ -0,0 +1,75 @@ +/* + * + * GRAPHQL SYSTEM + * GraphQL request support - simpler than HTTP (no delta system) + * + */ + +-- Core GraphQL request table +CREATE TABLE graphql ( + id BLOB NOT NULL PRIMARY KEY, + workspace_id BLOB NOT NULL, + folder_id BLOB, + name TEXT NOT NULL, + url TEXT NOT NULL, + query TEXT NOT NULL DEFAULT '', + variables TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + last_run_at BIGINT NULL, + created_at BIGINT NOT NULL DEFAULT (unixepoch()), + updated_at BIGINT NOT NULL DEFAULT (unixepoch()), + + FOREIGN KEY (workspace_id) REFERENCES workspaces (id) ON DELETE CASCADE, + FOREIGN KEY (folder_id) REFERENCES files (id) ON DELETE SET NULL +); + +CREATE INDEX graphql_workspace_idx ON graphql (workspace_id); +CREATE INDEX graphql_folder_idx ON graphql (folder_id) WHERE folder_id IS NOT NULL; + +-- GraphQL request headers +CREATE TABLE graphql_header ( + id BLOB NOT NULL PRIMARY KEY, + graphql_id BLOB NOT NULL, + header_key TEXT NOT NULL, + header_value TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + display_order REAL NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL DEFAULT (unixepoch()), + updated_at BIGINT NOT NULL DEFAULT (unixepoch()), + + FOREIGN KEY (graphql_id) REFERENCES graphql (id) ON DELETE CASCADE +); + +CREATE INDEX graphql_header_graphql_idx ON graphql_header (graphql_id); +CREATE INDEX graphql_header_order_idx ON graphql_header (graphql_id, display_order); + +-- GraphQL response (read-only) +CREATE TABLE graphql_response ( + id BLOB NOT NULL PRIMARY KEY, + graphql_id BLOB NOT NULL, + status INT32 NOT NULL, + body BLOB, + time DATETIME NOT NULL, + duration INT32 NOT NULL, + size INT32 NOT NULL, + created_at BIGINT NOT NULL DEFAULT (unixepoch()), + + FOREIGN KEY (graphql_id) REFERENCES graphql (id) ON DELETE CASCADE +); + +CREATE INDEX graphql_response_graphql_idx ON graphql_response (graphql_id); +CREATE INDEX graphql_response_time_idx ON graphql_response (graphql_id, time DESC); + +-- GraphQL response headers (read-only) +CREATE TABLE graphql_response_header ( + id BLOB NOT NULL PRIMARY KEY, + response_id BLOB NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + created_at BIGINT NOT NULL DEFAULT (unixepoch()), + + FOREIGN KEY (response_id) REFERENCES graphql_response (id) ON DELETE CASCADE +); + +CREATE INDEX graphql_response_header_response_idx ON graphql_response_header (response_id); diff --git a/packages/db/pkg/sqlc/sqlc.yaml b/packages/db/pkg/sqlc/sqlc.yaml index dddf43631..6874ef5e3 100644 --- a/packages/db/pkg/sqlc/sqlc.yaml +++ b/packages/db/pkg/sqlc/sqlc.yaml @@ -274,6 +274,19 @@ sql: import: 'github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap' package: 'idwrap' type: 'IDWrap' + ## flow_node_graphql + ### flow_node_id + - column: 'flow_node_graphql.flow_node_id' + go_type: + import: 'github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap' + package: 'idwrap' + type: 'IDWrap' + ### graphql_id + - column: 'flow_node_graphql.graphql_id' + go_type: + import: 'github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap' + package: 'idwrap' + type: 'IDWrap' ## flow_node_condition ### flow_node_id - column: 'flow_node_condition.flow_node_id' @@ -368,6 +381,13 @@ sql: package: 'idwrap' type: 'IDWrap' pointer: true + ### graphql_response_id + - column: 'node_execution.graphql_response_id' + go_type: + import: 'github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap' + package: 'idwrap' + type: 'IDWrap' + pointer: true ## files ### id - column: 'files.id' @@ -779,3 +799,62 @@ sql: import: 'github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap' package: 'idwrap' type: 'IDWrap' + ## GraphQL system + ### graphql table + - column: 'graphql.id' + go_type: + import: 'github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap' + package: 'idwrap' + type: 'IDWrap' + - column: 'graphql.workspace_id' + go_type: + import: 'github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap' + package: 'idwrap' + type: 'IDWrap' + - column: 'graphql.folder_id' + go_type: + import: 'github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap' + package: 'idwrap' + type: 'IDWrap' + pointer: true + - column: 'graphql.created_at' + go_type: 'int64' + - column: 'graphql.updated_at' + go_type: 'int64' + ### graphql_header table + - column: 'graphql_header.id' + go_type: + import: 'github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap' + package: 'idwrap' + type: 'IDWrap' + - column: 'graphql_header.graphql_id' + go_type: + import: 'github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap' + package: 'idwrap' + type: 'IDWrap' + - column: 'graphql_header.created_at' + go_type: 'int64' + - column: 'graphql_header.updated_at' + go_type: 'int64' + ### graphql_response table + - column: 'graphql_response.id' + go_type: + import: 'github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap' + package: 'idwrap' + type: 'IDWrap' + - column: 'graphql_response.graphql_id' + go_type: + import: 'github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap' + package: 'idwrap' + type: 'IDWrap' + ### graphql_response_header table + - column: 'graphql_response_header.id' + go_type: + import: 'github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap' + package: 'idwrap' + type: 'IDWrap' + - column: 'graphql_response_header.response_id' + go_type: + import: 'github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap' + package: 'idwrap' + type: 'IDWrap' diff --git a/packages/server/cmd/server/server.go b/packages/server/cmd/server/server.go index e925223f2..958ca1a48 100644 --- a/packages/server/cmd/server/server.go +++ b/packages/server/cmd/server/server.go @@ -30,6 +30,7 @@ import ( "github.com/the-dev-tools/dev-tools/packages/server/internal/api/rexportv2" "github.com/the-dev-tools/dev-tools/packages/server/internal/api/rfile" "github.com/the-dev-tools/dev-tools/packages/server/internal/api/rflowv2" + "github.com/the-dev-tools/dev-tools/packages/server/internal/api/rgraphql" "github.com/the-dev-tools/dev-tools/packages/server/internal/api/rhealth" "github.com/the-dev-tools/dev-tools/packages/server/internal/api/rhttp" "github.com/the-dev-tools/dev-tools/packages/server/internal/api/rimportv2" @@ -49,6 +50,7 @@ import ( "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/senv" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sfile" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sflow" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sgraphql" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/shttp" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/suser" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sworkspace" @@ -56,6 +58,7 @@ import ( envapiv1 "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/environment/v1" filesystemv1 "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/file_system/v1" flowv1 "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/flow/v1" + graphqlv1 "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/graph_q_l/v1" httpv1 "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/http/v1" "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/node_js_executor/v1/node_js_executorv1connect" apiv1 "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/workspace/v1" @@ -163,6 +166,11 @@ func run() error { httpResponseService := shttp.NewHttpResponseService(queries) httpResponseReader := shttp.NewHttpResponseReader(currentDB) + // GraphQL + graphqlService := sgraphql.New(queries, logger) + graphqlHeaderService := sgraphql.NewGraphQLHeaderService(queries) + graphqlResponseService := sgraphql.NewGraphQLResponseService(queries) + // File Service fileService := sfile.New(queries, logger) @@ -195,6 +203,7 @@ func run() error { flowNodeAIService := sflow.NewNodeAIService(queries) flowNodeAiProviderService := sflow.NewNodeAiProviderService(queries) flowNodeMemoryService := sflow.NewNodeMemoryService(queries) + flowNodeGraphQLService := sflow.NewNodeGraphQLService(queries) nodeExecutionService := sflow.NewNodeExecutionService(queries) nodeExecutionReader := sflow.NewNodeExecutionReader(currentDB) @@ -454,15 +463,19 @@ func run() error { NodeJs: &flowNodeNodeJsService, NodeAI: &flowNodeAIService, NodeAiProvider: &flowNodeAiProviderService, - NodeMemory: &flowNodeMemoryService, - NodeExecution: &nodeExecutionService, + NodeMemory: &flowNodeMemoryService, + NodeGraphQL: &flowNodeGraphQLService, + NodeExecution: &nodeExecutionService, FlowVariable: &flowVariableService, Env: &environmentService, Var: &variableService, Http: &httpService, HttpBodyRaw: httpBodyRawService, - HttpResponse: httpResponseService, - File: fileService, + HttpResponse: httpResponseService, + GraphQLResponse: graphqlResponseService, + GraphQL: &graphqlService, + GraphQLHeader: &graphqlHeaderService, + File: fileService, Importer: workspaceImporter, Credential: credentialService, }, @@ -479,6 +492,7 @@ func run() error { Ai: streamers.Ai, AiProvider: streamers.AiProvider, Memory: streamers.Memory, + NodeGraphQL: streamers.NodeGraphQL, Execution: streamers.Execution, HttpResponse: streamers.HttpResponse, HttpResponseHeader: streamers.HttpResponseHeader, @@ -540,6 +554,36 @@ func run() error { }) newServiceManager.AddService(rcredential.CreateService(credentialSrv, optionsAll)) + // GraphQL Service + graphqlStreamers := &rgraphql.GraphQLStreamers{ + GraphQL: streamers.GraphQL, + GraphQLHeader: streamers.GraphQLHeader, + GraphQLResponse: streamers.GraphQLResponse, + GraphQLResponseHeader: streamers.GraphQLResponseHeader, + File: streamers.File, + } + + graphqlSrv := rgraphql.New(rgraphql.GraphQLServiceRPCDeps{ + DB: currentDB, + Services: rgraphql.GraphQLServiceRPCServices{ + GraphQL: graphqlService, + Header: graphqlHeaderService, + Response: graphqlResponseService, + User: userService, + Workspace: workspaceService, + WorkspaceUser: workspaceUserService, + Env: environmentService, + Variable: variableService, + File: fileService, + }, + Readers: rgraphql.GraphQLServiceRPCReaders{ + User: userReader, + Workspace: workspaceReader, + }, + Streamers: graphqlStreamers, + }) + newServiceManager.AddService(rgraphql.CreateService(graphqlSrv, optionsAll)) + // Reference Service refServiceRPC := rreference.NewReferenceServiceRPC(rreference.ReferenceServiceRPCDeps{ DB: currentDB, @@ -701,12 +745,17 @@ type Streamers struct { Ai eventstream.SyncStreamer[rflowv2.AiTopic, rflowv2.AiEvent] AiProvider eventstream.SyncStreamer[rflowv2.AiProviderTopic, rflowv2.AiProviderEvent] Memory eventstream.SyncStreamer[rflowv2.MemoryTopic, rflowv2.MemoryEvent] + NodeGraphQL eventstream.SyncStreamer[rflowv2.NodeGraphQLTopic, rflowv2.NodeGraphQLEvent] Execution eventstream.SyncStreamer[rflowv2.ExecutionTopic, rflowv2.ExecutionEvent] File eventstream.SyncStreamer[rfile.FileTopic, rfile.FileEvent] Credential eventstream.SyncStreamer[rcredential.CredentialTopic, rcredential.CredentialEvent] CredentialOpenAi eventstream.SyncStreamer[rcredential.CredentialOpenAiTopic, rcredential.CredentialOpenAiEvent] CredentialGemini eventstream.SyncStreamer[rcredential.CredentialGeminiTopic, rcredential.CredentialGeminiEvent] CredentialAnthropic eventstream.SyncStreamer[rcredential.CredentialAnthropicTopic, rcredential.CredentialAnthropicEvent] + GraphQL eventstream.SyncStreamer[rgraphql.GraphQLTopic, rgraphql.GraphQLEvent] + GraphQLHeader eventstream.SyncStreamer[rgraphql.GraphQLHeaderTopic, rgraphql.GraphQLHeaderEvent] + GraphQLResponse eventstream.SyncStreamer[rgraphql.GraphQLResponseTopic, rgraphql.GraphQLResponseEvent] + GraphQLResponseHeader eventstream.SyncStreamer[rgraphql.GraphQLResponseHeaderTopic, rgraphql.GraphQLResponseHeaderEvent] } func NewStreamers() *Streamers { @@ -738,12 +787,17 @@ func NewStreamers() *Streamers { Ai: memory.NewInMemorySyncStreamer[rflowv2.AiTopic, rflowv2.AiEvent](), AiProvider: memory.NewInMemorySyncStreamer[rflowv2.AiProviderTopic, rflowv2.AiProviderEvent](), Memory: memory.NewInMemorySyncStreamer[rflowv2.MemoryTopic, rflowv2.MemoryEvent](), + NodeGraphQL: memory.NewInMemorySyncStreamer[rflowv2.NodeGraphQLTopic, rflowv2.NodeGraphQLEvent](), Execution: memory.NewInMemorySyncStreamer[rflowv2.ExecutionTopic, rflowv2.ExecutionEvent](), File: memory.NewInMemorySyncStreamer[rfile.FileTopic, rfile.FileEvent](), Credential: memory.NewInMemorySyncStreamer[rcredential.CredentialTopic, rcredential.CredentialEvent](), CredentialOpenAi: memory.NewInMemorySyncStreamer[rcredential.CredentialOpenAiTopic, rcredential.CredentialOpenAiEvent](), CredentialGemini: memory.NewInMemorySyncStreamer[rcredential.CredentialGeminiTopic, rcredential.CredentialGeminiEvent](), - CredentialAnthropic: memory.NewInMemorySyncStreamer[rcredential.CredentialAnthropicTopic, rcredential.CredentialAnthropicEvent](), + CredentialAnthropic: memory.NewInMemorySyncStreamer[rcredential.CredentialAnthropicTopic, rcredential.CredentialAnthropicEvent](), + GraphQL: memory.NewInMemorySyncStreamer[rgraphql.GraphQLTopic, rgraphql.GraphQLEvent](), + GraphQLHeader: memory.NewInMemorySyncStreamer[rgraphql.GraphQLHeaderTopic, rgraphql.GraphQLHeaderEvent](), + GraphQLResponse: memory.NewInMemorySyncStreamer[rgraphql.GraphQLResponseTopic, rgraphql.GraphQLResponseEvent](), + GraphQLResponseHeader: memory.NewInMemorySyncStreamer[rgraphql.GraphQLResponseHeaderTopic, rgraphql.GraphQLResponseHeaderEvent](), } } @@ -775,12 +829,17 @@ func (s *Streamers) Shutdown() { s.Ai.Shutdown() s.AiProvider.Shutdown() s.Memory.Shutdown() + s.NodeGraphQL.Shutdown() s.Execution.Shutdown() s.File.Shutdown() s.Credential.Shutdown() s.CredentialOpenAi.Shutdown() s.CredentialGemini.Shutdown() s.CredentialAnthropic.Shutdown() + s.GraphQL.Shutdown() + s.GraphQLHeader.Shutdown() + s.GraphQLResponse.Shutdown() + s.GraphQLResponseHeader.Shutdown() } // registerCascadeHandlers registers all handlers needed for cascade deletion events. @@ -1001,4 +1060,30 @@ func registerCascadeHandlers(registry *streamregistry.Registry, httpStreamers *r }) }) } + + // GraphQL entity + if streamers.GraphQL != nil { + registry.Register(mutation.EntityGraphQL, func(evt mutation.Event) { + if evt.Op != mutation.OpDelete { + return + } + streamers.GraphQL.Publish(rgraphql.GraphQLTopic{WorkspaceID: evt.WorkspaceID}, rgraphql.GraphQLEvent{ + Type: "delete", + GraphQL: &graphqlv1.GraphQL{GraphqlId: evt.ID.Bytes()}, + }) + }) + } + + // GraphQL Header entity + if streamers.GraphQLHeader != nil { + registry.Register(mutation.EntityGraphQLHeader, func(evt mutation.Event) { + if evt.Op != mutation.OpDelete { + return + } + streamers.GraphQLHeader.Publish(rgraphql.GraphQLHeaderTopic{WorkspaceID: evt.WorkspaceID}, rgraphql.GraphQLHeaderEvent{ + Type: "delete", + GraphQLHeader: &graphqlv1.GraphQLHeader{GraphqlHeaderId: evt.ID.Bytes(), GraphqlId: evt.ParentID.Bytes()}, + }) + }) + } } diff --git a/packages/server/docs/specs/GRAPHQL.md b/packages/server/docs/specs/GRAPHQL.md new file mode 100644 index 000000000..a5129ec80 --- /dev/null +++ b/packages/server/docs/specs/GRAPHQL.md @@ -0,0 +1,357 @@ +# GraphQL Specification + +## Overview + +The GraphQL system adds first-class GraphQL request support to DevTools. It enables users to compose GraphQL queries/mutations, execute them against any GraphQL endpoint, introspect schemas for autocompletion and documentation, and view responses -- all following the same architecture patterns as the existing HTTP system. + +## Reference Implementation + +This design is informed by [Bruno](https://github.com/usebruno/bruno)'s GraphQL implementation, adapted to DevTools' TypeScript + Go stack (TypeSpec, Connect RPC, TanStack React DB, CodeMirror 6). + +### What Bruno Does + +- **Query Editor**: CodeMirror with `codemirror-graphql` for syntax highlighting, schema-aware autocompletion, real-time validation, and query formatting via Prettier +- **Variables Editor**: JSON editor for GraphQL variables with prettify support +- **Schema Introspection**: Fetches schema via standard introspection query (`getIntrospectionQuery()` from `graphql` lib), caches result, builds `GraphQLSchema` object via `buildClientSchema()` +- **Documentation Explorer**: Custom component that navigates the `GraphQLSchema` type map with breadcrumb navigation, search, and clickable type references +- **Request Execution**: HTTP POST with `Content-Type: application/json`, body `{ "query": "...", "variables": {...} }` +- **Tabbed UI**: Query (default), Variables, Headers, Auth, Docs tabs + +### What We Include + +- Query editor with schema-aware autocompletion and validation (via `cm6-graphql` for CodeMirror 6) +- Variables editor (JSON) +- Headers (key-value table for manual auth and custom headers) +- Schema introspection and caching in SQLite +- Documentation explorer +- Request execution and response display + +### What We Exclude (For Now) + +- **Scripts/hooks**: Pre/post-request scripts (not needed) +- **Variable extraction**: Already handled automatically by DevTools +- **Auth UI**: Users set auth manually via headers; dedicated auth UI added later +- **Delta system**: Not needed initially; can be added later + +--- + +## Core Concepts + +### 1. Request Definition + +A GraphQL request defines what to send to a GraphQL endpoint. + +- **URL**: The GraphQL endpoint (e.g., `https://api.example.com/graphql`) +- **Query**: The GraphQL query/mutation string +- **Variables**: JSON string of variables to pass with the query +- **Headers**: Key-value pairs with enable/disable toggle (used for auth tokens, custom headers) + +Unlike HTTP requests, GraphQL is always: +- Method: **POST** +- Content-Type: **application/json** +- Body: `{ "query": "...", "variables": {...} }` + +### 2. Schema Introspection + +GraphQL's self-documenting nature is a key feature: + +1. User clicks "Fetch Schema" in the UI +2. Backend sends the standard introspection query to the endpoint (with user's headers for auth) +3. Backend returns the raw introspection JSON +4. Frontend builds a `GraphQLSchema` object via `buildClientSchema()` from the `graphql` JS library +5. Schema enables: autocompletion in the query editor, validation/linting, and the documentation explorer + +Schema introspection results are stored in SQLite (not localStorage like Bruno) for persistence and consistency. + +### 3. Execution & Response + +When a GraphQL request is "Run": + +1. **Interpolation**: Variables (`{{ varName }}`) are substituted into URL, query, variables, and header values +2. **Construction**: Build JSON body `{ "query": "...", "variables": {...} }` +3. **Transmission**: HTTP POST via the existing Go HTTP client (`httpclient` package) +4. **Response**: Status, headers, body (JSON), timing, and size are captured +5. **Persistence**: Response stored in `graphql_response` table, linked to the GraphQL request + +--- + +## Architecture + +### Design Decision: Separate Entity Type + +GraphQL is a **new entity type** rather than an extension of HTTP because: + +1. HTTP's `BodyKind` enum (`FormData`/`UrlEncoded`/`Raw`) doesn't conceptually fit GraphQL's `query + variables` model +2. GraphQL requires schema storage -- an entirely new concern that doesn't belong on HTTP +3. Execution is fundamentally simpler (always POST, always JSON, fixed body structure) +4. Follows the existing pattern where each protocol is its own entity + +GraphQL does **not** use the delta system initially to keep scope manageable. + +### File System Integration + +A new `GraphQL` value is added to the `FileKind` enum in `file-system.tsp`, allowing GraphQL requests to appear in the workspace sidebar tree alongside HTTP requests and flows. + +--- + +## Backend + +### API Layer (`packages/server/internal/api/rgraphql`) + +- **Role**: Entry point for Connect RPC +- **Responsibilities**: + - Validates incoming Protobuf messages + - Orchestrates transactions (Fetch-Check-Act pattern) + - Calls the Service Layer + - Publishes events to `eventstream` for real-time UI updates +- **Key RPC Operations**: + - `GraphQLRun`: Execute a GraphQL request + - `GraphQLIntrospect`: Fetch schema via introspection query + - `GraphQLDuplicate`: Clone a GraphQL request + - Standard CRUD for GraphQL entity and headers + - Streaming sync for TanStack DB real-time collections +- **Files**: `rgraphql.go` (service struct, streamers), `rgraphql_exec.go` (execution), `rgraphql_crud.go` (management), `rgraphql_sync.go` (streaming) + +### Service Layer (`packages/server/pkg/service/sgraphql`) + +- **Role**: Business logic and data access adapter +- **Pattern**: Reader (non-blocking, `*sql.DB`) + Writer (transactional, `*sql.Tx`) +- **Responsibilities**: + - Converts between Internal Models (`mgraphql`) and DB Models (`gen`) + - Executes `sqlc` queries + - Handles duplication logic (copying headers) + +### Domain Model (`packages/server/pkg/model/mgraphql`) + +Pure Go structs decoupled from DB and API: + +```go +type GraphQL struct { + ID idwrap.IDWrap + WorkspaceID idwrap.IDWrap + FolderID *idwrap.IDWrap + Name string + Url string + Query string // GraphQL query/mutation string + Variables string // JSON string of variables + Description string + LastRunAt *int64 + CreatedAt int64 + UpdatedAt int64 +} + +type GraphQLHeader struct { + ID idwrap.IDWrap + GraphQLID idwrap.IDWrap + Key string + Value string + Description string + Enabled bool + DisplayOrder float32 +} + +type GraphQLResponse struct { + ID idwrap.IDWrap + GraphQLID idwrap.IDWrap + Status int32 + Body []byte + Time int64 + Duration int32 + Size int32 +} + +type GraphQLResponseHeader struct { + ID idwrap.IDWrap + ResponseID idwrap.IDWrap + Key string + Value string +} +``` + +### GraphQL Executor (`packages/server/pkg/graphql/executor.go`) + +Analogous to `packages/server/pkg/http/request/request.go` but simpler: + +```go +func PrepareGraphQLRequest(gql mgraphql.GraphQL, headers []mgraphql.GraphQLHeader, varMap map[string]any) (*http.Request, error) +func PrepareIntrospectionRequest(url string, headers []mgraphql.GraphQLHeader, varMap map[string]any) (*http.Request, error) +``` + +Both always produce HTTP POST with `Content-Type: application/json`. The introspection variant uses the well-known introspection query string. + +--- + +## Database Schema + +### Tables + +- **`graphql`**: Core request metadata (name, url, query, variables) +- **`graphql_header`**: Request headers (key, value, enabled, order) +- **`graphql_response`**: Execution results (status, body, duration, size) +- **`graphql_response_header`**: Response headers + +No delta fields. No assertions table (can be added later). + +Schema file: `packages/db/pkg/sqlc/schema/08_graphql.sql` + +--- + +## Frontend + +### CodeMirror 6 GraphQL Integration + +- **Package**: `cm6-graphql` (official CM6 GraphQL extension from GraphiQL monorepo) +- **Features**: Syntax highlighting, schema-aware autocompletion, linting/validation +- **Location**: `packages/client/src/features/graphql-editor/index.tsx` +- **Hook**: `useGraphQLEditorExtensions(schema?: GraphQLSchema)` returns CM6 extensions + +Also adds `'graphql'` to the prettier language support in `packages/client/src/features/expression/prettier.tsx`. + +### Page Components (`packages/client/src/pages/graphql/`) + +Following the pattern of `packages/client/src/pages/http/`: + +| Component | Description | +|-----------|-------------| +| `page.tsx` | Main page with resizable request/response split panels | +| `request/top-bar.tsx` | URL input, Send button, Fetch Schema button | +| `request/panel.tsx` | Tabbed panel: Query, Variables, Headers, Docs | +| `request/query-editor.tsx` | CodeMirror with `cm6-graphql` extensions | +| `request/variables-editor.tsx` | CodeMirror with JSON language | +| `request/header.tsx` | Headers key-value table | +| `request/doc-explorer.tsx` | Schema documentation browser | +| `response/body.tsx` | Response body viewer (JSON syntax highlighting) | + +### Documentation Explorer + +Custom component (not importing GraphiQL's, which has heavy context dependencies): + +- **Navigation**: Stack-based with breadcrumbs (root -> type -> field) +- **Root view**: Lists Query, Mutation, Subscription root types +- **Type view**: Fields with types, arguments, descriptions +- **Search**: Debounced filter across type/field names +- **Type links**: Clickable references that push onto navigation stack +- **Built with**: React Aria components, Tailwind CSS, `graphql` JS library's type introspection APIs + +### Routing + +Route: `/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan/` + +Added to `packages/client/src/shared/routes.tsx` and sidebar file tree handler. + +--- + +## TypeSpec Definition + +File: `packages/spec/api/graphql.tsp` + +```typespec +using DevTools; +namespace Api.GraphQL; + +@TanStackDB.collection +model GraphQL { + @primaryKey graphqlId: Id; + name: string; + url: string; + query: string; + variables: string; + lastRunAt?: Protobuf.WellKnown.Timestamp; +} + +@TanStackDB.collection +model GraphQLHeader { + @primaryKey graphqlHeaderId: Id; + @foreignKey graphqlId: Id; + key: string; + value: string; + enabled: boolean; + description: string; + order: float32; +} + +@TanStackDB.collection(#{ isReadOnly: true }) +model GraphQLResponse { + @primaryKey graphqlResponseId: Id; + @foreignKey graphqlId: Id; + status: int32; + body: string; + time: Protobuf.WellKnown.Timestamp; + duration: int32; + size: int32; +} + +@TanStackDB.collection(#{ isReadOnly: true }) +model GraphQLResponseHeader { + @primaryKey graphqlResponseHeaderId: Id; + @foreignKey graphqlResponseId: Id; + key: string; + value: string; +} + +model GraphQLRunRequest { graphqlId: Id; } +op GraphQLRun(...GraphQLRunRequest): {}; + +model GraphQLDuplicateRequest { graphqlId: Id; } +op GraphQLDuplicate(...GraphQLDuplicateRequest): {}; + +model GraphQLIntrospectRequest { graphqlId: Id; } +model GraphQLIntrospectResponse { sdl: string; introspectionJson: string; } +op GraphQLIntrospect(...GraphQLIntrospectRequest): GraphQLIntrospectResponse; +``` + +--- + +## Implementation Order + +1. TypeSpec + code generation (`graphql.tsp`, `FileKind.GraphQL`, run `spec:build`) +2. Database schema + sqlc (`08_graphql.sql`, queries, `sqlc.yaml`, run `db:generate`) +3. Go models (`mgraphql/`) +4. Go services (`sgraphql/` - reader, writer, mapper for each entity) +5. Go executor (`pkg/graphql/executor.go`) +6. Go RPC handlers (`rgraphql/` - CRUD, exec, introspect, sync) +7. Server wiring (`server.go` - streamers, services, cascade handlers) +8. Frontend packages (`cm6-graphql`, `graphql` npm deps) +9. Frontend components (pages, editor, doc explorer, routes) + +--- + +## Files Changed / Created + +### New Files + +``` +packages/spec/api/graphql.tsp +packages/db/pkg/sqlc/schema/08_graphql.sql +packages/db/pkg/sqlc/queries/graphql.sql +packages/server/pkg/model/mgraphql/mgraphql.go +packages/server/pkg/service/sgraphql/ (sgraphql.go, reader.go, writer.go, mapper.go, header*.go, response*.go) +packages/server/pkg/graphql/executor.go +packages/server/internal/api/rgraphql/ (rgraphql.go, _crud.go, _crud_header.go, _exec.go, _converter.go, _sync.go) +packages/client/src/features/graphql-editor/index.tsx +packages/client/src/pages/graphql/ (page.tsx, tab.tsx, request/*, response/*, routes/*) +``` + +### Modified Files + +``` +packages/spec/api/main.tsp (add graphql.tsp import) +packages/spec/api/file-system.tsp (add GraphQL to FileKind) +packages/db/pkg/sqlc/sqlc.yaml (add graphql column overrides) +packages/server/cmd/server/server.go (wire services, streamers, cascade) +packages/client/package.json (add cm6-graphql, graphql deps) +packages/client/src/shared/routes.tsx (add GraphQL routes) +packages/client/src/features/expression/prettier.tsx (add graphql language) +``` + +--- + +## Verification + +1. `direnv exec . pnpm nx run spec:build` succeeds +2. `direnv exec . pnpm nx run db:generate` succeeds +3. `direnv exec . pnpm nx run server:dev` starts without errors +4. `direnv exec . pnpm nx run client:dev` builds successfully +5. `direnv exec . task lint` passes +6. `direnv exec . task test` passes +7. E2E: Create GraphQL request -> enter endpoint -> write query -> fetch schema -> verify autocompletion -> send request -> verify response display -> browse docs diff --git a/packages/server/internal/api/rflowv2/logging_test.go b/packages/server/internal/api/rflowv2/logging_test.go index b3c9315a3..bd3908f7e 100644 --- a/packages/server/internal/api/rflowv2/logging_test.go +++ b/packages/server/internal/api/rflowv2/logging_test.go @@ -80,6 +80,9 @@ func TestFlowRun_Logging(t *testing.T) { nil, // NodeAIService nil, // NodeAiProviderService nil, // NodeMemoryService + nil, // NodeGraphQLService + nil, // GraphQLService + nil, // GraphQLHeaderService &wsService, &varService, &flowVarService, diff --git a/packages/server/internal/api/rflowv2/rflowv2.go b/packages/server/internal/api/rflowv2/rflowv2.go index cb8015384..26e2e0ece 100644 --- a/packages/server/internal/api/rflowv2/rflowv2.go +++ b/packages/server/internal/api/rflowv2/rflowv2.go @@ -24,6 +24,7 @@ import ( "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/senv" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sfile" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sflow" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sgraphql" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/shttp" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sworkspace" flowv1 "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/flow/v1" @@ -181,6 +182,12 @@ type nodeJsWithFlow struct { baseNode *mflow.Node } +type nodeGraphQLWithFlow struct { + nodeGraphQL mflow.NodeGraphQL + flowID idwrap.IDWrap + baseNode *mflow.Node +} + // Shared event type strings for all entity types. // Using mutation.Operation.String() values for consistency. const ( @@ -269,14 +276,18 @@ type FlowServiceV2Services struct { NodeAI *sflow.NodeAIService NodeAiProvider *sflow.NodeAiProviderService NodeMemory *sflow.NodeMemoryService + NodeGraphQL *sflow.NodeGraphQLService NodeExecution *sflow.NodeExecutionService FlowVariable *sflow.FlowVariableService Env *senv.EnvironmentService Var *senv.VariableService Http *shttp.HTTPService HttpBodyRaw *shttp.HttpBodyRawService - HttpResponse shttp.HttpResponseService - File *sfile.FileService + HttpResponse shttp.HttpResponseService + GraphQLResponse sgraphql.GraphQLResponseService + GraphQL *sgraphql.GraphQLService + GraphQLHeader *sgraphql.GraphQLHeaderService + File *sfile.FileService Importer WorkspaceImporter Credential scredential.CredentialService } @@ -318,6 +329,9 @@ func (s *FlowServiceV2Services) Validate() error { if s.NodeMemory == nil { return fmt.Errorf("node memory service is required") } + if s.NodeGraphQL == nil { + return fmt.Errorf("node graphql service is required") + } if s.NodeExecution == nil { return fmt.Errorf("node execution service is required") } @@ -352,6 +366,7 @@ type FlowServiceV2Streamers struct { Ai eventstream.SyncStreamer[AiTopic, AiEvent] AiProvider eventstream.SyncStreamer[AiProviderTopic, AiProviderEvent] Memory eventstream.SyncStreamer[MemoryTopic, MemoryEvent] + NodeGraphQL eventstream.SyncStreamer[NodeGraphQLTopic, NodeGraphQLEvent] Execution eventstream.SyncStreamer[ExecutionTopic, ExecutionEvent] HttpResponse eventstream.SyncStreamer[rhttp.HttpResponseTopic, rhttp.HttpResponseEvent] HttpResponseHeader eventstream.SyncStreamer[rhttp.HttpResponseHeaderTopic, rhttp.HttpResponseHeaderEvent] @@ -411,6 +426,7 @@ type FlowServiceV2RPC struct { nais *sflow.NodeAIService naps *sflow.NodeAiProviderService nmems *sflow.NodeMemoryService + ngqs *sflow.NodeGraphQLService nes *sflow.NodeExecutionService fvs *sflow.FlowVariableService envs *senv.EnvironmentService @@ -422,6 +438,7 @@ type FlowServiceV2RPC struct { // V2 import services workspaceImportService WorkspaceImporter httpResponseService shttp.HttpResponseService + graphqlResponseService sgraphql.GraphQLResponseService flowStream eventstream.SyncStreamer[FlowTopic, FlowEvent] nodeStream eventstream.SyncStreamer[NodeTopic, NodeEvent] edgeStream eventstream.SyncStreamer[EdgeTopic, EdgeEvent] @@ -434,6 +451,7 @@ type FlowServiceV2RPC struct { aiStream eventstream.SyncStreamer[AiTopic, AiEvent] aiProviderStream eventstream.SyncStreamer[AiProviderTopic, AiProviderEvent] memoryStream eventstream.SyncStreamer[MemoryTopic, MemoryEvent] + nodeGraphQLStream eventstream.SyncStreamer[NodeGraphQLTopic, NodeGraphQLEvent] executionStream eventstream.SyncStreamer[ExecutionTopic, ExecutionEvent] httpResponseStream eventstream.SyncStreamer[rhttp.HttpResponseTopic, rhttp.HttpResponseEvent] httpResponseHeaderStream eventstream.SyncStreamer[rhttp.HttpResponseHeaderTopic, rhttp.HttpResponseHeaderEvent] @@ -464,7 +482,8 @@ func New(deps FlowServiceV2Deps) *FlowServiceV2RPC { builder := flowbuilder.New( deps.Services.Node, deps.Services.NodeRequest, deps.Services.NodeFor, deps.Services.NodeForEach, deps.Services.NodeIf, deps.Services.NodeJs, deps.Services.NodeAI, - deps.Services.NodeAiProvider, deps.Services.NodeMemory, + deps.Services.NodeAiProvider, deps.Services.NodeMemory, deps.Services.NodeGraphQL, + deps.Services.GraphQL, deps.Services.GraphQLHeader, deps.Services.Workspace, deps.Services.Var, deps.Services.FlowVariable, deps.Resolver, deps.Logger, llmFactory, ) @@ -489,6 +508,7 @@ func New(deps FlowServiceV2Deps) *FlowServiceV2RPC { nais: deps.Services.NodeAI, naps: deps.Services.NodeAiProvider, nmems: deps.Services.NodeMemory, + ngqs: deps.Services.NodeGraphQL, nes: deps.Services.NodeExecution, fvs: deps.Services.FlowVariable, envs: deps.Services.Env, @@ -499,6 +519,7 @@ func New(deps FlowServiceV2Deps) *FlowServiceV2RPC { logger: deps.Logger, workspaceImportService: deps.Services.Importer, httpResponseService: deps.Services.HttpResponse, + graphqlResponseService: deps.Services.GraphQLResponse, flowStream: deps.Streamers.Flow, nodeStream: deps.Streamers.Node, edgeStream: deps.Streamers.Edge, @@ -511,6 +532,7 @@ func New(deps FlowServiceV2Deps) *FlowServiceV2RPC { aiStream: deps.Streamers.Ai, aiProviderStream: deps.Streamers.AiProvider, memoryStream: deps.Streamers.Memory, + nodeGraphQLStream: deps.Streamers.NodeGraphQL, executionStream: deps.Streamers.Execution, httpResponseStream: deps.Streamers.HttpResponse, httpResponseHeaderStream: deps.Streamers.HttpResponseHeader, @@ -546,7 +568,8 @@ func (s *FlowServiceV2RPC) mutationPublisher() mutation.Publisher { jsStream: s.jsStream, aiStream: s.aiStream, aiProviderStream: s.aiProviderStream, - memoryStream: s.memoryStream, + memoryStream: s.memoryStream, + nodeGraphQLStream: s.nodeGraphQLStream, } } @@ -561,8 +584,9 @@ type rflowPublisher struct { forEachStream eventstream.SyncStreamer[ForEachTopic, ForEachEvent] jsStream eventstream.SyncStreamer[JsTopic, JsEvent] aiStream eventstream.SyncStreamer[AiTopic, AiEvent] - aiProviderStream eventstream.SyncStreamer[AiProviderTopic, AiProviderEvent] - memoryStream eventstream.SyncStreamer[MemoryTopic, MemoryEvent] + aiProviderStream eventstream.SyncStreamer[AiProviderTopic, AiProviderEvent] + memoryStream eventstream.SyncStreamer[MemoryTopic, MemoryEvent] + nodeGraphQLStream eventstream.SyncStreamer[NodeGraphQLTopic, NodeGraphQLEvent] } func (p *rflowPublisher) PublishAll(events []mutation.Event) { @@ -589,6 +613,8 @@ func (p *rflowPublisher) PublishAll(events []mutation.Event) { p.publishNodeAiProvider(evt) case mutation.EntityFlowNodeMemory: p.publishNodeMemory(evt) + case mutation.EntityFlowNodeGraphQL: + p.publishNodeGraphQL(evt) case mutation.EntityFlowEdge: p.publishEdge(evt) case mutation.EntityFlowVariable: @@ -1021,3 +1047,34 @@ func (p *rflowPublisher) publishNodeMemory(evt mutation.Event) { }) } } + +func (p *rflowPublisher) publishNodeGraphQL(evt mutation.Event) { + if p.nodeStream == nil { + return + } + + var node *flowv1.Node + var flowID idwrap.IDWrap + + switch evt.Op { + case mutation.OpInsert, mutation.OpUpdate: + if data, ok := evt.Payload.(nodeGraphQLWithFlow); ok && data.baseNode != nil { + node = serializeNode(*data.baseNode) + flowID = data.flowID + } + case mutation.OpDelete: + node = &flowv1.Node{ + NodeId: evt.ID.Bytes(), + FlowId: evt.ParentID.Bytes(), + } + flowID = evt.ParentID + } + + if node != nil { + p.nodeStream.Publish(NodeTopic{FlowID: flowID}, NodeEvent{ + Type: nodeEventUpdate, + FlowID: flowID, + Node: node, + }) + } +} diff --git a/packages/server/internal/api/rflowv2/rflowv2_common.go b/packages/server/internal/api/rflowv2/rflowv2_common.go index 4cbda58ab..73c848dfd 100644 --- a/packages/server/internal/api/rflowv2/rflowv2_common.go +++ b/packages/server/internal/api/rflowv2/rflowv2_common.go @@ -124,6 +124,16 @@ func serializeNodeAI(n mflow.NodeAI) *flowv1.NodeAi { } } +func serializeNodeGraphQL(n mflow.NodeGraphQL) *flowv1.NodeGraphQL { + msg := &flowv1.NodeGraphQL{ + NodeId: n.FlowNodeID.Bytes(), + } + if n.GraphQLID != nil && !isZeroID(*n.GraphQLID) { + msg.GraphqlId = n.GraphQLID.Bytes() + } + return msg +} + func serializeNodeExecution(execution mflow.NodeExecution) *flowv1.NodeExecution { result := &flowv1.NodeExecution{ NodeExecutionId: execution.ID.Bytes(), @@ -182,6 +192,11 @@ func serializeNodeExecution(execution mflow.NodeExecution) *flowv1.NodeExecution result.HttpResponseId = execution.ResponseID.Bytes() } + // Handle GraphQL response ID + if execution.GraphQLResponseID != nil { + result.GraphqlResponseId = execution.GraphQLResponseID.Bytes() + } + // Handle completion timestamp if execution.CompletedAt != nil { result.CompletedAt = timestamppb.New(time.Unix(*execution.CompletedAt, 0)) diff --git a/packages/server/internal/api/rflowv2/rflowv2_exec.go b/packages/server/internal/api/rflowv2/rflowv2_exec.go index ece8e26f2..ad65562ee 100644 --- a/packages/server/internal/api/rflowv2/rflowv2_exec.go +++ b/packages/server/internal/api/rflowv2/rflowv2_exec.go @@ -16,6 +16,7 @@ import ( devtoolsdb "github.com/the-dev-tools/dev-tools/packages/db" "github.com/the-dev-tools/dev-tools/packages/server/internal/api/rlog" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/node/ngraphql" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/node/nrequest" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/runner" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/runner/flowlocalrunner" @@ -208,6 +209,45 @@ func (s *FlowServiceV2RPC) executeFlow( respDrain.Wait() }() + gqlRespChan := make(chan ngraphql.NodeGraphQLSideResp, len(nodes)*2+1) + gqlResponsePublished := make(map[string]chan struct{}) + var gqlResponsePublishedMu sync.Mutex + var gqlRespDrain sync.WaitGroup + gqlRespDrain.Add(1) + go func() { + defer gqlRespDrain.Done() + for resp := range gqlRespChan { + responseID := resp.Response.ID.String() + + gqlResponsePublishedMu.Lock() + publishedChan := make(chan struct{}) + gqlResponsePublished[responseID] = publishedChan + gqlResponsePublishedMu.Unlock() + + // Save GraphQL Response + if err := s.graphqlResponseService.Create(ctx, resp.Response); err != nil { + s.logger.Error("failed to save graphql response", "error", err) + } + + // Save Response Headers + for _, h := range resp.RespHeaders { + if err := s.graphqlResponseService.CreateHeader(ctx, h); err != nil { + s.logger.Error("failed to save graphql response header", "error", err) + } + } + + close(publishedChan) + + if resp.Done != nil { + close(resp.Done) + } + } + }() + defer func() { + close(gqlRespChan) + gqlRespDrain.Wait() + }() + sharedHTTPClient := httpclient.New() edgeMap := mflow.NewEdgesMap(edges) // Build edgesBySource map for O(1) edge lookup by source node ID @@ -226,6 +266,7 @@ func (s *FlowServiceV2RPC) executeFlow( timeoutDuration, sharedHTTPClient, requestRespChan, + gqlRespChan, s.jsClient, ) if err != nil { @@ -357,11 +398,20 @@ func (s *FlowServiceV2RPC) executeFlow( } model := mflow.NodeExecution{ - ID: execID, - NodeID: status.NodeID, - Name: executionName, - State: status.State, - ResponseID: status.AuxiliaryID, + ID: execID, + NodeID: status.NodeID, + Name: executionName, + State: status.State, + } + + // Set the appropriate response ID based on node kind + nodeKindForAux := nodeKindMap[status.NodeID] + if status.AuxiliaryID != nil { + if nodeKindForAux == mflow.NODE_KIND_GRAPHQL { + model.GraphQLResponseID = status.AuxiliaryID + } else { + model.ResponseID = status.AuxiliaryID + } } if status.Error != nil { @@ -403,24 +453,37 @@ func (s *FlowServiceV2RPC) executeFlow( } // If this execution has a ResponseID, wait for the response to be published first - // This ensures frontend receives HttpResponse before NodeExecution + // This ensures frontend receives HttpResponse/GraphQLResponse before NodeExecution if status.AuxiliaryID != nil { respIDStr := status.AuxiliaryID.String() + + // Check HTTP response published map responsePublishedMu.Lock() publishedChan, ok := responsePublished[respIDStr] responsePublishedMu.Unlock() if ok { select { case <-publishedChan: - // Response published, safe to continue case <-ctx.Done(): - // Context cancelled, continue anyway } - // Clean up map entry to prevent memory leak responsePublishedMu.Lock() delete(responsePublished, respIDStr) responsePublishedMu.Unlock() } + + // Check GraphQL response published map + gqlResponsePublishedMu.Lock() + gqlPublishedChan, gqlOK := gqlResponsePublished[respIDStr] + gqlResponsePublishedMu.Unlock() + if gqlOK { + select { + case <-gqlPublishedChan: + case <-ctx.Done(): + } + gqlResponsePublishedMu.Lock() + delete(gqlResponsePublished, respIDStr) + gqlResponsePublishedMu.Unlock() + } } // Publish execution event @@ -650,6 +713,7 @@ func (s *FlowServiceV2RPC) createFlowVersionSnapshot( aiData *mflow.NodeAI aiProviderData *mflow.NodeAiProvider memoryData *mflow.NodeMemory + graphqlData *mflow.NodeGraphQL } nodeConfigs := make([]nodeConfig, 0, len(sourceNodes)) @@ -719,6 +783,14 @@ func (s *FlowServiceV2RPC) createFlowVersionSnapshot( } else if memoryData != nil { config.memoryData = memoryData } + + case mflow.NODE_KIND_GRAPHQL: + graphqlData, err := s.ngqs.GetNodeGraphQL(ctx, sourceNode.ID) + if err != nil { + s.logger.Warn("failed to get graphql node config, using defaults", "node_id", sourceNode.ID.String(), "error", err) + } else if graphqlData != nil { + config.graphqlData = graphqlData + } } nodeConfigs = append(nodeConfigs, config) @@ -754,6 +826,11 @@ func (s *FlowServiceV2RPC) createFlowVersionSnapshot( txService := s.nmems.TX(tx) nmemsWriter = &txService } + var ngqsWriter *sflow.NodeGraphQLService + if s.ngqs != nil { + txService := s.ngqs.TX(tx) + ngqsWriter = &txService + } edgeWriter := s.es.TX(tx) varWriter := s.fvs.TX(tx) @@ -958,6 +1035,19 @@ func (s *FlowServiceV2RPC) createFlowVersionSnapshot( } // Memory node events are handled through nodeStream subscription } + + case mflow.NODE_KIND_GRAPHQL: + if ngqsWriter == nil { + s.logger.Warn("NodeGraphQL service not available, skipping GraphQL node config", "node_id", sourceNode.ID.String()) + } else if config.graphqlData != nil { + newGraphQLData := mflow.NodeGraphQL{ + FlowNodeID: newNodeID, + GraphQLID: config.graphqlData.GraphQLID, + } + if err := ngqsWriter.CreateNodeGraphQL(ctx, newGraphQLData); err != nil { + return mflow.Flow{}, nil, fmt.Errorf("create graphql node: %w", err) + } + } } // Collect base node event diff --git a/packages/server/internal/api/rflowv2/rflowv2_exec_test.go b/packages/server/internal/api/rflowv2/rflowv2_exec_test.go index 63a9630e5..bd0e4fd12 100644 --- a/packages/server/internal/api/rflowv2/rflowv2_exec_test.go +++ b/packages/server/internal/api/rflowv2/rflowv2_exec_test.go @@ -75,6 +75,9 @@ func setupTestService(t *testing.T) (*FlowServiceV2RPC, *gen.Queries, context.Co nil, // NodeAIService &aiProviderService, &memoryService, + nil, // NodeGraphQLService + nil, // GraphQLService + nil, // GraphQLHeaderService &wsService, &varService, &flowVarService, diff --git a/packages/server/internal/api/rflowv2/rflowv2_node_condition_test.go b/packages/server/internal/api/rflowv2/rflowv2_node_condition_test.go index b3ea048c6..eeaab136e 100644 --- a/packages/server/internal/api/rflowv2/rflowv2_node_condition_test.go +++ b/packages/server/internal/api/rflowv2/rflowv2_node_condition_test.go @@ -71,6 +71,9 @@ func TestNodeCondition_CRUD(t *testing.T) { nil, // NodeAIService nil, // NodeAiProviderService nil, // NodeMemoryService + nil, // NodeGraphQLService + nil, // GraphQLService + nil, // GraphQLHeaderService &wsService, &varService, &flowVarService, diff --git a/packages/server/internal/api/rflowv2/rflowv2_node_exec_test.go b/packages/server/internal/api/rflowv2/rflowv2_node_exec_test.go index b1b64869c..029f41d5e 100644 --- a/packages/server/internal/api/rflowv2/rflowv2_node_exec_test.go +++ b/packages/server/internal/api/rflowv2/rflowv2_node_exec_test.go @@ -71,6 +71,9 @@ func TestNodeExecution_Collection(t *testing.T) { nil, // NodeAIService nil, // NodeAiProviderService nil, // NodeMemoryService + nil, // NodeGraphQLService + nil, // GraphQLService + nil, // GraphQLHeaderService &wsService, &varService, &flowVarService, @@ -218,6 +221,9 @@ func TestNodeExecution_Collection_VersionFlow(t *testing.T) { nil, // NodeAIService nil, // NodeAiProviderService nil, // NodeMemoryService + nil, // NodeGraphQLService + nil, // GraphQLService + nil, // GraphQLHeaderService &wsService, &varService, &flowVarService, diff --git a/packages/server/internal/api/rflowv2/rflowv2_node_graphql.go b/packages/server/internal/api/rflowv2/rflowv2_node_graphql.go new file mode 100644 index 000000000..835a47e48 --- /dev/null +++ b/packages/server/internal/api/rflowv2/rflowv2_node_graphql.go @@ -0,0 +1,446 @@ +//nolint:revive // exported +package rflowv2 + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sync" + + "connectrpc.com/connect" + emptypb "google.golang.org/protobuf/types/known/emptypb" + + "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mflow" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/mutation" + flowv1 "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/flow/v1" +) + +// NodeGraphQLTopic identifies the flow whose GraphQL nodes are being published. +type NodeGraphQLTopic struct { + FlowID idwrap.IDWrap +} + +// NodeGraphQLEvent describes a GraphQL node change for sync streaming. +type NodeGraphQLEvent struct { + Type string + FlowID idwrap.IDWrap + Node *flowv1.NodeGraphQL +} + +func (s *FlowServiceV2RPC) NodeGraphQLCollection( + ctx context.Context, + _ *connect.Request[emptypb.Empty], +) (*connect.Response[flowv1.NodeGraphQLCollectionResponse], error) { + flows, err := s.listAccessibleFlows(ctx) + if err != nil { + return nil, err + } + + var items []*flowv1.NodeGraphQL + for _, flow := range flows { + nodes, err := s.nsReader.GetNodesByFlowID(ctx, flow.ID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, connect.NewError(connect.CodeInternal, err) + } + for _, node := range nodes { + if node.NodeKind != mflow.NODE_KIND_GRAPHQL { + continue + } + nodeGQL, err := s.ngqs.GetNodeGraphQL(ctx, node.ID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + continue + } + return nil, connect.NewError(connect.CodeInternal, err) + } + items = append(items, serializeNodeGraphQL(*nodeGQL)) + } + } + + return connect.NewResponse(&flowv1.NodeGraphQLCollectionResponse{Items: items}), nil +} + +func (s *FlowServiceV2RPC) NodeGraphQLInsert( + ctx context.Context, + req *connect.Request[flowv1.NodeGraphQLInsertRequest], +) (*connect.Response[emptypb.Empty], error) { + type insertData struct { + nodeID idwrap.IDWrap + graphqlID *idwrap.IDWrap + baseNode *mflow.Node + flowID idwrap.IDWrap + workspaceID idwrap.IDWrap + } + var validatedItems []insertData + + for _, item := range req.Msg.GetItems() { + nodeID, err := idwrap.NewFromBytes(item.GetNodeId()) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid node id: %w", err)) + } + + var graphqlID *idwrap.IDWrap + if len(item.GetGraphqlId()) > 0 { + parsedID, err := idwrap.NewFromBytes(item.GetGraphqlId()) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid graphql id: %w", err)) + } + if !isZeroID(parsedID) { + graphqlID = &parsedID + } + } + + // CRITICAL FIX: Get base node BEFORE transaction to avoid SQLite deadlock + // Allow nil baseNode to support out-of-order message arrival + baseNode, _ := s.ns.GetNode(ctx, nodeID) + + var flowID idwrap.IDWrap + var workspaceID idwrap.IDWrap + if baseNode != nil { + flowID = baseNode.FlowID + flow, err := s.fsReader.GetFlow(ctx, flowID) + if err == nil { + workspaceID = flow.WorkspaceID + } + } + + validatedItems = append(validatedItems, insertData{ + nodeID: nodeID, + graphqlID: graphqlID, + baseNode: baseNode, + flowID: flowID, + workspaceID: workspaceID, + }) + } + + if len(validatedItems) == 0 { + return connect.NewResponse(&emptypb.Empty{}), nil + } + + // Begin transaction with mutation context + mut := mutation.New(s.DB, mutation.WithPublisher(s.mutationPublisher())) + if err := mut.Begin(ctx); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + defer mut.Rollback() + + ngqsWriter := s.ngqs.TX(mut.TX()) + + for _, data := range validatedItems { + nodeGraphQL := mflow.NodeGraphQL{ + FlowNodeID: data.nodeID, + GraphQLID: data.graphqlID, + } + + if err := ngqsWriter.CreateNodeGraphQL(ctx, nodeGraphQL); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + // Only track for event publishing if base node exists + if data.baseNode != nil { + mut.Track(mutation.Event{ + Entity: mutation.EntityFlowNodeGraphQL, + Op: mutation.OpInsert, + ID: data.nodeID, + WorkspaceID: data.workspaceID, + ParentID: data.flowID, + Payload: nodeGraphQLWithFlow{ + nodeGraphQL: nodeGraphQL, + flowID: data.flowID, + baseNode: data.baseNode, + }, + }) + } + } + + // Commit transaction (auto-publishes events) + if err := mut.Commit(ctx); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + return connect.NewResponse(&emptypb.Empty{}), nil +} + +func (s *FlowServiceV2RPC) NodeGraphQLUpdate( + ctx context.Context, + req *connect.Request[flowv1.NodeGraphQLUpdateRequest], +) (*connect.Response[emptypb.Empty], error) { + type updateData struct { + nodeID idwrap.IDWrap + graphqlID *idwrap.IDWrap + baseNode *mflow.Node + workspaceID idwrap.IDWrap + } + var validatedItems []updateData + + for _, item := range req.Msg.GetItems() { + nodeID, err := idwrap.NewFromBytes(item.GetNodeId()) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid node id: %w", err)) + } + + nodeModel, err := s.ensureNodeAccess(ctx, nodeID) + if err != nil { + return nil, err + } + + // Get workspace ID for the flow + flow, err := s.fsReader.GetFlow(ctx, nodeModel.FlowID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + var graphqlID *idwrap.IDWrap + if graphqlBytes := item.GetGraphqlId(); len(graphqlBytes) > 0 { + parsedID, err := idwrap.NewFromBytes(graphqlBytes) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid graphql id: %w", err)) + } + if !isZeroID(parsedID) { + graphqlID = &parsedID + } + } + + validatedItems = append(validatedItems, updateData{ + nodeID: nodeID, + graphqlID: graphqlID, + baseNode: nodeModel, + workspaceID: flow.WorkspaceID, + }) + } + + if len(validatedItems) == 0 { + return connect.NewResponse(&emptypb.Empty{}), nil + } + + // Begin transaction with mutation context + mut := mutation.New(s.DB, mutation.WithPublisher(s.mutationPublisher())) + if err := mut.Begin(ctx); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + defer mut.Rollback() + + ngqsWriter := s.ngqs.TX(mut.TX()) + + for _, data := range validatedItems { + nodeGraphQL := mflow.NodeGraphQL{ + FlowNodeID: data.nodeID, + GraphQLID: data.graphqlID, + } + + if err := ngqsWriter.UpdateNodeGraphQL(ctx, nodeGraphQL); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + mut.Track(mutation.Event{ + Entity: mutation.EntityFlowNodeGraphQL, + Op: mutation.OpUpdate, + ID: data.nodeID, + WorkspaceID: data.workspaceID, + ParentID: data.baseNode.FlowID, + Payload: nodeGraphQLWithFlow{ + nodeGraphQL: nodeGraphQL, + flowID: data.baseNode.FlowID, + baseNode: data.baseNode, + }, + }) + } + + // Commit transaction (auto-publishes events) + if err := mut.Commit(ctx); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + return connect.NewResponse(&emptypb.Empty{}), nil +} + +func (s *FlowServiceV2RPC) NodeGraphQLDelete( + ctx context.Context, + req *connect.Request[flowv1.NodeGraphQLDeleteRequest], +) (*connect.Response[emptypb.Empty], error) { + type deleteData struct { + nodeID idwrap.IDWrap + flowID idwrap.IDWrap + } + var validatedItems []deleteData + + for _, item := range req.Msg.GetItems() { + nodeID, err := idwrap.NewFromBytes(item.GetNodeId()) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid node id: %w", err)) + } + + nodeModel, err := s.ensureNodeAccess(ctx, nodeID) + if err != nil { + return nil, err + } + + validatedItems = append(validatedItems, deleteData{ + nodeID: nodeID, + flowID: nodeModel.FlowID, + }) + } + + if len(validatedItems) == 0 { + return connect.NewResponse(&emptypb.Empty{}), nil + } + + // Begin transaction with mutation context + mut := mutation.New(s.DB, mutation.WithPublisher(s.mutationPublisher())) + if err := mut.Begin(ctx); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + defer mut.Rollback() + + for _, data := range validatedItems { + mut.Track(mutation.Event{ + Entity: mutation.EntityFlowNodeGraphQL, + Op: mutation.OpDelete, + ID: data.nodeID, + ParentID: data.flowID, + }) + if err := mut.Queries().DeleteFlowNodeGraphQL(ctx, data.nodeID); err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, connect.NewError(connect.CodeInternal, err) + } + } + + // Commit transaction (auto-publishes events) + if err := mut.Commit(ctx); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + return connect.NewResponse(&emptypb.Empty{}), nil +} + +func (s *FlowServiceV2RPC) NodeGraphQLSync( + ctx context.Context, + _ *connect.Request[emptypb.Empty], + stream *connect.ServerStream[flowv1.NodeGraphQLSyncResponse], +) error { + if stream == nil { + return connect.NewError(connect.CodeInternal, errors.New("stream is required")) + } + return s.streamNodeGraphQLSync(ctx, func(resp *flowv1.NodeGraphQLSyncResponse) error { + return stream.Send(resp) + }) +} + +func (s *FlowServiceV2RPC) streamNodeGraphQLSync( + ctx context.Context, + send func(*flowv1.NodeGraphQLSyncResponse) error, +) error { + if s.nodeStream == nil { + return connect.NewError(connect.CodeUnavailable, errors.New("node stream not configured")) + } + + var flowSet sync.Map + + filter := func(topic NodeTopic) bool { + if _, ok := flowSet.Load(topic.FlowID.String()); ok { + return true + } + if err := s.ensureFlowAccess(ctx, topic.FlowID); err != nil { + return false + } + flowSet.Store(topic.FlowID.String(), struct{}{}) + return true + } + + events, err := s.nodeStream.Subscribe(ctx, filter) + if err != nil { + return connect.NewError(connect.CodeInternal, err) + } + + for { + select { + case evt, ok := <-events: + if !ok { + return nil + } + resp, err := s.nodeGraphQLEventToSyncResponse(ctx, evt.Payload) + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("failed to convert GraphQL node event: %w", err)) + } + if resp == nil { + continue + } + if err := send(resp); err != nil { + return err + } + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (s *FlowServiceV2RPC) nodeGraphQLEventToSyncResponse( + ctx context.Context, + evt NodeEvent, +) (*flowv1.NodeGraphQLSyncResponse, error) { + if evt.Node == nil { + return nil, nil + } + + // Only process GraphQL nodes + if evt.Node.GetKind() != flowv1.NodeKind_NODE_KIND_GRAPH_Q_L { + return nil, nil + } + + nodeID, err := idwrap.NewFromBytes(evt.Node.GetNodeId()) + if err != nil { + return nil, fmt.Errorf("invalid node id: %w", err) + } + + // Fetch the GraphQL configuration for this node (may not exist) + nodeGQL, err := s.ngqs.GetNodeGraphQL(ctx, nodeID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + + var syncEvent *flowv1.NodeGraphQLSync + switch evt.Type { + case nodeEventInsert: + insert := &flowv1.NodeGraphQLSyncInsert{ + NodeId: nodeID.Bytes(), + } + if nodeGQL != nil && nodeGQL.GraphQLID != nil && !isZeroID(*nodeGQL.GraphQLID) { + insert.GraphqlId = nodeGQL.GraphQLID.Bytes() + } + syncEvent = &flowv1.NodeGraphQLSync{ + Value: &flowv1.NodeGraphQLSync_ValueUnion{ + Kind: flowv1.NodeGraphQLSync_ValueUnion_KIND_INSERT, + Insert: insert, + }, + } + case nodeEventUpdate: + update := &flowv1.NodeGraphQLSyncUpdate{ + NodeId: nodeID.Bytes(), + } + if nodeGQL != nil && nodeGQL.GraphQLID != nil && !isZeroID(*nodeGQL.GraphQLID) { + update.GraphqlId = nodeGQL.GraphQLID.Bytes() + } + syncEvent = &flowv1.NodeGraphQLSync{ + Value: &flowv1.NodeGraphQLSync_ValueUnion{ + Kind: flowv1.NodeGraphQLSync_ValueUnion_KIND_UPDATE, + Update: update, + }, + } + case nodeEventDelete: + syncEvent = &flowv1.NodeGraphQLSync{ + Value: &flowv1.NodeGraphQLSync_ValueUnion{ + Kind: flowv1.NodeGraphQLSync_ValueUnion_KIND_DELETE, + Delete: &flowv1.NodeGraphQLSyncDelete{ + NodeId: nodeID.Bytes(), + }, + }, + } + default: + return nil, nil + } + + return &flowv1.NodeGraphQLSyncResponse{ + Items: []*flowv1.NodeGraphQLSync{syncEvent}, + }, nil +} diff --git a/packages/server/internal/api/rflowv2/rflowv2_testutil_test.go b/packages/server/internal/api/rflowv2/rflowv2_testutil_test.go index 0aaa7bb58..132b156ad 100644 --- a/packages/server/internal/api/rflowv2/rflowv2_testutil_test.go +++ b/packages/server/internal/api/rflowv2/rflowv2_testutil_test.go @@ -91,6 +91,9 @@ func NewRFlowTestContext(t *testing.T) *RFlowTestContext { nil, // NodeAIService - not needed for non-AI tests nil, // NodeAiProviderService - not needed for non-AI tests nil, // NodeMemoryService - not needed for non-AI tests + nil, // NodeGraphQLService - not needed for non-GraphQL tests + nil, // GraphQLService - not needed for non-GraphQL tests + nil, // GraphQLHeaderService - not needed for non-GraphQL tests &wsService, &varService, &flowVarService, diff --git a/packages/server/internal/api/rgraphql/rgraphql.go b/packages/server/internal/api/rgraphql/rgraphql.go new file mode 100644 index 000000000..5f1b89881 --- /dev/null +++ b/packages/server/internal/api/rgraphql/rgraphql.go @@ -0,0 +1,470 @@ +//nolint:revive // exported +package rgraphql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sync" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/the-dev-tools/dev-tools/packages/server/internal/api" + "github.com/the-dev-tools/dev-tools/packages/server/internal/api/middleware/mwauth" + "github.com/the-dev-tools/dev-tools/packages/server/internal/api/rfile" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/eventstream" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mworkspace" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/mutation" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/senv" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sfile" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sgraphql" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/suser" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sworkspace" + graphqlv1 "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/graph_q_l/v1" + "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/graph_q_l/v1/graph_q_lv1connect" +) + +const ( + eventTypeInsert = "insert" + eventTypeUpdate = "update" + eventTypeDelete = "delete" +) + +// Topic/Event types for each entity + +type GraphQLTopic struct { + WorkspaceID idwrap.IDWrap +} + +type GraphQLEvent struct { + Type string + GraphQL *graphqlv1.GraphQL +} + +type GraphQLHeaderTopic struct { + WorkspaceID idwrap.IDWrap +} + +type GraphQLHeaderEvent struct { + Type string + GraphQLHeader *graphqlv1.GraphQLHeader +} + +type GraphQLResponseTopic struct { + WorkspaceID idwrap.IDWrap +} + +type GraphQLResponseEvent struct { + Type string + GraphQLResponse *graphqlv1.GraphQLResponse +} + +type GraphQLResponseHeaderTopic struct { + WorkspaceID idwrap.IDWrap +} + +type GraphQLResponseHeaderEvent struct { + Type string + GraphQLResponseHeader *graphqlv1.GraphQLResponseHeader +} + +// GraphQLStreamers groups all event streams +type GraphQLStreamers struct { + GraphQL eventstream.SyncStreamer[GraphQLTopic, GraphQLEvent] + GraphQLHeader eventstream.SyncStreamer[GraphQLHeaderTopic, GraphQLHeaderEvent] + GraphQLResponse eventstream.SyncStreamer[GraphQLResponseTopic, GraphQLResponseEvent] + GraphQLResponseHeader eventstream.SyncStreamer[GraphQLResponseHeaderTopic, GraphQLResponseHeaderEvent] + File eventstream.SyncStreamer[rfile.FileTopic, rfile.FileEvent] +} + +// GraphQLServiceRPC handles GraphQL RPC operations +type GraphQLServiceRPC struct { + DB *sql.DB + + graphqlService sgraphql.GraphQLService + headerService sgraphql.GraphQLHeaderService + responseService sgraphql.GraphQLResponseService + + us suser.UserService + ws sworkspace.WorkspaceService + wus sworkspace.UserService + userReader *sworkspace.UserReader + wsReader *sworkspace.WorkspaceReader + + es senv.EnvService + vs senv.VariableService + + fileService *sfile.FileService + streamers *GraphQLStreamers +} + +type GraphQLServiceRPCDeps struct { + DB *sql.DB + Services GraphQLServiceRPCServices + Readers GraphQLServiceRPCReaders + Streamers *GraphQLStreamers +} + +type GraphQLServiceRPCServices struct { + GraphQL sgraphql.GraphQLService + Header sgraphql.GraphQLHeaderService + Response sgraphql.GraphQLResponseService + User suser.UserService + Workspace sworkspace.WorkspaceService + WorkspaceUser sworkspace.UserService + Env senv.EnvService + Variable senv.VariableService + File *sfile.FileService +} + +type GraphQLServiceRPCReaders struct { + User *sworkspace.UserReader + Workspace *sworkspace.WorkspaceReader +} + +func (d *GraphQLServiceRPCDeps) Validate() error { + if d.DB == nil { + return fmt.Errorf("db is required") + } + if d.Streamers == nil { + return fmt.Errorf("streamers is required") + } + return nil +} + +func New(deps GraphQLServiceRPCDeps) GraphQLServiceRPC { + if err := deps.Validate(); err != nil { + panic(fmt.Sprintf("GraphQLServiceRPC Deps validation failed: %v", err)) + } + + return GraphQLServiceRPC{ + DB: deps.DB, + graphqlService: deps.Services.GraphQL, + headerService: deps.Services.Header, + responseService: deps.Services.Response, + us: deps.Services.User, + ws: deps.Services.Workspace, + wus: deps.Services.WorkspaceUser, + userReader: deps.Readers.User, + wsReader: deps.Readers.Workspace, + es: deps.Services.Env, + vs: deps.Services.Variable, + fileService: deps.Services.File, + streamers: deps.Streamers, + } +} + +func CreateService(srv GraphQLServiceRPC, options []connect.HandlerOption) (*api.Service, error) { + path, handler := graph_q_lv1connect.NewGraphQLServiceHandler(&srv, options...) + return &api.Service{Path: path, Handler: handler}, nil +} + +// Access control helpers + +func (s *GraphQLServiceRPC) checkWorkspaceReadAccess(ctx context.Context, workspaceID idwrap.IDWrap) error { + userID, err := mwauth.GetContextUserID(ctx) + if err != nil { + return connect.NewError(connect.CodeUnauthenticated, err) + } + + wsUser, err := s.userReader.GetWorkspaceUsersByWorkspaceIDAndUserID(ctx, workspaceID, userID) + if err != nil { + if errors.Is(err, sworkspace.ErrWorkspaceUserNotFound) { + return connect.NewError(connect.CodeNotFound, errors.New("workspace not found or access denied")) + } + return connect.NewError(connect.CodeInternal, err) + } + + if wsUser.Role < mworkspace.RoleUser { + return connect.NewError(connect.CodePermissionDenied, errors.New("permission denied")) + } + return nil +} + +func (s *GraphQLServiceRPC) checkWorkspaceWriteAccess(ctx context.Context, workspaceID idwrap.IDWrap) error { + userID, err := mwauth.GetContextUserID(ctx) + if err != nil { + return connect.NewError(connect.CodeUnauthenticated, err) + } + + wsUser, err := s.userReader.GetWorkspaceUsersByWorkspaceIDAndUserID(ctx, workspaceID, userID) + if err != nil { + if errors.Is(err, sworkspace.ErrWorkspaceUserNotFound) { + return connect.NewError(connect.CodeNotFound, errors.New("workspace not found or access denied")) + } + return connect.NewError(connect.CodeInternal, err) + } + + if wsUser.Role < mworkspace.RoleAdmin { + return connect.NewError(connect.CodePermissionDenied, errors.New("permission denied")) + } + return nil +} + +func (s *GraphQLServiceRPC) checkWorkspaceDeleteAccess(ctx context.Context, workspaceID idwrap.IDWrap) error { + userID, err := mwauth.GetContextUserID(ctx) + if err != nil { + return connect.NewError(connect.CodeUnauthenticated, err) + } + + wsUser, err := s.userReader.GetWorkspaceUsersByWorkspaceIDAndUserID(ctx, workspaceID, userID) + if err != nil { + if errors.Is(err, sworkspace.ErrWorkspaceUserNotFound) { + return connect.NewError(connect.CodeNotFound, errors.New("workspace not found or access denied")) + } + return connect.NewError(connect.CodeInternal, err) + } + + if wsUser.Role != mworkspace.RoleOwner { + return connect.NewError(connect.CodePermissionDenied, errors.New("permission denied")) + } + return nil +} + +// Mutation publisher for auto-publish on commit + +func (s *GraphQLServiceRPC) mutationPublisher() mutation.Publisher { + return &rgraphqlPublisher{streamers: s.streamers} +} + +type rgraphqlPublisher struct { + streamers *GraphQLStreamers +} + +func (p *rgraphqlPublisher) PublishAll(events []mutation.Event) { + for _, evt := range events { + //nolint:exhaustive + switch evt.Entity { + case mutation.EntityGraphQL: + p.publishGraphQL(evt) + case mutation.EntityGraphQLHeader: + p.publishGraphQLHeader(evt) + } + } +} + +func (p *rgraphqlPublisher) publishGraphQL(evt mutation.Event) { + if p.streamers.GraphQL == nil { + return + } + var model *graphqlv1.GraphQL + var eventType string + + switch evt.Op { + case mutation.OpInsert, mutation.OpUpdate: + if evt.Op == mutation.OpInsert { + eventType = eventTypeInsert + } else { + eventType = eventTypeUpdate + } + if g, ok := evt.Payload.(mgraphql.GraphQL); ok { + model = ToAPIGraphQL(g) + } else if gp, ok := evt.Payload.(*mgraphql.GraphQL); ok { + model = ToAPIGraphQL(*gp) + } + case mutation.OpDelete: + eventType = eventTypeDelete + model = &graphqlv1.GraphQL{GraphqlId: evt.ID.Bytes()} + } + + if model != nil { + p.streamers.GraphQL.Publish(GraphQLTopic{WorkspaceID: evt.WorkspaceID}, GraphQLEvent{ + Type: eventType, + GraphQL: model, + }) + } +} + +func (p *rgraphqlPublisher) publishGraphQLHeader(evt mutation.Event) { + if p.streamers.GraphQLHeader == nil { + return + } + var model *graphqlv1.GraphQLHeader + var eventType string + + switch evt.Op { + case mutation.OpInsert, mutation.OpUpdate: + if evt.Op == mutation.OpInsert { + eventType = eventTypeInsert + } else { + eventType = eventTypeUpdate + } + if h, ok := evt.Payload.(mgraphql.GraphQLHeader); ok { + model = ToAPIGraphQLHeader(h) + } else if hp, ok := evt.Payload.(*mgraphql.GraphQLHeader); ok { + model = ToAPIGraphQLHeader(*hp) + } + case mutation.OpDelete: + eventType = eventTypeDelete + model = &graphqlv1.GraphQLHeader{GraphqlHeaderId: evt.ID.Bytes(), GraphqlId: evt.ParentID.Bytes()} + } + + if model != nil { + p.streamers.GraphQLHeader.Publish(GraphQLHeaderTopic{WorkspaceID: evt.WorkspaceID}, GraphQLHeaderEvent{ + Type: eventType, + GraphQLHeader: model, + }) + } +} + +// Sync stream handlers + +func (s *GraphQLServiceRPC) GraphQLSync(ctx context.Context, req *connect.Request[emptypb.Empty], stream *connect.ServerStream[graphqlv1.GraphQLSyncResponse]) error { + userID, err := mwauth.GetContextUserID(ctx) + if err != nil { + return connect.NewError(connect.CodeUnauthenticated, err) + } + return s.streamGraphQLSync(ctx, userID, stream.Send) +} + +func (s *GraphQLServiceRPC) streamGraphQLSync(ctx context.Context, userID idwrap.IDWrap, send func(*graphqlv1.GraphQLSyncResponse) error) error { + var workspaceSet sync.Map + + filter := func(topic GraphQLTopic) bool { + if _, ok := workspaceSet.Load(topic.WorkspaceID.String()); ok { + return true + } + belongs, err := s.us.CheckUserBelongsToWorkspace(ctx, userID, topic.WorkspaceID) + if err != nil || !belongs { + return false + } + workspaceSet.Store(topic.WorkspaceID.String(), struct{}{}) + return true + } + + converter := func(events []GraphQLEvent) *graphqlv1.GraphQLSyncResponse { + var items []*graphqlv1.GraphQLSync + for _, event := range events { + if resp := graphqlSyncResponseFrom(event); resp != nil && len(resp.Items) > 0 { + items = append(items, resp.Items...) + } + } + if len(items) == 0 { + return nil + } + return &graphqlv1.GraphQLSyncResponse{Items: items} + } + + return eventstream.StreamToClient(ctx, s.streamers.GraphQL, filter, converter, send, nil) +} + +func (s *GraphQLServiceRPC) GraphQLHeaderSync(ctx context.Context, req *connect.Request[emptypb.Empty], stream *connect.ServerStream[graphqlv1.GraphQLHeaderSyncResponse]) error { + userID, err := mwauth.GetContextUserID(ctx) + if err != nil { + return connect.NewError(connect.CodeUnauthenticated, err) + } + return s.streamGraphQLHeaderSync(ctx, userID, stream.Send) +} + +func (s *GraphQLServiceRPC) streamGraphQLHeaderSync(ctx context.Context, userID idwrap.IDWrap, send func(*graphqlv1.GraphQLHeaderSyncResponse) error) error { + var workspaceSet sync.Map + + filter := func(topic GraphQLHeaderTopic) bool { + if _, ok := workspaceSet.Load(topic.WorkspaceID.String()); ok { + return true + } + belongs, err := s.us.CheckUserBelongsToWorkspace(ctx, userID, topic.WorkspaceID) + if err != nil || !belongs { + return false + } + workspaceSet.Store(topic.WorkspaceID.String(), struct{}{}) + return true + } + + converter := func(events []GraphQLHeaderEvent) *graphqlv1.GraphQLHeaderSyncResponse { + var items []*graphqlv1.GraphQLHeaderSync + for _, event := range events { + if resp := graphqlHeaderSyncResponseFrom(event); resp != nil && len(resp.Items) > 0 { + items = append(items, resp.Items...) + } + } + if len(items) == 0 { + return nil + } + return &graphqlv1.GraphQLHeaderSyncResponse{Items: items} + } + + return eventstream.StreamToClient(ctx, s.streamers.GraphQLHeader, filter, converter, send, nil) +} + +func (s *GraphQLServiceRPC) GraphQLResponseSync(ctx context.Context, req *connect.Request[emptypb.Empty], stream *connect.ServerStream[graphqlv1.GraphQLResponseSyncResponse]) error { + userID, err := mwauth.GetContextUserID(ctx) + if err != nil { + return connect.NewError(connect.CodeUnauthenticated, err) + } + return s.streamGraphQLResponseSync(ctx, userID, stream.Send) +} + +func (s *GraphQLServiceRPC) streamGraphQLResponseSync(ctx context.Context, userID idwrap.IDWrap, send func(*graphqlv1.GraphQLResponseSyncResponse) error) error { + var workspaceSet sync.Map + + filter := func(topic GraphQLResponseTopic) bool { + if _, ok := workspaceSet.Load(topic.WorkspaceID.String()); ok { + return true + } + belongs, err := s.us.CheckUserBelongsToWorkspace(ctx, userID, topic.WorkspaceID) + if err != nil || !belongs { + return false + } + workspaceSet.Store(topic.WorkspaceID.String(), struct{}{}) + return true + } + + converter := func(events []GraphQLResponseEvent) *graphqlv1.GraphQLResponseSyncResponse { + var items []*graphqlv1.GraphQLResponseSync + for _, event := range events { + if resp := graphqlResponseSyncResponseFrom(event); resp != nil && len(resp.Items) > 0 { + items = append(items, resp.Items...) + } + } + if len(items) == 0 { + return nil + } + return &graphqlv1.GraphQLResponseSyncResponse{Items: items} + } + + return eventstream.StreamToClient(ctx, s.streamers.GraphQLResponse, filter, converter, send, nil) +} + +func (s *GraphQLServiceRPC) GraphQLResponseHeaderSync(ctx context.Context, req *connect.Request[emptypb.Empty], stream *connect.ServerStream[graphqlv1.GraphQLResponseHeaderSyncResponse]) error { + userID, err := mwauth.GetContextUserID(ctx) + if err != nil { + return connect.NewError(connect.CodeUnauthenticated, err) + } + return s.streamGraphQLResponseHeaderSync(ctx, userID, stream.Send) +} + +func (s *GraphQLServiceRPC) streamGraphQLResponseHeaderSync(ctx context.Context, userID idwrap.IDWrap, send func(*graphqlv1.GraphQLResponseHeaderSyncResponse) error) error { + var workspaceSet sync.Map + + filter := func(topic GraphQLResponseHeaderTopic) bool { + if _, ok := workspaceSet.Load(topic.WorkspaceID.String()); ok { + return true + } + belongs, err := s.us.CheckUserBelongsToWorkspace(ctx, userID, topic.WorkspaceID) + if err != nil || !belongs { + return false + } + workspaceSet.Store(topic.WorkspaceID.String(), struct{}{}) + return true + } + + converter := func(events []GraphQLResponseHeaderEvent) *graphqlv1.GraphQLResponseHeaderSyncResponse { + var items []*graphqlv1.GraphQLResponseHeaderSync + for _, event := range events { + if resp := graphqlResponseHeaderSyncResponseFrom(event); resp != nil && len(resp.Items) > 0 { + items = append(items, resp.Items...) + } + } + if len(items) == 0 { + return nil + } + return &graphqlv1.GraphQLResponseHeaderSyncResponse{Items: items} + } + + return eventstream.StreamToClient(ctx, s.streamers.GraphQLResponseHeader, filter, converter, send, nil) +} diff --git a/packages/server/internal/api/rgraphql/rgraphql_converter.go b/packages/server/internal/api/rgraphql/rgraphql_converter.go new file mode 100644 index 000000000..8a9b6d960 --- /dev/null +++ b/packages/server/internal/api/rgraphql/rgraphql_converter.go @@ -0,0 +1,262 @@ +//nolint:revive // exported +package rgraphql + +import ( + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" + graphqlv1 "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/graph_q_l/v1" +) + +// Model -> Proto + +func ToAPIGraphQL(g mgraphql.GraphQL) *graphqlv1.GraphQL { + result := &graphqlv1.GraphQL{ + GraphqlId: g.ID.Bytes(), + Name: g.Name, + Url: g.Url, + Query: g.Query, + Variables: g.Variables, + } + if g.LastRunAt != nil { + result.LastRunAt = timestamppb.New(time.Unix(*g.LastRunAt, 0)) + } + return result +} + +func ToAPIGraphQLHeader(h mgraphql.GraphQLHeader) *graphqlv1.GraphQLHeader { + return &graphqlv1.GraphQLHeader{ + GraphqlHeaderId: h.ID.Bytes(), + GraphqlId: h.GraphQLID.Bytes(), + Key: h.Key, + Value: h.Value, + Enabled: h.Enabled, + Description: h.Description, + Order: h.DisplayOrder, + } +} + +func ToAPIGraphQLResponse(r mgraphql.GraphQLResponse) *graphqlv1.GraphQLResponse { + return &graphqlv1.GraphQLResponse{ + GraphqlResponseId: r.ID.Bytes(), + GraphqlId: r.GraphQLID.Bytes(), + Status: r.Status, + Body: string(r.Body), + Time: timestamppb.New(time.Unix(r.Time, 0)), + Duration: r.Duration, + Size: r.Size, + } +} + +func ToAPIGraphQLResponseHeader(h mgraphql.GraphQLResponseHeader) *graphqlv1.GraphQLResponseHeader { + return &graphqlv1.GraphQLResponseHeader{ + GraphqlResponseHeaderId: h.ID.Bytes(), + GraphqlResponseId: h.ResponseID.Bytes(), + Key: h.HeaderKey, + Value: h.HeaderValue, + } +} + +// Sync response builders + +func graphqlSyncResponseFrom(event GraphQLEvent) *graphqlv1.GraphQLSyncResponse { + var value *graphqlv1.GraphQLSync_ValueUnion + + switch event.Type { + case eventTypeInsert: + name := event.GraphQL.GetName() + url := event.GraphQL.GetUrl() + query := event.GraphQL.GetQuery() + variables := event.GraphQL.GetVariables() + lastRunAt := event.GraphQL.GetLastRunAt() + value = &graphqlv1.GraphQLSync_ValueUnion{ + Kind: graphqlv1.GraphQLSync_ValueUnion_KIND_INSERT, + Insert: &graphqlv1.GraphQLSyncInsert{ + GraphqlId: event.GraphQL.GetGraphqlId(), + Name: name, + Url: url, + Query: query, + Variables: variables, + LastRunAt: lastRunAt, + }, + } + case eventTypeUpdate: + name := event.GraphQL.GetName() + url := event.GraphQL.GetUrl() + query := event.GraphQL.GetQuery() + variables := event.GraphQL.GetVariables() + lastRunAt := event.GraphQL.GetLastRunAt() + + var lastRunAtUnion *graphqlv1.GraphQLSyncUpdate_LastRunAtUnion + if lastRunAt != nil { + lastRunAtUnion = &graphqlv1.GraphQLSyncUpdate_LastRunAtUnion{ + Kind: graphqlv1.GraphQLSyncUpdate_LastRunAtUnion_KIND_VALUE, + Value: lastRunAt, + } + } + + value = &graphqlv1.GraphQLSync_ValueUnion{ + Kind: graphqlv1.GraphQLSync_ValueUnion_KIND_UPDATE, + Update: &graphqlv1.GraphQLSyncUpdate{ + GraphqlId: event.GraphQL.GetGraphqlId(), + Name: &name, + Url: &url, + Query: &query, + Variables: &variables, + LastRunAt: lastRunAtUnion, + }, + } + case eventTypeDelete: + value = &graphqlv1.GraphQLSync_ValueUnion{ + Kind: graphqlv1.GraphQLSync_ValueUnion_KIND_DELETE, + Delete: &graphqlv1.GraphQLSyncDelete{GraphqlId: event.GraphQL.GetGraphqlId()}, + } + } + + return &graphqlv1.GraphQLSyncResponse{ + Items: []*graphqlv1.GraphQLSync{{Value: value}}, + } +} + +func graphqlHeaderSyncResponseFrom(event GraphQLHeaderEvent) *graphqlv1.GraphQLHeaderSyncResponse { + var value *graphqlv1.GraphQLHeaderSync_ValueUnion + + switch event.Type { + case eventTypeInsert: + key := event.GraphQLHeader.GetKey() + val := event.GraphQLHeader.GetValue() + enabled := event.GraphQLHeader.GetEnabled() + description := event.GraphQLHeader.GetDescription() + order := event.GraphQLHeader.GetOrder() + value = &graphqlv1.GraphQLHeaderSync_ValueUnion{ + Kind: graphqlv1.GraphQLHeaderSync_ValueUnion_KIND_INSERT, + Insert: &graphqlv1.GraphQLHeaderSyncInsert{ + GraphqlHeaderId: event.GraphQLHeader.GetGraphqlHeaderId(), + GraphqlId: event.GraphQLHeader.GetGraphqlId(), + Key: key, + Value: val, + Enabled: enabled, + Description: description, + Order: order, + }, + } + case eventTypeUpdate: + key := event.GraphQLHeader.GetKey() + val := event.GraphQLHeader.GetValue() + enabled := event.GraphQLHeader.GetEnabled() + description := event.GraphQLHeader.GetDescription() + order := event.GraphQLHeader.GetOrder() + value = &graphqlv1.GraphQLHeaderSync_ValueUnion{ + Kind: graphqlv1.GraphQLHeaderSync_ValueUnion_KIND_UPDATE, + Update: &graphqlv1.GraphQLHeaderSyncUpdate{ + GraphqlHeaderId: event.GraphQLHeader.GetGraphqlHeaderId(), + Key: &key, + Value: &val, + Enabled: &enabled, + Description: &description, + Order: &order, + }, + } + case eventTypeDelete: + value = &graphqlv1.GraphQLHeaderSync_ValueUnion{ + Kind: graphqlv1.GraphQLHeaderSync_ValueUnion_KIND_DELETE, + Delete: &graphqlv1.GraphQLHeaderSyncDelete{GraphqlHeaderId: event.GraphQLHeader.GetGraphqlHeaderId()}, + } + } + + return &graphqlv1.GraphQLHeaderSyncResponse{ + Items: []*graphqlv1.GraphQLHeaderSync{{Value: value}}, + } +} + +func graphqlResponseSyncResponseFrom(event GraphQLResponseEvent) *graphqlv1.GraphQLResponseSyncResponse { + var value *graphqlv1.GraphQLResponseSync_ValueUnion + + switch event.Type { + case eventTypeInsert: + status := event.GraphQLResponse.GetStatus() + body := event.GraphQLResponse.GetBody() + t := event.GraphQLResponse.GetTime() + duration := event.GraphQLResponse.GetDuration() + size := event.GraphQLResponse.GetSize() + value = &graphqlv1.GraphQLResponseSync_ValueUnion{ + Kind: graphqlv1.GraphQLResponseSync_ValueUnion_KIND_INSERT, + Insert: &graphqlv1.GraphQLResponseSyncInsert{ + GraphqlResponseId: event.GraphQLResponse.GetGraphqlResponseId(), + GraphqlId: event.GraphQLResponse.GetGraphqlId(), + Status: status, + Body: body, + Time: t, + Duration: duration, + Size: size, + }, + } + case eventTypeUpdate: + status := event.GraphQLResponse.GetStatus() + body := event.GraphQLResponse.GetBody() + t := event.GraphQLResponse.GetTime() + duration := event.GraphQLResponse.GetDuration() + size := event.GraphQLResponse.GetSize() + value = &graphqlv1.GraphQLResponseSync_ValueUnion{ + Kind: graphqlv1.GraphQLResponseSync_ValueUnion_KIND_UPDATE, + Update: &graphqlv1.GraphQLResponseSyncUpdate{ + GraphqlResponseId: event.GraphQLResponse.GetGraphqlResponseId(), + Status: &status, + Body: &body, + Time: t, + Duration: &duration, + Size: &size, + }, + } + case eventTypeDelete: + value = &graphqlv1.GraphQLResponseSync_ValueUnion{ + Kind: graphqlv1.GraphQLResponseSync_ValueUnion_KIND_DELETE, + Delete: &graphqlv1.GraphQLResponseSyncDelete{GraphqlResponseId: event.GraphQLResponse.GetGraphqlResponseId()}, + } + } + + return &graphqlv1.GraphQLResponseSyncResponse{ + Items: []*graphqlv1.GraphQLResponseSync{{Value: value}}, + } +} + +func graphqlResponseHeaderSyncResponseFrom(event GraphQLResponseHeaderEvent) *graphqlv1.GraphQLResponseHeaderSyncResponse { + var value *graphqlv1.GraphQLResponseHeaderSync_ValueUnion + + switch event.Type { + case eventTypeInsert: + key := event.GraphQLResponseHeader.GetKey() + val := event.GraphQLResponseHeader.GetValue() + value = &graphqlv1.GraphQLResponseHeaderSync_ValueUnion{ + Kind: graphqlv1.GraphQLResponseHeaderSync_ValueUnion_KIND_INSERT, + Insert: &graphqlv1.GraphQLResponseHeaderSyncInsert{ + GraphqlResponseHeaderId: event.GraphQLResponseHeader.GetGraphqlResponseHeaderId(), + GraphqlResponseId: event.GraphQLResponseHeader.GetGraphqlResponseId(), + Key: key, + Value: val, + }, + } + case eventTypeUpdate: + key := event.GraphQLResponseHeader.GetKey() + val := event.GraphQLResponseHeader.GetValue() + value = &graphqlv1.GraphQLResponseHeaderSync_ValueUnion{ + Kind: graphqlv1.GraphQLResponseHeaderSync_ValueUnion_KIND_UPDATE, + Update: &graphqlv1.GraphQLResponseHeaderSyncUpdate{ + GraphqlResponseHeaderId: event.GraphQLResponseHeader.GetGraphqlResponseHeaderId(), + Key: &key, + Value: &val, + }, + } + case eventTypeDelete: + value = &graphqlv1.GraphQLResponseHeaderSync_ValueUnion{ + Kind: graphqlv1.GraphQLResponseHeaderSync_ValueUnion_KIND_DELETE, + Delete: &graphqlv1.GraphQLResponseHeaderSyncDelete{GraphqlResponseHeaderId: event.GraphQLResponseHeader.GetGraphqlResponseHeaderId()}, + } + } + + return &graphqlv1.GraphQLResponseHeaderSyncResponse{ + Items: []*graphqlv1.GraphQLResponseHeaderSync{{Value: value}}, + } +} diff --git a/packages/server/internal/api/rgraphql/rgraphql_crud.go b/packages/server/internal/api/rgraphql/rgraphql_crud.go new file mode 100644 index 000000000..277939026 --- /dev/null +++ b/packages/server/internal/api/rgraphql/rgraphql_crud.go @@ -0,0 +1,207 @@ +//nolint:revive // exported +package rgraphql + +import ( + "context" + "errors" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/the-dev-tools/dev-tools/packages/server/internal/api/middleware/mwauth" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sgraphql" + graphqlv1 "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/graph_q_l/v1" +) + +func (s *GraphQLServiceRPC) GraphQLCollection(ctx context.Context, req *connect.Request[emptypb.Empty]) (*connect.Response[graphqlv1.GraphQLCollectionResponse], error) { + userID, err := mwauth.GetContextUserID(ctx) + if err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, err) + } + + workspaces, err := s.wsReader.GetWorkspacesByUserIDOrdered(ctx, userID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + var allItems []*graphqlv1.GraphQL + for _, ws := range workspaces { + items, err := s.graphqlService.GetByWorkspaceID(ctx, ws.ID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + for _, item := range items { + allItems = append(allItems, ToAPIGraphQL(item)) + } + } + + return connect.NewResponse(&graphqlv1.GraphQLCollectionResponse{Items: allItems}), nil +} + +func (s *GraphQLServiceRPC) GraphQLInsert(ctx context.Context, req *connect.Request[graphqlv1.GraphQLInsertRequest]) (*connect.Response[emptypb.Empty], error) { + if len(req.Msg.GetItems()) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("at least one item must be provided")) + } + + userID, err := mwauth.GetContextUserID(ctx) + if err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, err) + } + + workspaces, err := s.wsReader.GetWorkspacesByUserIDOrdered(ctx, userID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + if len(workspaces) == 0 { + return nil, connect.NewError(connect.CodeNotFound, errors.New("user has no workspaces")) + } + + defaultWorkspaceID := workspaces[0].ID + if err := s.checkWorkspaceWriteAccess(ctx, defaultWorkspaceID); err != nil { + return nil, err + } + + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + defer tx.Rollback() //nolint:errcheck + + txGraphqlService := s.graphqlService.TX(tx) + + for _, item := range req.Msg.Items { + if len(item.GraphqlId) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("graphql_id is required")) + } + + gqlID, err := idwrap.NewFromBytes(item.GraphqlId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + model := &mgraphql.GraphQL{ + ID: gqlID, + WorkspaceID: defaultWorkspaceID, + Name: item.Name, + Url: item.Url, + Query: item.Query, + Variables: item.Variables, + } + + if err := txGraphqlService.Create(ctx, model); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + if s.streamers.GraphQL != nil { + s.streamers.GraphQL.Publish(GraphQLTopic{WorkspaceID: defaultWorkspaceID}, GraphQLEvent{ + Type: eventTypeInsert, + GraphQL: ToAPIGraphQL(*model), + }) + } + } + + if err := tx.Commit(); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + return connect.NewResponse(&emptypb.Empty{}), nil +} + +func (s *GraphQLServiceRPC) GraphQLUpdate(ctx context.Context, req *connect.Request[graphqlv1.GraphQLUpdateRequest]) (*connect.Response[emptypb.Empty], error) { + if len(req.Msg.GetItems()) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("at least one item must be provided")) + } + + for _, item := range req.Msg.Items { + if len(item.GraphqlId) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("graphql_id is required")) + } + + gqlID, err := idwrap.NewFromBytes(item.GraphqlId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + existing, err := s.graphqlService.Get(ctx, gqlID) + if err != nil { + if errors.Is(err, sgraphql.ErrNoGraphQLFound) { + return nil, connect.NewError(connect.CodeNotFound, err) + } + return nil, connect.NewError(connect.CodeInternal, err) + } + + if err := s.checkWorkspaceWriteAccess(ctx, existing.WorkspaceID); err != nil { + return nil, err + } + + if item.Name != nil { + existing.Name = *item.Name + } + if item.Url != nil { + existing.Url = *item.Url + } + if item.Query != nil { + existing.Query = *item.Query + } + if item.Variables != nil { + existing.Variables = *item.Variables + } + + if err := s.graphqlService.Update(ctx, existing); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + if s.streamers.GraphQL != nil { + s.streamers.GraphQL.Publish(GraphQLTopic{WorkspaceID: existing.WorkspaceID}, GraphQLEvent{ + Type: eventTypeUpdate, + GraphQL: ToAPIGraphQL(*existing), + }) + } + } + + return connect.NewResponse(&emptypb.Empty{}), nil +} + +func (s *GraphQLServiceRPC) GraphQLDelete(ctx context.Context, req *connect.Request[graphqlv1.GraphQLDeleteRequest]) (*connect.Response[emptypb.Empty], error) { + if len(req.Msg.GetItems()) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("at least one item must be provided")) + } + + for _, item := range req.Msg.Items { + if len(item.GraphqlId) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("graphql_id is required")) + } + + gqlID, err := idwrap.NewFromBytes(item.GraphqlId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + existing, err := s.graphqlService.Get(ctx, gqlID) + if err != nil { + if errors.Is(err, sgraphql.ErrNoGraphQLFound) { + return nil, connect.NewError(connect.CodeNotFound, err) + } + return nil, connect.NewError(connect.CodeInternal, err) + } + + if err := s.checkWorkspaceDeleteAccess(ctx, existing.WorkspaceID); err != nil { + return nil, err + } + + if err := s.graphqlService.Delete(ctx, gqlID); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + if s.streamers.GraphQL != nil { + s.streamers.GraphQL.Publish(GraphQLTopic{WorkspaceID: existing.WorkspaceID}, GraphQLEvent{ + Type: eventTypeDelete, + GraphQL: &graphqlv1.GraphQL{GraphqlId: gqlID.Bytes()}, + }) + } + } + + return connect.NewResponse(&emptypb.Empty{}), nil +} diff --git a/packages/server/internal/api/rgraphql/rgraphql_crud_header.go b/packages/server/internal/api/rgraphql/rgraphql_crud_header.go new file mode 100644 index 000000000..557a3f47e --- /dev/null +++ b/packages/server/internal/api/rgraphql/rgraphql_crud_header.go @@ -0,0 +1,212 @@ +//nolint:revive // exported +package rgraphql + +import ( + "context" + "errors" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/the-dev-tools/dev-tools/packages/server/internal/api/middleware/mwauth" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sgraphql" + graphqlv1 "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/graph_q_l/v1" +) + +func (s *GraphQLServiceRPC) GraphQLHeaderCollection(ctx context.Context, req *connect.Request[emptypb.Empty]) (*connect.Response[graphqlv1.GraphQLHeaderCollectionResponse], error) { + userID, err := mwauth.GetContextUserID(ctx) + if err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, err) + } + + workspaces, err := s.wsReader.GetWorkspacesByUserIDOrdered(ctx, userID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + var allItems []*graphqlv1.GraphQLHeader + for _, ws := range workspaces { + gqlList, err := s.graphqlService.GetByWorkspaceID(ctx, ws.ID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + for _, gql := range gqlList { + headers, err := s.headerService.GetByGraphQLID(ctx, gql.ID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + for _, h := range headers { + allItems = append(allItems, ToAPIGraphQLHeader(h)) + } + } + } + + return connect.NewResponse(&graphqlv1.GraphQLHeaderCollectionResponse{Items: allItems}), nil +} + +func (s *GraphQLServiceRPC) GraphQLHeaderInsert(ctx context.Context, req *connect.Request[graphqlv1.GraphQLHeaderInsertRequest]) (*connect.Response[emptypb.Empty], error) { + if len(req.Msg.GetItems()) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("at least one item must be provided")) + } + + for _, item := range req.Msg.Items { + if len(item.GraphqlHeaderId) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("graphql_header_id is required")) + } + if len(item.GraphqlId) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("graphql_id is required")) + } + + headerID, err := idwrap.NewFromBytes(item.GraphqlHeaderId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + gqlID, err := idwrap.NewFromBytes(item.GraphqlId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + workspaceID, err := s.graphqlService.GetWorkspaceID(ctx, gqlID) + if err != nil { + if errors.Is(err, sgraphql.ErrNoGraphQLFound) { + return nil, connect.NewError(connect.CodeNotFound, err) + } + return nil, connect.NewError(connect.CodeInternal, err) + } + + if err := s.checkWorkspaceWriteAccess(ctx, workspaceID); err != nil { + return nil, err + } + + header := &mgraphql.GraphQLHeader{ + ID: headerID, + GraphQLID: gqlID, + Key: item.Key, + Value: item.Value, + Enabled: item.Enabled, + Description: item.Description, + DisplayOrder: item.Order, + } + + if err := s.headerService.Create(ctx, header); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + if s.streamers.GraphQLHeader != nil { + s.streamers.GraphQLHeader.Publish(GraphQLHeaderTopic{WorkspaceID: workspaceID}, GraphQLHeaderEvent{ + Type: eventTypeInsert, + GraphQLHeader: ToAPIGraphQLHeader(*header), + }) + } + } + + return connect.NewResponse(&emptypb.Empty{}), nil +} + +func (s *GraphQLServiceRPC) GraphQLHeaderUpdate(ctx context.Context, req *connect.Request[graphqlv1.GraphQLHeaderUpdateRequest]) (*connect.Response[emptypb.Empty], error) { + if len(req.Msg.GetItems()) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("at least one item must be provided")) + } + + for _, item := range req.Msg.Items { + if len(item.GraphqlHeaderId) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("graphql_header_id is required")) + } + + headerID, err := idwrap.NewFromBytes(item.GraphqlHeaderId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + existingHeaders, err := s.headerService.GetByIDs(ctx, []idwrap.IDWrap{headerID}) + if err != nil || len(existingHeaders) == 0 { + return nil, connect.NewError(connect.CodeNotFound, errors.New("header not found")) + } + existing := existingHeaders[0] + + workspaceID, err := s.graphqlService.GetWorkspaceID(ctx, existing.GraphQLID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + if err := s.checkWorkspaceWriteAccess(ctx, workspaceID); err != nil { + return nil, err + } + + if item.Key != nil { + existing.Key = *item.Key + } + if item.Value != nil { + existing.Value = *item.Value + } + if item.Enabled != nil { + existing.Enabled = *item.Enabled + } + if item.Description != nil { + existing.Description = *item.Description + } + if item.Order != nil { + existing.DisplayOrder = *item.Order + } + + if err := s.headerService.Update(ctx, &existing); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + if s.streamers.GraphQLHeader != nil { + s.streamers.GraphQLHeader.Publish(GraphQLHeaderTopic{WorkspaceID: workspaceID}, GraphQLHeaderEvent{ + Type: eventTypeUpdate, + GraphQLHeader: ToAPIGraphQLHeader(existing), + }) + } + } + + return connect.NewResponse(&emptypb.Empty{}), nil +} + +func (s *GraphQLServiceRPC) GraphQLHeaderDelete(ctx context.Context, req *connect.Request[graphqlv1.GraphQLHeaderDeleteRequest]) (*connect.Response[emptypb.Empty], error) { + if len(req.Msg.GetItems()) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("at least one item must be provided")) + } + + for _, item := range req.Msg.Items { + if len(item.GraphqlHeaderId) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("graphql_header_id is required")) + } + + headerID, err := idwrap.NewFromBytes(item.GraphqlHeaderId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + existingHeaders, err := s.headerService.GetByIDs(ctx, []idwrap.IDWrap{headerID}) + if err != nil || len(existingHeaders) == 0 { + return nil, connect.NewError(connect.CodeNotFound, errors.New("header not found")) + } + existing := existingHeaders[0] + + workspaceID, err := s.graphqlService.GetWorkspaceID(ctx, existing.GraphQLID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + if err := s.checkWorkspaceDeleteAccess(ctx, workspaceID); err != nil { + return nil, err + } + + if err := s.headerService.Delete(ctx, headerID); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + if s.streamers.GraphQLHeader != nil { + s.streamers.GraphQLHeader.Publish(GraphQLHeaderTopic{WorkspaceID: workspaceID}, GraphQLHeaderEvent{ + Type: eventTypeDelete, + GraphQLHeader: &graphqlv1.GraphQLHeader{GraphqlHeaderId: headerID.Bytes(), GraphqlId: existing.GraphQLID.Bytes()}, + }) + } + } + + return connect.NewResponse(&emptypb.Empty{}), nil +} diff --git a/packages/server/internal/api/rgraphql/rgraphql_crud_response.go b/packages/server/internal/api/rgraphql/rgraphql_crud_response.go new file mode 100644 index 000000000..1ea2978c4 --- /dev/null +++ b/packages/server/internal/api/rgraphql/rgraphql_crud_response.go @@ -0,0 +1,62 @@ +//nolint:revive // exported +package rgraphql + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/the-dev-tools/dev-tools/packages/server/internal/api/middleware/mwauth" + graphqlv1 "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/graph_q_l/v1" +) + +func (s *GraphQLServiceRPC) GraphQLResponseCollection(ctx context.Context, req *connect.Request[emptypb.Empty]) (*connect.Response[graphqlv1.GraphQLResponseCollectionResponse], error) { + userID, err := mwauth.GetContextUserID(ctx) + if err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, err) + } + + workspaces, err := s.wsReader.GetWorkspacesByUserIDOrdered(ctx, userID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + var allItems []*graphqlv1.GraphQLResponse + for _, ws := range workspaces { + responses, err := s.responseService.GetByWorkspaceID(ctx, ws.ID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + for _, r := range responses { + allItems = append(allItems, ToAPIGraphQLResponse(r)) + } + } + + return connect.NewResponse(&graphqlv1.GraphQLResponseCollectionResponse{Items: allItems}), nil +} + +func (s *GraphQLServiceRPC) GraphQLResponseHeaderCollection(ctx context.Context, req *connect.Request[emptypb.Empty]) (*connect.Response[graphqlv1.GraphQLResponseHeaderCollectionResponse], error) { + userID, err := mwauth.GetContextUserID(ctx) + if err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, err) + } + + workspaces, err := s.wsReader.GetWorkspacesByUserIDOrdered(ctx, userID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + var allItems []*graphqlv1.GraphQLResponseHeader + for _, ws := range workspaces { + headers, err := s.responseService.GetHeadersByWorkspaceID(ctx, ws.ID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + for _, h := range headers { + allItems = append(allItems, ToAPIGraphQLResponseHeader(h)) + } + } + + return connect.NewResponse(&graphqlv1.GraphQLResponseHeaderCollectionResponse{Items: allItems}), nil +} diff --git a/packages/server/internal/api/rgraphql/rgraphql_exec.go b/packages/server/internal/api/rgraphql/rgraphql_exec.go new file mode 100644 index 000000000..47f523c1a --- /dev/null +++ b/packages/server/internal/api/rgraphql/rgraphql_exec.go @@ -0,0 +1,482 @@ +//nolint:revive // exported +package rgraphql + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/emptypb" + + devtoolsdb "github.com/the-dev-tools/dev-tools/packages/db" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/httpclient" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/menv" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/senv" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sgraphql" + graphqlv1 "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/graph_q_l/v1" +) + +const introspectionQuery = `query IntrospectionQuery { + __schema { + queryType { name } + mutationType { name } + subscriptionType { name } + types { + ...FullType + } + directives { + name + description + locations + args { + ...InputValue + } + } + } +} + +fragment FullType on __Type { + kind + name + description + fields(includeDeprecated: true) { + name + description + args { + ...InputValue + } + type { + ...TypeRef + } + isDeprecated + deprecationReason + } + inputFields { + ...InputValue + } + interfaces { + ...TypeRef + } + enumValues(includeDeprecated: true) { + name + description + isDeprecated + deprecationReason + } + possibleTypes { + ...TypeRef + } +} + +fragment InputValue on __InputValue { + name + description + type { ...TypeRef } + defaultValue +} + +fragment TypeRef on __Type { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + } + } + } + } + } + } +}` + +func (s *GraphQLServiceRPC) GraphQLRun(ctx context.Context, req *connect.Request[graphqlv1.GraphQLRunRequest]) (*connect.Response[emptypb.Empty], error) { + if len(req.Msg.GraphqlId) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("graphql_id is required")) + } + + gqlID, err := idwrap.NewFromBytes(req.Msg.GraphqlId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + gqlEntry, err := s.graphqlService.Get(ctx, gqlID) + if err != nil { + if errors.Is(err, sgraphql.ErrNoGraphQLFound) { + return nil, connect.NewError(connect.CodeNotFound, err) + } + return nil, connect.NewError(connect.CodeInternal, err) + } + + if err := s.checkWorkspaceReadAccess(ctx, gqlEntry.WorkspaceID); err != nil { + return nil, err + } + + // Build variable map from workspace env + varMap, err := s.buildWorkspaceVarMap(ctx, gqlEntry.WorkspaceID) + if err != nil { + varMap = make(map[string]any) + } + + // Get headers + headers, err := s.headerService.GetByGraphQLID(ctx, gqlID) + if err != nil { + headers = []mgraphql.GraphQLHeader{} + } + + // Build and execute GraphQL request + httpReq, err := prepareGraphQLRequest(gqlEntry, headers, varMap) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("failed to prepare request: %w", err)) + } + + client := httpclient.New() + startTime := time.Now() + + resp, err := client.Do(httpReq.WithContext(ctx)) + if err != nil { + return nil, connect.NewError(connect.CodeUnavailable, fmt.Errorf("request failed: %w", err)) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to read response: %w", err)) + } + + duration := time.Since(startTime).Milliseconds() + + // Store response + responseID := idwrap.NewNow() + nowUnix := time.Now().Unix() + + gqlResponse := mgraphql.GraphQLResponse{ + ID: responseID, + GraphQLID: gqlID, + Status: int32(resp.StatusCode), //nolint:gosec + Body: body, + Time: startTime.Unix(), + Duration: int32(duration), //nolint:gosec + Size: int32(len(body)), //nolint:gosec + CreatedAt: nowUnix, + } + + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + defer devtoolsdb.TxnRollback(tx) + + txResponseService := s.responseService.TX(tx) + + if err := txResponseService.Create(ctx, gqlResponse); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + // Store response headers + var respHeaderEvents []GraphQLResponseHeaderEvent + for key, values := range resp.Header { + for _, val := range values { + headerID := idwrap.NewNow() + respHeader := mgraphql.GraphQLResponseHeader{ + ID: headerID, + ResponseID: responseID, + HeaderKey: key, + HeaderValue: val, + CreatedAt: nowUnix, + } + if err := txResponseService.CreateHeader(ctx, respHeader); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + respHeaderEvents = append(respHeaderEvents, GraphQLResponseHeaderEvent{ + Type: eventTypeInsert, + GraphQLResponseHeader: ToAPIGraphQLResponseHeader(respHeader), + }) + } + } + + // Update last_run_at + now := time.Now().Unix() + gqlEntry.LastRunAt = &now + txGraphqlService := s.graphqlService.TX(tx) + if err := txGraphqlService.Update(ctx, gqlEntry); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + if err := tx.Commit(); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + // Publish events + if s.streamers.GraphQLResponse != nil { + s.streamers.GraphQLResponse.Publish(GraphQLResponseTopic{WorkspaceID: gqlEntry.WorkspaceID}, GraphQLResponseEvent{ + Type: eventTypeInsert, + GraphQLResponse: ToAPIGraphQLResponse(gqlResponse), + }) + } + if s.streamers.GraphQLResponseHeader != nil { + topic := GraphQLResponseHeaderTopic{WorkspaceID: gqlEntry.WorkspaceID} + for _, evt := range respHeaderEvents { + s.streamers.GraphQLResponseHeader.Publish(topic, evt) + } + } + if s.streamers.GraphQL != nil { + s.streamers.GraphQL.Publish(GraphQLTopic{WorkspaceID: gqlEntry.WorkspaceID}, GraphQLEvent{ + Type: eventTypeUpdate, + GraphQL: ToAPIGraphQL(*gqlEntry), + }) + } + + return connect.NewResponse(&emptypb.Empty{}), nil +} + +func (s *GraphQLServiceRPC) GraphQLDuplicate(ctx context.Context, req *connect.Request[graphqlv1.GraphQLDuplicateRequest]) (*connect.Response[emptypb.Empty], error) { + if len(req.Msg.GraphqlId) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("graphql_id is required")) + } + + gqlID, err := idwrap.NewFromBytes(req.Msg.GraphqlId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + gqlEntry, err := s.graphqlService.Get(ctx, gqlID) + if err != nil { + if errors.Is(err, sgraphql.ErrNoGraphQLFound) { + return nil, connect.NewError(connect.CodeNotFound, err) + } + return nil, connect.NewError(connect.CodeInternal, err) + } + + if err := s.checkWorkspaceWriteAccess(ctx, gqlEntry.WorkspaceID); err != nil { + return nil, err + } + + // Read headers outside TX + headers, err := s.headerService.GetByGraphQLID(ctx, gqlID) + if err != nil { + headers = []mgraphql.GraphQLHeader{} + } + + newGQLID := idwrap.NewNow() + + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + defer devtoolsdb.TxnRollback(tx) + + txGraphqlService := s.graphqlService.TX(tx) + txHeaderService := s.headerService.TX(tx) + + newEntry := &mgraphql.GraphQL{ + ID: newGQLID, + WorkspaceID: gqlEntry.WorkspaceID, + FolderID: gqlEntry.FolderID, + Name: fmt.Sprintf("Copy of %s", gqlEntry.Name), + Url: gqlEntry.Url, + Query: gqlEntry.Query, + Variables: gqlEntry.Variables, + Description: gqlEntry.Description, + } + + if err := txGraphqlService.Create(ctx, newEntry); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + for _, h := range headers { + newHeader := &mgraphql.GraphQLHeader{ + ID: idwrap.NewNow(), + GraphQLID: newGQLID, + Key: h.Key, + Value: h.Value, + Enabled: h.Enabled, + Description: h.Description, + DisplayOrder: h.DisplayOrder, + } + if err := txHeaderService.Create(ctx, newHeader); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + } + + if err := tx.Commit(); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + // Publish GraphQL insert event + if s.streamers.GraphQL != nil { + s.streamers.GraphQL.Publish(GraphQLTopic{WorkspaceID: gqlEntry.WorkspaceID}, GraphQLEvent{ + Type: eventTypeInsert, + GraphQL: ToAPIGraphQL(*newEntry), + }) + } + + return connect.NewResponse(&emptypb.Empty{}), nil +} + +func (s *GraphQLServiceRPC) GraphQLIntrospect(ctx context.Context, req *connect.Request[graphqlv1.GraphQLIntrospectRequest]) (*connect.Response[graphqlv1.GraphQLIntrospectResponse], error) { + if len(req.Msg.GraphqlId) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("graphql_id is required")) + } + + gqlID, err := idwrap.NewFromBytes(req.Msg.GraphqlId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + gqlEntry, err := s.graphqlService.Get(ctx, gqlID) + if err != nil { + if errors.Is(err, sgraphql.ErrNoGraphQLFound) { + return nil, connect.NewError(connect.CodeNotFound, err) + } + return nil, connect.NewError(connect.CodeInternal, err) + } + + if err := s.checkWorkspaceReadAccess(ctx, gqlEntry.WorkspaceID); err != nil { + return nil, err + } + + varMap, err := s.buildWorkspaceVarMap(ctx, gqlEntry.WorkspaceID) + if err != nil { + varMap = make(map[string]any) + } + + headers, err := s.headerService.GetByGraphQLID(ctx, gqlID) + if err != nil { + headers = []mgraphql.GraphQLHeader{} + } + + // Build introspection request + body, _ := json.Marshal(map[string]any{ + "query": introspectionQuery, + }) + + url := interpolateString(gqlEntry.Url, varMap) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("failed to create request: %w", err)) + } + httpReq.Header.Set("Content-Type", "application/json") + + for _, h := range headers { + if h.Enabled { + httpReq.Header.Set(interpolateString(h.Key, varMap), interpolateString(h.Value, varMap)) + } + } + + client := httpclient.New() + resp, err := client.Do(httpReq) + if err != nil { + return nil, connect.NewError(connect.CodeUnavailable, fmt.Errorf("introspection request failed: %w", err)) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to read response: %w", err)) + } + + return connect.NewResponse(&graphqlv1.GraphQLIntrospectResponse{ + IntrospectionJson: string(respBody), + Sdl: "", // SDL conversion would need a graphql library - return empty for now + }), nil +} + +// Helper functions + +func (s *GraphQLServiceRPC) buildWorkspaceVarMap(ctx context.Context, workspaceID idwrap.IDWrap) (map[string]any, error) { + workspace, err := s.ws.Get(ctx, workspaceID) + if err != nil { + return nil, fmt.Errorf("failed to get workspace: %w", err) + } + + var globalVars []menv.Variable + if workspace.GlobalEnv != (idwrap.IDWrap{}) { + globalVars, err = s.vs.GetVariableByEnvID(ctx, workspace.GlobalEnv) + if err != nil && !errors.Is(err, senv.ErrNoVarFound) { + return nil, fmt.Errorf("failed to get global environment variables: %w", err) + } + } + + varMap := make(map[string]any) + for _, envVar := range globalVars { + if envVar.IsEnabled() { + varMap[envVar.VarKey] = envVar.Value + } + } + + return varMap, nil +} + +func prepareGraphQLRequest(gql *mgraphql.GraphQL, headers []mgraphql.GraphQLHeader, varMap map[string]any) (*http.Request, error) { + url := interpolateString(gql.Url, varMap) + query := interpolateString(gql.Query, varMap) + variables := interpolateString(gql.Variables, varMap) + + var varsMap map[string]any + if variables != "" { + if err := json.Unmarshal([]byte(variables), &varsMap); err != nil { + varsMap = nil + } + } + + bodyMap := map[string]any{"query": query} + if varsMap != nil { + bodyMap["variables"] = varsMap + } + + bodyBytes, err := json.Marshal(bodyMap) + if err != nil { + return nil, fmt.Errorf("failed to marshal body: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + for _, h := range headers { + if h.Enabled { + req.Header.Set(interpolateString(h.Key, varMap), interpolateString(h.Value, varMap)) + } + } + + return req, nil +} + +func interpolateString(s string, varMap map[string]any) string { + result := s + for key, val := range varMap { + placeholder := "{{" + key + "}}" + valStr := fmt.Sprintf("%v", val) + result = strings.ReplaceAll(result, placeholder, valStr) + // Also support {{ key }} (with spaces) + placeholder = "{{ " + key + " }}" + result = strings.ReplaceAll(result, placeholder, valStr) + } + return result +} diff --git a/packages/server/internal/converter/converter.go b/packages/server/internal/converter/converter.go index f2aab59ee..3f23672db 100644 --- a/packages/server/internal/converter/converter.go +++ b/packages/server/internal/converter/converter.go @@ -375,6 +375,8 @@ func ToAPINodeKind(kind mflow.NodeKind) flowv1.NodeKind { return flowv1.NodeKind_NODE_KIND_AI_PROVIDER case mflow.NODE_KIND_AI_MEMORY: return flowv1.NodeKind_NODE_KIND_AI_MEMORY + case mflow.NODE_KIND_GRAPHQL: + return flowv1.NodeKind_NODE_KIND_GRAPH_Q_L default: return flowv1.NodeKind_NODE_KIND_UNSPECIFIED } diff --git a/packages/server/pkg/flow/flowbuilder/builder.go b/packages/server/pkg/flow/flowbuilder/builder.go index 49389b52c..5cfff10e5 100644 --- a/packages/server/pkg/flow/flowbuilder/builder.go +++ b/packages/server/pkg/flow/flowbuilder/builder.go @@ -13,6 +13,7 @@ import ( "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/node/nai" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/node/nfor" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/node/nforeach" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/node/ngraphql" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/node/nif" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/node/njs" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/node/nmemory" @@ -27,6 +28,7 @@ import ( "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/scredential" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/senv" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sflow" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sgraphql" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sworkspace" "github.com/the-dev-tools/dev-tools/packages/spec/dist/buf/go/api/node_js_executor/v1/node_js_executorv1connect" ) @@ -41,6 +43,9 @@ type Builder struct { NodeAI *sflow.NodeAIService NodeAiProvider *sflow.NodeAiProviderService NodeMemory *sflow.NodeMemoryService + NodeGraphQL *sflow.NodeGraphQLService + GraphQL *sgraphql.GraphQLService + GraphQLHeader *sgraphql.GraphQLHeaderService Workspace *sworkspace.WorkspaceService Variable *senv.VariableService @@ -61,6 +66,9 @@ func New( nais *sflow.NodeAIService, naps *sflow.NodeAiProviderService, nmems *sflow.NodeMemoryService, + ngqs *sflow.NodeGraphQLService, + gqls *sgraphql.GraphQLService, + gqlhs *sgraphql.GraphQLHeaderService, ws *sworkspace.WorkspaceService, vs *senv.VariableService, fvs *sflow.FlowVariableService, @@ -78,6 +86,9 @@ func New( NodeAI: nais, NodeAiProvider: naps, NodeMemory: nmems, + NodeGraphQL: ngqs, + GraphQL: gqls, + GraphQLHeader: gqlhs, Workspace: ws, Variable: vs, FlowVariable: fvs, @@ -94,6 +105,7 @@ func (b *Builder) BuildNodes( timeout time.Duration, httpClient httpclient.HttpClient, respChan chan nrequest.NodeRequestSideResp, + gqlRespChan chan ngraphql.NodeGraphQLSideResp, jsClient node_js_executorv1connect.NodeJsExecutorServiceClient, ) (map[idwrap.IDWrap]node.FlowNode, idwrap.IDWrap, error) { flowNodeMap := make(map[idwrap.IDWrap]node.FlowNode, len(nodes)) @@ -264,6 +276,33 @@ func (b *Builder) BuildNodes( memoryCfg.WindowSize, ) } + case mflow.NODE_KIND_GRAPHQL: + gqlCfg, err := b.NodeGraphQL.GetNodeGraphQL(ctx, nodeModel.ID) + if err != nil { + return nil, idwrap.IDWrap{}, err + } + if gqlCfg == nil || gqlCfg.GraphQLID == nil || isZeroID(*gqlCfg.GraphQLID) { + return nil, idwrap.IDWrap{}, fmt.Errorf("graphql node %s missing graphql configuration", nodeModel.ID.String()) + } + // Fetch GraphQL entity + headers + gql, err := b.GraphQL.Get(ctx, *gqlCfg.GraphQLID) + if err != nil { + return nil, idwrap.IDWrap{}, fmt.Errorf("get graphql %s: %w", gqlCfg.GraphQLID.String(), err) + } + headers, err := b.GraphQLHeader.GetByGraphQLID(ctx, *gqlCfg.GraphQLID) + if err != nil { + return nil, idwrap.IDWrap{}, fmt.Errorf("get graphql headers %s: %w", gqlCfg.GraphQLID.String(), err) + } + + flowNodeMap[nodeModel.ID] = ngraphql.New( + nodeModel.ID, + nodeModel.Name, + *gql, + headers, + httpClient, + gqlRespChan, + b.Logger, + ) default: return nil, idwrap.IDWrap{}, fmt.Errorf("node kind %d not supported", nodeModel.NodeKind) } diff --git a/packages/server/pkg/flow/node/ngraphql/ngraphql.go b/packages/server/pkg/flow/node/ngraphql/ngraphql.go new file mode 100644 index 000000000..b24c3c328 --- /dev/null +++ b/packages/server/pkg/flow/node/ngraphql/ngraphql.go @@ -0,0 +1,294 @@ +//nolint:revive // exported +package ngraphql + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "github.com/the-dev-tools/dev-tools/packages/server/pkg/expression" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/node" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/httpclient" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mflow" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" +) + +type NodeGraphQL struct { + FlowNodeID idwrap.IDWrap + Name string + + GraphQL mgraphql.GraphQL + Headers []mgraphql.GraphQLHeader + HttpClient httpclient.HttpClient + SideRespChan chan NodeGraphQLSideResp + logger *slog.Logger +} + +type NodeGraphQLSideResp struct { + ExecutionID idwrap.IDWrap + GraphQL mgraphql.GraphQL + Headers []mgraphql.GraphQLHeader + Response mgraphql.GraphQLResponse + RespHeaders []mgraphql.GraphQLResponseHeader + Done chan struct{} +} + +const ( + outputResponseName = "response" + outputRequestName = "request" +) + +type graphqlRequestBody struct { + Query string `json:"query"` + Variables json.RawMessage `json:"variables,omitempty"` +} + +func New( + id idwrap.IDWrap, + name string, + gql mgraphql.GraphQL, + headers []mgraphql.GraphQLHeader, + httpClient httpclient.HttpClient, + sideRespChan chan NodeGraphQLSideResp, + logger *slog.Logger, +) *NodeGraphQL { + return &NodeGraphQL{ + FlowNodeID: id, + Name: name, + GraphQL: gql, + Headers: headers, + HttpClient: httpClient, + SideRespChan: sideRespChan, + logger: logger, + } +} + +func (n *NodeGraphQL) GetID() idwrap.IDWrap { + return n.FlowNodeID +} + +func (n *NodeGraphQL) SetID(id idwrap.IDWrap) { + n.FlowNodeID = id +} + +func (n *NodeGraphQL) GetName() string { + return n.Name +} + +func (n *NodeGraphQL) RunSync(ctx context.Context, req *node.FlowNodeRequest) node.FlowNodeResult { + nextID := mflow.GetNextNodeID(req.EdgeSourceMap, n.GetID(), mflow.HandleUnspecified) + result := node.FlowNodeResult{ + NextNodeID: nextID, + Err: nil, + } + + varMapCopy := node.DeepCopyVarMap(req) + + // Build unified environment for interpolation + env := expression.NewUnifiedEnv(varMapCopy) + + // Interpolate URL, query, variables, and headers + url, _ := env.Interpolate(n.GraphQL.Url) + query, _ := env.Interpolate(n.GraphQL.Query) + variables, _ := env.Interpolate(n.GraphQL.Variables) + + // Build request body + var varsJSON json.RawMessage + if variables != "" { + // Try to parse as JSON; if invalid, use as string + if json.Valid([]byte(variables)) { + varsJSON = json.RawMessage(variables) + } else { + // Wrap as JSON string + b, _ := json.Marshal(variables) + varsJSON = b + } + } + + body := graphqlRequestBody{ + Query: query, + Variables: varsJSON, + } + bodyBytes, err := json.Marshal(body) + if err != nil { + result.Err = fmt.Errorf("failed to marshal graphql request body: %w", err) + return result + } + + // Build HTTP request + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes)) + if err != nil { + result.Err = fmt.Errorf("failed to create graphql http request: %w", err) + return result + } + httpReq.Header.Set("Content-Type", "application/json") + + // Apply headers + for _, h := range n.Headers { + if h.Enabled { + key, _ := env.Interpolate(h.Key) + value, _ := env.Interpolate(h.Value) + httpReq.Header.Set(key, value) + } + } + + if ctx.Err() != nil { + return result + } + + // Execute request + startTime := time.Now() + httpResp, err := n.HttpClient.Do(httpReq) + duration := time.Since(startTime) + if err != nil { + result.Err = fmt.Errorf("graphql request failed: %w", err) + return result + } + defer httpResp.Body.Close() + + // Read response body + respBody, err := io.ReadAll(httpResp.Body) + if err != nil { + result.Err = fmt.Errorf("failed to read graphql response body: %w", err) + return result + } + + if ctx.Err() != nil { + return result + } + + // Build response headers + respHeaderModels := make([]mgraphql.GraphQLResponseHeader, 0) + for key, values := range httpResp.Header { + for _, value := range values { + respHeaderModels = append(respHeaderModels, mgraphql.GraphQLResponseHeader{ + ID: idwrap.NewNow(), + HeaderKey: key, + HeaderValue: value, + }) + } + } + + // Build output map + var respBodyParsed any + if err := json.Unmarshal(respBody, &respBodyParsed); err != nil { + // If not valid JSON, use as string + respBodyParsed = string(respBody) + } + + requestHeaders := make(map[string]any) + for _, h := range n.Headers { + if h.Enabled { + requestHeaders[h.Key] = h.Value + } + } + + respHeaders := make(map[string]any) + for key, values := range httpResp.Header { + if len(values) == 1 { + respHeaders[key] = values[0] + } else { + anyValues := make([]any, len(values)) + for i, v := range values { + anyValues[i] = v + } + respHeaders[key] = anyValues + } + } + + outputMap := map[string]any{ + outputRequestName: map[string]any{ + "url": url, + "query": query, + "variables": variables, + "headers": requestHeaders, + }, + outputResponseName: map[string]any{ + "status": float64(httpResp.StatusCode), + "body": respBodyParsed, + "headers": respHeaders, + "duration": float64(duration.Milliseconds()), + }, + } + + if err := node.WriteNodeVarBulk(req, n.Name, outputMap); err != nil { + result.Err = err + return result + } + + // Create GraphQL response model + responseID := idwrap.NewNow() + gqlResponse := mgraphql.GraphQLResponse{ + ID: responseID, + GraphQLID: n.GraphQL.ID, + Status: int32(httpResp.StatusCode), + Body: respBody, + Time: time.Now().Unix(), + Duration: int32(duration.Milliseconds()), + Size: int32(len(respBody)), + } + + // Set response IDs + for i := range respHeaderModels { + respHeaderModels[i].ResponseID = responseID + } + + result.AuxiliaryID = &responseID + + // Send through side channel for persistence + done := make(chan struct{}) + n.SideRespChan <- NodeGraphQLSideResp{ + ExecutionID: req.ExecutionID, + GraphQL: n.GraphQL, + Headers: n.Headers, + Response: gqlResponse, + RespHeaders: respHeaderModels, + Done: done, + } + select { + case <-done: + case <-ctx.Done(): + } + + return result +} + +func (n *NodeGraphQL) RunAsync(ctx context.Context, req *node.FlowNodeRequest, resultChan chan node.FlowNodeResult) { + result := n.RunSync(ctx, req) + if ctx.Err() != nil { + return + } + resultChan <- result +} + +// GetRequiredVariables implements node.VariableIntrospector. +func (n *NodeGraphQL) GetRequiredVariables() []string { + var sources []string + sources = append(sources, n.GraphQL.Url, n.GraphQL.Query, n.GraphQL.Variables) + for _, h := range n.Headers { + if h.Enabled { + sources = append(sources, h.Key, h.Value) + } + } + return expression.ExtractVarKeysFromMultiple(sources...) +} + +// GetOutputVariables implements node.VariableIntrospector. +func (n *NodeGraphQL) GetOutputVariables() []string { + return []string{ + "response.status", + "response.body", + "response.headers", + "response.duration", + "request.url", + "request.query", + "request.variables", + "request.headers", + } +} diff --git a/packages/server/pkg/model/mflow/execution.go b/packages/server/pkg/model/mflow/execution.go index ac68768fc..0c52d567b 100644 --- a/packages/server/pkg/model/mflow/execution.go +++ b/packages/server/pkg/model/mflow/execution.go @@ -18,6 +18,7 @@ type NodeExecution struct { OutputData []byte `json:"output_data,omitempty"` OutputDataCompressType int8 `json:"output_data_compress_type"` ResponseID *idwrap.IDWrap `json:"response_id,omitempty"` + GraphQLResponseID *idwrap.IDWrap `json:"graphql_response_id,omitempty"` CompletedAt *int64 `json:"completed_at,omitempty"` } diff --git a/packages/server/pkg/model/mflow/node.go b/packages/server/pkg/model/mflow/node.go index b964bd8c1..8218e74ae 100644 --- a/packages/server/pkg/model/mflow/node.go +++ b/packages/server/pkg/model/mflow/node.go @@ -18,6 +18,7 @@ const ( NODE_KIND_AI NodeKind = 7 NODE_KIND_AI_PROVIDER NodeKind = 8 NODE_KIND_AI_MEMORY NodeKind = 9 + NODE_KIND_GRAPHQL NodeKind = 10 ) type NodeState = int8 diff --git a/packages/server/pkg/model/mflow/node_types.go b/packages/server/pkg/model/mflow/node_types.go index c5e3ac740..c3007f682 100644 --- a/packages/server/pkg/model/mflow/node_types.go +++ b/packages/server/pkg/model/mflow/node_types.go @@ -252,3 +252,10 @@ type NodeMemory struct { MemoryType AiMemoryType WindowSize int32 } + +// --- GraphQL Node --- + +type NodeGraphQL struct { + FlowNodeID idwrap.IDWrap + GraphQLID *idwrap.IDWrap +} diff --git a/packages/server/pkg/model/mgraphql/mgraphql.go b/packages/server/pkg/model/mgraphql/mgraphql.go new file mode 100644 index 000000000..06f7eb4bd --- /dev/null +++ b/packages/server/pkg/model/mgraphql/mgraphql.go @@ -0,0 +1,50 @@ +package mgraphql + +import ( + "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" +) + +type GraphQL struct { + ID idwrap.IDWrap `json:"id"` + WorkspaceID idwrap.IDWrap `json:"workspace_id"` + FolderID *idwrap.IDWrap `json:"folder_id,omitempty"` + Name string `json:"name"` + Url string `json:"url"` + Query string `json:"query"` + Variables string `json:"variables"` + Description string `json:"description"` + LastRunAt *int64 `json:"last_run_at,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type GraphQLHeader struct { + ID idwrap.IDWrap `json:"id"` + GraphQLID idwrap.IDWrap `json:"graphql_id"` + Key string `json:"key"` + Value string `json:"value"` + Enabled bool `json:"enabled"` + Description string `json:"description"` + DisplayOrder float32 `json:"order"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type GraphQLResponse struct { + ID idwrap.IDWrap `json:"id"` + GraphQLID idwrap.IDWrap `json:"graphql_id"` + Status int32 `json:"status"` + Body []byte `json:"body"` + Time int64 `json:"time"` + Duration int32 `json:"duration"` + Size int32 `json:"size"` + CreatedAt int64 `json:"created_at"` +} + +type GraphQLResponseHeader struct { + ID idwrap.IDWrap `json:"id"` + ResponseID idwrap.IDWrap `json:"response_id"` + HeaderKey string `json:"header_key"` + HeaderValue string `json:"header_value"` + CreatedAt int64 `json:"created_at"` +} diff --git a/packages/server/pkg/mutation/event.go b/packages/server/pkg/mutation/event.go index 8c791db60..f2990a773 100644 --- a/packages/server/pkg/mutation/event.go +++ b/packages/server/pkg/mutation/event.go @@ -38,6 +38,7 @@ const ( EntityFlowNodeAI EntityFlowNodeAiProvider EntityFlowNodeMemory + EntityFlowNodeGraphQL EntityFlowEdge EntityFlowVariable EntityFlowTag @@ -47,6 +48,12 @@ const ( // Credential entities EntityCredential + + // GraphQL entities + EntityGraphQL + EntityGraphQLHeader + EntityGraphQLResponse + EntityGraphQLResponseHeader ) // Operation identifies the type of mutation. diff --git a/packages/server/pkg/service/sflow/node_execution_mapper.go b/packages/server/pkg/service/sflow/node_execution_mapper.go index 81beb09ab..0449b86fe 100644 --- a/packages/server/pkg/service/sflow/node_execution_mapper.go +++ b/packages/server/pkg/service/sflow/node_execution_mapper.go @@ -34,6 +34,7 @@ func ConvertNodeExecutionToDB(ne mflow.NodeExecution) *gen.NodeExecution { OutputDataCompressType: ne.OutputDataCompressType, Error: errorSQL, HttpResponseID: ne.ResponseID, + GraphqlResponseID: ne.GraphQLResponseID, CompletedAt: completedAtSQL, } } @@ -62,6 +63,7 @@ func ConvertNodeExecutionToModel(ne gen.NodeExecution) *mflow.NodeExecution { OutputDataCompressType: ne.OutputDataCompressType, Error: errorPtr, ResponseID: responseIDPtr, + GraphQLResponseID: ne.GraphqlResponseID, CompletedAt: completedAtPtr, } } diff --git a/packages/server/pkg/service/sflow/node_execution_writer.go b/packages/server/pkg/service/sflow/node_execution_writer.go index 80d26759c..6b59dfdba 100644 --- a/packages/server/pkg/service/sflow/node_execution_writer.go +++ b/packages/server/pkg/service/sflow/node_execution_writer.go @@ -48,6 +48,7 @@ func (w *NodeExecutionWriter) CreateNodeExecution(ctx context.Context, ne mflow. OutputData: ne.OutputData, OutputDataCompressType: ne.OutputDataCompressType, HttpResponseID: ne.ResponseID, + GraphqlResponseID: ne.GraphQLResponseID, CompletedAt: completedAtSQL, }) @@ -78,6 +79,7 @@ func (w *NodeExecutionWriter) UpdateNodeExecution(ctx context.Context, ne mflow. OutputData: ne.OutputData, OutputDataCompressType: ne.OutputDataCompressType, HttpResponseID: ne.ResponseID, + GraphqlResponseID: ne.GraphQLResponseID, CompletedAt: completedAtSQL, }) @@ -112,6 +114,7 @@ func (w *NodeExecutionWriter) UpsertNodeExecution(ctx context.Context, ne mflow. OutputData: ne.OutputData, OutputDataCompressType: ne.OutputDataCompressType, HttpResponseID: ne.ResponseID, + GraphqlResponseID: ne.GraphQLResponseID, CompletedAt: completedAtSQL, }) diff --git a/packages/server/pkg/service/sflow/node_graphql.go b/packages/server/pkg/service/sflow/node_graphql.go new file mode 100644 index 000000000..843fdc6e5 --- /dev/null +++ b/packages/server/pkg/service/sflow/node_graphql.go @@ -0,0 +1,60 @@ +//nolint:revive // exported +package sflow + +import ( + "context" + "database/sql" + + "github.com/the-dev-tools/dev-tools/packages/db/pkg/sqlc/gen" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mflow" +) + +type NodeGraphQLService struct { + reader *NodeGraphQLReader + queries *gen.Queries +} + +func NewNodeGraphQLService(queries *gen.Queries) NodeGraphQLService { + return NodeGraphQLService{ + reader: NewNodeGraphQLReaderFromQueries(queries), + queries: queries, + } +} + +func (ngs NodeGraphQLService) TX(tx *sql.Tx) NodeGraphQLService { + newQueries := ngs.queries.WithTx(tx) + return NodeGraphQLService{ + reader: NewNodeGraphQLReaderFromQueries(newQueries), + queries: newQueries, + } +} + +func NewNodeGraphQLServiceTX(ctx context.Context, tx *sql.Tx) (*NodeGraphQLService, error) { + queries, err := gen.Prepare(ctx, tx) + if err != nil { + return nil, err + } + return &NodeGraphQLService{ + reader: NewNodeGraphQLReaderFromQueries(queries), + queries: queries, + }, nil +} + +func (ngs NodeGraphQLService) GetNodeGraphQL(ctx context.Context, id idwrap.IDWrap) (*mflow.NodeGraphQL, error) { + return ngs.reader.GetNodeGraphQL(ctx, id) +} + +func (ngs NodeGraphQLService) CreateNodeGraphQL(ctx context.Context, ng mflow.NodeGraphQL) error { + return NewNodeGraphQLWriterFromQueries(ngs.queries).CreateNodeGraphQL(ctx, ng) +} + +func (ngs NodeGraphQLService) UpdateNodeGraphQL(ctx context.Context, ng mflow.NodeGraphQL) error { + return NewNodeGraphQLWriterFromQueries(ngs.queries).UpdateNodeGraphQL(ctx, ng) +} + +func (ngs NodeGraphQLService) DeleteNodeGraphQL(ctx context.Context, id idwrap.IDWrap) error { + return NewNodeGraphQLWriterFromQueries(ngs.queries).DeleteNodeGraphQL(ctx, id) +} + +func (ngs NodeGraphQLService) Reader() *NodeGraphQLReader { return ngs.reader } diff --git a/packages/server/pkg/service/sflow/node_graphql_mapper.go b/packages/server/pkg/service/sflow/node_graphql_mapper.go new file mode 100644 index 000000000..347032e30 --- /dev/null +++ b/packages/server/pkg/service/sflow/node_graphql_mapper.go @@ -0,0 +1,26 @@ +package sflow + +import ( + "github.com/the-dev-tools/dev-tools/packages/db/pkg/sqlc/gen" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mflow" +) + +func ConvertToDBNodeGraphQL(ng mflow.NodeGraphQL) (gen.FlowNodeGraphql, bool) { + if ng.GraphQLID == nil || isZeroID(*ng.GraphQLID) { + return gen.FlowNodeGraphql{}, false + } + + return gen.FlowNodeGraphql{ + FlowNodeID: ng.FlowNodeID, + GraphqlID: *ng.GraphQLID, + }, true +} + +func ConvertToModelNodeGraphQL(ng gen.FlowNodeGraphql) *mflow.NodeGraphQL { + graphqlID := ng.GraphqlID + + return &mflow.NodeGraphQL{ + FlowNodeID: ng.FlowNodeID, + GraphQLID: &graphqlID, + } +} diff --git a/packages/server/pkg/service/sflow/node_graphql_reader.go b/packages/server/pkg/service/sflow/node_graphql_reader.go new file mode 100644 index 000000000..a299682bd --- /dev/null +++ b/packages/server/pkg/service/sflow/node_graphql_reader.go @@ -0,0 +1,34 @@ +package sflow + +import ( + "context" + "database/sql" + "errors" + + "github.com/the-dev-tools/dev-tools/packages/db/pkg/sqlc/gen" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mflow" +) + +type NodeGraphQLReader struct { + queries *gen.Queries +} + +func NewNodeGraphQLReader(db *sql.DB) *NodeGraphQLReader { + return &NodeGraphQLReader{queries: gen.New(db)} +} + +func NewNodeGraphQLReaderFromQueries(queries *gen.Queries) *NodeGraphQLReader { + return &NodeGraphQLReader{queries: queries} +} + +func (r *NodeGraphQLReader) GetNodeGraphQL(ctx context.Context, id idwrap.IDWrap) (*mflow.NodeGraphQL, error) { + nodeGQL, err := r.queries.GetFlowNodeGraphQL(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return ConvertToModelNodeGraphQL(nodeGQL), nil +} diff --git a/packages/server/pkg/service/sflow/node_graphql_writer.go b/packages/server/pkg/service/sflow/node_graphql_writer.go new file mode 100644 index 000000000..859beff0a --- /dev/null +++ b/packages/server/pkg/service/sflow/node_graphql_writer.go @@ -0,0 +1,47 @@ +package sflow + +import ( + "context" + "database/sql" + "errors" + + "github.com/the-dev-tools/dev-tools/packages/db/pkg/sqlc/gen" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mflow" +) + +type NodeGraphQLWriter struct { + queries *gen.Queries +} + +func NewNodeGraphQLWriter(tx gen.DBTX) *NodeGraphQLWriter { + return &NodeGraphQLWriter{queries: gen.New(tx)} +} + +func NewNodeGraphQLWriterFromQueries(queries *gen.Queries) *NodeGraphQLWriter { + return &NodeGraphQLWriter{queries: queries} +} + +func (w *NodeGraphQLWriter) CreateNodeGraphQL(ctx context.Context, ng mflow.NodeGraphQL) error { + dbModel, ok := ConvertToDBNodeGraphQL(ng) + if !ok { + return nil + } + return w.queries.CreateFlowNodeGraphQL(ctx, gen.CreateFlowNodeGraphQLParams(dbModel)) +} + +func (w *NodeGraphQLWriter) UpdateNodeGraphQL(ctx context.Context, ng mflow.NodeGraphQL) error { + dbModel, ok := ConvertToDBNodeGraphQL(ng) + if !ok { + // Treat removal of GraphQLID as request to delete any existing binding. + if err := w.queries.DeleteFlowNodeGraphQL(ctx, ng.FlowNodeID); err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + return nil + } + return w.queries.UpdateFlowNodeGraphQL(ctx, gen.UpdateFlowNodeGraphQLParams(dbModel)) +} + +func (w *NodeGraphQLWriter) DeleteNodeGraphQL(ctx context.Context, id idwrap.IDWrap) error { + return w.queries.DeleteFlowNodeGraphQL(ctx, id) +} diff --git a/packages/server/pkg/service/sgraphql/header.go b/packages/server/pkg/service/sgraphql/header.go new file mode 100644 index 000000000..2bbb951e5 --- /dev/null +++ b/packages/server/pkg/service/sgraphql/header.go @@ -0,0 +1,88 @@ +package sgraphql + +import ( + "context" + "database/sql" + "errors" + + "github.com/the-dev-tools/dev-tools/packages/db/pkg/sqlc/gen" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/dbtime" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" +) + +var ErrNoGraphQLHeaderFound = errors.New("no graphql header found") + +type GraphQLHeaderService struct { + queries *gen.Queries +} + +func NewGraphQLHeaderService(queries *gen.Queries) GraphQLHeaderService { + return GraphQLHeaderService{queries: queries} +} + +func (s GraphQLHeaderService) TX(tx *sql.Tx) GraphQLHeaderService { + return GraphQLHeaderService{queries: s.queries.WithTx(tx)} +} + +func (s GraphQLHeaderService) GetByGraphQLID(ctx context.Context, graphqlID idwrap.IDWrap) ([]mgraphql.GraphQLHeader, error) { + headers, err := s.queries.GetGraphQLHeaders(ctx, graphqlID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return []mgraphql.GraphQLHeader{}, nil + } + return nil, err + } + + result := make([]mgraphql.GraphQLHeader, len(headers)) + for i, h := range headers { + result[i] = ConvertToModelGraphQLHeader(h) + } + return result, nil +} + +func (s GraphQLHeaderService) GetByIDs(ctx context.Context, ids []idwrap.IDWrap) ([]mgraphql.GraphQLHeader, error) { + headers, err := s.queries.GetGraphQLHeadersByIDs(ctx, ids) + if err != nil { + return nil, err + } + + result := make([]mgraphql.GraphQLHeader, len(headers)) + for i, h := range headers { + result[i] = ConvertToModelGraphQLHeader(h) + } + return result, nil +} + +func (s GraphQLHeaderService) Create(ctx context.Context, header *mgraphql.GraphQLHeader) error { + now := dbtime.DBNow() + header.CreatedAt = now.Unix() + header.UpdatedAt = now.Unix() + + return s.queries.CreateGraphQLHeader(ctx, gen.CreateGraphQLHeaderParams{ + ID: header.ID, + GraphqlID: header.GraphQLID, + HeaderKey: header.Key, + HeaderValue: header.Value, + Description: header.Description, + Enabled: header.Enabled, + DisplayOrder: float64(header.DisplayOrder), + CreatedAt: header.CreatedAt, + UpdatedAt: header.UpdatedAt, + }) +} + +func (s GraphQLHeaderService) Update(ctx context.Context, header *mgraphql.GraphQLHeader) error { + return s.queries.UpdateGraphQLHeader(ctx, gen.UpdateGraphQLHeaderParams{ + ID: header.ID, + HeaderKey: header.Key, + HeaderValue: header.Value, + Description: header.Description, + Enabled: header.Enabled, + DisplayOrder: float64(header.DisplayOrder), + }) +} + +func (s GraphQLHeaderService) Delete(ctx context.Context, id idwrap.IDWrap) error { + return s.queries.DeleteGraphQLHeader(ctx, id) +} diff --git a/packages/server/pkg/service/sgraphql/mapper.go b/packages/server/pkg/service/sgraphql/mapper.go new file mode 100644 index 000000000..2dbca4f62 --- /dev/null +++ b/packages/server/pkg/service/sgraphql/mapper.go @@ -0,0 +1,111 @@ +package sgraphql + +import ( + "time" + + "github.com/the-dev-tools/dev-tools/packages/db/pkg/sqlc/gen" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" +) + +func interfaceToInt64Ptr(v interface{}) *int64 { + if v == nil { + return nil + } + switch val := v.(type) { + case int64: + return &val + case int: + i := int64(val) + return &i + default: + return nil + } +} + +func interfaceToInt32(v interface{}) int32 { + switch val := v.(type) { + case int32: + return val + case int64: + return int32(val) //nolint:gosec // G115 + default: + return 0 + } +} + +func ConvertToDBGraphQL(gql mgraphql.GraphQL) gen.Graphql { + var lastRunAt interface{} + if gql.LastRunAt != nil { + lastRunAt = *gql.LastRunAt + } + + return gen.Graphql{ + ID: gql.ID, + WorkspaceID: gql.WorkspaceID, + FolderID: gql.FolderID, + Name: gql.Name, + Url: gql.Url, + Query: gql.Query, + Variables: gql.Variables, + Description: gql.Description, + LastRunAt: lastRunAt, + CreatedAt: gql.CreatedAt, + UpdatedAt: gql.UpdatedAt, + } +} + +func ConvertToModelGraphQL(gql gen.Graphql) *mgraphql.GraphQL { + return &mgraphql.GraphQL{ + ID: gql.ID, + WorkspaceID: gql.WorkspaceID, + FolderID: gql.FolderID, + Name: gql.Name, + Url: gql.Url, + Query: gql.Query, + Variables: gql.Variables, + Description: gql.Description, + LastRunAt: interfaceToInt64Ptr(gql.LastRunAt), + CreatedAt: gql.CreatedAt, + UpdatedAt: gql.UpdatedAt, + } +} + +func ConvertToDBGraphQLResponse(resp mgraphql.GraphQLResponse) gen.GraphqlResponse { + return gen.GraphqlResponse{ + ID: resp.ID, + GraphqlID: resp.GraphQLID, + Status: resp.Status, + Body: resp.Body, + Time: time.Unix(resp.Time, 0), + Duration: resp.Duration, + Size: resp.Size, + CreatedAt: resp.CreatedAt, + } +} + +func ConvertToModelGraphQLResponse(resp gen.GraphqlResponse) mgraphql.GraphQLResponse { + return mgraphql.GraphQLResponse{ + ID: resp.ID, + GraphQLID: resp.GraphqlID, + Status: interfaceToInt32(resp.Status), + Body: resp.Body, + Time: resp.Time.Unix(), + Duration: interfaceToInt32(resp.Duration), + Size: interfaceToInt32(resp.Size), + CreatedAt: resp.CreatedAt, + } +} + +func ConvertToModelGraphQLHeader(h gen.GraphqlHeader) mgraphql.GraphQLHeader { + return mgraphql.GraphQLHeader{ + ID: h.ID, + GraphQLID: h.GraphqlID, + Key: h.HeaderKey, + Value: h.HeaderValue, + Enabled: h.Enabled, + Description: h.Description, + DisplayOrder: float32(h.DisplayOrder), + CreatedAt: h.CreatedAt, + UpdatedAt: h.UpdatedAt, + } +} diff --git a/packages/server/pkg/service/sgraphql/reader.go b/packages/server/pkg/service/sgraphql/reader.go new file mode 100644 index 000000000..4b3733f79 --- /dev/null +++ b/packages/server/pkg/service/sgraphql/reader.go @@ -0,0 +1,73 @@ +package sgraphql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + + "github.com/the-dev-tools/dev-tools/packages/db/pkg/sqlc/gen" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" +) + +type Reader struct { + queries *gen.Queries + logger *slog.Logger +} + +func NewReader(db *sql.DB, logger *slog.Logger) *Reader { + return &Reader{ + queries: gen.New(db), + logger: logger, + } +} + +func NewReaderFromQueries(queries *gen.Queries, logger *slog.Logger) *Reader { + return &Reader{ + queries: queries, + logger: logger, + } +} + +func (r *Reader) Get(ctx context.Context, id idwrap.IDWrap) (*mgraphql.GraphQL, error) { + gql, err := r.queries.GetGraphQL(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + if r.logger != nil { + r.logger.DebugContext(ctx, fmt.Sprintf("GraphQL ID: %s not found", id.String())) + } + return nil, ErrNoGraphQLFound + } + return nil, err + } + return ConvertToModelGraphQL(gql), nil +} + +func (r *Reader) GetByWorkspaceID(ctx context.Context, workspaceID idwrap.IDWrap) ([]mgraphql.GraphQL, error) { + gqls, err := r.queries.GetGraphQLsByWorkspaceID(ctx, workspaceID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return []mgraphql.GraphQL{}, nil + } + return nil, err + } + + result := make([]mgraphql.GraphQL, len(gqls)) + for i, gql := range gqls { + result[i] = *ConvertToModelGraphQL(gql) + } + return result, nil +} + +func (r *Reader) GetWorkspaceID(ctx context.Context, id idwrap.IDWrap) (idwrap.IDWrap, error) { + workspaceID, err := r.queries.GetGraphQLWorkspaceID(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return idwrap.IDWrap{}, ErrNoGraphQLFound + } + return idwrap.IDWrap{}, err + } + return workspaceID, nil +} diff --git a/packages/server/pkg/service/sgraphql/response.go b/packages/server/pkg/service/sgraphql/response.go new file mode 100644 index 000000000..0498d1871 --- /dev/null +++ b/packages/server/pkg/service/sgraphql/response.go @@ -0,0 +1,122 @@ +package sgraphql + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/the-dev-tools/dev-tools/packages/db/pkg/sqlc/gen" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" +) + +var ErrNoGraphQLResponseFound = errors.New("no graphql response found") + +type GraphQLResponseService struct { + queries *gen.Queries +} + +func NewGraphQLResponseService(queries *gen.Queries) GraphQLResponseService { + return GraphQLResponseService{queries: queries} +} + +func (s GraphQLResponseService) TX(tx *sql.Tx) GraphQLResponseService { + return GraphQLResponseService{queries: s.queries.WithTx(tx)} +} + +func (s GraphQLResponseService) Create(ctx context.Context, resp mgraphql.GraphQLResponse) error { + return s.queries.CreateGraphQLResponse(ctx, gen.CreateGraphQLResponseParams{ + ID: resp.ID, + GraphqlID: resp.GraphQLID, + Status: resp.Status, + Body: resp.Body, + Time: time.Unix(resp.Time, 0), + Duration: resp.Duration, + Size: resp.Size, + CreatedAt: resp.CreatedAt, + }) +} + +func (s GraphQLResponseService) GetByGraphQLID(ctx context.Context, graphqlID idwrap.IDWrap) ([]mgraphql.GraphQLResponse, error) { + responses, err := s.queries.GetGraphQLResponsesByGraphQLID(ctx, graphqlID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return []mgraphql.GraphQLResponse{}, nil + } + return nil, err + } + + result := make([]mgraphql.GraphQLResponse, len(responses)) + for i, resp := range responses { + result[i] = ConvertToModelGraphQLResponse(resp) + } + return result, nil +} + +func (s GraphQLResponseService) GetByWorkspaceID(ctx context.Context, workspaceID idwrap.IDWrap) ([]mgraphql.GraphQLResponse, error) { + responses, err := s.queries.GetGraphQLResponsesByWorkspaceID(ctx, workspaceID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return []mgraphql.GraphQLResponse{}, nil + } + return nil, err + } + + result := make([]mgraphql.GraphQLResponse, len(responses)) + for i, resp := range responses { + result[i] = ConvertToModelGraphQLResponse(resp) + } + return result, nil +} + +func (s GraphQLResponseService) CreateHeader(ctx context.Context, header mgraphql.GraphQLResponseHeader) error { + return s.queries.CreateGraphQLResponseHeader(ctx, gen.CreateGraphQLResponseHeaderParams{ + ID: header.ID, + ResponseID: header.ResponseID, + Key: header.HeaderKey, + Value: header.HeaderValue, + CreatedAt: header.CreatedAt, + }) +} + +func (s GraphQLResponseService) GetHeadersByWorkspaceID(ctx context.Context, workspaceID idwrap.IDWrap) ([]mgraphql.GraphQLResponseHeader, error) { + headers, err := s.queries.GetGraphQLResponseHeadersByWorkspaceID(ctx, workspaceID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return []mgraphql.GraphQLResponseHeader{}, nil + } + return nil, err + } + + result := make([]mgraphql.GraphQLResponseHeader, len(headers)) + for i, h := range headers { + result[i] = mgraphql.GraphQLResponseHeader{ + ID: h.ID, + ResponseID: h.ResponseID, + HeaderKey: h.Key, + HeaderValue: h.Value, + CreatedAt: h.CreatedAt, + } + } + return result, nil +} + +func (s GraphQLResponseService) GetHeadersByResponseID(ctx context.Context, responseID idwrap.IDWrap) ([]mgraphql.GraphQLResponseHeader, error) { + headers, err := s.queries.GetGraphQLResponseHeadersByResponseID(ctx, responseID) + if err != nil { + return nil, err + } + + result := make([]mgraphql.GraphQLResponseHeader, len(headers)) + for i, h := range headers { + result[i] = mgraphql.GraphQLResponseHeader{ + ID: h.ID, + ResponseID: h.ResponseID, + HeaderKey: h.Key, + HeaderValue: h.Value, + CreatedAt: h.CreatedAt, + } + } + return result, nil +} diff --git a/packages/server/pkg/service/sgraphql/sgraphql.go b/packages/server/pkg/service/sgraphql/sgraphql.go new file mode 100644 index 000000000..120f02f65 --- /dev/null +++ b/packages/server/pkg/service/sgraphql/sgraphql.go @@ -0,0 +1,62 @@ +package sgraphql + +import ( + "context" + "database/sql" + "log/slog" + + "github.com/the-dev-tools/dev-tools/packages/db/pkg/sqlc/gen" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" +) + +var ErrNoGraphQLFound = sql.ErrNoRows + +type GraphQLService struct { + reader *Reader + queries *gen.Queries + logger *slog.Logger +} + +func New(queries *gen.Queries, logger *slog.Logger) GraphQLService { + return GraphQLService{ + reader: NewReaderFromQueries(queries, logger), + queries: queries, + logger: logger, + } +} + +func (s GraphQLService) TX(tx *sql.Tx) GraphQLService { + newQueries := s.queries.WithTx(tx) + return GraphQLService{ + reader: NewReaderFromQueries(newQueries, s.logger), + queries: newQueries, + logger: s.logger, + } +} + +func (s GraphQLService) Create(ctx context.Context, gql *mgraphql.GraphQL) error { + return NewWriterFromQueries(s.queries).Create(ctx, gql) +} + +func (s GraphQLService) Get(ctx context.Context, id idwrap.IDWrap) (*mgraphql.GraphQL, error) { + return s.reader.Get(ctx, id) +} + +func (s GraphQLService) GetByWorkspaceID(ctx context.Context, workspaceID idwrap.IDWrap) ([]mgraphql.GraphQL, error) { + return s.reader.GetByWorkspaceID(ctx, workspaceID) +} + +func (s GraphQLService) GetWorkspaceID(ctx context.Context, id idwrap.IDWrap) (idwrap.IDWrap, error) { + return s.reader.GetWorkspaceID(ctx, id) +} + +func (s GraphQLService) Update(ctx context.Context, gql *mgraphql.GraphQL) error { + return NewWriterFromQueries(s.queries).Update(ctx, gql) +} + +func (s GraphQLService) Delete(ctx context.Context, id idwrap.IDWrap) error { + return NewWriterFromQueries(s.queries).Delete(ctx, id) +} + +func (s GraphQLService) Reader() *Reader { return s.reader } diff --git a/packages/server/pkg/service/sgraphql/writer.go b/packages/server/pkg/service/sgraphql/writer.go new file mode 100644 index 000000000..3a16f4efc --- /dev/null +++ b/packages/server/pkg/service/sgraphql/writer.go @@ -0,0 +1,50 @@ +package sgraphql + +import ( + "context" + + "github.com/the-dev-tools/dev-tools/packages/db/pkg/sqlc/gen" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/dbtime" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" +) + +type Writer struct { + queries *gen.Queries +} + +func NewWriterFromQueries(queries *gen.Queries) *Writer { + return &Writer{queries: queries} +} + +func (w *Writer) Create(ctx context.Context, gql *mgraphql.GraphQL) error { + now := dbtime.DBNow() + gql.CreatedAt = now.Unix() + gql.UpdatedAt = now.Unix() + + dbGQL := ConvertToDBGraphQL(*gql) + return w.queries.CreateGraphQL(ctx, gen.CreateGraphQLParams(dbGQL)) +} + +func (w *Writer) Update(ctx context.Context, gql *mgraphql.GraphQL) error { + gql.UpdatedAt = dbtime.DBNow().Unix() + + var lastRunAt interface{} + if gql.LastRunAt != nil { + lastRunAt = *gql.LastRunAt + } + + return w.queries.UpdateGraphQL(ctx, gen.UpdateGraphQLParams{ + ID: gql.ID, + Name: gql.Name, + Url: gql.Url, + Query: gql.Query, + Variables: gql.Variables, + Description: gql.Description, + LastRunAt: lastRunAt, + }) +} + +func (w *Writer) Delete(ctx context.Context, id idwrap.IDWrap) error { + return w.queries.DeleteGraphQL(ctx, id) +} diff --git a/packages/server/test/e2e_har_to_cli_test.go b/packages/server/test/e2e_har_to_cli_test.go index bc80a209e..35576c196 100644 --- a/packages/server/test/e2e_har_to_cli_test.go +++ b/packages/server/test/e2e_har_to_cli_test.go @@ -27,6 +27,7 @@ import ( "github.com/the-dev-tools/dev-tools/packages/server/internal/api/rimportv2" "github.com/the-dev-tools/dev-tools/packages/server/pkg/eventstream/memory" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/flowbuilder" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/node/ngraphql" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/node/nrequest" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/runner" "github.com/the-dev-tools/dev-tools/packages/server/pkg/flow/runner/flowlocalrunner" @@ -271,6 +272,9 @@ func TestE2E_HAR_To_CLI_Chain(t *testing.T) { nil, // NodeAIService nil, // NodeAiProviderService nil, // NodeMemoryService + nil, // NodeGraphQLService + nil, // GraphQLService + nil, // GraphQLHeaderService cli.Workspace, cli.Variable, cli.FlowVariable, @@ -475,6 +479,16 @@ func executeFlow(ctx context.Context, flowPtr *mflow.Flow, c *cliServices, build }() defer close(requestRespChan) + gqlRespChan := make(chan ngraphql.NodeGraphQLSideResp, requestBufferSize) + go func() { + for resp := range gqlRespChan { + if resp.Done != nil { + close(resp.Done) + } + } + }() + defer close(gqlRespChan) + // Build flow node map flowNodeMap, startNodeID, err := builder.BuildNodes( ctx, @@ -483,6 +497,7 @@ func executeFlow(ctx context.Context, flowPtr *mflow.Flow, c *cliServices, build nodeTimeout, httpClient, requestRespChan, + gqlRespChan, nil, // No JS client needed for this test ) if err != nil { diff --git a/packages/spec/api/file-system.tsp b/packages/spec/api/file-system.tsp index b8d639bef..88fcdb0b7 100644 --- a/packages/spec/api/file-system.tsp +++ b/packages/spec/api/file-system.tsp @@ -8,6 +8,7 @@ enum FileKind { HttpDelta, Flow, Credential, + GraphQL, } @TanStackDB.collection diff --git a/packages/spec/api/flow.tsp b/packages/spec/api/flow.tsp index 5887df404..a1d243459 100644 --- a/packages/spec/api/flow.tsp +++ b/packages/spec/api/flow.tsp @@ -70,6 +70,7 @@ enum NodeKind { Ai, AiProvider, AiMemory, + GraphQL, } enum AiMemoryType { @@ -134,6 +135,12 @@ model NodeHttp { @foreignKey deltaHttpId?: Id; } +@TanStackDB.collection +model NodeGraphQL { + @primaryKey nodeId: Id; + @foreignKey graphqlId: Id; +} + enum ErrorHandling { Ignore, Break, @@ -200,5 +207,6 @@ model NodeExecution { input?: Protobuf.WellKnown.Json; output?: Protobuf.WellKnown.Json; httpResponseId?: Id; + graphqlResponseId?: Id; completedAt?: Protobuf.WellKnown.Timestamp; } diff --git a/packages/spec/api/graphql.tsp b/packages/spec/api/graphql.tsp new file mode 100644 index 000000000..22a540eae --- /dev/null +++ b/packages/spec/api/graphql.tsp @@ -0,0 +1,66 @@ +using DevTools; + +namespace Api.GraphQL; + +@TanStackDB.collection +model GraphQL { + @primaryKey graphqlId: Id; + name: string; + url: string; + query: string; + variables: string; + lastRunAt?: Protobuf.WellKnown.Timestamp; +} + +@TanStackDB.collection +model GraphQLHeader { + @primaryKey graphqlHeaderId: Id; + @foreignKey graphqlId: Id; + key: string; + value: string; + enabled: boolean; + description: string; + order: float32; +} + +@TanStackDB.collection(#{ isReadOnly: true }) +model GraphQLResponse { + @primaryKey graphqlResponseId: Id; + @foreignKey graphqlId: Id; + status: int32; + body: string; + time: Protobuf.WellKnown.Timestamp; + duration: int32; + size: int32; +} + +@TanStackDB.collection(#{ isReadOnly: true }) +model GraphQLResponseHeader { + @primaryKey graphqlResponseHeaderId: Id; + @foreignKey graphqlResponseId: Id; + key: string; + value: string; +} + +model GraphQLRunRequest { + graphqlId: Id; +} + +op GraphQLRun(...GraphQLRunRequest): {}; + +model GraphQLDuplicateRequest { + graphqlId: Id; +} + +op GraphQLDuplicate(...GraphQLDuplicateRequest): {}; + +model GraphQLIntrospectRequest { + graphqlId: Id; +} + +model GraphQLIntrospectResponse { + sdl: string; + introspectionJson: string; +} + +op GraphQLIntrospect(...GraphQLIntrospectRequest): GraphQLIntrospectResponse; diff --git a/packages/spec/api/main.tsp b/packages/spec/api/main.tsp index 89c8ab389..329652cf7 100644 --- a/packages/spec/api/main.tsp +++ b/packages/spec/api/main.tsp @@ -7,6 +7,7 @@ import "./environment.tsp"; import "./export.tsp"; import "./file-system.tsp"; import "./flow.tsp"; +import "./graphql.tsp"; import "./health.tsp"; import "./http.tsp"; import "./import.tsp"; From a43caadf8397a59cc7ce6edbc1ac8b1059b73b53 Mon Sep 17 00:00:00 2001 From: moosebay Date: Fri, 13 Feb 2026 17:37:07 +0300 Subject: [PATCH 2/2] feat: add GraphQL CLI support and YAML export/import Wire GraphQL services (NodeGraphQL, GraphQL, GraphQLHeader) into the CLI so flow nodes with GraphQL steps execute instead of panicking. Add full YAML round-trip support: export serializes graphql_requests and graphql flow steps, import deserializes them back with use_request template resolution. --- apps/cli/cmd/flow.go | 6 +- apps/cli/internal/common/services.go | 11 ++ packages/server/pkg/ioworkspace/exporter.go | 47 ++++++- packages/server/pkg/ioworkspace/importer.go | 26 ++++ .../server/pkg/ioworkspace/importer_flow.go | 58 ++++++++ packages/server/pkg/ioworkspace/types.go | 26 +++- .../translate/yamlflowsimplev2/converter.go | 10 +- .../yamlflowsimplev2/converter_flow.go | 8 +- .../yamlflowsimplev2/converter_node.go | 101 +++++++++++++- .../translate/yamlflowsimplev2/exporter.go | 125 +++++++++++++++++- .../pkg/translate/yamlflowsimplev2/types.go | 26 ++++ .../pkg/translate/yamlflowsimplev2/utils.go | 11 ++ 12 files changed, 442 insertions(+), 13 deletions(-) diff --git a/apps/cli/cmd/flow.go b/apps/cli/cmd/flow.go index f622604ad..9651556fa 100644 --- a/apps/cli/cmd/flow.go +++ b/apps/cli/cmd/flow.go @@ -176,9 +176,9 @@ var yamlflowRunCmd = &cobra.Command{ &services.NodeAI, &services.NodeAiProvider, &services.NodeMemory, - nil, // NodeGraphQLService - not yet supported in CLI - nil, // GraphQLService - not yet supported in CLI - nil, // GraphQLHeaderService - not yet supported in CLI + &services.NodeGraphQL, + &services.GraphQL, + &services.GraphQLHeader, &services.Workspace, &services.Variable, &services.FlowVariable, diff --git a/apps/cli/internal/common/services.go b/apps/cli/internal/common/services.go index 09780f11a..4a6e2ff5e 100644 --- a/apps/cli/internal/common/services.go +++ b/apps/cli/internal/common/services.go @@ -10,6 +10,7 @@ import ( "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/scredential" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/senv" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sflow" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sgraphql" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/shttp" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sworkspace" ) @@ -40,6 +41,11 @@ type Services struct { NodeAI sflow.NodeAIService NodeAiProvider sflow.NodeAiProviderService NodeMemory sflow.NodeMemoryService + NodeGraphQL sflow.NodeGraphQLService + + // GraphQL + GraphQL sgraphql.GraphQLService + GraphQLHeader sgraphql.GraphQLHeaderService // Credentials Credential scredential.CredentialService @@ -87,6 +93,11 @@ func CreateServices(ctx context.Context, db *sql.DB, logger *slog.Logger) (*Serv NodeAI: sflow.NewNodeAIService(queries), NodeAiProvider: sflow.NewNodeAiProviderService(queries), NodeMemory: sflow.NewNodeMemoryService(queries), + NodeGraphQL: sflow.NewNodeGraphQLService(queries), + + // GraphQL + GraphQL: sgraphql.New(queries, logger), + GraphQLHeader: sgraphql.NewGraphQLHeaderService(queries), // Credentials Credential: scredential.NewCredentialService(queries), diff --git a/packages/server/pkg/ioworkspace/exporter.go b/packages/server/pkg/ioworkspace/exporter.go index 4bba6d4db..e4ec7977d 100644 --- a/packages/server/pkg/ioworkspace/exporter.go +++ b/packages/server/pkg/ioworkspace/exporter.go @@ -12,6 +12,7 @@ import ( "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/senv" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sfile" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sflow" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sgraphql" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/shttp" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sworkspace" ) @@ -52,6 +53,10 @@ func (s *IOWorkspaceService) Export(ctx context.Context, opts ExportOptions) (*W if err := s.exportHTTP(ctx, opts, bundle); err != nil { return nil, fmt.Errorf("failed to export HTTP requests: %w", err) } + + if err := s.exportGraphQL(ctx, opts, bundle); err != nil { + return nil, fmt.Errorf("failed to export GraphQL requests: %w", err) + } } // Export flows if requested @@ -213,6 +218,7 @@ func (s *IOWorkspaceService) exportFlows(ctx context.Context, opts ExportOptions nodeForService := sflow.NewNodeForService(s.queries) nodeForEachService := sflow.NewNodeForEachService(s.queries) nodeJSService := sflow.NewNodeJsService(s.queries) + nodeGraphQLService := sflow.NewNodeGraphQLService(s.queries) var flowIDs []idwrap.IDWrap @@ -266,7 +272,7 @@ func (s *IOWorkspaceService) exportFlows(ctx context.Context, opts ExportOptions // Export node implementations based on node types for _, node := range nodes { - if err := s.exportNodeImplementation(ctx, node, bundle, nodeRequestService, nodeIfService, nodeForService, nodeForEachService, nodeJSService); err != nil { + if err := s.exportNodeImplementation(ctx, node, bundle, nodeRequestService, nodeIfService, nodeForService, nodeForEachService, nodeJSService, nodeGraphQLService); err != nil { return fmt.Errorf("failed to export node implementation for node %s: %w", node.ID.String(), err) } } @@ -280,7 +286,34 @@ func (s *IOWorkspaceService) exportFlows(ctx context.Context, opts ExportOptions "condition_nodes", len(bundle.FlowConditionNodes), "for_nodes", len(bundle.FlowForNodes), "foreach_nodes", len(bundle.FlowForEachNodes), - "js_nodes", len(bundle.FlowJSNodes)) + "js_nodes", len(bundle.FlowJSNodes), + "graphql_nodes", len(bundle.FlowGraphQLNodes)) + + return nil +} + +// exportGraphQL exports GraphQL requests and their headers +func (s *IOWorkspaceService) exportGraphQL(ctx context.Context, opts ExportOptions, bundle *WorkspaceBundle) error { + graphqlService := sgraphql.New(s.queries, s.logger) + graphqlHeaderService := sgraphql.NewGraphQLHeaderService(s.queries) + + gqlRequests, err := graphqlService.GetByWorkspaceID(ctx, opts.WorkspaceID) + if err != nil { + return fmt.Errorf("failed to get GraphQL requests: %w", err) + } + bundle.GraphQLRequests = gqlRequests + + for _, gql := range gqlRequests { + headers, err := graphqlHeaderService.GetByGraphQLID(ctx, gql.ID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("failed to get headers for GraphQL %s: %w", gql.ID.String(), err) + } + bundle.GraphQLHeaders = append(bundle.GraphQLHeaders, headers...) + } + + s.logger.DebugContext(ctx, "Exported GraphQL requests", + "count", len(bundle.GraphQLRequests), + "headers", len(bundle.GraphQLHeaders)) return nil } @@ -295,6 +328,7 @@ func (s *IOWorkspaceService) exportNodeImplementation( nodeForService sflow.NodeForService, nodeForEachService sflow.NodeForEachService, nodeJSService sflow.NodeJsService, + nodeGraphQLService sflow.NodeGraphQLService, ) error { switch node.NodeKind { case mflow.NODE_KIND_REQUEST: @@ -341,6 +375,15 @@ func (s *IOWorkspaceService) exportNodeImplementation( if nodeJS != nil { bundle.FlowJSNodes = append(bundle.FlowJSNodes, *nodeJS) } + + case mflow.NODE_KIND_GRAPHQL: + nodeGraphQL, err := nodeGraphQLService.GetNodeGraphQL(ctx, node.ID) + if err != nil { + return fmt.Errorf("failed to get graphql node: %w", err) + } + if nodeGraphQL != nil { + bundle.FlowGraphQLNodes = append(bundle.FlowGraphQLNodes, *nodeGraphQL) + } } return nil diff --git a/packages/server/pkg/ioworkspace/importer.go b/packages/server/pkg/ioworkspace/importer.go index e8cba2e3a..0f2e94490 100644 --- a/packages/server/pkg/ioworkspace/importer.go +++ b/packages/server/pkg/ioworkspace/importer.go @@ -9,6 +9,7 @@ import ( "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/senv" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sfile" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sflow" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sgraphql" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/shttp" ) @@ -35,6 +36,9 @@ type ImportResult struct { FlowAINodesCreated int FlowAIProviderNodesCreated int FlowAIMemoryNodesCreated int + FlowGraphQLNodesCreated int + GraphQLRequestsCreated int + GraphQLHeadersCreated int EnvironmentsCreated int EnvironmentVarsCreated int @@ -85,6 +89,10 @@ func (s *IOWorkspaceService) Import(ctx context.Context, tx *sql.Tx, bundle *Wor nodeAIService := sflow.NewNodeAIService(s.queries).TX(tx) nodeAIProviderService := sflow.NewNodeAiProviderService(s.queries).TX(tx) nodeMemoryService := sflow.NewNodeMemoryService(s.queries).TX(tx) + nodeGraphQLService := sflow.NewNodeGraphQLService(s.queries).TX(tx) + + graphqlService := sgraphql.New(s.queries, nil).TX(tx) + graphqlHeaderService := sgraphql.NewGraphQLHeaderService(s.queries).TX(tx) fileService := sfile.New(s.queries, nil).TX(tx) envService := senv.NewEnvironmentService(s.queries, nil).TX(tx) @@ -104,6 +112,12 @@ func (s *IOWorkspaceService) Import(ctx context.Context, tx *sql.Tx, bundle *Wor } } + if opts.ImportHTTP && len(bundle.GraphQLRequests) > 0 { + if err := s.importGraphQLRequests(ctx, graphqlService, bundle, opts, result); err != nil { + return nil, fmt.Errorf("failed to import GraphQL requests: %w", err) + } + } + if opts.CreateFiles && len(bundle.Files) > 0 { if err := s.importFiles(ctx, fileService, bundle, opts, result); err != nil { return nil, fmt.Errorf("failed to import files: %w", err) @@ -131,6 +145,12 @@ func (s *IOWorkspaceService) Import(ctx context.Context, tx *sql.Tx, bundle *Wor } } + if opts.ImportHTTP && len(bundle.GraphQLHeaders) > 0 { + if err := s.importGraphQLHeaders(ctx, graphqlHeaderService, bundle, opts, result); err != nil { + return nil, fmt.Errorf("failed to import GraphQL headers: %w", err) + } + } + if opts.ImportHTTP { if len(bundle.HTTPHeaders) > 0 { if err := s.importHTTPHeaders(ctx, httpHeaderService, bundle, opts, result); err != nil { @@ -231,6 +251,12 @@ func (s *IOWorkspaceService) Import(ctx context.Context, tx *sql.Tx, bundle *Wor return nil, fmt.Errorf("failed to import flow AI memory nodes: %w", err) } } + + if len(bundle.FlowGraphQLNodes) > 0 { + if err := s.importFlowGraphQLNodes(ctx, nodeGraphQLService, bundle, opts, result); err != nil { + return nil, fmt.Errorf("failed to import flow GraphQL nodes: %w", err) + } + } } return result, nil diff --git a/packages/server/pkg/ioworkspace/importer_flow.go b/packages/server/pkg/ioworkspace/importer_flow.go index 12cec53d8..5cdf45834 100644 --- a/packages/server/pkg/ioworkspace/importer_flow.go +++ b/packages/server/pkg/ioworkspace/importer_flow.go @@ -7,6 +7,7 @@ import ( "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sflow" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/service/sgraphql" ) // importFlows imports flows from the bundle. @@ -279,3 +280,60 @@ func (s *IOWorkspaceService) importFlowAIMemoryNodes(ctx context.Context, nodeMe } return nil } + +// importGraphQLRequests imports GraphQL requests from the bundle. +func (s *IOWorkspaceService) importGraphQLRequests(ctx context.Context, graphqlService sgraphql.GraphQLService, bundle *WorkspaceBundle, opts ImportOptions, result *ImportResult) error { + for _, gql := range bundle.GraphQLRequests { + // Generate new ID if not preserving + if !opts.PreserveIDs { + gql.ID = idwrap.NewNow() + } + + // Update workspace ID + gql.WorkspaceID = opts.WorkspaceID + + // Create GraphQL request + if err := graphqlService.Create(ctx, &gql); err != nil { + return fmt.Errorf("failed to create GraphQL request %s: %w", gql.Name, err) + } + + result.GraphQLRequestsCreated++ + } + return nil +} + +// importGraphQLHeaders imports GraphQL headers from the bundle. +func (s *IOWorkspaceService) importGraphQLHeaders(ctx context.Context, graphqlHeaderService sgraphql.GraphQLHeaderService, bundle *WorkspaceBundle, opts ImportOptions, result *ImportResult) error { + for _, header := range bundle.GraphQLHeaders { + // Generate new ID if not preserving + if !opts.PreserveIDs { + header.ID = idwrap.NewNow() + } + + // Create header + if err := graphqlHeaderService.Create(ctx, &header); err != nil { + return fmt.Errorf("failed to create GraphQL header: %w", err) + } + + result.GraphQLHeadersCreated++ + } + return nil +} + +// importFlowGraphQLNodes imports flow GraphQL nodes from the bundle. +func (s *IOWorkspaceService) importFlowGraphQLNodes(ctx context.Context, nodeGraphQLService sflow.NodeGraphQLService, bundle *WorkspaceBundle, opts ImportOptions, result *ImportResult) error { + for _, gqlNode := range bundle.FlowGraphQLNodes { + // Remap flow node ID + if newNodeID, ok := result.NodeIDMap[gqlNode.FlowNodeID]; ok { + gqlNode.FlowNodeID = newNodeID + } + + // Create GraphQL node + if err := nodeGraphQLService.CreateNodeGraphQL(ctx, gqlNode); err != nil { + return fmt.Errorf("failed to create flow GraphQL node: %w", err) + } + + result.FlowGraphQLNodesCreated++ + } + return nil +} diff --git a/packages/server/pkg/ioworkspace/types.go b/packages/server/pkg/ioworkspace/types.go index 39190e661..fa2f8b8aa 100644 --- a/packages/server/pkg/ioworkspace/types.go +++ b/packages/server/pkg/ioworkspace/types.go @@ -6,6 +6,7 @@ import ( "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/menv" "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mfile" "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mflow" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mhttp" "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mworkspace" ) @@ -18,7 +19,7 @@ type WorkspaceBundle struct { Workspace mworkspace.Workspace // HTTP requests and associated data structures - HTTPRequests []mhttp.HTTP + HTTPRequests []mhttp.HTTP HTTPSearchParams []mhttp.HTTPSearchParam HTTPHeaders []mhttp.HTTPHeader HTTPBodyForms []mhttp.HTTPBodyForm @@ -26,6 +27,10 @@ type WorkspaceBundle struct { HTTPBodyRaw []mhttp.HTTPBodyRaw HTTPAsserts []mhttp.HTTPAssert + // GraphQL requests and associated data + GraphQLRequests []mgraphql.GraphQL + GraphQLHeaders []mgraphql.GraphQLHeader + // File organization Files []mfile.File @@ -44,6 +49,7 @@ type WorkspaceBundle struct { FlowAINodes []mflow.NodeAI FlowAIProviderNodes []mflow.NodeAiProvider FlowAIMemoryNodes []mflow.NodeMemory + FlowGraphQLNodes []mflow.NodeGraphQL // Environments and variables Environments []menv.Env @@ -64,6 +70,8 @@ func (wb *WorkspaceBundle) CountEntities() map[string]int { "http_body_urlencoded": len(wb.HTTPBodyUrlencoded), "http_body_raw": len(wb.HTTPBodyRaw), "http_asserts": len(wb.HTTPAsserts), + "graphql_requests": len(wb.GraphQLRequests), + "graphql_headers": len(wb.GraphQLHeaders), "files": len(wb.Files), "flows": len(wb.Flows), "flow_variables": len(wb.FlowVariables), @@ -76,8 +84,9 @@ func (wb *WorkspaceBundle) CountEntities() map[string]int { "flow_js_nodes": len(wb.FlowJSNodes), "flow_ai_nodes": len(wb.FlowAINodes), "flow_ai_provider_nodes": len(wb.FlowAIProviderNodes), - "flow_ai_memory_nodes": len(wb.FlowAIMemoryNodes), - "environments": len(wb.Environments), + "flow_ai_memory_nodes": len(wb.FlowAIMemoryNodes), + "flow_graphql_nodes": len(wb.FlowGraphQLNodes), + "environments": len(wb.Environments), "environment_vars": len(wb.EnvironmentVars), "credentials": len(wb.Credentials), } @@ -94,6 +103,17 @@ func (wb *WorkspaceBundle) GetHTTPByID(id idwrap.IDWrap) *mhttp.HTTP { return nil } +// GetGraphQLByID finds and returns a GraphQL request by its ID. +// Returns nil if the GraphQL request is not found. +func (wb *WorkspaceBundle) GetGraphQLByID(id idwrap.IDWrap) *mgraphql.GraphQL { + for i := range wb.GraphQLRequests { + if wb.GraphQLRequests[i].ID.Compare(id) == 0 { + return &wb.GraphQLRequests[i] + } + } + return nil +} + // GetFlowByID finds and returns a flow by its ID. // Returns nil if the flow is not found. func (wb *WorkspaceBundle) GetFlowByID(id idwrap.IDWrap) *mflow.Flow { diff --git a/packages/server/pkg/translate/yamlflowsimplev2/converter.go b/packages/server/pkg/translate/yamlflowsimplev2/converter.go index 290391f30..60cad4751 100644 --- a/packages/server/pkg/translate/yamlflowsimplev2/converter.go +++ b/packages/server/pkg/translate/yamlflowsimplev2/converter.go @@ -55,9 +55,17 @@ func ConvertSimplifiedYAML(data []byte, opts ConvertOptionsV2) (*ioworkspace.Wor } } + // Prepare GraphQL request templates + graphqlTemplates := make(map[string]YamlGraphQLDefV2) + for _, gql := range yamlFormat.GraphQLRequests { + if gql.Name != "" { + graphqlTemplates[gql.Name] = gql + } + } + // Process flows and generate HTTP requests for _, flowEntry := range yamlFormat.Flows { - flowData, err := processFlow(flowEntry, yamlFormat.Run, requestTemplates, opts) + flowData, err := processFlow(flowEntry, yamlFormat.Run, requestTemplates, graphqlTemplates, opts) if err != nil { return nil, fmt.Errorf("failed to process flow '%s': %w", flowEntry.Name, err) } diff --git a/packages/server/pkg/translate/yamlflowsimplev2/converter_flow.go b/packages/server/pkg/translate/yamlflowsimplev2/converter_flow.go index 405692dcd..a120f88e5 100644 --- a/packages/server/pkg/translate/yamlflowsimplev2/converter_flow.go +++ b/packages/server/pkg/translate/yamlflowsimplev2/converter_flow.go @@ -15,7 +15,7 @@ import ( ) // processFlow processes a single flow and returns the generated data -func processFlow(flowEntry YamlFlowFlowV2, runEntries []YamlRunEntryV2, templates map[string]YamlRequestDefV2, opts ConvertOptionsV2) (*ioworkspace.WorkspaceBundle, error) { +func processFlow(flowEntry YamlFlowFlowV2, runEntries []YamlRunEntryV2, templates map[string]YamlRequestDefV2, graphqlTemplates map[string]YamlGraphQLDefV2, opts ConvertOptionsV2) (*ioworkspace.WorkspaceBundle, error) { result := &ioworkspace.WorkspaceBundle{} flowID := idwrap.NewNow() @@ -68,7 +68,7 @@ func processFlow(flowEntry YamlFlowFlowV2, runEntries []YamlRunEntryV2, template startNodeID := idwrap.NewNow() // Process steps - processRes, err := processSteps(flowEntry, templates, varMap, flowID, startNodeID, opts, result) + processRes, err := processSteps(flowEntry, templates, graphqlTemplates, varMap, flowID, startNodeID, opts, result) if err != nil { return nil, fmt.Errorf("failed to process steps: %w", err) } @@ -270,6 +270,10 @@ func mergeFlowData(result *ioworkspace.WorkspaceBundle, flowData *ioworkspace.Wo result.FlowAINodes = append(result.FlowAINodes, flowData.FlowAINodes...) result.FlowAIProviderNodes = append(result.FlowAIProviderNodes, flowData.FlowAIProviderNodes...) result.FlowAIMemoryNodes = append(result.FlowAIMemoryNodes, flowData.FlowAIMemoryNodes...) + + result.GraphQLRequests = append(result.GraphQLRequests, flowData.GraphQLRequests...) + result.GraphQLHeaders = append(result.GraphQLHeaders, flowData.GraphQLHeaders...) + result.FlowGraphQLNodes = append(result.FlowGraphQLNodes, flowData.FlowGraphQLNodes...) } func mergeAssociatedData(result *ioworkspace.WorkspaceBundle, assoc *HTTPAssociatedData) { diff --git a/packages/server/pkg/translate/yamlflowsimplev2/converter_node.go b/packages/server/pkg/translate/yamlflowsimplev2/converter_node.go index 3365000e3..2d5f800c6 100644 --- a/packages/server/pkg/translate/yamlflowsimplev2/converter_node.go +++ b/packages/server/pkg/translate/yamlflowsimplev2/converter_node.go @@ -11,6 +11,7 @@ import ( "github.com/the-dev-tools/dev-tools/packages/server/pkg/ioworkspace" "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mcondition" "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mflow" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mhttp" "github.com/the-dev-tools/dev-tools/packages/server/pkg/varsystem" ) @@ -27,7 +28,7 @@ func createStartNodeWithID(nodeID, flowID idwrap.IDWrap, result *ioworkspace.Wor } // processSteps processes all steps in a flow -func processSteps(flowEntry YamlFlowFlowV2, templates map[string]YamlRequestDefV2, varMap varsystem.VarMap, flowID, startNodeID idwrap.IDWrap, opts ConvertOptionsV2, result *ioworkspace.WorkspaceBundle) (*StepProcessingResult, error) { +func processSteps(flowEntry YamlFlowFlowV2, templates map[string]YamlRequestDefV2, graphqlTemplates map[string]YamlGraphQLDefV2, varMap varsystem.VarMap, flowID, startNodeID idwrap.IDWrap, opts ConvertOptionsV2, result *ioworkspace.WorkspaceBundle) (*StepProcessingResult, error) { nodeInfoMap := make(map[string]*nodeInfo) nodeList := make([]*nodeInfo, 0) startNodeFound := false @@ -43,6 +44,9 @@ func processSteps(flowEntry YamlFlowFlowV2, templates map[string]YamlRequestDefV case stepWrapper.Request != nil: nodeName = stepWrapper.Request.Name dependsOn = stepWrapper.Request.DependsOn + case stepWrapper.GraphQL != nil: + nodeName = stepWrapper.GraphQL.Name + dependsOn = stepWrapper.GraphQL.DependsOn case stepWrapper.If != nil: nodeName = stepWrapper.If.Name dependsOn = stepWrapper.If.DependsOn @@ -98,6 +102,10 @@ func processSteps(flowEntry YamlFlowFlowV2, templates map[string]YamlRequestDefV file := createFileForHTTP(*httpReq, opts) result.Files = append(result.Files, file) } + case stepWrapper.GraphQL != nil: + if err := processGraphQLStructStep(stepWrapper.GraphQL, nodeID, flowID, graphqlTemplates, opts, result); err != nil { + return nil, err + } case stepWrapper.If != nil: if stepWrapper.If.Condition == "" { return nil, NewYamlFlowErrorV2("missing required condition", "if", i) @@ -443,3 +451,94 @@ func processAIMemoryStructStep(step *YamlStepAIMemory, nodeID, flowID idwrap.IDW result.FlowAIMemoryNodes = append(result.FlowAIMemoryNodes, memoryNode) return nil } + +func processGraphQLStructStep(step *YamlStepGraphQL, nodeID, flowID idwrap.IDWrap, templates map[string]YamlGraphQLDefV2, opts ConvertOptionsV2, result *ioworkspace.WorkspaceBundle) error { + url := step.URL + query := step.Query + variables := step.Variables + var headers HeaderMapOrSlice + + if step.UseRequest != "" { + if tmpl, ok := templates[step.UseRequest]; ok { + if tmpl.URL != "" { + url = tmpl.URL + } + if tmpl.Query != "" { + query = tmpl.Query + } + if tmpl.Variables != "" { + variables = tmpl.Variables + } + headers = tmpl.Headers + } else { + return NewYamlFlowErrorV2(fmt.Sprintf("graphql step '%s' references unknown template '%s'", step.Name, step.UseRequest), "use_request", step.UseRequest) + } + } + + // Step-level values override template + if step.URL != "" { + url = step.URL + } + if step.Query != "" { + query = step.Query + } + if step.Variables != "" { + variables = step.Variables + } + if len(step.Headers) > 0 { + headers = append(headers, step.Headers...) + } + + if url == "" { + return NewYamlFlowErrorV2(fmt.Sprintf("graphql step '%s' missing required url", step.Name), "url", nil) + } + + gqlID := idwrap.NewNow() + now := time.Now().UnixMilli() + + gqlReq := mgraphql.GraphQL{ + ID: gqlID, + WorkspaceID: opts.WorkspaceID, + FolderID: opts.FolderID, + Name: step.Name, + Url: url, + Query: query, + Variables: variables, + CreatedAt: now, + UpdatedAt: now, + } + result.GraphQLRequests = append(result.GraphQLRequests, gqlReq) + + // Create headers + for i, h := range headers { + header := mgraphql.GraphQLHeader{ + ID: idwrap.NewNow(), + GraphQLID: gqlID, + Key: h.Name, + Value: h.Value, + Enabled: h.Enabled, + DisplayOrder: float32(i), + CreatedAt: now, + UpdatedAt: now, + } + result.GraphQLHeaders = append(result.GraphQLHeaders, header) + } + + // Create flow node + flowNode := mflow.Node{ + ID: nodeID, + FlowID: flowID, + Name: step.Name, + NodeKind: mflow.NODE_KIND_GRAPHQL, + } + result.FlowNodes = append(result.FlowNodes, flowNode) + + // Create GraphQL node linking flow node to GraphQL entity + graphqlNode := mflow.NodeGraphQL{ + FlowNodeID: nodeID, + GraphQLID: &gqlID, + } + result.FlowGraphQLNodes = append(result.FlowGraphQLNodes, graphqlNode) + + return nil +} diff --git a/packages/server/pkg/translate/yamlflowsimplev2/exporter.go b/packages/server/pkg/translate/yamlflowsimplev2/exporter.go index 92b31f87b..f507889ff 100644 --- a/packages/server/pkg/translate/yamlflowsimplev2/exporter.go +++ b/packages/server/pkg/translate/yamlflowsimplev2/exporter.go @@ -11,6 +11,7 @@ import ( "github.com/the-dev-tools/dev-tools/packages/server/pkg/ioworkspace" "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mcredential" "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mflow" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mhttp" "gopkg.in/yaml.v3" @@ -117,6 +118,21 @@ func MarshalSimplifiedYAML(data *ioworkspace.WorkspaceBundle) ([]byte, error) { aiMemoryNodeMap[n.FlowNodeID] = n } + graphqlNodeMap := make(map[idwrap.IDWrap]mflow.NodeGraphQL) + for _, n := range data.FlowGraphQLNodes { + graphqlNodeMap[n.FlowNodeID] = n + } + + graphqlMap := make(map[idwrap.IDWrap]mgraphql.GraphQL) + for _, g := range data.GraphQLRequests { + graphqlMap[g.ID] = g + } + + graphqlHeadersMap := make(map[idwrap.IDWrap][]mgraphql.GraphQLHeader) + for _, h := range data.GraphQLHeaders { + graphqlHeadersMap[h.GraphQLID] = append(graphqlHeadersMap[h.GraphQLID], h) + } + // Credential Map (ID -> Credential) credentialMap := make(map[idwrap.IDWrap]mcredential.Credential) for _, c := range data.Credentials { @@ -213,6 +229,73 @@ func MarshalSimplifiedYAML(data *ioworkspace.WorkspaceBundle) ([]byte, error) { yamlFormat.Requests = requests } + // 2b. Build top-level graphql_requests section + graphqlIDToRequestName := make(map[idwrap.IDWrap]string) + graphqlNameUsed := make(map[string]bool) + + // First pass: collect all GraphQL requests used in flows + for _, flow := range data.Flows { + for _, n := range data.FlowNodes { + if n.FlowID != flow.ID || n.NodeKind != mflow.NODE_KIND_GRAPHQL { + continue + } + gqlNode, ok := graphqlNodeMap[n.ID] + if !ok || gqlNode.GraphQLID == nil { + continue + } + gqlReq, ok := graphqlMap[*gqlNode.GraphQLID] + if !ok { + continue + } + + if _, exists := graphqlIDToRequestName[gqlReq.ID]; exists { + continue + } + + gqlName := gqlReq.Name + if gqlName == "" { + gqlName = "GraphQL Request" + } + + baseName := gqlName + counter := 1 + for graphqlNameUsed[gqlName] { + gqlName = fmt.Sprintf("%s_%d", baseName, counter) + counter++ + } + graphqlNameUsed[gqlName] = true + graphqlIDToRequestName[gqlReq.ID] = gqlName + } + } + + // Second pass: build the graphql_requests section + var graphqlRequests []YamlGraphQLDefV2 + var graphqlIDs []idwrap.IDWrap + for gqlID := range graphqlIDToRequestName { + graphqlIDs = append(graphqlIDs, gqlID) + } + sort.Slice(graphqlIDs, func(i, j int) bool { + return graphqlIDToRequestName[graphqlIDs[i]] < graphqlIDToRequestName[graphqlIDs[j]] + }) + + for _, gqlID := range graphqlIDs { + gqlName := graphqlIDToRequestName[gqlID] + gqlReq := graphqlMap[gqlID] + + gqlDef := YamlGraphQLDefV2{ + Name: gqlName, + URL: gqlReq.Url, + Query: gqlReq.Query, + Variables: gqlReq.Variables, + Headers: buildGraphQLHeaderMapOrSlice(graphqlHeadersMap[gqlID]), + } + graphqlRequests = append(graphqlRequests, gqlDef) + } + + if len(graphqlRequests) > 0 { + yamlFormat.GraphQLRequests = graphqlRequests + } + // 3. Process each Flow flowNameUsed := make(map[string]bool) for _, flow := range data.Flows { @@ -444,6 +527,30 @@ func MarshalSimplifiedYAML(data *ioworkspace.WorkspaceBundle) ([]byte, error) { } stepWrapper.AIMemory = memoryStep + case mflow.NODE_KIND_GRAPHQL: + gqlNode, ok := graphqlNodeMap[node.ID] + if !ok || gqlNode.GraphQLID == nil { + continue + } + gqlReq, ok := graphqlMap[*gqlNode.GraphQLID] + if !ok { + continue + } + + gqlStep := &YamlStepGraphQL{ + YamlStepCommon: common, + } + + if gqlName, exists := graphqlIDToRequestName[gqlReq.ID]; exists { + gqlStep.UseRequest = gqlName + } else { + gqlStep.URL = gqlReq.Url + gqlStep.Query = gqlReq.Query + gqlStep.Variables = gqlReq.Variables + gqlStep.Headers = buildGraphQLHeaderMapOrSlice(graphqlHeadersMap[gqlReq.ID]) + } + stepWrapper.GraphQL = gqlStep + case mflow.NODE_KIND_MANUAL_START: if node.ID == startNodeID { stepWrapper.ManualStart = &common @@ -455,7 +562,7 @@ func MarshalSimplifiedYAML(data *ioworkspace.WorkspaceBundle) ([]byte, error) { // Add to flow // Because stepWrapper has pointer fields, "empty" fields are nil // Checking if any field is set (simplified check, assume one set if we got here) - isValid := stepWrapper.Request != nil || stepWrapper.If != nil || stepWrapper.For != nil || + isValid := stepWrapper.Request != nil || stepWrapper.GraphQL != nil || stepWrapper.If != nil || stepWrapper.For != nil || stepWrapper.ForEach != nil || stepWrapper.JS != nil || stepWrapper.AI != nil || stepWrapper.AIProvider != nil || stepWrapper.AIMemory != nil || stepWrapper.ManualStart != nil if isValid { @@ -524,6 +631,22 @@ func MarshalSimplifiedYAML(data *ioworkspace.WorkspaceBundle) ([]byte, error) { return yaml.Marshal(yamlFormat) } +func buildGraphQLHeaderMapOrSlice(headers []mgraphql.GraphQLHeader) HeaderMapOrSlice { + if len(headers) == 0 { + return nil + } + var result []YamlNameValuePairV2 + for _, h := range headers { + result = append(result, YamlNameValuePairV2{ + Name: h.Key, + Value: h.Value, + Enabled: h.Enabled, + Description: h.Description, + }) + } + return HeaderMapOrSlice(result) +} + type deltaLookupContext struct { httpMap map[idwrap.IDWrap]mhttp.HTTP headersMap map[idwrap.IDWrap][]mhttp.HTTPHeader diff --git a/packages/server/pkg/translate/yamlflowsimplev2/types.go b/packages/server/pkg/translate/yamlflowsimplev2/types.go index 26e88308a..79357d83b 100644 --- a/packages/server/pkg/translate/yamlflowsimplev2/types.go +++ b/packages/server/pkg/translate/yamlflowsimplev2/types.go @@ -9,6 +9,7 @@ import ( "github.com/the-dev-tools/dev-tools/packages/server/pkg/compress" "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap" "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mflow" + "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mgraphql" ) // YamlFlowFormatV2 represents the modern YAML structure for simplified workflows @@ -20,6 +21,7 @@ type YamlFlowFormatV2 struct { Run []YamlRunEntryV2 `yaml:"run,omitempty"` RequestTemplates map[string]YamlRequestDefV2 `yaml:"request_templates,omitempty"` Requests []YamlRequestDefV2 `yaml:"requests,omitempty"` + GraphQLRequests []YamlGraphQLDefV2 `yaml:"graphql_requests,omitempty"` Flows []YamlFlowFlowV2 `yaml:"flows"` Environments []YamlEnvironmentV2 `yaml:"environments,omitempty"` } @@ -51,6 +53,15 @@ type YamlRequestDefV2 struct { Description string `yaml:"description,omitempty"` } +// YamlGraphQLDefV2 represents a GraphQL request definition (template or standalone) +type YamlGraphQLDefV2 struct { + Name string `yaml:"name,omitempty"` + URL string `yaml:"url,omitempty"` + Query string `yaml:"query"` + Variables string `yaml:"variables,omitempty"` + Headers HeaderMapOrSlice `yaml:"headers,omitempty"` +} + // YamlFlowFlowV2 represents a flow in the modern YAML format type YamlFlowFlowV2 struct { Name string `yaml:"name"` @@ -64,6 +75,7 @@ type YamlFlowFlowV2 struct { // A step is a map with a single key that identifies the type type YamlStepWrapper struct { Request *YamlStepRequest `yaml:"request,omitempty"` + GraphQL *YamlStepGraphQL `yaml:"graphql,omitempty"` If *YamlStepIf `yaml:"if,omitempty"` For *YamlStepFor `yaml:"for,omitempty"` ForEach *YamlStepForEach `yaml:"for_each,omitempty"` @@ -91,6 +103,15 @@ type YamlStepRequest struct { Assertions AssertionsOrSlice `yaml:"assertions,omitempty"` } +type YamlStepGraphQL struct { + YamlStepCommon `yaml:",inline"` + UseRequest string `yaml:"use_request,omitempty"` + URL string `yaml:"url,omitempty"` + Query string `yaml:"query,omitempty"` + Variables string `yaml:"variables,omitempty"` + Headers HeaderMapOrSlice `yaml:"headers,omitempty"` +} + type YamlStepIf struct { YamlStepCommon `yaml:",inline"` Condition string `yaml:"condition"` @@ -380,6 +401,10 @@ type YamlFlowDataV2 struct { // HTTP request data HTTPRequests []YamlHTTPRequestV2 + // GraphQL request data + GraphQLRequests []mgraphql.GraphQL + GraphQLHeaders []mgraphql.GraphQLHeader + // Flow node implementations RequestNodes []mflow.NodeRequest ConditionNodes []mflow.NodeIf @@ -389,6 +414,7 @@ type YamlFlowDataV2 struct { AINodes []mflow.NodeAI AIProviderNodes []mflow.NodeAiProvider AIMemoryNodes []mflow.NodeMemory + GraphQLNodes []mflow.NodeGraphQL } // YamlVariableV2 represents a variable during parsing diff --git a/packages/server/pkg/translate/yamlflowsimplev2/utils.go b/packages/server/pkg/translate/yamlflowsimplev2/utils.go index af846b121..b5a99b277 100644 --- a/packages/server/pkg/translate/yamlflowsimplev2/utils.go +++ b/packages/server/pkg/translate/yamlflowsimplev2/utils.go @@ -236,6 +236,17 @@ func ValidateYAMLStructure(yamlFormat *YamlFlowFormatV2) error { } } + // Check for duplicate GraphQL request names + graphqlNames := make(map[string]bool) + for _, gql := range yamlFormat.GraphQLRequests { + if gql.Name != "" { + if graphqlNames[gql.Name] { + return NewYamlFlowErrorV2(fmt.Sprintf("duplicate graphql request name: %s", gql.Name), "graphql_requests", gql.Name) + } + graphqlNames[gql.Name] = true + } + } + // Check for flow dependencies that reference non-existent flows for _, runEntry := range yamlFormat.Run { flowName := runEntry.Flow