├── .circleci └── config.yml ├── .dockerignore ├── .gitignore ├── LICENSE.md ├── Makefile ├── README.md ├── dbcore.fsproj ├── docs ├── CNAME ├── index.html ├── main.css ├── reset.css └── screenshot.png ├── examples └── notes │ ├── .gitignore │ ├── Makefile │ ├── README.md │ ├── dbcore.yml │ └── sql │ ├── init.sql │ ├── mysql │ └── schema.sql │ ├── psql │ ├── init.sql │ └── schema.sql │ └── sqlite │ └── schema.sql ├── src ├── Config.fs ├── Database.fs ├── Program.fs ├── Reader │ ├── InformationSchema.fs │ ├── Reader.fs │ └── SQLite.fs └── Template.fs └── templates ├── api └── go │ ├── Makefile │ ├── cmd │ └── main.go │ ├── pkg │ ├── dao │ │ ├── DBCORE__tables___dao.go │ │ └── dao.go │ └── server │ │ ├── DBCORE__tables___controller.go │ │ ├── config.go │ │ ├── httputil.go │ │ ├── router.go │ │ ├── server.go │ │ └── session_controller.go │ └── scripts │ └── post-generate.sh └── browser └── react ├── .gitignore ├── .prettierrc ├── README.md ├── package.json ├── scripts ├── build.sh ├── post-generate.sh └── start.sh ├── src ├── api │ ├── index.ts │ ├── request.ts │ └── types.ts ├── components │ ├── Button.tsx │ ├── Form.tsx │ ├── Header.tsx │ ├── Heading.tsx │ ├── Input.tsx │ ├── Link.tsx │ └── List │ │ ├── Header.tsx │ │ ├── List.tsx │ │ ├── Row.tsx │ │ └── index.ts ├── hooks │ └── useListData.ts ├── main.tsx └── views │ ├── DBCORE__tables_capitalize__Create.tsx │ ├── DBCORE__tables_capitalize__Details.tsx │ ├── DBCORE__tables_capitalize__List.tsx │ ├── DBCORE__tables_capitalize__Update.tsx │ ├── Home.tsx │ └── Login.tsx ├── static ├── index.html └── style.css ├── tsconfig.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: mcr.microsoft.com/dotnet/sdk:5.0 6 | steps: 7 | - checkout 8 | - run: dotnet publish -c release 9 | - run: apt-get update -y && apt-get install -y sqlite3 10 | - run: sqlite3 ./examples/notes/notes.db < ./examples/notes/sql/sqlite/schema.sql 11 | - run: ./bin/release/netcoreapp3.0/linux-x64/publish/dbcore ./examples/notes 12 | - persist_to_workspace: 13 | root: ./examples 14 | paths: 15 | - notes 16 | 17 | test_example_go_api: 18 | docker: 19 | - image: golang:1 20 | working_directory: /tmp/notes/api 21 | steps: 22 | - checkout 23 | - attach_workspace: 24 | at: /tmp/ 25 | # CircleCI is weird. 26 | - run: rm -rf templates 27 | - run: sh ./scripts/post-generate.sh 28 | - run: go build cmd/main.go 29 | 30 | test_example_react_browser: 31 | docker: 32 | - image: node:lts 33 | working_directory: /tmp/notes/browser 34 | steps: 35 | - checkout 36 | - attach_workspace: 37 | at: /tmp/ 38 | - run: sh ./scripts/post-generate.sh 39 | - run: yarn typecheck 40 | - run: yarn build 41 | 42 | workflows: 43 | version: 2 44 | build: 45 | jobs: 46 | - build 47 | - test_example_go_api: 48 | requires: 49 | - build 50 | - test_example_react_browser: 51 | requires: 52 | - build 53 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | obj 2 | bin 3 | examples 4 | .git -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | obj 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Phil Eaton 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build clean 2 | 3 | build: 4 | docker run \ 5 | -v $(shell pwd):/build \ 6 | -w /build \ 7 | -u $(shell id -u ${USER}):$(shell id -g ${USER}) \ 8 | -e DOTNET_CLI_TELEMETRY_OPTOUT=1 \ 9 | -e DOTNET_CLI_HOME=/tmp/.dotnet \ 10 | mcr.microsoft.com/dotnet/sdk:5.0 dotnet publish -c release 11 | 12 | install: 13 | rm /usr/local/bin/dbcore 14 | ln -s $(CURDIR)/bin/release/netcoreapp3.0/linux-x64/publish/dbcore /usr/local/bin 15 | 16 | example-notes: 17 | dotnet run ./examples/notes 18 | (cd examples/notes && (cat sql/sqlite/schema.sql | sqlite3 notes.db) && (cat sql/init.sql | sqlite3 notes.db)) || echo "Database already initialized." 19 | (cd examples/notes/api && go build cmd/main.go) 20 | (cd examples/notes/browser && yarn tsc) 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DBCore (ALPHA) 2 | 3 | DBCore is a code generator build around database schemas and an API 4 | specification. Included with DBCore are templates for generating a Go 5 | REST API and React UI. 6 | 7 | ## Features and API specification 8 | 9 | While the DBCore project can build any templates from your 10 | database. It also defines an API specification with useful 11 | functionality for rapidly standing up an API around your database. 12 | 13 | Because DBCore does code generation, it can build well-typed code. The 14 | built-in Go API templates are a great example of this. 15 | 16 | But since the API specification is language-agnostic, all these 17 | features are supported no matter what language you use to generate a 18 | DBCore API. 19 | 20 | Major features include: 21 | 22 | * Get one, get many, create, edit, delete endpoints 23 | * Filtering, sorting, pagination 24 | * JWT-based authentication, per-endpoint/method SQL filter-based authorization 25 | 26 | Upcoming features include: 27 | 28 | * Lua-based hooks and transformations 29 | * SSO integration 30 | 31 | [See the docs site for more detail.](https://www.dbcore.org) 32 | 33 | ## Example 34 | 35 | ![Screenshot of list view with pagination](docs/screenshot.png) 36 | 37 | There's a built-in notes application with non-trivial 38 | authorization. Users belong to an org. Notes belong to a user. Notes 39 | that are marked public don't need a session. Otherwise they can only 40 | be viewed by other users within the same org. Only org admins or the 41 | notes creator can modify a note. 42 | 43 | ```bash 44 | $ git clone git@github.com:eatonphil/dbcore 45 | $ cd dbcore 46 | $ make example-notes 47 | $ cd ./examples/notes/api 48 | $ ./main 49 | INFO[0000] Starting server at :9090 pkg=server struct=Server 50 | ... in a new window ... 51 | $ curl -X POST -d '{"username": "alex", "password": "alex", "name": "Alex"}' localhost:9090/users/new 52 | {"id":1,"username":"alex","password":"alex","name":"Alex"} 53 | $ curl 'localhost:9090/users?limit=25&offset=0&sortColumn=id&sortOrder=desc' | jq 54 | { 55 | "total": 1, 56 | "data": [ 57 | { 58 | "id": 1, 59 | "username": "alex", 60 | "password": "alex", 61 | "name": "Alex" 62 | }, 63 | ] 64 | } 65 | ``` 66 | 67 | And to build the UI: 68 | 69 | ``` 70 | $ cd examples/notes/browser 71 | $ yarn start 72 | ``` 73 | 74 | Log in with any of the following credentials: 75 | 76 | * admin:admin (Org 1) 77 | * notes-admin:admin (Org 2) 78 | * editor:editor (Org 2) 79 | 80 | ## Dependencies 81 | 82 | * Go 83 | * PostgreSQL, MySQL or SQLite3 84 | * .NET Core 85 | 86 | ## Restrictions 87 | 88 | There are a bunch of restrictions! Here are a few known ones. You will 89 | discover more and you may fix them! 90 | 91 | * Only tables supported (i.e. no views) 92 | * Only single-column foreign keys supported 93 | * Only Go API, React UI templates provided 94 | -------------------------------------------------------------------------------- /dbcore.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | netcoreapp3.0 6 | $(OtherFlags) --warn:5 7 | linux-x64 8 | true 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Always 21 | Always 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | www.dbcore.org -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 14 | DBCore - Rapidly prototype applications powered by your database. 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 |
27 |
28 |

DBCore

29 |

Rapidly prototype applications powered by your database.

30 |
31 | 44 |

This software is in ALPHA development.

45 |
46 | Star 47 |
48 |
49 |
50 | 51 |
52 |
53 |
54 |

What makes DBCore special?

55 |
56 |

57 | DBCore reads your database and generates high-quality, 58 | statically typed Go code (for the API) and TypeScript (for 59 | the UI) based on templates. 60 |

61 |

62 | Not happy with the built-in templates? Write your 63 | own to generate any kind of application you want. 64 |

65 | Screenshot of the examples/todo application. 66 | 67 | 68 | 69 |
70 |
71 | 72 |
73 |
74 |
75 |

Generating a Go API

76 |
77 |

78 | In your project root folder create a dbcore.yml: 79 |

80 |
project: $project-name
 81 | database:
 82 |   # Or 'mysql'
 83 |   dialect: postgres
 84 | 
 85 |   # Omit if localhost
 86 |   host: $host-or-ip
 87 | 
 88 |   # Omit if 5432 for postgres or 3306 for mysql
 89 |   port: $port
 90 | 
 91 |   database: $database-name
 92 |   username: $username
 93 |   password: $password
 94 | 
 95 | api:
 96 |   template: go
 97 |   outDir: $outDir
 98 | 
 99 |   # e.g. "v1/", for URLs
100 |   routerPrefix: $router-prefix
101 | 
102 |   audit:
103 |     # Disabled by default
104 |     enabled: true
105 |     createdAt: $createdAtColumn
106 |     updatedAt: $updatedAtColumn
107 |     deletedAt: $deletedAtColumn
108 | 
109 |   auth:
110 |     # Disabled by default
111 |     enabled: true
112 |     table: $users
113 |     # Column for username field
114 |     username: $username
115 |     # Column for password field, bcrypt hash is stored
116 |     password: $password
117 | 
118 |   extra:
119 |     repo: $your-repo
120 | 
121 |   # Configuration that is read only at runtime and can be modified
122 |   # with only a restart not a regeneration.
123 |   runtime:
124 |     # Or use '$username:$password@tcp($host:$port)/$database?sql_mode=ANSI', sql_mode=ANSI is required
125 |     dsn: postgres://$username:$password@$host:$port/$database?sslmode=disable
126 | 
127 |     session:
128 |       duration: 2hr
129 |       secret: $my-secret-signing-key
130 |

131 | Clone the repo and run make && make install 132 | within the repo root. You will need Docker, only. 133 |

134 |

135 | Then go to your project directory and run 136 | dbcore . to generate the project. Finally 137 | run go run cmd/main.go to start the server. 138 |

139 |
140 |
141 | 142 |
143 |
144 |
145 |

API Specification

146 |
147 |

Authentication

148 |

149 | When authentication is enabled, make a 150 | JSON POST request with {"username": 151 | "$your-username", "password": "$your-password"} 152 | to /$version/session/start to generate a 153 | token. It is valid for 2 hours by default. 154 |

155 |

156 | Passwords are stored as BCrypt hashes. 157 |

158 |

159 | You can store this token in the au cookie or 160 | you can submit it as bearer token by setting 161 | the Authorization header to BEARER 162 | $your-token. 163 |

164 |

Example

