Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
699 changes: 699 additions & 0 deletions design/module-gen.md

Large diffs are not rendered by default.

40 changes: 7 additions & 33 deletions helpers/codegen/generator/config.go
Original file line number Diff line number Diff line change
@@ -1,54 +1,28 @@
package generator

// Config drives one codegen run. Exactly one of ModuleConfig / ClientConfig /
// EntrypointConfig is set (none of them means library mode); that choice selects
// the output file names and how the generated bindings import the SDK runtime.
type Config struct {
// Lang is the language to generate the module for.
Lang SDKLang

// OutputDir is the path to put the generated code.
// Usually this is the path to the module source directory.
// This allows generating extra file aside the client bindings
// like go.mod, go.sum etc...
OutputDir string

// IntrospectionJSON is an optional pre-computed introspection json string.
IntrospectionJSON string

// TypeDefsPath is the path of the file to write the typedefs module id.
TypeDefsPath string

// Generate the client in bundle mode.
Bundle bool

// ModuleConfig is the specific config to generate module or typedefs.
// ModuleConfig is the specific config to generate a module's own bindings.
ModuleConfig *ModuleGeneratorConfig

// ClientConfig is the specific config to generate standalone client.
ClientConfig *ClientGeneratorConfig

// EntrypointConfig is the specific config to generate the static dispatch
// entrypoint file (currently TypeScript only).
// entrypoint file.
EntrypointConfig *EntrypointGeneratorConfig
}

// Specific configuration for module generation.
type ModuleGeneratorConfig struct {
// Name of the module to generate code for.
// Name of the module to generate code for. Its own types stay in the core
// file; only its dependencies are split into per-module files.
ModuleName string

// ModuleSourcePath is the subpath in OutputDir where the module source subpath is located.
ModuleSourcePath string

// ModuleParentPath is the path from the module source subpath to the context directory
ModuleParentPath string

// Whether we are initializing a new module.
// Currently, this is only used in go codegen to enforce backwards-compatible behavior
// where a pre-existing go.mod file is checked during dagger init for whether its module
// name is the expected value.
IsInit bool

// If set, use `@dagger.io/dagger` with the given version and use it in the generated client.
LibVersion string
}

// Module-source kinds a generated client can bind to. A local module
Expand Down
50 changes: 1 addition & 49 deletions helpers/codegen/generator/generator.go
Original file line number Diff line number Diff line change
@@ -1,57 +1,9 @@
package generator

import (
"context"
"errors"
"io/fs"
"os/exec"

"codegen/introspection"
)

var ErrUnknownSDKLang = errors.New("unknown sdk language")

type SDKLang string

const (
SDKLangTypeScript SDKLang = "typescript"
)

type Generator interface {
// GenerateModule runs codegen in a context of a module and returns a map of
// default filename to content for that file.
GenerateModule(ctx context.Context, schema *introspection.Schema, schemaVersion string) (*GeneratedState, error)

// GenerateClient runs codegen in a context of a standalone client and returns
// a map of default filename to content for that file.
GenerateClient(ctx context.Context, schema *introspection.Schema, schemaVersion string) (*GeneratedState, error)

// GenerateLibrary only generate the library bindings for the given schema.
GenerateLibrary(ctx context.Context, schema *introspection.Schema, schemaVersion string) (*GeneratedState, error)

// GenerateTypeDefs extract type definitions from a module and returns a map
// of default filename to content for that file.
GenerateTypeDefs(ctx context.Context, schema *introspection.Schema, schemaVersion string) (*GeneratedState, error)

// GenerateEntrypoint renders the static dispatch entrypoint file for a
// module from a previously-emitted typedef JSON (see
// `Config.EntrypointConfig`).
GenerateEntrypoint(ctx context.Context) (*GeneratedState, error)
}
import "io/fs"

