Skip to content

Latest commit

 

History

History
358 lines (290 loc) · 12.2 KB

README.md

File metadata and controls

358 lines (290 loc) · 12.2 KB

Contributing to Hasura codegen

Introduction

The specification for building a codegen, at it's most basic form, is an exposed function called templater() which takes actionName, actionSdl, and optional/nullable metadata if the action is derived (derive) and returns an array of objects with keys name and content. These objects are used to create each file entry generated by the codegen.

This codegen would create a Markdown file with the content of the Action's GraphQL schema document:

interface DeriveParams {
  // The SDL for the Derived operation
  operation: string
  // Endpoint to the Hasura instance
  endpoint: string
}
const templater = (
  actionName: string,
  actionSdl: string,
  derive: DeriveParams | null
) => {
  const response = [
    {
      name: actionName + '.md',
      content: JSON.stringify(actionSdl),
    },
  ]
  return response
}

In order to make the process of creating new codegens as easy as possible, a higher-level API has been constructed which provides convenience utilities and most of the information you would likely want to use.

There are two parts to these utility API's:

  1. A buildActionTypes() function, which takes the actionName and actionSdl, and returns the following type:
/**
 * An interface for the paramaters of Action codegen functions
 * The type provides the name, return type, list of action arguments
 * and a type-map of all types in the Action SDL
 */
export interface ActionParams {
  actionName: string
  actionArgs: InputValueApi[]
  returnType: string
  typeMap: ITypeMap
}

The actionArgs here is an array of InputValueAPI types from the graphql-extra library (which powers most of the codegen library). This tool provides typed, high-level bindings for manipulating and working with GraphQL AST's and Document SDL's. The ITypeMap type is an object of enum and field/input types with their corresponding graphql-extra API values.

Using buildActionTypes() from a codegen looks like this:

import { buildActionTypes } from '../schemaTools'
import { DeriveParams } from '../types'

const templater = (
  actionName: string,
  actionSdl: string,
  derive: DeriveParams | null
) => {
  const actionParams = buildActionTypes(actionName, actionSdl)
  // ....
}
  1. Custom-made GraphQL-Schema-to-Language-Type converters for common languages that allow us to provide you with matching type definitions based on the Action SDL for your codegen.

These are created by taking a GraphQL schema, and passing it's typeMap through language-specific converters to generate the equivalent representations of the types in the corresponding language. Using a type converter in a codegen looks like this:

import { graphqlSchemaToTypescript } from '../languages-functional'
import { typescriptExpressTemplate } from '../templates'
import { buildActionTypes } from '../schemaTools'
import { DeriveParams } from '../types'

const templater = (
  actionName: string,
  actionSdl: string,
  derive: DeriveParams | null
) => {
  const typeDefs = graphqlSchemaToTypescript(actionSdl)
  // ...
}

Architecture of a Codegen Template

Putting these two together, buildActionTypes() and optionally a language type-converter, makes our job really easy. You will notice a standard pattern among the codegen templates, they all look something like this:

const templater = (
  actionName: string,
  actionSdl: string,
  derive: DeriveParams | null
) => {
  const actionParams = buildActionTypes(actionName, actionSdl)
  const templateParams = { ...actionParams, derive }

  const codegen = typescriptExpressTemplate({
    ...templateParams,
    typeDefs: graphqlSchemaToTypescript(actionSdl),
  })

  const response = [
    {
      name: actionName + 'TypescriptExpress.ts',
      content: codegen,
    },
  ]

  return response
}

// In Typescript, this is needed to expose templater() to global namespace
globalThis.templater = templater

The piece that changes betwen codegens, is which template function the action information and language types are passed to. A "template function" is a pattern which emerged out of this architecture, and is a method containing an ES6 template-string which takes a type of CodegenTemplateParams. This type is an extension of ActionParams which optionally includes type-definition strings, and derived operation info:

interface CodegenTemplateParams extends ActionParams {
  typeDefs?: string
  derive: DeriveParams | null
}

Let's have a look at the Typescript + Express codegen template:

import { html as template } from 'common-tags'
import { CodegenTemplateParams } from '../types'

const sampleValues = {
  Int: 1111,
  String: '"<sample value>"',
  Boolean: false,
  Float: 11.11,
  ID: 1111,
}

export const typescriptExpressTemplate = (params: CodegenTemplateParams) => {
  const {
    actionArgs,
    actionName,
    returnType,
    typeDefs,
    derive,
    typeMap,
  } = params

  const returnTypeDef = typeMap.types[returnType]

  const baseTemplate = template`
    import { Request, Response } from 'express'
    ${typeDefs}

    function ${actionName}Handler(args: ${actionName}Args): ${returnType} {
      return {
        ${returnTypeDef
          .map((f) => {
            return `${f.getName()}: ${
              sampleValues[f.getType().getTypename()] || sampleValues['String']
            }`
          })
          .join(',\n')},
      }
    }

    // Request Handler
    app.post('/${actionName}', async (req: Request, res: Response) => {
      // get request input
      const params: ${actionName}Args = req.body.input

      // run some business logic
      const result = ${actionName}Handler(params)

      /*
      // In case of errors:
      return res.status(400).json({
        message: "error happened"
      })
      */

      // success
      return res.json(result)
    })
  `

  const hasuraOperation = ' `' + derive?.operation + '`\n\n'

  const derivedTemplate =
    template`
    import { Request, Response } from 'express'
    import fetch from 'node-fetch'
    ${typeDefs}
    const HASURA_OPERATION =` +
    hasuraOperation +
    template`

    const execute = async (variables) => {
      const fetchResponse = await fetch('http://localhost:8080/v1/graphql', {
        method: 'POST',
        body: JSON.stringify({
          query: HASURA_OPERATION,
          variables,
        }),
      })
      const data = await fetchResponse.json()
      console.log('DEBUG: ', data)
      return data
    }

    // Request Handler
    app.post('/${actionName}', async (req: Request, res: Response) => {
      // get request input
      const params: ${actionName}Args = req.body.input
      // execute the parent operation in Hasura
      const { data, errors } = await execute(params)
      if (errors) return res.status(400).json(errors[0])
      // run some business logic

      // success
      return res.json(data)
    })
  `

  if (derive?.operation) return derivedTemplate
  else return baseTemplate
}