165 |
$ curl -X POST -d '{"username": "alex", "password": "alex"}' localhost:9090/v1/session/start
166 | {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1OTA3MjkyNjMsImlhdCI6MTU5MDcyMjA2MywibmJmIjoxNTkwNzIyMDYzLCJ1c2VybmFtZSI6InBoaWwifQ.4AAveeFRpXckn3cRFyCQew2V7jmcU4OOYH68wcv6afI"}
167 | 168 |

Authorization

169 |

170 | Allow lists per endpoint and method are specified in ANSI 171 | SQL. If you only include a WHERE clause 172 | (omitting the WHERE token) it will default to 173 | applying this search on the endpoint's table. 174 |

175 |

176 | Allowances are specified in the 177 | api.runtime.auth.allow section and are a dictionary 178 | mapping table labels to a dictionary mapping methods to 179 | filters. 180 |

181 |

182 | Request variables can be interpolated into the filter for 183 | session-based authorization. 184 |

185 |

Built-in request variables

186 |

187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 207 | 208 | 209 | 210 |
ParameterDefinitionExample
$req_usernameUsername of the current sessionadmin
$req_object_id 204 | Id of the current object being acted on, depends on 205 | the type of the primary key. Null if not relevant. 206 | 1
211 |

212 |

Example

213 |
api:
214 |   runtime:
215 |     auth:
216 |       allow:
217 |         notes:
218 | 	  # Must be public or tied to the current user's organization.
219 |           get: |
220 |             is_public IS TRUE OR
221 |             created_by IN (
222 |               SELECT id
223 |               FROM users
224 |               WHERE organization IN (
225 |                 SELECT organization
226 |                 FROM users
227 |                 WHERE username = $req_username
228 |               )
229 |             )
230 |           # Must be created by the user or tied to the current user's organization and an admin.
231 |           put: &ownedOrOrgAdmin |
232 |             created_by IN (
233 |               SELECT id
234 |               FROM users
235 |               WHERE
236 |                 organization IN (
237 |                   SELECT organization
238 |                   FROM users
239 |                   WHERE username = $req_username
240 |                 ) AND
241 |                 (is_admin IS TRUE OR username = $req_username)
242 |             )
243 |           # Same as edit (put)
244 |           delete: *ownedOrOrgAdmin
245 |           # Must be in the same org
246 |           post: |
247 |             SELECT id
248 |             FROM users
249 |             WHERE
250 |               organization IN (
251 |                 SELECT organization
252 |                 FROM users
253 |                 WHERE username = $req_username
254 |               )
255 |

256 | Filters are only applied if the key exists and is not the 257 | empty string. For an in-depth example. See the 258 | [organization-oriented example note-taking app in the 259 | repo](https://github.com/eatonphil/dbcore/tree/master/examples/notes). 260 |

261 | 262 |

Get many rows from a table

263 |

264 | Make a GET request to /$version/$table. 265 |

266 |

Query parameters

267 |
268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 |
ParameterDefinitionExample
limitNumber of rows to returnlimit=25
offsetNumber of rows to skipoffset=0
sortColumnColumn to sort onsortColumn=id
sortOrderOrder to sort (one of asc or desc)sortOrder=desc
filterSQL where filter to eliminate resultsfilter=id>3
303 |

Example

304 |
$ curl 'localhost:9090/v1/users?limit=25&offset=0&sortColumn=id&sortOrder=desc&filter=id=1'
305 | {
306 |   "total": 1,
307 |   "data": [
308 |     {
309 |       "id": 1,
310 |       "username": "alex",
311 |       "password": "<REDACTED>",
312 |       "name": "Alex"
313 |     },
314 |   ]
315 | }
316 | 317 |

Create a new row

318 |

319 | Make a POST request to /$version/$table. 320 |

321 |

Example

322 |
$ curl -X POST -d '{"username": "alex", "password": "alex", "name": "Alex"}' localhost:9090/v1/users
323 | {"id":1,"username":"alex","password":"<REDACTED>","name":"Alex"}
324 | 325 |

Get a row

326 |

327 | Make a GET request to /$version/$table/$id. 328 |

329 |

330 | This endpoint is only available if the table has a primary key. 331 |

332 |

Example

333 |
$ curl localhost:9090/v1/users/1
334 | {"id":1,"username":"alex","password":"<REDACTED>","name":"Alex"}
335 | 336 |

Update a row

337 |

338 | Make a PUT request to /$version/$table/$id. 339 |

340 |

341 | This endpoint is only available if the table has a primary key. 342 |

343 |

Example

344 |
$ curl -X PUT -d '{"id": 1, "username": "alex", "password": "alex", "name": "Alex K"}' localhost:9090/v1/users/1
345 | {"id":1,"username":"alex","password":"<REDACTED>","name":"Alex K"}
346 | 347 |

Delete a row

348 |

349 | Make a DELETE request to /$version/$table/$id. 350 |

351 |

352 | This endpoint is only available if the table has a primary key. 353 |

354 |

Example

355 |
$ curl -X DELETE localhost:9090/v1/users/1
356 |
357 |
358 | 359 |
360 |
361 |
362 |

Generating a TypeScript/React UI

363 |
364 |

365 | Using the same configuration as for the API, after 366 | running dbcore . you can run yarn 367 | start in browser/ to start the 368 | application at http://localhost:9091. 369 |

370 |

371 | Use browser.defaultRoute to override the 372 | default home page. 373 |

374 |
375 |
376 | 377 | 378 | -------------------------------------------------------------------------------- /docs/main.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | font-family: sans-serif; 7 | font-size: 1.1rem; 8 | line-height: 1.5rem; 9 | } 10 | 11 | h1, h2 { 12 | line-height: 2.2rem; 13 | } 14 | 15 | section { 16 | padding: 50px 0; 17 | } 18 | 19 | section:nth-child(odd) { 20 | background: #f0f0f0; 21 | border-top: 1px solid #ddd; 22 | border-bottom: 1px solid #ddd; 23 | } 24 | 25 | section:nth-child(odd) code { 26 | background: #ddd; 27 | } 28 | 29 | table { 30 | width: 100%; 31 | border: 1px solid #ccc; 32 | } 33 | 34 | th, td { 35 | text-align: left; 36 | padding: 15px; 37 | } 38 | 39 | th, tr:not(:last-of-type) td { 40 | border-bottom: 1px solid #ccc; 41 | } 42 | 43 | .container { 44 | width: 100%; 45 | max-width: 600px; 46 | margin: 0 auto; 47 | } 48 | 49 | .container:before, .container:after{ 50 | content: ' '; 51 | display: table; 52 | } 53 | 54 | .d-flex { 55 | display: flex; 56 | align-items: center; 57 | } 58 | 59 | pre { 60 | background: white; 61 | border: 1px solid #ccc; 62 | padding: 50px; 63 | overflow: auto; 64 | width: 800px; 65 | margin-left: -100px; 66 | } 67 | 68 | code { 69 | background: #eee; 70 | color: #bb2c4f; 71 | padding: 0 5px; 72 | } 73 | 74 | pre code { 75 | background: initial !important; 76 | color: initial; 77 | padding: initial; 78 | } 79 | 80 | .text-right { 81 | text-align: right; 82 | } 83 | 84 | section.hero { 85 | padding: 100px 0; 86 | background: #841443; 87 | color: white; 88 | } 89 | 90 | .hero header { 91 | color: white; 92 | } 93 | 94 | .hero h1 { 95 | margin: 0; 96 | margin-bottom: 50px; 97 | font-size: 100px; 98 | } 99 | 100 | .hero strong { 101 | font-weight: initial; 102 | border-bottom: 2px solid white; 103 | } 104 | 105 | .hero ul { 106 | line-height: 2rem; 107 | } 108 | 109 | .hero p { 110 | color: #eee; 111 | } 112 | 113 | ul.checks { 114 | list-style: none; 115 | } 116 | 117 | ul.checks li:before { 118 | content: '\2713'; 119 | color: #00ff00; 120 | margin-left: -20px; 121 | margin-right: 10px; 122 | } 123 | 124 | .hero li { 125 | padding: 3px 0; 126 | } 127 | 128 | .hero .star { 129 | text-align: center; 130 | padding: 15px 0; 131 | } 132 | 133 | @media (max-width: 800px) { 134 | .container { 135 | width: 100%; 136 | padding: 0 15px; 137 | } 138 | 139 | pre { 140 | width: 100%; 141 | margin-left: 0; 142 | } 143 | } 144 | 145 | @media (max-width: 411px) { 146 | .hero h1 { 147 | font-size: 75px; 148 | margin: 15px 0; 149 | } 150 | 151 | .hero img { 152 | display: none; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /docs/reset.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { /* 1 */ 178 | overflow: visible; 179 | } 180 | 181 | /** 182 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 183 | * 1. Remove the inheritance of text transform in Firefox. 184 | */ 185 | 186 | button, 187 | select { /* 1 */ 188 | text-transform: none; 189 | } 190 | 191 | /** 192 | * Correct the inability to style clickable types in iOS and Safari. 193 | */ 194 | 195 | button, 196 | [type="button"], 197 | [type="reset"], 198 | [type="submit"] { 199 | -webkit-appearance: button; 200 | } 201 | 202 | /** 203 | * Remove the inner border and padding in Firefox. 204 | */ 205 | 206 | button::-moz-focus-inner, 207 | [type="button"]::-moz-focus-inner, 208 | [type="reset"]::-moz-focus-inner, 209 | [type="submit"]::-moz-focus-inner { 210 | border-style: none; 211 | padding: 0; 212 | } 213 | 214 | /** 215 | * Restore the focus styles unset by the previous rule. 216 | */ 217 | 218 | button:-moz-focusring, 219 | [type="button"]:-moz-focusring, 220 | [type="reset"]:-moz-focusring, 221 | [type="submit"]:-moz-focusring { 222 | outline: 1px dotted ButtonText; 223 | } 224 | 225 | /** 226 | * Correct the padding in Firefox. 227 | */ 228 | 229 | fieldset { 230 | padding: 0.35em 0.75em 0.625em; 231 | } 232 | 233 | /** 234 | * 1. Correct the text wrapping in Edge and IE. 235 | * 2. Correct the color inheritance from `fieldset` elements in IE. 236 | * 3. Remove the padding so developers are not caught out when they zero out 237 | * `fieldset` elements in all browsers. 238 | */ 239 | 240 | legend { 241 | box-sizing: border-box; /* 1 */ 242 | color: inherit; /* 2 */ 243 | display: table; /* 1 */ 244 | max-width: 100%; /* 1 */ 245 | padding: 0; /* 3 */ 246 | white-space: normal; /* 1 */ 247 | } 248 | 249 | /** 250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 251 | */ 252 | 253 | progress { 254 | vertical-align: baseline; 255 | } 256 | 257 | /** 258 | * Remove the default vertical scrollbar in IE 10+. 259 | */ 260 | 261 | textarea { 262 | overflow: auto; 263 | } 264 | 265 | /** 266 | * 1. Add the correct box sizing in IE 10. 267 | * 2. Remove the padding in IE 10. 268 | */ 269 | 270 | [type="checkbox"], 271 | [type="radio"] { 272 | box-sizing: border-box; /* 1 */ 273 | padding: 0; /* 2 */ 274 | } 275 | 276 | /** 277 | * Correct the cursor style of increment and decrement buttons in Chrome. 278 | */ 279 | 280 | [type="number"]::-webkit-inner-spin-button, 281 | [type="number"]::-webkit-outer-spin-button { 282 | height: auto; 283 | } 284 | 285 | /** 286 | * 1. Correct the odd appearance in Chrome and Safari. 287 | * 2. Correct the outline style in Safari. 288 | */ 289 | 290 | [type="search"] { 291 | -webkit-appearance: textfield; /* 1 */ 292 | outline-offset: -2px; /* 2 */ 293 | } 294 | 295 | /** 296 | * Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | [type="search"]::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /** 304 | * 1. Correct the inability to style clickable types in iOS and Safari. 305 | * 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; /* 1 */ 310 | font: inherit; /* 2 */ 311 | } 312 | 313 | /* Interactive 314 | ========================================================================== */ 315 | 316 | /* 317 | * Add the correct display in Edge, IE 10+, and Firefox. 318 | */ 319 | 320 | details { 321 | display: block; 322 | } 323 | 324 | /* 325 | * Add the correct display in all browsers. 326 | */ 327 | 328 | summary { 329 | display: list-item; 330 | } 331 | 332 | /* Misc 333 | ========================================================================== */ 334 | 335 | /** 336 | * Add the correct display in IE 10+. 337 | */ 338 | 339 | template { 340 | display: none; 341 | } 342 | 343 | /** 344 | * Add the correct display in IE 10. 345 | */ 346 | 347 | [hidden] { 348 | display: none; 349 | } 350 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eatonphil/dbcore/3a611aad39788b46cbd22aedbc85fb1b21b14d06/docs/screenshot.png -------------------------------------------------------------------------------- /examples/notes/.gitignore: -------------------------------------------------------------------------------- 1 | browser 2 | go.mod 3 | go.sum 4 | api 5 | *.db -------------------------------------------------------------------------------- /examples/notes/Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | go build api/cmd/main.go 3 | -------------------------------------------------------------------------------- /examples/notes/README.md: -------------------------------------------------------------------------------- 1 | ## Example 2 | 3 | ![Screenshot of list view with pagination](../../docs/screenshot.png) 4 | 5 | This is a built-in notes application with non-trivial 6 | authorization. Users belong to an org. Notes belong to a user. Notes 7 | that are marked public don't need a session. Otherwise they can only 8 | be viewed by other users within the same org. Only org admins or the 9 | notes creator can modify a note. 10 | 11 | ```bash 12 | $ git clone git@github.com:eatonphil/dbcore 13 | $ cd dbcore 14 | $ make example-notes 15 | $ cd ./examples/notes/api 16 | $ ./main 17 | INFO[0000] Starting server at :9090 pkg=server struct=Server 18 | ... in a new window ... 19 | $ curl -X POST -d '{"username": "alex", "password": "alex", "name": "Alex"}' localhost:9090/users/new 20 | {"id":1,"username":"alex","password":"alex","name":"Alex"} 21 | $ curl 'localhost:9090/users?limit=25&offset=0&sortColumn=id&sortOrder=desc' | jq 22 | { 23 | "total": 1, 24 | "data": [ 25 | { 26 | "id": 1, 27 | "username": "alex", 28 | "password": "alex", 29 | "name": "Alex" 30 | }, 31 | ] 32 | } 33 | ``` 34 | 35 | And to build the UI: 36 | 37 | ``` 38 | $ cd examples/notes/browser 39 | $ yarn start 40 | ``` 41 | 42 | Log in with any of the following credentials: 43 | 44 | * admin:admin (Org 1) 45 | * notes-admin:admin (Org 2) 46 | * editor:editor (Org 2) 47 | -------------------------------------------------------------------------------- /examples/notes/dbcore.yml: -------------------------------------------------------------------------------- 1 | project: notes 2 | 3 | database: 4 | dialect: sqlite 5 | database: notes.db 6 | 7 | api: 8 | routerPrefix: v1/ 9 | auth: 10 | enabled: true 11 | audit: 12 | enabled: true 13 | createdAt: created_at 14 | updatedAt: updated_at 15 | deletedAt: deleted_at 16 | extra: 17 | repo: github.com/eatonphil/dbcore-notes 18 | runtime: 19 | dsn: file:../notes.db 20 | address: localhost:9090 21 | allowed-origins: 22 | - http://localhost:9091 23 | session: 24 | secret: NOTES 25 | auth: 26 | allow: 27 | notes: 28 | # Must be public or tied to the current user's organization. 29 | get: | 30 | is_public IS TRUE OR 31 | created_by IN ( 32 | SELECT id 33 | FROM users 34 | WHERE organization IN ( 35 | SELECT organization 36 | FROM users 37 | WHERE username = $req_username 38 | ) 39 | ) 40 | # Must be created by the user or tied to the current user's organization and an admin. 41 | put: &ownedOrOrgAdmin | 42 | created_by IN ( 43 | SELECT id 44 | FROM users 45 | WHERE 46 | organization IN ( 47 | SELECT organization 48 | FROM users 49 | WHERE username = $req_username 50 | ) AND 51 | (is_admin IS TRUE OR username = $req_username) 52 | ) 53 | # Same as edit (put) 54 | delete: *ownedOrOrgAdmin 55 | # Must be in the same org 56 | post: | 57 | SELECT id 58 | FROM users 59 | WHERE 60 | organization IN ( 61 | SELECT organization 62 | FROM users 63 | WHERE username = $req_username 64 | ) 65 | users: 66 | # Must be in the same org 67 | get: &inOrg | 68 | organization IN ( 69 | SELECT o.id 70 | FROM organizations o 71 | JOIN users u ON u.organization = o.id 72 | WHERE u.username = $req_username 73 | ) 74 | put: *inOrg 75 | delete: &inOrgAndIsAdmin | 76 | created_by = $req_username OR 77 | organization IN ( 78 | SELECT o.id 79 | FROM organizations o 80 | JOIN users u ON organization = o.id 81 | WHERE u.username = $req_username AND u.is_admin IS TRUE 82 | ) 83 | post: *inOrgAndIsAdmin 84 | organizations: 85 | get: | 86 | id IN ( 87 | SELECT o.id 88 | FROM organizations o 89 | JOIN users u ON u.organization = o.id 90 | WHERE u.username = $req_username 91 | ) 92 | put: &inOrgAndIsAdmin | 93 | id IN ( 94 | SELECT o.id 95 | FROM organizations o 96 | JOIN users u ON u.organization = o.id 97 | WHERE u.username = $req_username AND u.is_admin IS TRUE 98 | ) 99 | delete: *inOrgAndIsAdmin 100 | 101 | browser: {} 102 | -------------------------------------------------------------------------------- /examples/notes/sql/init.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO organizations 2 | VALUES (1, 'Admin', DATETIME('now'), DATETIME('now'), null); 3 | 4 | -- password: admin 5 | INSERT INTO users (is_admin, organization, username, password, name, created_at, updated_at) 6 | VALUES (TRUE, 1, 'admin', '$2y$12$rH9QTzZJmPGIPIfofMfRsOh8vD5u612VYOduOvq951vIp7ddQn4Ai', 'Admin', DATETIME('now'), DATETIME('now')); 7 | 8 | INSERT INTO organizations 9 | VALUES (2, 'Notes Today', DATETIME('now'), DATETIME('now'), null); 10 | 11 | -- password: admin 12 | INSERT INTO users (is_admin, organization, username, password, name, created_at, updated_at) 13 | VALUES (TRUE, 2, 'notes-admin', '$2y$12$rH9QTzZJmPGIPIfofMfRsOh8vD5u612VYOduOvq951vIp7ddQn4Ai', 'Notes Admin', DATETIME('now'), DATETIME('now')); 14 | 15 | -- password: editor 16 | INSERT INTO users (is_admin, organization, username, password, name, created_at, updated_at) 17 | VALUES (FALSE, 2, 'editor', '$2y$12$F5RlC/VhW1LNcCZi5ZZ1P.JnBvBShzSiMt3rKqZjhvJvylT6bJu2i', 'Editor', DATETIME('now'), DATETIME('now')); 18 | -------------------------------------------------------------------------------- /examples/notes/sql/mysql/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id BIGINT AUTO_INCREMENT PRIMARY KEY, 3 | username TEXT NOT NULL, 4 | password TEXT NOT NULL, 5 | name TEXT NOT NULL, 6 | created_at DATETIME NOT NULL, 7 | updated_at DATETIME NOT NULL, 8 | deleted_at DATETIME, 9 | ); 10 | 11 | CREATE TABLE notes ( 12 | id BIGINT AUTO_INCREMENT PRIMARY KEY, 13 | note TEXT NOT NULL, 14 | created_by BIGINT NOT NULL, 15 | created_at DATETIME NOT NULL, 16 | updated_at DATETIME NOT NULL, 17 | deleted_at DATETIME, 18 | FOREIGN KEY (created_by) REFERENCES users (id) 19 | ); 20 | -------------------------------------------------------------------------------- /examples/notes/sql/psql/init.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE notes; 2 | CREATE USER notes WITH PASSWORD 'notes'; 3 | GRANT ALL ON DATABASE notes TO notes; 4 | -------------------------------------------------------------------------------- /examples/notes/sql/psql/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id BIGSERIAL PRIMARY KEY, 3 | username TEXT NOT NULL, 4 | password TEXT NOT NULL, 5 | name TEXT NOT NULL, 6 | created_at TIMESTAMPTZ NOT NULL, 7 | updated_at TIMESTAMPTZ NOT NULL, 8 | deleted_at TIMESTAMPTZ 9 | ); 10 | 11 | CREATE TABLE notes ( 12 | id BIGSERIAL PRIMARY KEY, 13 | note TEXT NOT NULL, 14 | created_by BIGINT NOT NULL, 15 | created_at TIMESTAMPTZ NOT NULL, 16 | updated_at TIMESTAMPTZ NOT NULL, 17 | deleted_at TIMESTAMPTZ, 18 | FOREIGN KEY (created_by) REFERENCES users (id) 19 | ); 20 | -------------------------------------------------------------------------------- /examples/notes/sql/sqlite/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE organizations ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | name TEXT NOT NULL, 4 | created_at TEXT NOT NULL, 5 | updated_at TEXT NOT NULL, 6 | deleted_at TEXT 7 | ); 8 | 9 | CREATE TABLE users ( 10 | id INTEGER PRIMARY KEY AUTOINCREMENT, 11 | username TEXT NOT NULL, 12 | password TEXT NOT NULL, 13 | name TEXT NOT NULL, 14 | created_at TEXT NOT NULL, 15 | updated_at TEXT NOT NULL, 16 | deleted_at TEXT, 17 | organization INTEGER NOT NULL, 18 | is_admin INTEGER NOT NULL, 19 | FOREIGN KEY (organization) REFERENCES organizations (id) 20 | ); 21 | 22 | CREATE TABLE notes ( 23 | id INTEGER PRIMARY KEY AUTOINCREMENT, 24 | note TEXT NOT NULL, 25 | created_by INTEGER NOT NULL, 26 | created_at TEXT NOT NULL, 27 | updated_at TEXT NOT NULL, 28 | deleted_at TEXT, 29 | is_public INTEGER NOT NULL, 30 | FOREIGN KEY (created_by) REFERENCES users (id) 31 | ); 32 | -------------------------------------------------------------------------------- /src/Config.fs: -------------------------------------------------------------------------------- 1 | module Config 2 | 3 | open System.Collections.Generic 4 | open System.IO 5 | 6 | open YamlDotNet.Serialization 7 | open YamlDotNet.Serialization.NamingConventions 8 | 9 | 10 | type DatabaseTableConfig() = 11 | let mutable label = "" 12 | 13 | member val Name = "" with get, set 14 | 15 | member this.Label 16 | with get() : string = 17 | if label <> "" then label else this.Name 18 | and set(value: string) = label <- value 19 | 20 | 21 | type DatabaseConfig() = 22 | let mutable port = "" 23 | let mutable schema = "" 24 | 25 | member val Dialect = "postgres" with get, set 26 | member val Host = "localhost" with get, set 27 | member val Database = "" with get, set 28 | member val Username = "" with get, set 29 | member val Password = "" with get, set 30 | 31 | member val Tables: array = [||] with get, set 32 | 33 | member this.Schema 34 | with get() : string = 35 | if schema <> "" then schema else 36 | match this.Dialect with 37 | | "postgres" -> "public" 38 | | "mysql" -> this.Database 39 | | "sqlite" -> "" 40 | | _ -> failwith ("database.schema must be set for unknown dialect: " + this.Dialect) 41 | 42 | member this.Port 43 | with get() : string = 44 | if port <> "" then port else 45 | match this.Dialect with 46 | | "postgres" -> "5432" 47 | | "mysql" -> "3306" 48 | | "sqlite" -> "" 49 | | _ -> failwith ("database.port must be set for unknown dialect: " + this.Dialect) 50 | and set(value: string) = port <- value 51 | 52 | 53 | type IConfig = 54 | abstract member OutDir : string with get, set 55 | abstract member Template : string with get, set 56 | 57 | 58 | type ApiAuthAllowConfig() = 59 | member val Get = "" with get, set 60 | member val Put = "" with get, set 61 | member val Post = "" with get, set 62 | member val Delete = "" with get, set 63 | 64 | type ApiAuthConfig() = 65 | member val Enabled = false with get, set 66 | member val Table = "users" with get, set 67 | member val Username = "username" with get, set 68 | member val Password = "password" with get, set 69 | member val Allow = Dictionary() with get, set 70 | 71 | 72 | type ApiAuditConfig() = 73 | member val Enabled = false with get, set 74 | member val CreatedAt = "" with get, set 75 | member val UpdatedAt = "" with get, set 76 | member val DeletedAt = "" with get, set 77 | 78 | 79 | type ApiConfig() = 80 | interface IConfig with 81 | member val OutDir = "api" with get, set 82 | member val Template = "go" with get, set 83 | 84 | member val Auth = ApiAuthConfig() with get, set 85 | member val RouterPrefix = "" with get, set 86 | member val Extra = Dictionary() with get, set 87 | member val Runtime = Dictionary() with get, set 88 | 89 | member val Audit = ApiAuditConfig() with get, set 90 | 91 | member this.Validate() = 92 | // TODO: should probably turn validation into a schema per template 93 | if (this :> IConfig).Template = "go" && not (this.Extra.ContainsKey "repo") 94 | then failwith "Repo is required: `api.extra.repo: $repo`" 95 | 96 | 97 | type BrowserConfig() = 98 | interface IConfig with 99 | member val OutDir = "browser" with get, set 100 | member val Template = "react" with get, set 101 | 102 | member val DefaultRoute = "" with get, set 103 | 104 | 105 | type CustomConfig() = 106 | interface IConfig with 107 | member val OutDir = "" with get, set 108 | member val Template = "" with get, set 109 | 110 | member val Extra = Dictionary() with get, set 111 | 112 | type Config() = 113 | member val Project = "" with get, set 114 | member val CultureName = System.Globalization.CultureInfo.CurrentCulture.Name with get, set 115 | 116 | member val Database = DatabaseConfig() with get, set 117 | member val Api = ApiConfig() with get, set 118 | member val Browser = BrowserConfig() with get, set 119 | member val Custom : array = [| |] with get, set 120 | 121 | member this.Validate() = 122 | if this.Project = "" then failwith "Project name is required: `project: $name`" 123 | 124 | this.Api.Validate() 125 | 126 | let GetConfig(f: string) : Config = 127 | use file = new FileStream(f, FileMode.Open, FileAccess.Read) 128 | use stream = new StreamReader(file) 129 | let deserializer = 130 | (DeserializerBuilder()) 131 | .WithNamingConvention(CamelCaseNamingConvention.Instance) 132 | .Build() 133 | let config = deserializer.Deserialize(stream) 134 | 135 | config 136 | -------------------------------------------------------------------------------- /src/Database.fs: -------------------------------------------------------------------------------- 1 | module Database 2 | 3 | 4 | type Column = 5 | { 6 | Name: string 7 | Type: string 8 | Nullable: bool 9 | AutoIncrement: bool 10 | } 11 | 12 | 13 | type Constraint = 14 | { 15 | Column: string 16 | Type: string 17 | ForeignTable: string 18 | ForeignColumn: string 19 | } 20 | 21 | 22 | type Table = 23 | { 24 | Name: string 25 | Columns: Column[] 26 | ForeignKeys: Constraint[] 27 | PrimaryKey: Option 28 | } 29 | -------------------------------------------------------------------------------- /src/Program.fs: -------------------------------------------------------------------------------- 1 | open System.IO 2 | 3 | [] 4 | let main (args: string []): int = 5 | let projectDir = if args.Length > 0 6 | then args.[0] 7 | else failwith "Expected project directory" 8 | 9 | let cfg = Config.GetConfig(Path.Combine(projectDir, "dbcore.yml")) 10 | cfg.Validate() 11 | 12 | if cfg.Database.Dialect = "sqlite" then 13 | cfg.Database.Database <- Path.Combine(projectDir, cfg.Database.Database) 14 | let db = Reader.Reader(cfg.Database) 15 | let tables: Template.Table[] = 16 | let notAuditOrAutoIncrement(c: Database.Column) : bool = 17 | if c.AutoIncrement then false 18 | else if not cfg.Api.Audit.Enabled then true 19 | else if (c.Name = cfg.Api.Audit.CreatedAt || 20 | c.Name = cfg.Api.Audit.UpdatedAt || 21 | c.Name = cfg.Api.Audit.DeletedAt) then false 22 | else true 23 | 24 | let notAutoIncrement(c: Database.Column) : bool = not c.AutoIncrement 25 | 26 | [| 27 | for table in db.GetTables() do 28 | let mutable label = table.Name 29 | for t in cfg.Database.Tables do 30 | if t.Name = table.Name then label <- t.Label 31 | 32 | let columnsNoAudit = table.Columns |> Array.filter notAuditOrAutoIncrement 33 | let columnsNoAutoIncrement = table.Columns |> Array.filter notAutoIncrement 34 | 35 | let t: Template.Table = { 36 | Label = label 37 | Name = table.Name 38 | Columns = table.Columns 39 | ForeignKeys = table.ForeignKeys 40 | PrimaryKey = table.PrimaryKey 41 | 42 | ColumnsNoAutoIncrement = columnsNoAutoIncrement 43 | ColumnsNoAudit = columnsNoAudit 44 | } 45 | t 46 | |] 47 | 48 | let ctx: Template.Context = { 49 | Project = cfg.Project 50 | Database = {| Dialect = cfg.Database.Dialect |} 51 | Api = cfg.Api 52 | Browser = cfg.Browser 53 | Tables = tables 54 | Template = "" 55 | OutDir = "" 56 | CultureName = cfg.CultureName 57 | } 58 | Template.GenerateApi(projectDir, cfg.Api, ctx) 59 | Template.GenerateBrowser(projectDir, cfg.Browser, ctx) 60 | 61 | 0 62 | -------------------------------------------------------------------------------- /src/Reader/InformationSchema.fs: -------------------------------------------------------------------------------- 1 | namespace Reader 2 | 3 | open System.Data 4 | 5 | open Npgsql 6 | open MySql.Data.MySqlClient 7 | 8 | open Database 9 | 10 | 11 | // This is for SQL implementations that follow the ANSI standard of 12 | // using information_schema tables to store database metadata. 13 | [] 14 | type InformationSchema(cfg0: Config.DatabaseConfig) = 15 | let cfg = cfg0 16 | let connFactory = 17 | match cfg.Dialect with 18 | | "mysql" -> fun (dsn) -> new MySqlConnection(dsn) :> IDbConnection 19 | | "postgres" -> fun (dsn) -> new NpgsqlConnection(dsn) :> IDbConnection 20 | | d -> failwith ("Unsupported SQL dialect: " + d) 21 | 22 | let getConn() : IDbConnection = 23 | let dsn = 24 | sprintf "Host=%s;Port=%s;Database=%s;Username=%s;Password=%s;" 25 | cfg.Host 26 | cfg.Port 27 | cfg.Database 28 | cfg.Username 29 | cfg.Password 30 | let conn = connFactory(dsn) 31 | conn.Open() 32 | conn 33 | 34 | let addStringParameter(cmd: IDbCommand, name: string, value: string) : unit = 35 | let p = cmd.CreateParameter() 36 | p.ParameterName <- name 37 | p.Value <- value 38 | cmd.Parameters.Add(p) |> ignore 39 | 40 | let getConstraints(conn: IDbConnection, table: string, typ: string) : Constraint[] = 41 | let query = 42 | match cfg.Dialect with 43 | | "postgres" -> 44 | "SELECT 45 | kcu.column_name, 46 | ccu.table_name, 47 | ccu.column_name 48 | FROM 49 | information_schema.table_constraints AS tc 50 | JOIN information_schema.key_column_usage AS kcu 51 | ON tc.constraint_name = kcu.constraint_name 52 | AND tc.table_schema = kcu.table_schema 53 | JOIN information_schema.constraint_column_usage AS ccu 54 | ON ccu.constraint_name = tc.constraint_name 55 | AND ccu.table_schema = tc.table_schema 56 | WHERE tc.constraint_type=@type AND tc.table_name=@name" 57 | | "mysql" -> 58 | "SELECT 59 | column_name, 60 | COALESCE(referenced_table_name, ''), 61 | COALESCE(referenced_column_name, '') 62 | FROM 63 | information_schema.table_constraints tc 64 | JOIN information_schema.key_column_usage kcu 65 | ON kcu.table_schema = tc.table_schema 66 | AND kcu.table_name = tc.table_name 67 | AND kcu.constraint_name = tc.constraint_name 68 | WHERE tc.constraint_type=@type AND tc.table_name=@name" 69 | | d -> failwith "Unknown dialect: " + d 70 | use cmd = conn.CreateCommand() 71 | cmd.CommandText <- query 72 | 73 | addStringParameter(cmd, "name", table) 74 | addStringParameter(cmd, "type", typ) 75 | cmd.Prepare() 76 | use dr = cmd.ExecuteReader() 77 | [| 78 | while dr.Read() do 79 | yield { 80 | Column = dr.GetString(0) 81 | Type = "" 82 | ForeignTable = dr.GetString(1) 83 | ForeignColumn = dr.GetString(2) 84 | } 85 | |] 86 | 87 | let getTable(conn: IDbConnection, name: string) : Table = 88 | let columns = 89 | let autoIncrementCheck = 90 | match cfg.Dialect with 91 | | "postgres" -> "COALESCE(column_default, '') LIKE '%seq%' -- Not the greatest way to detect auto incrementing, but ok for now" 92 | | "mysql" -> "extra LIKE '%auto_increment%'" 93 | | d -> failwith ("Unsupported SQL dialect: " + d) 94 | let query = 95 | sprintf "SELECT 96 | column_name, 97 | data_type, 98 | is_nullable <> 'NO', 99 | %s 100 | FROM 101 | information_schema.columns 102 | WHERE 103 | table_schema=@schema AND table_name=@name" autoIncrementCheck 104 | use cmd = conn.CreateCommand() 105 | cmd.CommandText <- query 106 | addStringParameter(cmd, "name", name) 107 | addStringParameter(cmd, "schema", cfg.Schema) 108 | cmd.Prepare() 109 | use dr = cmd.ExecuteReader() 110 | [| 111 | while dr.Read() do 112 | yield { 113 | Name = dr.GetString(0) 114 | Type = dr.GetString(1).ToLower() 115 | Nullable = dr.GetBoolean(2) 116 | AutoIncrement = dr.GetBoolean(3) 117 | } 118 | |] 119 | 120 | if columns.Length = 0 121 | then failwith ("Expected more than 0 columns in table " + name) 122 | else "" |> ignore 123 | 124 | let foreignKeys = getConstraints(conn, name, "FOREIGN KEY") 125 | 126 | let primaryKey = 127 | let keys = getConstraints(conn, name, "PRIMARY KEY") 128 | if keys.Length = 0 then None 129 | else 130 | let typ = [ for c in columns do 131 | if c.Name = keys.[0].Column then 132 | yield c.Type ].[0] 133 | Some ({ keys.[0] with Type = typ.ToLower() }) 134 | 135 | { 136 | Name = name 137 | Columns = columns 138 | ForeignKeys = foreignKeys 139 | PrimaryKey = primaryKey 140 | } 141 | 142 | member this.GetTables() : Table[] = 143 | use conn = getConn() 144 | 145 | // Fetch names first so cmd can be closed 146 | let tableNames = 147 | let query = 148 | "SELECT 149 | table_name 150 | FROM 151 | information_schema.tables 152 | WHERE 153 | table_schema=@schema" 154 | use cmd = conn.CreateCommand() 155 | cmd.CommandText <- query 156 | addStringParameter(cmd, "schema", cfg.Schema) 157 | 158 | use dr = cmd.ExecuteReader() 159 | [| while dr.Read() do yield dr.GetString 0 |] 160 | 161 | [| for name in tableNames do yield getTable(conn, name) |] 162 | -------------------------------------------------------------------------------- /src/Reader/Reader.fs: -------------------------------------------------------------------------------- 1 | namespace Reader 2 | 3 | 4 | type Reader(cfg0: Config.DatabaseConfig) = 5 | let cfg = cfg0 6 | 7 | member this.GetTables() : Database.Table[] = 8 | match cfg.Dialect with 9 | | "sqlite" -> SQLite(cfg).GetTables() 10 | | "mysql" | "postgres" -> InformationSchema(cfg).GetTables() 11 | | d -> failwith ("Unsupported SQL dialect: " + d) 12 | 13 | -------------------------------------------------------------------------------- /src/Reader/SQLite.fs: -------------------------------------------------------------------------------- 1 | namespace Reader 2 | 3 | open System.Data 4 | 5 | open Microsoft.Data.Sqlite 6 | 7 | open Database 8 | 9 | type SQLite(cfg0: Config.DatabaseConfig) = 10 | let cfg = cfg0 11 | 12 | let getForeignKeys(conn: SqliteConnection, name: string) : Constraint[] = 13 | use cmd = conn.CreateCommand() 14 | cmd.CommandText <- """SELECT "from", "table", "to" FROM pragma_foreign_key_list($schema)""" 15 | let _ = cmd.Parameters.AddWithValue("$schema", cfg.Schema) 16 | cmd.Prepare() 17 | use dr = cmd.ExecuteReader() 18 | [| 19 | while dr.Read() do 20 | yield { 21 | Column = dr.GetString(0) 22 | Type = "" 23 | ForeignTable = dr.GetString(1) 24 | ForeignColumn = dr.GetString(2) 25 | } 26 | |] 27 | 28 | let getTable(conn: SqliteConnection, name: string) : Table = 29 | let mutable primaryKey : Option = None 30 | let columns = 31 | use cmd = conn.CreateCommand() 32 | cmd.CommandText <- """SELECT 33 | name, 34 | type, 35 | "notnull", 36 | pk = (SELECT 1 FROM sqlite_master WHERE tbl_name=$name AND sql LIKE '%%AUTOINCREMENT%%'), 37 | pk 38 | FROM 39 | pragma_table_info($name)""" 40 | let _ = cmd.Parameters.AddWithValue("$name", name) 41 | cmd.Prepare() 42 | use dr = cmd.ExecuteReader() 43 | [| 44 | while dr.Read() do 45 | let name = dr.GetString(0) 46 | let typ = dr.GetString(1).ToLower() 47 | let pk = dr.GetBoolean(4) 48 | if pk then 49 | primaryKey <- Some { 50 | Column = name 51 | Type = typ 52 | ForeignTable = "" 53 | ForeignColumn = "" 54 | } 55 | 56 | yield { 57 | Name = name 58 | Type = typ 59 | Nullable = not (pk || dr.GetBoolean 2) 60 | AutoIncrement = dr.GetBoolean(3) 61 | } 62 | |] 63 | 64 | if columns.Length = 0 65 | then failwith ("Expected more than 0 columns in table " + name) 66 | else "" |> ignore 67 | 68 | let foreignKeys = getForeignKeys(conn, name) 69 | 70 | { 71 | Name = name 72 | Columns = columns 73 | ForeignKeys = foreignKeys 74 | PrimaryKey = primaryKey 75 | } 76 | 77 | member this.GetTables() : Table[] = 78 | use conn = new SqliteConnection("Data Source=" + cfg.Database) 79 | conn.Open() 80 | let tableNames = 81 | use cmd = conn.CreateCommand() 82 | cmd.CommandText <- "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" 83 | 84 | use dr = cmd.ExecuteReader() 85 | [| while dr.Read() do yield dr.GetString 0 |] 86 | 87 | [| for name in tableNames do yield getTable(conn, name) |] 88 | -------------------------------------------------------------------------------- /src/Template.fs: -------------------------------------------------------------------------------- 1 | module Template 2 | 3 | open System.Diagnostics 4 | open System.IO 5 | open System.Text.RegularExpressions 6 | 7 | 8 | let rec private getFiles(dir: string) : seq = 9 | seq { 10 | yield! Directory.EnumerateFiles(dir, "*.*") 11 | for d in Directory.EnumerateDirectories(dir) do 12 | yield! getFiles(d) 13 | } 14 | 15 | 16 | type Table = 17 | { 18 | Name: string 19 | Label: string 20 | Columns: Database.Column[] 21 | ForeignKeys: Database.Constraint[] 22 | PrimaryKey: Option 23 | 24 | ColumnsNoAutoIncrement: Database.Column[] 25 | ColumnsNoAudit: Database.Column[] 26 | } 27 | 28 | 29 | [] 30 | type Context = 31 | { 32 | Project: string 33 | Database: {| Dialect: string |} 34 | Tables: Table[] 35 | Api: Config.ApiConfig 36 | Browser: Config.BrowserConfig 37 | OutDir: string 38 | Template: string 39 | CultureName: string 40 | } 41 | 42 | 43 | let private writeProjectToDisk(sourceDir: string, outDir: string, ctx: Context) = 44 | let ti = System.Globalization.CultureInfo(ctx.CultureName, false).TextInfo 45 | 46 | // Register helpers 47 | let helpers = 48 | [ 49 | ("dbcore_capitalize", System.Converter(fun a -> ti.ToTitleCase(a))); 50 | ] 51 | let tctx = Scriban.TemplateContext() 52 | let so = Scriban.Runtime.ScriptObject() 53 | for (name, fn) in helpers do 54 | Scriban.Runtime.ScriptObjectExtensions.Import(so, name, fn) 55 | tctx.PushGlobal(so) 56 | 57 | // Copy/render each template 58 | for f in getFiles(sourceDir) do 59 | let tpl = Scriban.Template.Parse(File.ReadAllText(f), f) 60 | 61 | // Drop the SourceDir/ prefix 62 | let f = f.Substring(sourceDir.Length + 1) 63 | 64 | // Order by substring length descending 65 | let pathTemplateExpansions = 66 | [ 67 | ("tables_capitalize", 68 | fun (path: string, sub: string) -> 69 | [ for t in ctx.Tables do 70 | yield (path.Replace(sub, ti.ToTitleCase(t.Label)), 71 | {| ctx with Table = t |}) ]); 72 | ("tables", 73 | fun (path: string, sub: string) -> 74 | [ for t in ctx.Tables do 75 | yield (path.Replace(sub, t.Label), 76 | {| ctx with Table = t |}) ]); 77 | ("", 78 | fun (path: string, sub: string) -> 79 | [(path, {| ctx with Table = ctx.Tables.[0] |})]) 80 | ] 81 | 82 | let mutable fsAndCtxs = [] 83 | for (path, expand) in pathTemplateExpansions do 84 | let escapedPath = sprintf "DBCORE__%s__" path 85 | // Only expand once on the longest match. 86 | if fsAndCtxs.Length = 0 && (f.Contains(escapedPath) || path = "") then 87 | fsAndCtxs <- expand(f, escapedPath) 88 | 89 | for (f, ctx) in fsAndCtxs do 90 | let outFile = Path.Combine(outDir, f) 91 | printfn "[DEBUG] Generating: %s" outFile 92 | 93 | // Create directory if not exists 94 | FileInfo(outFile).Directory.Create() 95 | 96 | tctx.PushGlobal(Scriban.Runtime.ScriptObject.From(ctx)) 97 | File.WriteAllText(outFile, tpl.Render(tctx)) 98 | 99 | 100 | let private generate(templateDir: string, projectDir: string, cfg: Config.IConfig, ctx: Context) = 101 | // Required for distribution to get right base path (especially within single file executable) 102 | let baseDir = System.AppContext.BaseDirectory 103 | let sourceDir = Path.Combine(baseDir, templateDir, cfg.Template) 104 | let outDir = Path.Combine(projectDir, cfg.OutDir) 105 | writeProjectToDisk(sourceDir, outDir, { ctx with OutDir=cfg.OutDir; Template=cfg.Template }) 106 | 107 | printfn "[DEBUG] Running post install script: %s" 108 | (Path.Combine(outDir, "scripts/post-generate.sh")) 109 | let processInfo = new ProcessStartInfo( 110 | FileName = "bash", 111 | Arguments = "scripts/post-generate.sh", 112 | WorkingDirectory = outDir) 113 | use p = Process.Start(processInfo) 114 | p.WaitForExit() 115 | 116 | 117 | let GenerateApi(projectDir: string, cfg: Config.IConfig, ctx: Context) = 118 | generate("templates/api", projectDir, cfg, ctx) 119 | 120 | let GenerateBrowser(projectDir: string, cfg: Config.IConfig, ctx: Context) = 121 | generate("templates/browser", projectDir, cfg, ctx) 122 | -------------------------------------------------------------------------------- /templates/api/go/Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | go build cmd/main.go 3 | -------------------------------------------------------------------------------- /templates/api/go/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "{{ api.extra.repo }}/{{ out_dir }}/pkg/server" 4 | 5 | func main() { 6 | cfg, err := server.NewConfig("dbcore.yml") 7 | if err != nil { 8 | panic(err) 9 | } 10 | 11 | s, err := server.New(cfg) 12 | if err != nil { 13 | panic(err) 14 | } 15 | 16 | s.Start() 17 | } 18 | -------------------------------------------------------------------------------- /templates/api/go/pkg/dao/DBCORE__tables___dao.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/guregu/null" 8 | "github.com/jmoiron/sqlx" 9 | ) 10 | 11 | {{~ 12 | func toGoType 13 | case $0.type 14 | when "int", "integer" 15 | if $0.nullable 16 | "null.Int" 17 | else 18 | "int32" 19 | end 20 | when "bigint" 21 | if $0.nullable 22 | "null.Int" 23 | else 24 | "int64" 25 | end 26 | when "text", "varchar", "char" 27 | if $0.nullable 28 | "null.String" 29 | else 30 | "string" 31 | end 32 | when "boolean" 33 | if $0.nullable 34 | "null.Bool" 35 | else 36 | "bool" 37 | end 38 | when "timestamp", "timestamp with time zone" 39 | if $0.nullable 40 | "null.Time" 41 | else 42 | "time.Time" 43 | end 44 | else 45 | "Unsupported type: " + $0.type 46 | end 47 | end 48 | ~}} 49 | 50 | type {{ table.label|dbcore_capitalize }} struct { 51 | {{~ for column in table.columns ~}} 52 | C_{{ column.name }} {{ toGoType column }} `db:"{{ column.name }}" json:"{{ column.name }}"` 53 | {{~ end ~}} 54 | } 55 | 56 | type {{ table.label|dbcore_capitalize }}PaginatedResponse struct { 57 | Total uint64 `json:"total"` 58 | Data []{{ table.label|dbcore_capitalize }} `json:"data"` 59 | } 60 | 61 | func (d DAO) {{ table.label|dbcore_capitalize }}GetMany( 62 | where *Filter, 63 | p Pagination, 64 | baseWhere string, 65 | baseCtx map[string]interface{}, 66 | ) (*{{ table.label|dbcore_capitalize }}PaginatedResponse, error) { 67 | if where == nil { 68 | where = &Filter{} 69 | {{ if api.audit.enabled && api.audit.deleted_at }} 70 | where.filter = `WHERE "{{ api.audit.deleted_at }}" IS NULL` 71 | {{~ end ~}} 72 | } {{~ if api.audit.enabled && api.audit.deleted_at ~}} else { 73 | where.filter = where.filter + ` AND 74 | "{{ api.audit.deleted_at }}" IS NULL` 75 | }{{~ end ~}} 76 | 77 | {{~ if api.auth.enabled ~}} 78 | if baseWhere != "" { 79 | stmt, args := d.{{ table.label }}FilterToCompleteSQLStatement(baseWhere, baseCtx) 80 | 81 | // Combine base filter and where filter strings and args 82 | // TODO: handle restrictions on tables without a primary key 83 | where.filter = where.filter + ` AND "{{ table.primary_key.value.column }}" IN (` + stmt + ")" 84 | where.args = append(where.args, args...) 85 | } 86 | {{~ end ~}} 87 | 88 | query := fmt.Sprintf(` 89 | SELECT 90 | {{~ for column in table.columns ~}} 91 | "{{ column.name }}"{{ if !for.last || database.dialect != "sqlite" }},{{ end }} 92 | {{~ end ~}} 93 | {{~ if database.dialect != "sqlite" ~}} 94 | COUNT(1) OVER() AS __total 95 | {{~ end ~}} 96 | FROM 97 | "{{ table.name }}" t 98 | %s 99 | ORDER BY 100 | %s 101 | LIMIT %d 102 | OFFSET %d`, where.filter, p.Order, p.Limit, p.Offset) 103 | d.logger.Debug(query) 104 | rows, err := d.db.Queryx(query, where.args...) 105 | if err != nil { 106 | return nil, fmt.Errorf("Error in query: %s", err) 107 | } 108 | defer rows.Close() 109 | 110 | var response {{ table.label|dbcore_capitalize }}PaginatedResponse 111 | response.Data = []{{ table.label|dbcore_capitalize }}{} 112 | for rows.Next() { 113 | if err := rows.Err(); err != nil { 114 | return nil, err 115 | } 116 | 117 | var row struct { 118 | {{ table.label|dbcore_capitalize }} 119 | {{~ if database.dialect != "sqlite" ~}} 120 | Total uint64 `db:"__total"` 121 | {{~ end ~}} 122 | } 123 | err := rows.StructScan(&row) 124 | if err != nil { 125 | return nil, fmt.Errorf("Error scanning struct: %s", err) 126 | } 127 | 128 | {{~ if database.dialect != "sqlite" ~}} 129 | response.Total = row.Total 130 | {{~ end ~}} 131 | response.Data = append(response.Data, row.{{ table.label|dbcore_capitalize }}) 132 | } 133 | 134 | {{~ if database.dialect == "sqlite" ~}} 135 | // COUNT() OVER() doesn't seem to work in the Go SQLite 136 | // package even though it works in the sqlite3 CLI. 137 | query = fmt.Sprintf(` 138 | SELECT 139 | COUNT(1) 140 | FROM 141 | "{{table.name}}" 142 | %s 143 | ORDER BY 144 | %s`, where.filter, p.Order) 145 | d.logger.Debug(query) 146 | row := d.db.QueryRowx(query, where.args...) 147 | err = row.Scan(&response.Total) 148 | if err != nil { 149 | return nil, fmt.Errorf("Error fetching total: %s", err, query) 150 | } 151 | {{~ end ~}} 152 | 153 | err = rows.Err() 154 | return &response, err 155 | } 156 | 157 | func (d DAO) {{ table.label|dbcore_capitalize }}Insert(body *{{ table.label|dbcore_capitalize }}) error { 158 | query := ` 159 | INSERT INTO {{ table.name }} ( 160 | {{~ for column in table.columns_no_audit ~}} 161 | "{{ column.name }}"{{ if !for.last }},{{ end }} 162 | {{~ end ~}} 163 | {{~ if api.audit.enabled ~}} 164 | , "{{ api.audit.created_at }}", "{{ api.audit.updated_at }}" 165 | {{~ end ~}}) 166 | VALUES ( 167 | {{~ for column in table.columns_no_audit ~}} 168 | {{ if database.dialect == "postgres" }}${{ for.index + 1 }}{{ else }}?{{ end }}{{ if !for.last }}, {{ end }} 169 | {{~ end ~}}, 170 | {{~ if api.audit.enabled ~}} 171 | {{~ if database.dialect == "sqlite" ~}} 172 | DATETIME('now'), 173 | DATETIME('now') 174 | {{~ else ~}} 175 | NOW(), 176 | NOW() 177 | {{~ end ~}} 178 | {{~ end ~}})` 179 | d.logger.Debug(query) 180 | 181 | {{~ if database.dialect == "postgres" ~}} 182 | row := d.db.QueryRowx(query +` 183 | RETURNING {{ if table.primary_key.value }}{{ table.primary_key.value.column }}{{ else }}{{ table.columns[0].name }}{{ end }} 184 | `, 185 | {{~ for column in table.columns_no_audit ~}} 186 | body.C_{{ column.name }}, 187 | {{~ end ~}} 188 | ) 189 | return row.Scan(&body.C_{{ if table.primary_key.value }}{{ table.primary_key.value.column }}{{ else }}{{ table.columns[0].name }}{{ end }}) 190 | {{~ else if database.dialect == "mysql" || database.dialect == "sqlite" ~}} 191 | stmt, err := d.db.Prepare(query) 192 | if err != nil { 193 | return err 194 | } 195 | 196 | {{ if database.dialect == "mysql" || database.dialect == "sqlite" }}var res sql.Result{{ end }} 197 | {{ if database.dialect == "mysql" || database.dialect == "sqlite" }}res{{ else }}_{{ end }}, err = stmt.Exec( 198 | {{~ for column in table.columns_no_audit ~}} 199 | body.C_{{ column.name }}, 200 | {{~ end ~}}) 201 | if err != nil { 202 | return err 203 | } 204 | 205 | {{~ if table.primary_key.value ~}} 206 | id, err := res.LastInsertId() 207 | if err != nil { 208 | return err 209 | } 210 | 211 | body.C_{{ table.primary_key.value.column }} = {{ toGoType table.primary_key.value }}(id) 212 | {{~ end ~}} 213 | return nil 214 | {{~ end ~}} 215 | } 216 | 217 | {{ if table.primary_key.value }} 218 | func (o {{ table.label|dbcore_capitalize }}) Id() {{ toGoType table.primary_key.value }} { 219 | return o.C_{{ table.primary_key.value.column }} 220 | } 221 | 222 | func (d DAO) {{ table.label|dbcore_capitalize }}Get( 223 | key {{ toGoType table.primary_key.value }}, 224 | ) (*{{ table.label|dbcore_capitalize }}, error) { 225 | where, err := ParseFilter(fmt.Sprintf("{{ table.primary_key.value.column }} = %#v", key)) 226 | if err != nil { 227 | panic(err) 228 | } 229 | 230 | pagination := Pagination{ 231 | Limit: 1, 232 | Offset: 0, 233 | Order: fmt.Sprintf("{{ table.primary_key.value.column }} DESC"), 234 | } 235 | 236 | r, err := d.{{ table.label|dbcore_capitalize }}GetMany(where, pagination, "", nil) 237 | if err != nil { 238 | return nil, err 239 | } 240 | 241 | if r.Total != 1 { 242 | return nil, ErrNotFound 243 | } 244 | 245 | return &r.Data[0], nil 246 | } 247 | 248 | func (d DAO) {{ table.label|dbcore_capitalize }}Update(key {{ toGoType table.primary_key.value }}, body {{ table.label|dbcore_capitalize }}) error { 249 | query := ` 250 | UPDATE 251 | "{{ table.name }}" 252 | SET 253 | {{~ for column in table.columns_no_audit ~}} 254 | "{{column.name}}" = {{ if database.dialect == "postgres" }}${{ index }}{{ else }}?{{ end }}, 255 | {{~ end ~}} 256 | {{~ if database.dialect == "sqlite" ~}} 257 | "{{ api.audit.updated_at }}" = DATETIME('now') 258 | {{~ else ~}} 259 | "{{ api.audit.updated_at }}" = NOW() 260 | {{~ end ~}} 261 | WHERE 262 | {{~ if database.dialect == "postgres" ~}} 263 | "{{ table.primary_key.value.column }}" = ${{ table.columns_no_audit | array.size + 1 }} 264 | {{~ else ~}} 265 | "{{ table.primary_key.value.column }}" = ? 266 | {{~ end ~}}` 267 | d.logger.Debug(query) 268 | stmt, err := d.db.Prepare(query) 269 | if err != nil { 270 | return nil 271 | } 272 | 273 | _, err = stmt.Exec( 274 | {{~ for column in table.columns_no_audit ~}} 275 | body.C_{{ column.name }}, 276 | {{~ end ~}} 277 | key) 278 | return err 279 | } 280 | 281 | func (d DAO) {{ table.label|dbcore_capitalize }}Delete(key {{ toGoType table.primary_key.value }}) error { 282 | query := ` 283 | {{~ if api.audit.enabled && api.audit.deleted_at ~}} 284 | UPDATE 285 | "{{ table.name }}" 286 | SET 287 | {{~ if database.dialect == "sqlite" }} 288 | "{{ api.audit.deleted_at }}" = DATETIME('now') 289 | {{~ else ~}} 290 | "{{ api.audit.deleted_at }}" = NOW() 291 | {{~ end ~}} 292 | {{~ else ~}} 293 | DELETE 294 | FROM "{{ table.name }}" 295 | {{~ end ~}} 296 | WHERE 297 | "{{ table.primary_key.value.column }}" = {{ if database.dialect == "postgres" }}$1{{ else }}?{{ end }}` 298 | 299 | d.logger.Debug(query) 300 | stmt, err := d.db.Prepare(query) 301 | if err != nil { 302 | return err 303 | } 304 | 305 | _, err = stmt.Exec(key) 306 | return err 307 | } 308 | 309 | func (d DAO) {{ table.label }}FilterToCompleteSQLStatement( 310 | filter string, 311 | ctx map[string]interface{}, 312 | ) (string, []interface{}) { 313 | query, args := applyVariablesFromContext(filter, ctx) 314 | 315 | // Allow override of select and from parts if specified 316 | _, err := sqlparser.Parse(query) 317 | if err != nil { 318 | // TODO: handle restrictions on tables without a primary key 319 | 320 | query = `SELECT "{{ table.primary_key.value.column }}" FROM "{{ table.name }}" WHERE ` + query 321 | } 322 | 323 | return query, args 324 | } 325 | 326 | func (d DAO) {{ table.label|dbcore_capitalize }}IsAllowed(filter string, ctx map[string]interface{}) bool { 327 | query, args := d.{{ table.label }}FilterToCompleteSQLStatement(filter, ctx) 328 | 329 | query = fmt.Sprintf(`SELECT COUNT(1) FROM (%s)`, query) 330 | d.logger.Debug(query) 331 | row := d.db.QueryRowx(query, args...) 332 | 333 | var count uint 334 | err := row.Scan(&count) 335 | if err != nil { 336 | d.logger.Warnf("Error fetching allow count: %s", err) 337 | return false 338 | } 339 | 340 | return count > 0 341 | } 342 | 343 | {{ end }} 344 | -------------------------------------------------------------------------------- /templates/api/go/pkg/dao/dao.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | 7 | "github.com/jmoiron/sqlx" 8 | "github.com/sirupsen/logrus" 9 | "github.com/xwb1989/sqlparser" 10 | "github.com/xwb1989/sqlparser/dependency/querypb" 11 | "github.com/xwb1989/sqlparser/dependency/sqltypes" 12 | ) 13 | 14 | var ErrNotFound = errors.New("Not found") 15 | 16 | type Pagination struct { 17 | Limit uint64 18 | Offset uint64 19 | Order string 20 | } 21 | 22 | 23 | type Filter struct { 24 | args []interface{} 25 | filter string 26 | } 27 | 28 | func ParseFilter(filter string) (*Filter, error) { 29 | if filter == "" { 30 | return nil, nil 31 | } 32 | 33 | // TODO: validate filter uses acceptable subset of WHERE 34 | 35 | // Add stub select to make filter into a statement 36 | stmt, err := sqlparser.Parse("SELECT 1 WHERE " + filter) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | where := stmt.(*sqlparser.Select).Where 42 | 43 | bindings := map[string]*querypb.BindVariable{} 44 | // dbcore is a random choice for a binding variable prefix so 45 | // we can be more certain when we find-replace on it 46 | sqlparser.Normalize(stmt, bindings, "dbcore") 47 | 48 | // Map :dbcore[0-9]* to $[0-9]*, will screw up if this literal 49 | // appeared in text in the query. 50 | re := regexp.MustCompile(":dbcore([0-9]*)") 51 | 52 | var invalidValue error 53 | var args []interface{} 54 | whereWithBindings := re.ReplaceAllStringFunc(sqlparser.String(where), func (match string) string { 55 | // This library has no sane way to produce a Go value 56 | // from a parsed bind variable. 57 | match = match[1:] // Drop the preceeding colon 58 | v, _ := sqltypes.BindVariableToValue(bindings[match]) 59 | s := v.ToString() 60 | if v.IsSigned() { 61 | i, err := strconv.ParseInt(s, 10, 64) 62 | if err != nil { 63 | invalidValue = err 64 | return "" 65 | } 66 | 67 | args = append(args, i) 68 | } else if v.IsFloat() { 69 | fl, err := strconv.ParseFloat(s, 64) 70 | if err != nil { 71 | invalidValue = err 72 | return "" 73 | } 74 | 75 | args = append(args, fl) 76 | } else if v.IsText() || v.IsQuoted() { 77 | args = append(args, s) 78 | } else if v.IsNull() { 79 | args = append(args, nil) 80 | } else { 81 | invalidValue = fmt.Errorf(`Unsupported value: "%s"`, s) 82 | } 83 | 84 | {{ if database.dialect == "postgres" }} 85 | return fmt.Sprintf("$%d", len(args)) 86 | {{ else if database.dialect == "mysql" || database.dialect == "sqlite" }} 87 | return "?" 88 | {{ end }} 89 | }) 90 | 91 | return &Filter{ 92 | // Take only the filter part from the statement 93 | filter: whereWithBindings, 94 | args: args, 95 | }, nil 96 | } 97 | 98 | // map $-prefixed variables from request context that can be turned 99 | // into parameterized queries 100 | func applyVariablesFromContext(filter string, ctx map[string]interface{}) (string, []interface{}) { 101 | re := regexp.MustCompile("\\$[a-zA-Z_]+") 102 | var args []interface{} 103 | applied := re.ReplaceAllStringFunc(filter, func (match string) string { 104 | mapping, ok := ctx[match[1:]] // skip $ prefix 105 | if mapping == nil || !ok { 106 | args = append(args, nil) 107 | } else { 108 | args = append(args, mapping) 109 | } 110 | 111 | {{ if database.dialect == "postgres" }} 112 | return fmt.Sprintf("$%d", len(args)) 113 | {{ else if database.dialect == "mysql" || database.dialect == "sqlite" }} 114 | return "?" 115 | {{ end }} 116 | }) 117 | 118 | return applied, args 119 | } 120 | 121 | type DAO struct { 122 | db *sqlx.DB 123 | logger logrus.FieldLogger 124 | } 125 | 126 | func New(db *sqlx.DB, logger logrus.FieldLogger) *DAO { 127 | return &DAO{ 128 | db: db, 129 | logger: logger.WithFields(logrus.Fields{ 130 | "struct": "DAO", 131 | "pkg": "dao", 132 | }), 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /templates/api/go/pkg/server/DBCORE__tables___controller.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "golang.org/x/crypto/bcrypt" 8 | 9 | "github.com/julienschmidt/httprouter" 10 | 11 | "{{ api.extra.repo }}/{{ out_dir }}/pkg/dao" 12 | ) 13 | 14 | {{~ if table.primary_key.value ~}} 15 | {{~ 16 | func toGoType 17 | case $0.type 18 | when "int", "integer" 19 | "int32" 20 | when "bigint" 21 | "int64" 22 | when "text", "varchar", "char" 23 | "string" 24 | when "boolean" 25 | "bool" 26 | when "timestamp", "timestamp with time zone" 27 | "time.Time" 28 | else 29 | "Unsupported type: " + $0.type 30 | end 31 | end 32 | ~}} 33 | 34 | func (s Server) {{ table.label }}RequestFilterContext( 35 | r *http.Request, 36 | objectId *{{ toGoType table.primary_key.value }}, 37 | ) map[string]interface{} { 38 | ctx := map[string]interface{}{ 39 | "req_username": s.getSessionUsername(r), 40 | } 41 | 42 | if objectId != nil { 43 | ctx["req_object_id"] = *objectId 44 | } 45 | 46 | return ctx 47 | } 48 | 49 | func (s Server) {{ table.label }}RequestIsAllowed( 50 | r *http.Request, 51 | filter string, 52 | objectId *{{ toGoType table.primary_key.value }}, 53 | ) bool { 54 | ctx := s.{{ table.label }}RequestFilterContext(r, objectId) 55 | return s.dao.{{ table.label|dbcore_capitalize }}IsAllowed(filter, ctx) 56 | } 57 | 58 | func (s Server) {{ table.label }}GetManyController(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 59 | extraFilter, pageInfo, err := getFilterAndPageInfo(r) 60 | if err != nil { 61 | sendErrorResponse(w, err) 62 | return 63 | } 64 | 65 | baseFilter := s.auth.allow["{{ table.label }}"]["get"] 66 | baseContext := s.{{ table.label }}RequestFilterContext(r, nil) 67 | 68 | result, err := s.dao.{{ table.label|dbcore_capitalize }}GetMany(extraFilter, *pageInfo, baseFilter, baseContext) 69 | if err != nil { 70 | sendErrorResponse(w, err) 71 | return 72 | } 73 | 74 | {{ if table.label == api.auth.table }} 75 | for i, _ := range result.Data { 76 | result.Data[i].C_{{ api.auth.password }} = "" 77 | } 78 | {{ end }} 79 | 80 | sendResponse(w, result) 81 | } 82 | 83 | func (s Server) {{ table.label }}CreateController(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 84 | baseFilter := s.auth.allow["{{ table.label }}"]["post"] 85 | if baseFilter != "" { 86 | if !s.{{ table.label }}RequestIsAllowed(r, baseFilter, nil) { 87 | sendAuthorizationErrorResponse(w) 88 | return 89 | } 90 | } 91 | 92 | var body dao.{{ table.label|dbcore_capitalize }} 93 | err := getBody(r, &body) 94 | if err != nil { 95 | s.logger.Debug("Expected valid JSON, got: %s", err) 96 | sendValidationErrorResponse(w, "Expected valid JSON") 97 | return 98 | } 99 | 100 | {{ if api.auth.enabled && table.label == api.auth.table }} 101 | hash, err := bcrypt.GenerateFromPassword( 102 | []byte(body.C_{{ api.auth.password }}), bcrypt.DefaultCost) 103 | body.C_{{ api.auth.password }} = string(hash) 104 | {{ end }} 105 | 106 | err = s.dao.{{ table.label|dbcore_capitalize }}Insert(&body) 107 | if err != nil { 108 | sendErrorResponse(w, err) 109 | return 110 | } 111 | 112 | {{ if table.label == api.auth.table }} 113 | body.C_{{ api.auth.password }} = "" 114 | {{ end }} 115 | 116 | sendResponse(w, body) 117 | } 118 | 119 | func parse{{ table.label|dbcore_capitalize }}Key(key string) {{ toGoType table.primary_key.value }} { 120 | {{~ 121 | case table.primary_key.value.type 122 | when "text", "varchar", "char" 123 | "\t return key" 124 | when "int", "integer" 125 | "\t i, _ := strconv.ParseInt(key, 10, 32)\n\t return int32(i)" 126 | when "bigint" 127 | "\t i, _ := strconv.ParseInt(key, 10, 64)\n\t return i" 128 | when "timestamp", "timestamp with time zone" 129 | "\t t, _ := time.Parse(time.RFC3339, key)\n\t return t" 130 | when "boolean" 131 | "\t return key == \"true\"" 132 | else 133 | "\t return \"\"" 134 | end 135 | ~}} 136 | } 137 | 138 | func (s Server) {{ table.label }}GetController(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 139 | k := parse{{ table.label|dbcore_capitalize }}Key(ps.ByName("key")) 140 | 141 | baseFilter := s.auth.allow["{{ table.label }}"]["get"] 142 | if baseFilter != "" { 143 | if !s.{{ table.label }}RequestIsAllowed(r, baseFilter, &k) { 144 | sendAuthorizationErrorResponse(w) 145 | return 146 | } 147 | } 148 | 149 | result, err := s.dao.{{ table.label|dbcore_capitalize }}Get(k) 150 | if err != nil { 151 | if err == dao.ErrNotFound { 152 | sendNotFoundErrorResponse(w) 153 | return 154 | } 155 | 156 | sendErrorResponse(w, err) 157 | return 158 | } 159 | 160 | {{ if table.label == api.auth.table }} 161 | result.C_{{ api.auth.password }} = "" 162 | {{ end }} 163 | 164 | sendResponse(w, result) 165 | } 166 | 167 | func (s Server) {{ table.label }}UpdateController(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 168 | k := parse{{ table.label|dbcore_capitalize }}Key(ps.ByName("key")) 169 | 170 | baseFilter := s.auth.allow["{{ table.label }}"]["put"] 171 | if baseFilter != "" { 172 | if !s.{{ table.label }}RequestIsAllowed(r, baseFilter, &k) { 173 | sendAuthorizationErrorResponse(w) 174 | return 175 | } 176 | } 177 | 178 | var body dao.{{ table.label|dbcore_capitalize }} 179 | err := getBody(r, &body) 180 | if err != nil { 181 | s.logger.Debug("Expected valid JSON, got: %s", err) 182 | sendValidationErrorResponse(w, "Expected valid JSON") 183 | return 184 | } 185 | 186 | {{ if api.auth.enabled && table.label == api.auth.table }} 187 | result, err := s.dao.{{ table.label|dbcore_capitalize }}Get(k) 188 | if err != nil { 189 | sendErrorResponse(w, err) 190 | return 191 | } 192 | 193 | body.C_{{ api.auth.password }} = result.C_{{ api.auth.password }} 194 | {{ end }} 195 | 196 | body.C_{{ table.primary_key.value.column }} = k 197 | err = s.dao.{{ table.label|dbcore_capitalize }}Update(k, body) 198 | if err != nil { 199 | sendErrorResponse(w, err) 200 | return 201 | } 202 | 203 | {{ if table.label == api.auth.table }} 204 | body.C_{{ api.auth.password }} = "" 205 | {{ end }} 206 | 207 | sendResponse(w, body) 208 | } 209 | 210 | func (s Server) {{ table.label }}DeleteController(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 211 | k := parse{{ table.label|dbcore_capitalize }}Key(ps.ByName("key")) 212 | 213 | baseFilter := s.auth.allow["{{ table.label }}"]["delete"] 214 | if baseFilter != "" { 215 | if !s.{{ table.label }}RequestIsAllowed(r, baseFilter, &k) { 216 | sendAuthorizationErrorResponse(w) 217 | return 218 | } 219 | } 220 | 221 | err := s.dao.{{ table.label|dbcore_capitalize }}Delete(k) 222 | if err != nil { 223 | sendErrorResponse(w, err) 224 | return 225 | } 226 | 227 | sendResponse(w, struct{}{}) 228 | } 229 | {{~ end ~}} 230 | -------------------------------------------------------------------------------- /templates/api/go/pkg/server/config.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/spf13/viper" 8 | ) 9 | 10 | type Config struct { 11 | *viper.Viper 12 | } 13 | 14 | func (c Config) GetString(arg string, defaults ...string) string { 15 | v := c.Viper.GetString(arg) 16 | if v != "" { 17 | return v 18 | } 19 | 20 | if len(defaults) == 1 { 21 | return defaults[0] 22 | } 23 | 24 | panic(fmt.Sprintf("Missing required configuration: %s", arg)) 25 | } 26 | 27 | func (c Config) GetDuration(arg string, defaults ...time.Duration) time.Duration { 28 | v := c.Viper.GetDuration(arg) 29 | if v != 0 { 30 | return v 31 | } 32 | 33 | if len(defaults) == 1 { 34 | return defaults[0] 35 | } 36 | 37 | panic(fmt.Sprintf("Missing required configuration: %s", arg)) 38 | } 39 | 40 | func NewConfig(file string) (*Config, error) { 41 | v := viper.New() 42 | v.SetConfigFile(file) 43 | err := v.ReadInConfig() 44 | return &Config{v}, err 45 | } 46 | -------------------------------------------------------------------------------- /templates/api/go/pkg/server/httputil.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | 10 | "{{ api.extra.repo }}/{{ out_dir }}/pkg/dao" 11 | ) 12 | 13 | func sendAuthorizationErrorResponse(w http.ResponseWriter) { 14 | w.WriteHeader(http.StatusForbidden) 15 | json.NewEncoder(w).Encode(struct{ 16 | Error string `json:"error"` 17 | }{ 18 | "Restricted interaction", 19 | }) 20 | } 21 | 22 | func sendNotFoundErrorResponse(w http.ResponseWriter) { 23 | w.WriteHeader(http.StatusNotFound) 24 | json.NewEncoder(w).Encode(struct{ 25 | Error string `json:"error"` 26 | }{ 27 | "Not found", 28 | })} 29 | 30 | 31 | func sendValidationErrorResponse(w http.ResponseWriter, msg string) { 32 | w.WriteHeader(http.StatusBadRequest) 33 | json.NewEncoder(w).Encode(struct{ 34 | Error string `json:"error"` 35 | }{ 36 | msg, 37 | }) 38 | } 39 | 40 | func sendErrorResponse(w http.ResponseWriter, err error) { 41 | w.WriteHeader(http.StatusInternalServerError) 42 | json.NewEncoder(w).Encode(struct{ 43 | Error string `json:"error"` 44 | }{ 45 | err.Error(), 46 | }) 47 | } 48 | 49 | func sendResponse(w http.ResponseWriter, obj interface{}) { 50 | json.NewEncoder(w).Encode(obj) 51 | } 52 | 53 | func getBody(r *http.Request, obj interface{}) error { 54 | decoder := json.NewDecoder(r.Body) 55 | return decoder.Decode(obj) 56 | } 57 | 58 | func getFilterAndPageInfo(r *http.Request) (*dao.Filter, *dao.Pagination, error) { 59 | getSingleUintParameter := func(param string, def uint64) (uint64, error) { 60 | values, ok := r.URL.Query()[param] 61 | if !ok || len(values) == 0 { 62 | return def, nil 63 | } 64 | 65 | return strconv.ParseUint(values[0], 10, 64) 66 | } 67 | 68 | limit, err := getSingleUintParameter("limit", 25) 69 | if err != nil { 70 | return nil, nil, err 71 | } 72 | 73 | offset, err := getSingleUintParameter("offset", 0) 74 | if err != nil { 75 | return nil, nil, err 76 | } 77 | 78 | sortColumn := r.URL.Query().Get("sortColumn") 79 | if sortColumn == "" { 80 | return nil, nil, fmt.Errorf(`Expected "sortColumn" parameter`) 81 | } 82 | 83 | sortOrder := strings.ToUpper(r.URL.Query().Get("sortOrder")) 84 | if sortOrder == "" { 85 | sortOrder = "DESC" 86 | } 87 | 88 | if !(sortOrder == "ASC" || sortOrder == "DESC") { 89 | return nil, nil, fmt.Errorf(`Expected "sortOrder" parameter to be "asc" or "desc"`) 90 | } 91 | 92 | var filter *dao.Filter 93 | filterString := r.URL.Query().Get("filter") 94 | if filterString != "" { 95 | filter, err = dao.ParseFilter(filterString) 96 | if err != nil { 97 | return nil, nil, fmt.Errorf(`Expected valid "filter" parameter: %s`, err) 98 | } 99 | } 100 | 101 | return filter, &dao.Pagination{ 102 | Limit: limit, 103 | Offset: offset, 104 | Order: sortColumn + " " + sortOrder, 105 | }, nil 106 | } 107 | -------------------------------------------------------------------------------- /templates/api/go/pkg/server/router.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "time" 7 | 8 | "github.com/dgrijalva/jwt-go" 9 | "github.com/sirupsen/logrus" 10 | 11 | "{{ api.extra.repo }}/{{ out_dir }}/pkg/dao" 12 | ) 13 | 14 | -------------------------------------------------------------------------------- /templates/api/go/pkg/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "runtime/debug" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "time" 11 | 12 | "github.com/jmoiron/sqlx" 13 | "github.com/julienschmidt/httprouter" 14 | {{ if database.dialect == "postgres" }} 15 | _ "github.com/lib/pq" 16 | {{ else if database.dialect == "mysql" }} 17 | _ "github.com/go-sql-driver/mysql" 18 | {{ else if database.dialect == "sqlite" }} 19 | _ "github.com/mattn/go-sqlite3" 20 | {{ end }} 21 | "github.com/sirupsen/logrus" 22 | 23 | "{{ api.extra.repo }}/{{ out_dir }}/pkg/dao" 24 | ) 25 | 26 | type serverAuth struct { 27 | secret string 28 | allow map[string]map[string]string 29 | } 30 | 31 | type Server struct { 32 | dao *dao.DAO 33 | router *httprouter.Router 34 | logger logrus.FieldLogger 35 | address string 36 | auth serverAuth 37 | sessionDuration time.Duration 38 | allowedOrigins []string 39 | } 40 | 41 | func (s Server) registerControllers() { 42 | {{~ for table in tables ~}} 43 | // Register {{table.label}} routes 44 | s.router.GET("/{{ api.router_prefix }}{{ table.label }}", s.{{table.label}}GetManyController) 45 | s.router.POST("/{{ api.router_prefix }}{{ table.label }}", s.{{table.label}}CreateController) 46 | {{~ if table.primary_key.value ~}} 47 | s.router.GET("/{{ api.router_prefix }}{{ table.label }}/:key", s.{{table.label}}GetController) 48 | s.router.PUT("/{{ api.router_prefix }}{{ table.label }}/:key", s.{{table.label}}UpdateController) 49 | s.router.DELETE("/{{ api.router_prefix }}{{ table.label }}/:key", s.{{table.label}}DeleteController) 50 | {{~ end ~}} 51 | {{ end }} 52 | 53 | {{ if api.auth.enabled }} 54 | // Register session route 55 | s.router.POST("/{{ api.router_prefix }}session/start", s.SessionStartController) 56 | s.router.POST("/{{ api.router_prefix }}session/stop", s.SessionStopController) 57 | {{ end }} 58 | } 59 | 60 | func (s Server) registerSigintHandler(srv *http.Server) { 61 | // Wait for SIGINT 62 | stop := make(chan os.Signal, 1) 63 | signal.Notify(stop, os.Interrupt) 64 | <-stop 65 | 66 | s.logger.Info("Signal received, shutting down") 67 | 68 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 69 | defer cancel() 70 | if err := srv.Shutdown(ctx); err != nil { 71 | s.logger.Warnf("Error during shutdown: %s", err) 72 | return 73 | } 74 | } 75 | 76 | func (s Server) handlePanic(w http.ResponseWriter, r *http.Request, err interface{}) { 77 | s.logger.Warnf("Unexpected panic: %s\n%s", err, debug.Stack()) 78 | sendErrorResponse(w, fmt.Errorf("Internal server error")) 79 | } 80 | 81 | func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 82 | s.logger.Infof("%s %s", r.Method, r.URL.RequestURI()) 83 | w.Header().Set("Content-Type", "application/json") 84 | 85 | for _, allowed := range s.allowedOrigins { 86 | origin := strings.ToLower(r.Header.Get("origin")) 87 | if strings.ToLower(allowed) == origin { 88 | w.Header().Set("Access-Control-Allow-Origin", origin) 89 | w.Header().Set("Access-Control-Allow-Credentials", "true") 90 | w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") 91 | w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, Origin") 92 | } 93 | 94 | if r.Method == http.MethodOptions { 95 | return 96 | } 97 | } 98 | 99 | s.router.ServeHTTP(w, r) 100 | } 101 | 102 | 103 | func (s Server) Start() { 104 | s.router.PanicHandler = s.handlePanic 105 | s.registerControllers() 106 | 107 | srv := &http.Server{ 108 | Handler: s, 109 | Addr: s.address, 110 | WriteTimeout: 15 * time.Second, 111 | ReadTimeout: 15 * time.Second, 112 | } 113 | 114 | go func() { 115 | s.logger.Infof("Starting server at %s", s.address) 116 | if err := srv.ListenAndServe(); err != nil { 117 | s.logger.Warnf("Exiting server with error: %s", err) 118 | return 119 | } 120 | 121 | s.logger.Info("Exiting server") 122 | }() 123 | 124 | s.registerSigintHandler(srv) 125 | } 126 | 127 | func New(conf *Config) (*Server, error) { 128 | dialect := conf.GetString("database.dialect") 129 | dsn := conf.GetString("api.runtime.dsn") 130 | 131 | if dialect == "sqlite" { 132 | dialect = "sqlite3" 133 | } 134 | 135 | db, err := sqlx.Connect(dialect, dsn) 136 | if err != nil { 137 | return nil, err 138 | } 139 | 140 | secret := conf.GetString("api.runtime.session.secret") 141 | {{ if api.auth.enabled }} 142 | if secret == "" { 143 | return nil, fmt.Errorf(`Configuration value "secret" must be specified`) 144 | } 145 | {{ end }} 146 | 147 | router := httprouter.New() 148 | logger := logrus.New() 149 | // TODO: make this configurable 150 | logger.SetLevel(logrus.DebugLevel) 151 | 152 | return &Server{ 153 | dao: dao.New(db, logger), 154 | router: router, 155 | logger: logger.WithFields(logrus.Fields{ 156 | "struct": "Server", 157 | "pkg": "server", 158 | }), 159 | address: conf.GetString("api.runtime.address", ":9090"), 160 | sessionDuration: conf.GetDuration("api.runtime.session.duration", time.Hour * 2), 161 | allowedOrigins : conf.GetStringSlice("api.runtime.allowed-origins"), 162 | auth: serverAuth{ 163 | secret: secret, 164 | allow: map[string]map[string]string{ 165 | {{~ for table in tables ~}} 166 | "{{ table.label }}": map[string]string { 167 | "get": conf.GetString("api.runtime.auth.allow.{{ table.label }}.get", ""), 168 | "put": conf.GetString("api.runtime.auth.allow.{{ table.label }}.put", ""), 169 | "post": conf.GetString("api.runtime.auth.allow.{{ table.label }}.post", ""), 170 | "delete": conf.GetString("api.runtime.auth.allow.{{ table.label }}.delete", ""), 171 | }, 172 | {{~ end ~}} 173 | }, 174 | }, 175 | }, nil 176 | } 177 | -------------------------------------------------------------------------------- /templates/api/go/pkg/server/session_controller.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | {{ if api.auth.enabled }} 4 | import ( 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "golang.org/x/crypto/bcrypt" 10 | 11 | "github.com/dgrijalva/jwt-go" 12 | "github.com/julienschmidt/httprouter" 13 | 14 | "{{ api.extra.repo }}/{{ out_dir }}/pkg/dao" 15 | ) 16 | 17 | func (s Server) getSessionUsername(r *http.Request) string { 18 | cookie, err := r.Cookie("au") 19 | if err != nil { 20 | // Fall back to header check 21 | cookie = &http.Cookie{} 22 | } 23 | 24 | token := cookie.Value 25 | if token == "" { 26 | authHeader := r.Header.Get("Authorization") 27 | if len(authHeader) > len("bearer ") && 28 | strings.ToLower(authHeader[:len("bearer ")]) == "bearer " { 29 | token = authHeader[len("bearer "):] 30 | } 31 | } 32 | 33 | t, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { 34 | if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { 35 | return nil, fmt.Errorf("Unexpected signing method: %v", t.Header["alg"]) 36 | } 37 | 38 | return []byte(s.auth.secret), nil 39 | }) 40 | if err != nil { 41 | s.logger.Debugf("Error parsing JWT: %s", err) 42 | return "" 43 | } 44 | 45 | claims, ok := t.Claims.(jwt.MapClaims) 46 | if !(ok && t.Valid) { 47 | return "" 48 | } 49 | 50 | if err := claims.Valid(); err != nil { 51 | s.logger.Debugf("Invalid claims: %s", err) 52 | return "" 53 | } 54 | 55 | usernameInterface, ok := claims["username"] 56 | if !ok { 57 | return "" 58 | } 59 | 60 | username, ok := usernameInterface.(string) 61 | if !ok { 62 | return "" 63 | } 64 | 65 | return username 66 | } 67 | 68 | func (s Server) SessionStartController(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 69 | var userPass struct { 70 | Username string `json:"username"` 71 | Password string `json:"password"` 72 | } 73 | 74 | err := getBody(r, &userPass) 75 | if err != nil { 76 | sendValidationErrorResponse(w, fmt.Sprintf("Expected username and password in body, got: %s", err)) 77 | return 78 | } 79 | 80 | if userPass.Username == "" || userPass.Password == "" { 81 | sendValidationErrorResponse(w, "Expected username and password in body") 82 | return 83 | } 84 | 85 | q := fmt.Sprintf("{{ api.auth.username }} = '%s'", userPass.Username) 86 | filter, err := dao.ParseFilter(q) 87 | if err != nil { 88 | s.logger.Debugf("Error while parsing {{ api.auth.username }}: %s", err) 89 | sendValidationErrorResponse(w, "Expected valid username") 90 | return 91 | } 92 | 93 | pageInfo := dao.Pagination{Offset: 0, Limit: 1, Order: `"{{ api.auth.username }}" DESC`} 94 | result, err := s.dao.{{ api.auth.table|dbcore_capitalize }}GetMany(filter, pageInfo, "", nil) 95 | if err != nil { 96 | sendErrorResponse(w, err) 97 | return 98 | } 99 | 100 | if result.Total == 0 { 101 | sendValidationErrorResponse(w, "Invalid username or password") 102 | return 103 | } 104 | 105 | user := result.Data[0] 106 | err = bcrypt.CompareHashAndPassword([]byte(user.C_{{ api.auth.password }}), []byte(userPass.Password)) 107 | if err != nil { 108 | sendValidationErrorResponse(w, "Invalid username or password") 109 | return 110 | } 111 | 112 | unsignedToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 113 | "username": user.C_{{ api.auth.username }}, 114 | "user_id": user.Id(), 115 | "exp": time.Now().Add(s.sessionDuration).Unix(), 116 | "nbf": time.Now().Unix(), 117 | "iat": time.Now().Unix(), 118 | }) 119 | token, err := unsignedToken.SignedString([]byte(s.auth.secret)) 120 | if err != nil { 121 | s.logger.Debugf("Error signing string: %s", err) 122 | sendErrorResponse(w, fmt.Errorf("Internal server error")) 123 | return 124 | } 125 | 126 | http.SetCookie(w, &http.Cookie{ 127 | Name: "au", 128 | Value: token, 129 | Expires: time.Now().Add(s.sessionDuration), 130 | Path: "/", 131 | }) 132 | 133 | sendResponse(w, struct{ 134 | Token string `json:"token"` 135 | }{token}) 136 | } 137 | 138 | func (s Server) SessionStopController(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 139 | http.SetCookie(w, &http.Cookie{ 140 | Name: "au", 141 | Value: "", 142 | Expires: time.Now().Add(-1 * s.sessionDuration), 143 | Path: "/", 144 | }) 145 | 146 | sendResponse(w, struct{ 147 | Token string `json:"token"` 148 | }{""}) 149 | } 150 | {{ else }} 151 | // Auth not enabled. 152 | {{ end }} 153 | -------------------------------------------------------------------------------- /templates/api/go/scripts/post-generate.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | set -u 5 | set -x 6 | 7 | go get golang.org/x/tools/cmd/goimports 8 | if [ ! -f ../go.mod ]; then 9 | (cd .. && go mod init {{ api.extra.repo }}) 10 | fi 11 | goimports -w ./ 12 | -------------------------------------------------------------------------------- /templates/browser/react/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .module-cache 3 | *.log* 4 | build 5 | dist 6 | yarn-.* -------------------------------------------------------------------------------- /templates/browser/react/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "semi": true, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "bracketSpacing": true, 7 | "singleQuote": true, 8 | "trailingComma": "none", 9 | "printWidth": 120 10 | } 11 | -------------------------------------------------------------------------------- /templates/browser/react/README.md: -------------------------------------------------------------------------------- 1 | # {{ project|dbcore_capitalize }} Browser Application 2 | 3 | To run development server: 4 | 5 | ``` 6 | $ yarn start 7 | ``` 8 | 9 | To typecheck: 10 | 11 | ``` 12 | $ yarn typecheck 13 | ``` 14 | 15 | To build: 16 | 17 | ``` 18 | $ yarn build 19 | ``` 20 | -------------------------------------------------------------------------------- /templates/browser/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "{{ project }}", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "start": "./scripts/start.sh", 6 | "build": "./scripts/build.sh", 7 | "typecheck": "tsc" 8 | }, 9 | "dependencies": { 10 | "react": "^16.13.1", 11 | "react-dom": "^16.13.1", 12 | "react-router": "^5.2.0", 13 | "react-router-dom": "^5.2.0", 14 | "typescript": "^3.9.5" 15 | }, 16 | "devDependencies": { 17 | "@types/react": "^16.9.38", 18 | "@types/react-dom": "^16.9.8", 19 | "@types/react-router": "^5.1.7", 20 | "@types/react-router-dom": "^5.1.5", 21 | "esbuild": "^0.4.9", 22 | "es-dev-server": "^1.54.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /templates/browser/react/scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eux 4 | 5 | # Easier than depending on a yaml parser? TODO: deal with TLS in server 6 | API_PREFIX="http://$(cat ../dbcore.yml | grep address | awk '{ print $2 }' | xargs)" 7 | yarn esbuild src/main.tsx --bundle --define:window.DBCORE_API_PREFIX=\"$API_PREFIX\" '--define:process.env.NODE_ENV="production"' --minify --outfile=build/bundle.js 8 | -------------------------------------------------------------------------------- /templates/browser/react/scripts/post-generate.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | set -u 5 | set -x 6 | 7 | chmod +x ./scripts/*.sh 8 | 9 | yarn 10 | -------------------------------------------------------------------------------- /templates/browser/react/scripts/start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eux 4 | 5 | rm -rf build 6 | mkdir build 7 | # Easier than depending on a yaml parser? TODO: deal with TLS in server 8 | API_PREFIX="http://$(cat ../dbcore.yml | grep address | awk '{ print $2 }' | xargs)" 9 | yarn esbuild src/main.tsx --bundle --define:window.DBCORE_API_PREFIX=\"$API_PREFIX\" '--define:process.env.NODE_ENV="development"' --outfile=build/bundle.js 10 | cp -r static/* build/ 11 | yarn es-dev-server --port 9091 --root-dir build --app-index index.html 12 | -------------------------------------------------------------------------------- /templates/browser/react/src/api/index.ts: -------------------------------------------------------------------------------- 1 | export { request } from './request'; 2 | export { 3 | {{~ for table in tables ~}} 4 | {{ table.label|dbcore_capitalize }}, 5 | {{~ end ~}} 6 | } from './types'; 7 | -------------------------------------------------------------------------------- /templates/browser/react/src/api/request.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | const apiPrefix = window.DBCORE_API_PREFIX; // Substituted by build process 3 | 4 | export async function request( 5 | endpoint: string, 6 | body?: object, 7 | method?: 'POST' | 'GET' | 'DELETE' | 'PUT', 8 | ) { 9 | const req = await window.fetch(apiPrefix + '/{{api.router_prefix}}'+endpoint, { 10 | method: !method ? (body ? 'POST' : 'GET') : method, 11 | body: body ? JSON.stringify(body) : undefined, 12 | headers: body ? { 13 | 'content-type': 'application/json', 14 | } : undefined, 15 | credentials: 'include', 16 | }); 17 | 18 | return req.json(); 19 | } 20 | -------------------------------------------------------------------------------- /templates/browser/react/src/api/types.ts: -------------------------------------------------------------------------------- 1 | {{~ 2 | func toTypeScriptType 3 | case $0 4 | when "integer", "int", "bigint", "smallint", "decimal", "numeric", "real", "double precision" 5 | "number" 6 | when "boolean" 7 | "boolean" 8 | else 9 | "string" 10 | end 11 | end 12 | ~}} 13 | {{~ for table in tables ~}} 14 | export interface {{ table.label|dbcore_capitalize }} { 15 | {{~ for column in table.columns ~}} 16 | {{ column.name }}{{ if column.nullable }}?{{ end }}: {{ toTypeScriptType column.type }}; 17 | {{~ end ~}} 18 | } 19 | {{~ end ~}} 20 | -------------------------------------------------------------------------------- /templates/browser/react/src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function Button({ 4 | children, 5 | type, 6 | }: React.HTMLProps) { 7 | return ( 8 |