type GeneratedState struct {
// Overlay is the overlay filesystem that contains generated code to write
// over the output directory.
Overlay fs.FS

// PostCommands are commands that need to be run after the codegen has
// finished. This is used for example to run `go mod tidy` after generating
// Go code.
PostCommands []*exec.Cmd

// NeedRegenerate indicates that the code needs to be generated again. This
// can happen if the codegen spat out templates that depend on generated
// types. In that case the codegen needs to be run again with both the
// templates and the initially generated types available.
NeedRegenerate bool
}
23 changes: 4 additions & 19 deletions helpers/codegen/generator/typescript/dep_split_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,7 @@ func TestDepTemplate_RendersDepTypes(t *testing.T) {
generator.SetSchemaParents(depSchema)

tmpl := templates.New("v0.21.0", full, "", generator.Config{
Lang: generator.SDKLangTypeScript,
ModuleConfig: &generator.ModuleGeneratorConfig{
ModuleName: "host",
ModuleSourcePath: ".",
},
ModuleConfig: &generator.ModuleGeneratorConfig{ModuleName: "host"},
})

out := renderDepTemplate(t, tmpl, depSchema, "hello")
Expand Down Expand Up @@ -141,11 +137,7 @@ func TestHeaderTemplate_EmitsDependencyExports(t *testing.T) {
}

tmpl := templates.New("v0.21.0", full, "", generator.Config{
Lang: generator.SDKLangTypeScript,
ModuleConfig: &generator.ModuleGeneratorConfig{
ModuleName: "host",
ModuleSourcePath: ".",
},
ModuleConfig: &generator.ModuleGeneratorConfig{ModuleName: "host"},
})

var b bytes.Buffer
Expand Down Expand Up @@ -215,9 +207,7 @@ func TestGenerate_SplitsDependencyFiles(t *testing.T) {
// that here since this test calls generate() directly.
generator.SetSchemaParents(schema)

state, err := generate(generator.Config{
Lang: generator.SDKLangTypeScript,
}, ClientGenFile, schema, "v0.21.0")
state, err := generate(generator.Config{}, ClientGenFile, schema, "v0.21.0")
require.NoError(t, err)

core := readOverlay(t, state, "client.gen.ts")
Expand Down Expand Up @@ -260,7 +250,6 @@ func TestGenerate_KeepsOwnTypesInClient(t *testing.T) {
generator.SetSchemaParents(schema)

state, err := generate(generator.Config{
Lang: generator.SDKLangTypeScript,
ModuleConfig: &generator.ModuleGeneratorConfig{ModuleName: "app"},
}, ClientGenFile, schema, "v0.21.0")
require.NoError(t, err)
Expand Down Expand Up @@ -316,7 +305,6 @@ func TestGenerate_Client_SplitsBoundModule(t *testing.T) {
generator.SetSchemaParents(schema)

state, err := generate(generator.Config{
Lang: generator.SDKLangTypeScript,
ClientConfig: &generator.ClientGeneratorConfig{ModuleName: "hello"},
}, CoreGenFile, schema, "v0.21.0")
require.NoError(t, err)
Expand Down Expand Up @@ -377,7 +365,6 @@ func TestGenerate_Client_ServeBoundModule(t *testing.T) {

t.Run("local module resolves against the workspace by a root-relative path", func(t *testing.T) {
state, err := generate(generator.Config{
Lang: generator.SDKLangTypeScript,
ClientConfig: &generator.ClientGeneratorConfig{
ModuleName: "hello",
BoundModule: generator.BoundModule{Kind: "DIR_SOURCE", Path: ".dagger/modules/hello"},
Expand All @@ -400,7 +387,6 @@ func TestGenerate_Client_ServeBoundModule(t *testing.T) {

t.Run("git module serves from its canonical ref + pin", func(t *testing.T) {
state, err := generate(generator.Config{
Lang: generator.SDKLangTypeScript,
ClientConfig: &generator.ClientGeneratorConfig{
ModuleName: "hello",
BoundModule: generator.BoundModule{Kind: "GIT_SOURCE", Ref: "github.com/foo/hello@main", Pin: "abcdef"},
Expand Down Expand Up @@ -470,8 +456,7 @@ func TestDepTemplate_CoreValuesAreValueImported(t *testing.T) {
generator.SetSchemaParents(depSchema)

tmpl := templates.New("v0.21.0", full, "", generator.Config{
Lang: generator.SDKLangTypeScript,
ModuleConfig: &generator.ModuleGeneratorConfig{ModuleName: "host", ModuleSourcePath: "."},
ModuleConfig: &generator.ModuleGeneratorConfig{ModuleName: "host"},
})

out := renderDepTemplate(t, tmpl, depSchema, "hello")
Expand Down
15 changes: 5 additions & 10 deletions helpers/codegen/generator/typescript/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,25 +30,20 @@ type TypeScriptGenerator struct {
Config generator.Config
}

// Generate will generate the TypeScript SDK code and might modify the schema to reorder types in a alphanumeric fashion.
// GenerateModule generates a module's own embedded bindings, flat in the output
// directory: the caller lays the result down as the module's sdk/ directory.
func (g *TypeScriptGenerator) GenerateModule(_ context.Context, schema *introspection.Schema, schemaVersion string) (*generator.GeneratedState, error) {
target := filepath.Join(g.Config.ModuleConfig.ModuleSourcePath, "sdk/src/api", ClientGenFile)

return generate(g.Config, target, schema, schemaVersion)
return generate(g.Config, ClientGenFile, schema, schemaVersion)
}

func (g *TypeScriptGenerator) GenerateClient(ctx context.Context, schema *introspection.Schema, schemaVersion string) (*generator.GeneratedState, error) {
func (g *TypeScriptGenerator) GenerateClient(_ context.Context, schema *introspection.Schema, schemaVersion string) (*generator.GeneratedState, error) {
return generate(g.Config, CoreGenFile, schema, schemaVersion)
}

func (g *TypeScriptGenerator) GenerateLibrary(ctx context.Context, schema *introspection.Schema, schemaVersion string) (*generator.GeneratedState, error) {
func (g *TypeScriptGenerator) GenerateLibrary(_ context.Context, schema *introspection.Schema, schemaVersion string) (*generator.GeneratedState, error) {
return generate(g.Config, ClientGenFile, schema, schemaVersion)
}

func (g *TypeScriptGenerator) GenerateTypeDefs(_ context.Context, _ *introspection.Schema, _ string) (*generator.GeneratedState, error) {
return nil, fmt.Errorf("not implemented for %s SDK", generator.SDKLangTypeScript)
}

func generate(config generator.Config, target string, schema *introspection.Schema, schemaVersion string) (*generator.GeneratedState, error) {
generator.SetSchema(schema)

Expand Down
138 changes: 138 additions & 0 deletions helpers/codegen/generator/typescript/module_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package typescriptgenerator

import (
"context"
"strconv"
"testing"

"github.com/stretchr/testify/require"

"codegen/generator"
"codegen/introspection"
)

// moduleSchema builds a schema shaped like a module's own view: the module's
// type plus one dependency's, both carrying a sourceMap directive so the
// dependency splitting has something to key off.
func moduleSchema(t *testing.T) *introspection.Schema {
t.Helper()

strField := func(name string) *introspection.Field {
return &introspection.Field{
Name: name,
TypeRef: &introspection.TypeRef{Kind: introspection.TypeKindNonNull, OfType: &introspection.TypeRef{Kind: introspection.TypeKindScalar, Name: "String"}},
}
}

schema := &introspection.Schema{
QueryType: struct {
Name string `json:"name,omitempty"`
}{Name: "Query"},
Types: introspection.Types{
{
Kind: introspection.TypeKindObject,
Name: "App",
Directives: introspection.Directives{newSourceMapFileDirective("app", "src/index.ts", 12)},
Fields: []*introspection.Field{strField("name")},
},
{
Kind: introspection.TypeKindObject,
Name: "Gendep",
Directives: introspection.Directives{newSourceMapDirective("gendep")},
Fields: []*introspection.Field{strField("value")},
},
},
}
generator.SetSchemaParents(schema)

return schema
}

// TestGenerateModule_Layout covers the on-disk contract module codegen has with
// the engine runtime: the runtime mounts the module's sdk/ directory as
// @dagger.io/dagger and requires sdk/client.gen.ts, so the bindings must land
// flat in the output directory (not nested under sdk/src/api) and import the
// bundled library sitting next to them.
func TestGenerateModule_Layout(t *testing.T) {
gen := &TypeScriptGenerator{Config: generator.Config{
ModuleConfig: &generator.ModuleGeneratorConfig{ModuleName: "app"},
}}

state, err := gen.GenerateModule(context.Background(), moduleSchema(t), "v0.21.0")
require.NoError(t, err)

core := readOverlay(t, state, "client.gen.ts")
dep := readOverlay(t, state, "gendep.gen.ts")

// Bundled library, imported from the sdk/ directory the files ship in.
require.Contains(t, core, `from "./core.js"`)
require.NotContains(t, core, `from "@dagger.io/dagger"`)
require.Contains(t, dep, `from "./core.js"`)

// The module's own types stay in the core file; only dependencies split out.
require.Contains(t, core, "export class App extends BaseClient")
require.Contains(t, core, `export * from "./gendep.gen.js"`)
require.Contains(t, dep, "export class Gendep extends BaseClient")

_, err = state.Overlay.Open("app.gen.ts")
require.Error(t, err, "the module's own types must not be split into app.gen.ts")
}

// TestGenerateModule_SourceMapPathIsRelativeToSDKDir pins the source-map
// breadcrumb rendered next to generated declarations. The link is recorded
// relative to the module root, while the file quoting it lives one level down
// in sdk/, so it needs one hop up to stay clickable.
func TestGenerateModule_SourceMapPathIsRelativeToSDKDir(t *testing.T) {
gen := &TypeScriptGenerator{Config: generator.Config{
ModuleConfig: &generator.ModuleGeneratorConfig{ModuleName: "app"},
}}

state, err := gen.GenerateModule(context.Background(), moduleSchema(t), "v0.21.0")
require.NoError(t, err)

require.Contains(t, readOverlay(t, state, "client.gen.ts"), "// app (../src/index.ts:12:0)")
}

// TestGenerateLibrary_ImportsRuntimeFromSource covers the third import arm: the
// SDK library's own bindings ship inside the library, so they reach the runtime
// by relative source path rather than through the bundle or the package name.
func TestGenerateLibrary_ImportsRuntimeFromSource(t *testing.T) {
gen := &TypeScriptGenerator{Config: generator.Config{}}

state, err := gen.GenerateLibrary(context.Background(), moduleSchema(t), "v0.21.0")
require.NoError(t, err)

core := readOverlay(t, state, "client.gen.ts")
require.Contains(t, core, `from "../common/context.js"`)
require.NotContains(t, core, `from "./core.js"`)
require.NotContains(t, core, `from "@dagger.io/dagger"`)
}

// TestGenerateClient_ImportsPackage covers the remaining arm: a standalone
// client resolves the SDK through the npm package it depends on.
func TestGenerateClient_ImportsPackage(t *testing.T) {
gen := &TypeScriptGenerator{Config: generator.Config{
ClientConfig: &generator.ClientGeneratorConfig{
ModuleName: "app",
BoundModule: generator.BoundModule{Kind: generator.ModuleKindDir, Path: ".dagger/modules/app"},
},
}}

state, err := gen.GenerateClient(context.Background(), moduleSchema(t), "v0.21.0")
require.NoError(t, err)

core := readOverlay(t, state, "dagger.gen.ts")
require.Contains(t, core, `from "@dagger.io/dagger"`)
require.NotContains(t, core, `from "./core.js"`)
}

func newSourceMapFileDirective(moduleName, filename string, line int) *introspection.Directive {
d := newSourceMapDirective(moduleName)
name := `"` + filename + `"`
num := strconv.Itoa(line)
d.Args = append(d.Args,
&introspection.DirectiveArg{Name: "filename", Value: &name},
&introspection.DirectiveArg{Name: "line", Value: &num},
)
return d
}
Loading