JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
basic features for updating/checking one instance
[af-coffee.git] / api.coffee
diff --git a/api.coffee b/api.coffee
new file mode 100644 (file)
index 0000000..cda3905
--- /dev/null
@@ -0,0 +1,226 @@
+# Copyright 2012 Jason Woofenden -- GPLv3
+
+fs = require 'fs'
+https = require 'https'
+async = require 'async'
+
+# SETTINGS
+HOST = 'api.appfog.com'
+PORT = 443
+
+# multipart/form-data isn't supposed to be chunk encoded, so we calculate the
+# length, and generate the textual parts as we go
+multipart_prepare = (boundary, data, callback) ->
+       arr = []
+       # async doesn't seem to work on dictionaries so convert to an array
+       for k, v of data
+               arr.push [k, v]
+       async.map arr,
+               ([k,v], callback) ->
+                       out = "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{k}\""
+                       if v.file
+                               out += "; filename=\"#{v.file}\""
+                       out += "\r\n"
+                       if v instanceof Object
+                               if v['Content-Type']?
+                                       ct = v['Content-Type']
+                               else
+                                       if v.file?
+                                               ct = 'application/octet-stream'
+                                       else
+                                               ct = 'text/plain;charset=UTF-8'
+                               out += "Content-Type: #{ct}\r\n\r\n"
+                               if v.file?
+                                       fs.stat v.file, (err, stat) ->
+                                               return callback err if err?
+                                               callback null, text: out, file: v.file, file_length: stat.size
+                               else
+                                       callback null, text: out
+                       else
+                               #out += "Content-Type: text/plain;charset=UTF-8\r\n\r\n"
+                               #out += "Content-Type: text/plain\r\n\r\n"
+                               out += "\r\n"
+                               out += v.toString()
+                               callback null, text: out
+               (err, parts) ->
+                       return callback err if err
+                       parts.push text: "--#{boundary}--"
+                       size = 0
+                       for part in parts
+                               size += part.text.length
+                               if part.file_length?
+                                       size += part.file_length
+                               size += 2
+                       callback null, size, parts
+
+# encode and print data (as created by multipart_prepare()) to writable stream
+# req (and end() it)
+multipart_write = (req, parts, callback) ->
+       async.forEachSeries parts,
+               (part, callback) ->
+                       req.write part.text
+                       if part.file?
+                               done = false
+                               file = fs.createReadStream part.file
+                               file.on 'error', (err) ->
+                                       unless done
+                                               done = true
+                                               callback err
+                               file.on 'end', ->
+                                       unless done
+                                               done = true
+                                               req.write '\r\n'
+                                               callback()
+                               file.pipe req, end: false
+                               return
+                       else
+                               req.write '\r\n'
+                               callback()
+               (err) ->
+                       req.end()
+                       return callback err if err
+                       callback()
+
+new_multipart_boundary = ->
+       return "----bmawvch#{Math.floor(Math.random() * 1000000000)}m#{Math.floor(Math.random() * 1000000000)}9rch48dh"
+
+request = (method, path, content_type, data, token, callback) ->
+       opts =
+               host: HOST
+               port: PORT
+               path: path
+               method: method
+               headers: 'Content-Type': content_type
+       opts.headers['Authorization'] = token if token?
+       # no Accept header: -> unsupported media type
+       opts.headers['Accept'] = '*/*;q=0.8' # -> bad request (in json)
+       #opts.headers['Accept'] = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' # -> bad request (in json) or blank 404
+       #opts.headers['Accept'] = 'application/json' -> unsupported media type
+       async.waterfall [
+               (callback) ->
+                       if content_type is 'multipart/form-data'
+                               boundary = new_multipart_boundary()
+                               opts.headers['Content-Type'] = "#{content_type}; boundary=#{boundary}"
+                               multipart_prepare boundary, data, (err, size, parts) ->
+                                       return callback err if err
+                                       opts.headers['Content-Length'] = size
+                                       callback null, parts
+                       else
+                               opts.headers['Content-Length'] = data?.length ? 0
+                               callback null, data
+               (data, callback) ->
+                       called = false
+                       req = https.request opts, (res) ->
+                               res.setEncoding 'utf8'
+                               response_text = ''
+                               res.on 'data', (data) ->
+                                       response_text += data
+                               res.on 'end', ->
+                                       unless called
+                                               called = true
+                                               if res.statusCode isnt 200
+                                                       callback code: res.statusCode, path: path, response: response_text
+                                               else
+                                                       callback null, response_text
+                       req.on 'error', (err) ->
+                               unless called
+                                       called = true
+                                       callback err
+                       if content_type is 'multipart/form-data'
+                               multipart_write req, data, (err) ->
+                                       if err?
+                                               unless called
+                                                       called = true
+                                                       callback err
+                                               req.end()
+                       else
+                               req.write data if data?
+                               req.end()
+               ], callback
+
+json_request = (method, path, data, token, callback) ->
+       if data?
+               data = JSON.stringify data
+       request method, path, 'application/json', data, token, (err, response) ->
+               return callback err if err
+               return callback() if response is ' '
+               return callback() if response is ''
+               try
+                       callback null, JSON.parse(response)
+               catch error
+                       callback "Error: AF server returned invalid JSON for #{method} to #{path}: \"#{response}\""
+
+json_get = (path, token, callback) ->
+       json_request 'GET', path, null, token, callback
+
+json_post = (path, data, token, callback) ->
+       json_request 'POST', path, data, token, callback
+
+json_put = (path, data, token, callback) ->
+       json_request 'PUT', path, data, token, callback
+
+exports.login = login = (username, password, callback) ->
+       json_post "/users/#{username}/tokens", {password: password}, null, (err, response) ->
+               return callback err if err
+               if response.token?.length
+                       callback null, response.token
+               else
+                       callback "login: couldn't find the token in the server response: #{JSON.stringify response}"
+
+# this is the friendly one that updates the files then restarts the app
+exports.app_publish = (token, app_name, zip_file, callback) ->
+       exports.app_update_files token, app_name, zip_file, (err) ->
+               return callback err if err?
+               # FIXME add "restart" call that only calls app_info once
+               exports.app_stop token, app_name, (err) ->
+                       if err?
+                               return callback "Couldn't stop the app (to restart it). App is probably still running old version (though perhaps accessing new files.). Server responded: #{err}"
+                       exports.app_start token, app_name, (err) ->
+                               if err?
+                                       return callback "CRITICAL ERROR: Couldn't start the app back up! Server responded: #{err}"
+                               callback()
+
+exports.app_update_files = (token, app_name, zip_file, callback) ->
+       request(
+               'POST',
+               "/apps/#{app_name}/application",
+               'multipart/form-data',
+               {
+                       _method: 'put',
+                       resources: '[]',
+                       application:
+                               file: zip_file,
+                               "Content-Type": 'application/octet-stream'
+               },
+               token,
+               callback
+       )
+
+exports.app_set_info = (token, app_name, info, callback) ->
+       json_put "/apps/#{app_name}", info, token, callback
+
+app_set_state = (token, app_name, state, callback) ->
+       exports.app_info token, app_name, (err, info) ->
+               return callback err if err?
+               info.state = state
+               exports.app_set_info token, app_name, info, callback
+
+exports.app_start = (token, app_name, callback) ->
+       app_set_state token, app_name, 'STARTED', callback
+
+exports.app_stop = (token, app_name, callback) ->
+       app_set_state token, app_name, 'STOPPED', callback
+
+
+app_get = (path) ->
+       return (token, app_name, callback) ->
+               json_get "/apps/#{app_name}#{path}", token, callback
+
+exports.app_instances = app_get '/instances'
+exports.app_crashes   = app_get '/crashes'
+exports.app_info      = app_get ''
+exports.app_logs      = app_get '/instances/0/files/logs'
+exports.app_stdout    = app_get '/instances/0/files/logs/stdout.log'
+exports.app_stderr    = app_get '/instances/0/files/logs/stderr.log'
+exports.app_files     = app_get '/instances/0/files/app/app.js'
+exports.app_stats     = app_get '/stats'