├── .travis.yml ├── README.md ├── bin └── postgresql-http-server ├── lib ├── basic_auth.coffee ├── cli.coffee ├── db.coffee ├── lexer.coffee ├── resources │ ├── columns.coffee │ ├── database.coffee │ ├── db.coffee │ ├── index.coffee │ ├── root.coffee │ ├── row.coffee │ ├── rows.coffee │ ├── schema.coffee │ ├── schemas.coffee │ ├── table.coffee │ ├── tables.coffee │ └── utils.coffee └── server.coffee ├── package.json ├── runtests.sh └── test ├── specs ├── resources │ ├── root.coffee │ └── rows.coffee └── utils.coffee └── sql └── init.sql /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.8 4 | env: 5 | - NODE_ENV="development" 6 | before_install: 7 | - sudo apt-get install -qq postgis postgresql-9.1-postgis 8 | - createdb -U postgres template_postgis 9 | - psql -U postgres -d template_postgis -f /usr/share/postgresql/9.1/contrib/postgis-1.5/postgis.sql 10 | - psql -U postgres -d template_postgis -f /usr/share/postgresql/9.1/contrib/postgis-1.5/spatial_ref_sys.sql 11 | before_script: 12 | - psql -c "CREATE DATABASE test TEMPLATE template_postgis;" -U postgres 13 | - psql -f ./test/sql/init.sql -U postgres -d test 14 | - ./bin/postgresql-http-server --user postgres --database test & 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PostgreSQL HTTP API Server 2 | 3 | NOTE: This project is on indefinite hold and has been superceded by [jdbc-http-server](https://github.com/bjornharrtell/jdbc-http-server) 4 | 5 | Attempt to implement something like the proposal at http://wiki.postgresql.org/wiki/HTTP_API 6 | 7 | [![Build Status](https://secure.travis-ci.org/bjornharrtell/postgresql-http-server.png?branch=master)](http://travis-ci.org/bjornharrtell/postgresql-http-server) 8 | 9 | ## Installing 10 | 11 | NOTE: Requires node.js 12 | 13 | # npm install postgresql-http-server 14 | 15 | ## Usage 16 | 17 | # postgresql-http-server --help 18 | PostgreSQL HTTP API Server 19 | 20 | Options: 21 | --port HTTP Server port [required] [default: 3000] 22 | --dbhost PostgreSQL host [required] [default: "localhost"] 23 | --dbport PostgreSQL port [required] [default: 5432] 24 | --database PostgreSQL database [required] [default: ] 25 | --user PostgreSQL username [required] [default: ] 26 | --password PostgreSQL password 27 | --raw Enable raw SQL usage [boolean] 28 | --cors Enable CORS support [boolean] 29 | --secure Enable Basic Auth support [boolean] 30 | (configured via PG_BASIC_PASS and PG_BASIC_USER) 31 | --help Show this message 32 | 33 | ## API Usage 34 | 35 | The API is discoverable which means you can access the root resource at / 36 | and follow links to subresources from there but lets say you have a database 37 | named testdb with a table named testtable in the public schema you can then 38 | do the following operations: 39 | 40 | Retrieve (GET) or update (PUT) a single row at: 41 | /db/testdb/schemas/public/tables/testtable/rows/id 42 | 43 | Retrieve rows (GET), update rows (PUT) or create a new row (POST) at: 44 | /db/testdb/schemas/public/tables/testtable/rows 45 | 46 | The above resources accepts parameters select, where, limit, offset 47 | and orderby where applicable. Examples: 48 | 49 | GET a maximum of 10 rows where cost>100 at: 50 | /db/testdb/schemas/public/tables/testtable/rows?where=cost>100&limit=10 51 | 52 | GET rows with fields id and geom (as WKT) intersecting a polygon 53 | /db/testdb/schemas/public/tables/testtable/rows?select=id,ST_AsText(geom) as geom&where=st_intersects(geom,'POLYGON((10 10,10 100,100 100,100 10,10 10))'::geometry) 54 | 55 | The default and currently the only dataformat is JSON. POSTing or PUTing 56 | expects a JSON object with properties corresponding to column names. 57 | 58 | Raw SQL queries can be POSTed to the database resource. Expected data 59 | is a JSON object with the SQL string as property named "sql". 60 | 61 | ## TODOs 62 | 63 | * Use real primary key (current single row operations assume a primary key named id) 64 | * Stream row data 65 | * Configurable max rows hard limit 66 | * Optional security "firewall" (initially no access, open access to paths/operations based on configurable rules) 67 | * Use as "plugin" to your existing express application 68 | 69 | ## License 70 | 71 | The MIT License (MIT) 72 | 73 | Copyright (c) 2012-2013 Björn Harrtell 74 | 75 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 76 | 77 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 78 | 79 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 80 | -------------------------------------------------------------------------------- /bin/postgresql-http-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env coffee 2 | 3 | if not process.env.NODE_ENV then process.env.NODE_ENV = 'production' 4 | 5 | path = require 'path' 6 | fs = require 'fs' 7 | require path.join __dirname, '../lib/cli.coffee' 8 | -------------------------------------------------------------------------------- /lib/basic_auth.coffee: -------------------------------------------------------------------------------- 1 | express = require('express') 2 | 3 | exports.secureAPI = express.basicAuth (user, pass) -> 4 | password = process.env.PG_BASIC_PASS 5 | user = process.env.PG_BASIC_USER || 'pguser' 6 | if (password == null || password == undefined) 7 | console.log("PG_BASIC_PASS env variable is missing....") 8 | return false; 9 | return user == user && pass == password 10 | 11 | -------------------------------------------------------------------------------- /lib/cli.coffee: -------------------------------------------------------------------------------- 1 | # CLI main entry point 2 | 3 | optimist = require 'optimist' 4 | optimist.usage 'PostgreSQL HTTP API Server' 5 | optimist.options 'port', 6 | describe : 'HTTP Server port' 7 | default : process.env.PG_PORT || 3000 8 | optimist.options 'dbhost', 9 | describe : 'PostgreSQL host' 10 | default : process.env.PG_HOST || 'localhost' 11 | optimist.options 'dbport', 12 | describe : 'PostgreSQL port' 13 | default : process.env.PG_PORT || 5432 14 | optimist.options 'database', 15 | describe : 'PostgreSQL database' 16 | default : process.env.PG_USER || process.env.USER 17 | optimist.options 'user', 18 | describe : 'PostgreSQL username' 19 | default : process.env.PG_DB || process.env.USER 20 | optimist.options 'password', 21 | describe : 'PostgreSQL password' 22 | default : process.env.PG_PASSWORD || null 23 | optimist.options 'raw', 24 | describe : 'Enable raw SQL usage' 25 | optimist.options 'cors', 26 | describe : 'Enable CORS support' 27 | optimist.options 'secure', 28 | describe : 'Enable Basic Auth' 29 | optimist.options 'help', 30 | describe : 'Show this message' 31 | argv = optimist.boolean('raw').boolean('cors').boolean('secure') 32 | .demand(['port', 'dbhost', 'dbport', 'database', 'user']) 33 | .argv 34 | 35 | if argv.help 36 | console.log optimist.help() 37 | else 38 | Server = require('./server') 39 | server = new Server() 40 | server.start(argv) 41 | -------------------------------------------------------------------------------- /lib/db.coffee: -------------------------------------------------------------------------------- 1 | ### 2 | Helper functions for DB access and SQL parsing 3 | ### 4 | 5 | pg = require 'pg' 6 | lexer = require './lexer' 7 | 8 | module.exports = (log, connectionString, database) -> 9 | ### 10 | config.sql - sql to query 11 | config.res - response to send query results to (or eventual error) 12 | config.values - parameter values 13 | config.callback - callback to be called on successful query with a single argument containing the query result 14 | ### 15 | query = (config) -> 16 | connectionStringDb = connectionString + "/" + (config.database || database) 17 | 18 | tokens = lexer.tokenize config.sql 19 | config.sql = (token[1] for token in tokens).join " " 20 | 21 | log.debug "Sending query to #{connectionStringDb}\nSQL: #{config.sql}\nParameters: #{JSON.stringify(config.values)}" 22 | 23 | callback = (err, result) -> 24 | if err 25 | log.error err.message 26 | config.res.send 500, err.message 27 | else 28 | config.callback result 29 | 30 | pg.connect connectionStringDb, (err, client, done) -> 31 | if err then callback err else client.query config.sql, config.values || [], callback 32 | done() 33 | 34 | parseWhere = (config, where) -> if where 35 | config.sql += " WHERE " 36 | tokens = lexer.tokenize where 37 | for token in tokens 38 | if token[0] is 'STRING' or token[0] is 'NUMBER' 39 | config.values.push token[1] 40 | config.sql += "$#{config.count}" 41 | config.count += 1 42 | else if token[0] is 'CONDITIONAL' 43 | config.sql += " #{token[1].toUpperCase()} " 44 | else if token[0] is 'LITERAL' 45 | config.sql += "\"#{token[1]}\"" 46 | else 47 | config.sql += token[1] 48 | 49 | parseLimit = (config, limit) -> if limit 50 | config.sql += " LIMIT $#{config.count}" 51 | config.values.push parseInt limit 52 | config.count += 1 53 | 54 | parseOffset = (config, offset) -> if offset 55 | config.sql += " OFFSET $#{config.count}" 56 | config.values.push parseInt offset 57 | config.count += 1 58 | 59 | parseOrderBy = (config, orderby) -> if orderby 60 | config.sql += " ORDER BY #{orderby}" 61 | 62 | parseRow = (row) -> 63 | fields = [] 64 | params = [] 65 | values = [] 66 | count = 1 67 | for k,v of row 68 | fields.push "\"#{k}\"" 69 | params.push "$#{count}" 70 | # TODO: use something like the below to accept GeoJSON instead of WKT 71 | #params.push if k is 'geom' then "ST_GeomFromGeoJSON($#{count})" else "$#{count}" 72 | values.push v 73 | count += 1 74 | 75 | fields: fields.join ',' 76 | params: params.join ',' 77 | values: values 78 | count: count 79 | 80 | query: query 81 | parseWhere: parseWhere 82 | parseLimit: parseLimit 83 | parseOffset: parseOffset 84 | parseOrderBy: parseOrderBy 85 | parseRow: parseRow 86 | 87 | -------------------------------------------------------------------------------- /lib/lexer.coffee: -------------------------------------------------------------------------------- 1 | class Lexer 2 | constructor: (sql, opts={}) -> 3 | @sql = sql 4 | @preserveWhitespace = opts.preserveWhitespace || false 5 | @tokens = [] 6 | @currentLine = 1 7 | i = 0 8 | while @chunk = sql.slice(i) 9 | bytesConsumed = @keywordToken() or 10 | @starToken() or 11 | @booleanToken() or 12 | @functionToken() or 13 | @windowExtension() or 14 | @sortOrderToken() or 15 | @seperatorToken() or 16 | @operatorToken() or 17 | @mathToken() or 18 | @dotToken() or 19 | @conditionalToken() or 20 | @numberToken() or 21 | @stringToken() or 22 | @parameterToken() or 23 | @parensToken() or 24 | @whitespaceToken() or 25 | @literalToken() 26 | throw new Error("NOTHING CONSUMED: Stopped at - '#{@chunk.slice(0,30)}'") if bytesConsumed < 1 27 | i += bytesConsumed 28 | @token('EOF', '') 29 | 30 | token: (name, value) -> 31 | @tokens.push([name, value, @currentLine]) 32 | 33 | tokenizeFromRegex: (name, regex, part=0, lengthPart=part, output=true) -> 34 | return 0 unless match = regex.exec(@chunk) 35 | partMatch = match[part] 36 | @token(name, partMatch) if output 37 | return match[lengthPart].length 38 | 39 | tokenizeFromWord: (name, word=name) -> 40 | word = @regexEscape(word) 41 | matcher = if (/^\w+$/).test(word) 42 | new RegExp("^(#{word})\\b",'ig') 43 | else 44 | new RegExp("^(#{word})",'ig') 45 | match = matcher.exec(@chunk) 46 | return 0 unless match 47 | @token(name, match[1]) 48 | return match[1].length 49 | 50 | tokenizeFromList: (name, list) -> 51 | ret = 0 52 | for entry in list 53 | ret = @tokenizeFromWord(name, entry) 54 | break if ret > 0 55 | ret 56 | 57 | keywordToken: -> 58 | @tokenizeFromWord('SELECT') or 59 | @tokenizeFromWord('DISTINCT') or 60 | @tokenizeFromWord('FROM') or 61 | @tokenizeFromWord('WHERE') or 62 | @tokenizeFromWord('GROUP') or 63 | @tokenizeFromWord('ORDER') or 64 | @tokenizeFromWord('BY') or 65 | @tokenizeFromWord('HAVING') or 66 | @tokenizeFromWord('LIMIT') or 67 | @tokenizeFromWord('JOIN') or 68 | @tokenizeFromWord('LEFT') or 69 | @tokenizeFromWord('RIGHT') or 70 | @tokenizeFromWord('INNER') or 71 | @tokenizeFromWord('OUTER') or 72 | @tokenizeFromWord('ON') or 73 | @tokenizeFromWord('AS') or 74 | @tokenizeFromWord('UNION') or 75 | @tokenizeFromWord('ALL') 76 | 77 | dotToken: -> @tokenizeFromWord('DOT', '.') 78 | operatorToken: -> @tokenizeFromList('OPERATOR', SQL_OPERATORS) 79 | mathToken: -> 80 | @tokenizeFromList('MATH', MATH) or 81 | @tokenizeFromList('MATH_MULTI', MATH_MULTI) 82 | conditionalToken: -> @tokenizeFromList('CONDITIONAL', SQL_CONDITIONALS) 83 | functionToken: -> @tokenizeFromList('FUNCTION', SQL_FUNCTIONS) 84 | sortOrderToken: -> @tokenizeFromList('DIRECTION', SQL_SORT_ORDERS) 85 | booleanToken: -> @tokenizeFromList('BOOLEAN', BOOLEAN) 86 | 87 | starToken: -> @tokenizeFromRegex('STAR', STAR) 88 | seperatorToken: -> @tokenizeFromRegex('SEPARATOR', SEPARATOR) 89 | literalToken: -> 90 | @tokenizeFromRegex('LITERAL', LITERAL) or 91 | @tokenizeFromRegex('LITERAL', LITERAL2) 92 | numberToken: -> @tokenizeFromRegex('NUMBER', NUMBER) 93 | parameterToken: -> @tokenizeFromRegex('PARAMETER', PARAMETER) 94 | stringToken: -> @tokenizeFromRegex('STRING', STRING, 1, 0) 95 | 96 | parensToken: -> 97 | @tokenizeFromRegex('LEFT_PAREN', /^\(/,) or 98 | @tokenizeFromRegex('RIGHT_PAREN', /^\)/,) 99 | 100 | windowExtension: -> 101 | match = (/^\.(win):(length|time)/i).exec(@chunk) 102 | return 0 unless match 103 | @token('WINDOW', match[1]) 104 | @token('WINDOW_FUNCTION', match[2]) 105 | match[0].length 106 | 107 | whitespaceToken: -> 108 | return 0 unless match = WHITESPACE.exec(@chunk) 109 | partMatch = match[0] 110 | newlines = partMatch.replace(/[^\n]/, '').length 111 | @currentLine += newlines 112 | @token(name, partMatch) if @preserveWhitespace 113 | return partMatch.length 114 | 115 | regexEscape: (str) -> 116 | str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&") 117 | 118 | SQL_KEYWORDS = ['SELECT', 'FROM', 'WHERE', 'GROUP BY', 'ORDER BY', 'HAVING', 'AS'] 119 | SQL_FUNCTIONS = ['AVG', 'COUNT', 'MIN', 'MAX', 'SUM'] 120 | SQL_SORT_ORDERS = ['ASC', 'DESC'] 121 | SQL_OPERATORS = ['=', '>', '<', 'LIKE', 'IS NOT', 'IS', '::'] 122 | SQL_CONDITIONALS = ['AND', 'OR'] 123 | BOOLEAN = ['TRUE', 'FALSE', 'NULL'] 124 | MATH = ['+', '-'] 125 | MATH_MULTI = ['/', '*'] 126 | STAR = /^\*/ 127 | SEPARATOR = /^,/ 128 | WHITESPACE = /^[ \n\r]+/ 129 | LITERAL = /^"[a-z_][a-z0-9_]{0,}"/i 130 | LITERAL2 = /^[a-z_]{0,}/i 131 | PARAMETER = /^\$[0-9]+/ 132 | NUMBER = /^[0-9]+(\.[0-9]+)?/ 133 | STRING = /^'([^\\']*(?:\\.[^\\']*)*)'/ 134 | 135 | 136 | 137 | exports.tokenize = (sql, opts) -> (new Lexer(sql, opts)).tokens 138 | 139 | -------------------------------------------------------------------------------- /lib/resources/columns.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (server) -> 2 | log = server.log 3 | db = server.db 4 | app = server.app 5 | 6 | log.debug "Setting up columns resource" 7 | 8 | path = '/db/:databaseName/schemas/:schemaName/tables/:tableName/columns' 9 | 10 | app.get path, (req, res) -> 11 | sql = "SELECT * FROM information_schema.columns WHERE table_catalog = $1 AND table_name = $2" 12 | db.query 13 | sql: sql 14 | res: res 15 | values: [req.params.databaseName, req.params.tableName] 16 | database: req.params.databaseName 17 | callback: (result) -> 18 | columns = {} 19 | for column in result.rows 20 | columns[column.column_name] = 21 | type: column.udt_name 22 | res.send columns 23 | 24 | -------------------------------------------------------------------------------- /lib/resources/database.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (server, raw) -> 2 | log = server.log 3 | db = server.db 4 | app = server.app 5 | 6 | log.debug "Setting up database resource" 7 | 8 | app.get '/db/:databaseName', (req, res) -> 9 | res.send 10 | type: 'database' 11 | children: ['schemas'] 12 | 13 | if raw 14 | app.post '/db/:databaseName', (req, res) -> 15 | console.log 'RAW SQL POST: ' + req.body.sql 16 | db.query 17 | sql: req.body.sql 18 | res: res 19 | database: req.params.databaseName 20 | callback: (result) -> 21 | res.send result.rows 22 | -------------------------------------------------------------------------------- /lib/resources/db.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (server) -> 2 | log = server.log 3 | db = server.db 4 | app = server.app 5 | 6 | log.debug "Setting up databases resource" 7 | 8 | app.get '/db', (req, res) -> 9 | sql = "SELECT * FROM pg_database" 10 | db.query 11 | sql: sql 12 | res: res 13 | callback: (result) -> 14 | databaseNames = (row.datname for row in result.rows) 15 | res.send 16 | type: 'databases' 17 | children: databaseNames 18 | -------------------------------------------------------------------------------- /lib/resources/index.coffee: -------------------------------------------------------------------------------- 1 | module.exports = 2 | root: require './root' 3 | db: require './db' 4 | database: require './database' 5 | schemas: require './schemas' 6 | schema: require './schema' 7 | tables: require './tables' 8 | table: require './table' 9 | rows: require './rows' 10 | row: require './row' 11 | columns: require './columns' 12 | -------------------------------------------------------------------------------- /lib/resources/root.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (server) -> 2 | log = server.log 3 | db = server.db 4 | app = server.app 5 | 6 | log.debug "Setting up root resource" 7 | 8 | app.get '/', (req, res) -> 9 | sql = "SELECT * FROM information_schema.sql_implementation_info WHERE implementation_info_id = $1" 10 | db.query 11 | sql: sql 12 | res: res 13 | values: ['18'] 14 | callback: (result) -> 15 | versionName = result.rows[0].character_value 16 | res.send 17 | type: 'server_root' 18 | version: null 19 | version_human: versionName 20 | description: "PostgreSQL #{versionName}" 21 | children: ['db'] 22 | 23 | -------------------------------------------------------------------------------- /lib/resources/row.coffee: -------------------------------------------------------------------------------- 1 | parseTable = require('./utils').parseTable 2 | 3 | # TODO: Use real PK 4 | module.exports = (server) -> 5 | log = server.log 6 | db = server.db 7 | app = server.app 8 | 9 | log.debug "Setting up row resource" 10 | 11 | path = '/db/:databaseName/schemas/:schemaName/tables/:tableName/rows/:id' 12 | 13 | app.get path, (req, res) -> 14 | table = parseTable req 15 | sql = "SELECT * FROM #{table} WHERE id = $1" 16 | db.query 17 | sql: sql 18 | res: res 19 | values: [req.params.id] 20 | database: req.params.databaseName 21 | callback: (result) -> 22 | if result.rows.length is 1 then res.send result.rows[0] else res.send 404 23 | 24 | app.put path, (req, res) -> 25 | parsedRow = db.parseRow req.body 26 | table = parseTable req 27 | db.query 28 | sql: "UPDATE #{table} SET (#{parsedRow.fields}) = (#{parsedRow.params}) WHERE id = $#{parsedRow.count}" 29 | values: parsedRow.values.concat req.params.id 30 | res: res 31 | count: parsedRow.count 32 | database: req.params.databaseName 33 | callback: (result) -> 34 | res.send 200 35 | 36 | app.delete path, (req, res) -> 37 | table = parseTable req 38 | sql = "DELETE FROM #{table} WHERE id = $1" 39 | db.query 40 | sql: sql 41 | res: res 42 | values: [req.params.id] 43 | database: req.params.databaseName 44 | callback: (result) -> 45 | res.send 200 46 | -------------------------------------------------------------------------------- /lib/resources/rows.coffee: -------------------------------------------------------------------------------- 1 | parseTable = require('./utils').parseTable 2 | 3 | module.exports = (server) -> 4 | log = server.log 5 | db = server.db 6 | app = server.app 7 | 8 | log.debug "Setting up rows resource" 9 | 10 | path = '/db/:databaseName/schemas/:schemaName/tables/:tableName/rows' 11 | 12 | app.get path, (req, res) -> 13 | fields = req.query.select ? '*' 14 | table = parseTable req 15 | config = 16 | sql: "SELECT #{fields} FROM #{table}" 17 | values: [] 18 | count: 1 19 | res: res 20 | database: req.params.databaseName 21 | callback: (result) -> 22 | res.send result.rows 23 | 24 | db.parseWhere config, req.query.where 25 | db.parseLimit config, req.query.limit 26 | db.parseOffset config, req.query.offset 27 | db.parseOrderBy config, req.query.orderby 28 | 29 | db.query config 30 | 31 | app.post path, (req, res) -> 32 | parsedRow = db.parseRow req.body 33 | 34 | table = parseTable req 35 | sql = "INSERT INTO #{table} (#{parsedRow.fields}) VALUES (#{parsedRow.params}) RETURNING id" 36 | db.query 37 | sql: sql 38 | res: res 39 | values: parsedRow.values 40 | database: req.params.databaseName 41 | callback: (result) -> 42 | res.contentType 'application/json' 43 | id = result.rows[0].id 44 | id = if typeof id is "string" then "\"#{id}\"" else "#{id}" 45 | res.send id, 201 46 | 47 | app.put path, (req, res) -> 48 | parsedRow = db.parseRow req.body 49 | 50 | table = parseTable req 51 | config = 52 | sql: "UPDATE #{table} SET (#{parsedRow.fields}) = (#{parsedRow.params})" 53 | values: parsedRow.values 54 | res: res 55 | count: parsedRow.count 56 | database: req.params.databaseName 57 | callback: (result) -> 58 | res.send 200 59 | 60 | db.parseWhere config, req.query.where 61 | 62 | db.query config 63 | 64 | app.delete path, (req, res) -> 65 | table = parseTable req 66 | config = 67 | res: res 68 | count: 1 69 | database: req.params.databaseName 70 | callback: (result) -> 71 | res.send 200 72 | 73 | if req.query.where 74 | config.sql = "DELETE FROM #{table}" 75 | config.values = [] 76 | db.parseWhere config, req.query.where 77 | else 78 | config.sql = "TRUNCATE #{table}" 79 | 80 | db.query config 81 | 82 | 83 | -------------------------------------------------------------------------------- /lib/resources/schema.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (server) -> 2 | log = server.log 3 | db = server.db 4 | app = server.app 5 | 6 | log.debug "Setting up schema resource" 7 | 8 | app.get '/db/:databaseName/schemas/:schemaName', (req, res) -> 9 | res.send 10 | type: 'schema' 11 | children: ['tables'] 12 | -------------------------------------------------------------------------------- /lib/resources/schemas.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (server) -> 2 | log = server.log 3 | db = server.db 4 | app = server.app 5 | 6 | log.debug "Setting up schemas resource" 7 | 8 | app.get '/db/:databaseName/schemas', (req, res) -> 9 | sql = "SELECT * FROM information_schema.schemata WHERE catalog_name = $1" 10 | db.query 11 | sql: sql 12 | res: res 13 | values: [req.params.databaseName] 14 | database: req.params.databaseName 15 | callback: (result) -> 16 | res.send 17 | type: 'schemas' 18 | children: (row.schema_name for row in result.rows) 19 | -------------------------------------------------------------------------------- /lib/resources/table.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (server) -> 2 | log = server.log 3 | db = server.db 4 | app = server.app 5 | 6 | log.debug "Setting up table resource" 7 | 8 | path = '/db/:databaseName/schemas/:schemaName/tables/:tableName' 9 | 10 | app.get path, (req, res) -> 11 | res.send 12 | type: 'table' 13 | children: ['rows'] 14 | # TODO: implement resources for children 'columns', 'constraints', 'indexes', 'rules', 'triggers' 15 | 16 | -------------------------------------------------------------------------------- /lib/resources/tables.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (server) -> 2 | log = server.log 3 | db = server.db 4 | app = server.app 5 | 6 | log.debug "Setting up tables resource" 7 | 8 | app.get '/db/:databaseName/schemas/:schemaName/tables', (req, res) -> 9 | sql = "SELECT * FROM pg_tables WHERE schemaname = $1" 10 | db.query 11 | sql: sql 12 | res: res 13 | values: [req.params.schemaName] 14 | database: req.params.databaseName 15 | callback: (result) -> 16 | res.send 17 | type: 'tables' 18 | children: (row.tablename for row in result.rows) 19 | -------------------------------------------------------------------------------- /lib/resources/utils.coffee: -------------------------------------------------------------------------------- 1 | exports.parseTable = (req) -> 2 | "\"#{req.params.schemaName}\".\"#{req.params.tableName}\"" 3 | -------------------------------------------------------------------------------- /lib/server.coffee: -------------------------------------------------------------------------------- 1 | express = require 'express' 2 | auth = require './basic_auth' 3 | argv = require('optimist').argv 4 | 5 | class Server 6 | constructor: (app) -> 7 | @log = new (require('log'))(if process.env.NODE_ENV is 'development' then 'debug' else 'info') 8 | 9 | if not app? 10 | app = express() 11 | app.configure -> 12 | app.use express.bodyParser() 13 | app.use express.methodOverride() 14 | 15 | app.configure 'development', -> 16 | app.use express.errorHandler { dumpExceptions: true, showStack: true } 17 | 18 | app.configure 'production', -> 19 | app.use express.errorHandler() 20 | if argv.secure 21 | app.use auth.secureAPI 22 | @app = app 23 | 24 | # Initialize 25 | # @param [Object] options 26 | # @option options [String] dbhost PostgreSQL host 27 | # @option options [String] dbport PostgreSQL port 28 | # @option options [String] database PostgreSQL database 29 | # @option options [String] user PostgreSQL username 30 | # @option options [String] password PostgreSQL password 31 | setup: (options) -> 32 | passwordString = if options.password then ":#{options.password}" else "" 33 | connectionString = "tcp://#{options.user}#{passwordString}@#{options.dbhost}" 34 | @log.info "Using connection string #{connectionString}" 35 | 36 | @db = require('./db')(@log, connectionString, options.database) 37 | 38 | if options.cors 39 | @log.info "Enable Cross-origin Resource Sharing" 40 | @app.options '/*', (req,res,next) -> 41 | res.header 'Access-Control-Allow-Origin', '*' 42 | res.header 'Access-Control-Allow-Headers', 'origin, x-requested-with, content-type' 43 | next() 44 | 45 | @app.get '/*', (req,res,next) -> 46 | res.header 'Access-Control-Allow-Origin', '*' 47 | res.header 'Access-Control-Allow-Headers', 'origin, x-requested-with, content-type' 48 | next() 49 | 50 | @app.post '/*', (req,res,next) -> 51 | res.header 'Access-Control-Allow-Origin', '*' 52 | res.header 'Access-Control-Allow-Headers', 'origin, x-requested-with, content-type' 53 | next() 54 | 55 | @log.info "Setting up resources" 56 | resources = require './resources' 57 | resources.root @ 58 | resources.db @ 59 | resources.database @, options.raw 60 | resources.schemas @ 61 | resources.schema @ 62 | resources.tables @ 63 | resources.table @ 64 | resources.rows @ 65 | resources.row @ 66 | resources.columns @ 67 | 68 | start: (argv) -> 69 | @setup argv 70 | @app.listen argv.port, => 71 | @log.info "Listening on port #{argv.port} in #{@app.settings.env} mode" 72 | 73 | module.exports = Server -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postgresql-http-server", 3 | "version": "0.6.0", 4 | "author": "Björn Harrtell ", 5 | "description": "PostgreSQL HTTP API Server", 6 | "homepage": "https://github.com/bjornharrtell/postgresql-http-server", 7 | "bin": "bin/postgresql-http-server", 8 | "main": "lib/cli.coffee", 9 | "scripts": { 10 | "test": "mocha --recursive --require coffee-script --reporter dot test/specs/**/*" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/bjornharrtell/postgresql-http-server.git" 15 | }, 16 | "keywords": [ 17 | "cli", 18 | "postgresql", 19 | "http", 20 | "server" 21 | ], 22 | "dependencies": { 23 | "coffee-script": "1.6.2", 24 | "optimist": "0.4.0", 25 | "express": "3.2.3", 26 | "passport": "0.1.16", 27 | "pg": "1.1.0", 28 | "log": "1.4.0" 29 | }, 30 | "devDependencies": { 31 | "mocha": "1.9.0" 32 | }, 33 | "engines": { 34 | "node": ">= 0.8.0" 35 | }, 36 | "license": "MIT" 37 | } 38 | -------------------------------------------------------------------------------- /runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # This script is intended to run the tests locally 3 | psql -f ./test/sql/init.sql -d test 4 | npm test 5 | -------------------------------------------------------------------------------- /test/specs/resources/root.coffee: -------------------------------------------------------------------------------- 1 | assert = require 'assert' 2 | 3 | test = require('../utils').test 4 | 5 | describe 'Root resource', -> 6 | it 'should answer a GET request with an object created from db instance info', (done) -> 7 | test 8 | path: '/' 9 | method: 'GET' 10 | callback: (res, data) -> 11 | assert data.version is null, 'version should be string' 12 | assert typeof data.version_human is 'string', 'human should be string' 13 | assert typeof data.description is 'string', 'description should be string' 14 | assert data.children[0] is 'db', "should have child db" 15 | done() 16 | -------------------------------------------------------------------------------- /test/specs/resources/rows.coffee: -------------------------------------------------------------------------------- 1 | assert = require 'assert' 2 | 3 | test = require('../utils').test 4 | 5 | path = '/db/test/schemas/testschema/tables/testtable/rows' 6 | 7 | describe 'Rows resource', -> 8 | it 'should answer first GET with empty recordset', (done) -> 9 | test 10 | path: path 11 | method: 'GET' 12 | callback: (res, data) -> 13 | assert data.length is 0, '#{data.length} should be 0' 14 | done() 15 | 16 | it 'should answer a POST with status 201', (done) -> 17 | test 18 | path: path 19 | method: 'POST' 20 | headers: 21 | 'Content-Type': 'application/json' 22 | body: 23 | name: 'first' 24 | callback: (res, data) -> 25 | assert res.statusCode is 201, "#{res.statusCode} should be 201" 26 | done() 27 | 28 | it 'should answer second GET with single record in recordset', (done) -> 29 | test 30 | path: path 31 | method: 'GET' 32 | callback: (res, data) -> 33 | assert data.length is 1, '#{data.length} should be 1' 34 | done() 35 | 36 | it 'should answer a second POST with status 201', (done) -> 37 | test 38 | path: path 39 | method: 'POST' 40 | headers: 41 | 'Content-Type': 'application/json' 42 | body: 43 | name: 'second' 44 | callback: (res, data) -> 45 | assert res.statusCode is 201, "#{res.statusCode} should be 201" 46 | done() 47 | 48 | it 'should answer a PUT with status 200', (done) -> 49 | test 50 | path: path + "?where=name%3D'first'" 51 | method: 'PUT' 52 | headers: 53 | 'Content-Type': 'application/json' 54 | body: 55 | name: 'updatedfirst', 56 | geom: 'POINT (-48.23456 20.12345)' 57 | callback: (res, data) -> 58 | assert res.statusCode is 200, "#{res.statusCode} should be 200" 59 | done() 60 | 61 | it 'should answer third GET with single record in recordset', (done) -> 62 | test 63 | path: path + "?where=name%3D'updatedfirst'" 64 | method: 'GET' 65 | callback: (res, data) -> 66 | assert data.length is 1, "#{data.length} should be 1" 67 | done() 68 | 69 | it 'should DELETE all records', (done) -> 70 | test 71 | path: path 72 | method: 'DELETE' 73 | callback: (res, data) -> 74 | assert res.statusCode is 200, "#{res.statusCode} should be 200" 75 | done() 76 | 77 | -------------------------------------------------------------------------------- /test/specs/utils.coffee: -------------------------------------------------------------------------------- 1 | http = require 'http' 2 | 3 | exports.test = (options) -> 4 | options.host = 'localhost' 5 | options.port = 3000 6 | req = http.request options, (res) -> 7 | res.on 'data', (data) -> 8 | # try to parse as JSON 9 | try data = JSON.parse data catch error 10 | # if data is still unparsed, parse as String 11 | if data instanceof Buffer then data = data.toString() 12 | options.callback res, data 13 | if options.body? 14 | req.write JSON.stringify options.body 15 | req.end() 16 | -------------------------------------------------------------------------------- /test/sql/init.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION postgis; 2 | DROP SCHEMA IF EXISTS testschema CASCADE; 3 | CREATE SCHEMA testschema; 4 | CREATE TABLE testschema.testtable (id SERIAL PRIMARY KEY, name varchar); 5 | SELECT AddGeometryColumn('testschema', 'testtable', 'geom', -1, 'POINT', 2); 6 | --------------------------------------------------------------------------------