so

Uploading applications

Volume 4, Issue 46; 03 Nov 2020

Every new project begins with writing tooling for the new project. Today’s diversion, a Gradle task to upload a web application. [Updated.]

I use Docker (and sometimes VMWare) to run local containers for most of my web development. For some applications, its practical to arrange for shared folders or mount points that allow containerized servers to read files directly out of my development tree. But more often these days, I need to upload resources (into a modules database, for example).

I’ve had various ways to do this over the years, some more clumsy than others. For the past several days with FusionDB, I’ve just been uploading all the XQuery modules every time one of them changed. It only takes a few extra seconds, but it irks me every time.

This morning, I decided to just tackle the problem once and for all. This is not the first time that I’ve wanted a Gradle task that would upload changed files.

And here itUpdated several hours after the initial post. That first version didn’t handle nested directories at all. #blush is:

abstract class CopyAndUpload extends DefaultTask {
  @Incremental
  @PathSensitive(PathSensitivity.NAME_ONLY)

  @InputDirectory
  abstract DirectoryProperty getInputDir()

  @OutputDirectory
  abstract DirectoryProperty getOutputDir()

  // The base URI for the HTTP command. The resulting
  // URI will be the base URI plus the relative path
  // of the changed document.
  @Input
  abstract Property<String> getBaseUri()

  // The HTTP method to use; defaults to defaultMethod
  // defined below.
  @Input
  @Optional
  abstract Property<String> getMethod()

  @TaskAction
  void execute(InputChanges inputChanges) {
    // Map from HTTP method to command. @@FILE@@ will be replaced
    // by the filename; @@URI@@ will be replaced by the URI.
    def commands = [
      "PUT": ["post", "-U", "-f", "@@FILE@@", "@@URI@@"],
      "POST": ["post", "-f", "@@FILE@@", "@@URI@@"]
    ]

    // Default HTTP method
    def defaultMethod = "PUT"

    // This seems like a bit of a hack.
    def sourceRoot = inputDir.file(".").get().toString()
    def targetRoot = outputDir.file(".").get().toString()

    // Get the base URI without a trailing slash.
    def uriRoot = getBaseUri().get()
    if (uriRoot.endsWith("/")) {
      uriRoot = uriRoot.substring(0, uriRoot.length() - 1)
    }

    // Iterate over the changed files
    inputChanges.getFileChanges(inputDir).each { change ->
      if (change.fileType == FileType.DIRECTORY) {
        def targetDir = targetRoot
        if (change.path != sourceRoot) {
          targetDir = targetRoot + change.path.substring(sourceRoot.length())
        }
        def dirFile = new File(targetDir)
        if (dirFile.exists()) {
          if (!dirFile.isDirectory()) {
            throw new GradleException("Cannot create directory: " + targetDir)
          }
        } else {
          if (! dirFile.mkdirs()) {
            throw new GradleException("Failed to create directory: " + targetDir)
          }
        }
        return
      }

      def method = getMethod().getOrElse(defaultMethod).toUpperCase()
      if (change.changeType == ChangeType.REMOVED) {
        method = "DELETE"
      }

      def command = null;
      if (commands.containsKey(method)) {
        command = commands.get(method)
      } else {
        throw new GradleException("No command for HTTP method: "+ method)
      }

      def relativeSrc = change.path.substring(sourceRoot.length())
      def targetFile = new File(targetRoot + relativeSrc)
      if (change.changeType == ChangeType.REMOVED) {
        targetFile.delete()
      } else {
        targetFile.text = change.file.text
      }

      def uri = uriRoot + relativeSrc

      def execArray = []
      command.each { arg ->
        if (arg == "@@FILE@@") {
          execArray.add(change.path)
        } else if (arg == "@@URI@@") {
          execArray.add(uri)
        } else {
          execArray.add(arg)
        }
      }

      def msg = execArray.execute()
      print(msg.text)
    }
  }
}

Now I can write a task like this one:

task uploadApp(type: CopyAndUpload) {
  inputDir = file("${projectDir}/src/app")
  outputDir = file("${buildDir}/app")
  baseUri = "http://fusiondb:4059/exist/rest/db/apps/app"
  doFirst {
    mkdir "${buildDir}/app"
  }
}

That task will copy new and changed files from src/app into build/app. Any file that’s copied will also be uploaded using post. “Post” is a convenience script that deals with media types and passwords. You will have to change the commands map to suit your environment.

Copying the files into build/app has nothing to do with uploading, per se, it’s just bookkeeping for Gradle so that it knows when a file has changed. It seems a small price to pay for the convenience of having just the changed files uploaded.

I haven’t attempted to package this up as proper Gradle plugin. If someone wants to point me in the right direction, I’ll probably do that. By the time I’ve copied it into two or three different build files, that’ll be irking me too!

Please provide your name and email address. Your email address will not be displayed and I won’t spam you, I promise. Your name and a link to your web address, if you provide one, will be displayed.

Your name:

Your email:

Homepage:

Do you comprehend the words on this page? (Please demonstrate that you aren't a mindless, screen-scraping robot.)

What is ten minus two?  (e.g. six plus two is 8)

Enter your comment in the box below. You may style your comment with the CommonMark flavor of Markdown.

All comments are moderated. I don’t promise to preserve all of your formatting and I reserve the right to remove comments for any reason.