A codegen is nothing more than a function which takes some information about an Action (it's arguments, return type, name, and sometimes a derived operation) and returns a string of code! Each template should return a baseTemplate, which is the code for a non-derived Action, and a derivedTemplate, which contains the code to perform an HTTP request back to Hasura containing the original derived mutation if it's derived.

Build Process

There is a fuse.ts in the project root, which uses Fusebox as a build tool to compile the codegens. You can run the build process with yarn build. A walkthrough of the fuse.ts file can help to explain what happens:

A path to template files from fuse.ts file is configured, and an array of codegen templates is defined. If you add a new codegen, it needs to go here.

// Path to codegen template files
const templatePath = './src/templates'
// List of codegen templates to generate
//prettier-ignore
const codegenTemplates = [
  { file: 'goServeMux.codegen.ts', folder: 'go-serve-mux', starterKit: false, },
  { file: 'http4kBasic.codegen.ts', folder: 'kotlin-http4k', starterKit: false },
  { file: 'javascriptExpress.codegen.ts', folder: 'node-express-jsdoc', starterKit: false },
  { file: 'kotlinKtor.codegen.ts', folder: 'kotlin-ktor', starterKit: false },
  { file: 'javaSpringBoot.codegen.ts', folder: 'java-spring', starterKit: false },
  { file: 'pythonFastAPI.codegen.ts', folder: 'python-fast-api', starterKit: true },
  { file: 'typescriptExpress.codegen.ts', folder: 'typescript-express',starterKit: false },
]

For each codegen template, task functions are defined which will be called later:

// This just creates the task definitions for each codegen template
for (const { file, folder } of codegenTemplates) {
  task(`prebuild:${file}`, () => {
    rm(`${projectRoot}/${folder}`)
  })

  task(`postbuild-clean:${file}`, () => {
    rm(`${projectRoot}/${folder}/actions-codegen.js.map`)
    rm(`${projectRoot}/${folder}/manifest-server.json`)
  })

  task(`build:${file}`, async (ctx) => {
    await ctx.getConfig(templatePath, file).runDev({
      target: 'browser',
      bundles: {
        app: './actions-codegen.js',
        distRoot: `${projectRoot}/${folder}`,
      },
    })
  })

  task(`browserify:${file}`, () => {
    const path = `${projectRoot}/${folder}/actions-codegen.js`
    browserify(path).bundle((err, buffer) => {
      const data = buffer.toString()
      if (err) console.log('BROWSERIFY ERR:', err)
      else fs.writeFileSync(path, data)
    })
  })

  task(`update-framework:${folder}`, async () => {
    src(`${projectRoot}/**`)
      .contentsOf('frameworks.json', (current) => {
        const frameworks = JSON.parse(current)
        let entry = frameworks.find((x) => x.name == folder)
        const template = codegenTemplates.find((x) => x.folder == folder)
        const values = { name: folder, hasStarterKit: template.starterKit }
        if (!entry) frameworks.push(values)
        else Object.assign(entry, values)
        return JSON.stringify(frameworks, null, 2)
      })
      .write()
      .exec()
  })
}

Finally, the previously defined tasks are executed:

// This invokes the generated tasks for each of the templates
task(`build`, async () => {
  for (const { file, folder } of codegenTemplates) {
    // Delete old version
    await exec(`prebuild:${file}`)
    // Generate new bundle
    await exec(`build:${file}`)
    // Browserify it so that it works in Browser + Node
    await exec(`browserify:${file}`)
    // Remove 'actions-codegen.js.map' and 'manifest-server.json' autogenerated files
    await exec(`postbuild-clean:${file}`)
    // Update 'frameworks.json'
    await exec(`update-framework:${folder}`)
  }
})

This series of build steps will:

  • Remove the old codegen files
  • Transpile the new version of each codegen into a single-file Javascript bundle in root-level folders of the repo
  • Run browserify on each of the bundles, so that they are isomorphic and work in both browser and Node environments
  • Remove some autogenerated manifests entries from the build step
  • Update the frameworks.json repo root to reflect any changes

You can contribute to codegen in one or more of the following ways:

Creating a type convertor for a new language

We have type convertors for a bunch of languages here. You can add a type convertor for a new language that can be levaraged in future to add codegen for different framworks/runtimes for that language.

TODO (elaborate instructions)

Creating a templates for a framework

We have a templaters for different frameworks and runtimes here.

TODO (elaborate instructions)

Bugs and improvements to existing assets

If you see some bugs in the codegen or if you feel that something could be done better, please [open an issue] about it. If you wish to work on a particular bug/enhancement, please comment on the issue and we will assign you accordingly.

If you are working on an issue or wanting to work on an issue, please make sure that you are on the same page with the maintainers about it. This is to avoid duplicate or unnecessary work.