├── .gitignore ├── GitHub-Jobs.postman_collection.json ├── Makefile ├── README.md ├── config └── config.edn ├── infra ├── docker-compose.yaml ├── docker │ └── images │ │ ├── datomic-console │ │ └── Dockerfile │ │ └── datomic-transactor │ │ ├── Dockerfile │ │ ├── config │ │ └── transactor.properties │ │ └── start-transactor.sh └── env.template ├── project.clj ├── src └── github_jobs │ ├── adapter.clj │ ├── controller.clj │ ├── data │ ├── job.clj │ └── schemas.clj │ ├── di │ ├── component.clj │ ├── context_deps.clj │ ├── datomic.clj │ ├── http_config.clj │ └── pedestal.clj │ ├── logic │ └── job.clj │ ├── model │ ├── category.clj │ └── job.clj │ ├── schemata │ └── job.clj │ ├── server.clj │ └── service.clj └── test └── github_jobs └── service_test.clj /.gitignore: -------------------------------------------------------------------------------- 1 | # Project related 2 | .idea 3 | .iml 4 | *.idea 5 | *.iml 6 | 7 | # Java related 8 | pom.xml 9 | pom.xml.asc 10 | *jar 11 | *.class 12 | 13 | # Leiningen 14 | classes/ 15 | lib/ 16 | native/ 17 | checkouts/ 18 | target/ 19 | .lein-* 20 | repl-port 21 | .nrepl-port 22 | .repl 23 | 24 | # Temp Files 25 | *.orig 26 | *~ 27 | .*.swp 28 | .*.swo 29 | *.tmp 30 | *.bak 31 | 32 | # OS X 33 | .DS_Store 34 | 35 | # Logging 36 | *.log 37 | /logs/ 38 | 39 | # Builds 40 | out/ 41 | build/ 42 | 43 | # Leningen from https://github.com/github/gitignore/blob/master/Leiningen.gitignore 44 | pom.xml 45 | pom.xml.asc 46 | *.jar 47 | *.class 48 | /lib/ 49 | /classes/ 50 | /target/ 51 | /checkouts/ 52 | .lein-deps-sum 53 | .lein-repl-history 54 | .lein-plugins/ 55 | .lein-failures 56 | .nrepl-port 57 | .cpcache/ 58 | 59 | # Environment 60 | **/.env 61 | -------------------------------------------------------------------------------- /GitHub-Jobs.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "06719eb1-5292-46d0-ac93-642553fced8f", 4 | "name": "GitHub-Jobs", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "POST - Job", 10 | "request": { 11 | "method": "POST", 12 | "header": [], 13 | "body": { 14 | "mode": "raw", 15 | "raw": "{\n\t\"id\": \"9221690f-618d-4e91-8988-b770fcf1cc6d\",\n\t\"title\": \"job title\",\n\t\"url\": \"http://job.com\",\n\t\"category\": [\"android\", \"java\", \"kotlin\", \"mobile\"]\n}", 16 | "options": { 17 | "raw": { 18 | "language": "json" 19 | } 20 | } 21 | }, 22 | "url": { 23 | "raw": "http://localhost:8890/api/job", 24 | "protocol": "http", 25 | "host": [ 26 | "localhost" 27 | ], 28 | "port": "8890", 29 | "path": [ 30 | "api", 31 | "job" 32 | ] 33 | } 34 | }, 35 | "response": [] 36 | }, 37 | { 38 | "name": "PUT - Job", 39 | "request": { 40 | "method": "PUT", 41 | "header": [], 42 | "body": { 43 | "mode": "raw", 44 | "raw": "{\n\t\"title\": \"android job\"\n}", 45 | "options": { 46 | "raw": { 47 | "language": "json" 48 | } 49 | } 50 | }, 51 | "url": { 52 | "raw": "http://localhost:8890/api/job/9931690f-618d-4e91-8988-b770fcf1cc6d", 53 | "protocol": "http", 54 | "host": [ 55 | "localhost" 56 | ], 57 | "port": "8890", 58 | "path": [ 59 | "api", 60 | "job", 61 | "9931690f-618d-4e91-8988-b770fcf1cc6d" 62 | ] 63 | } 64 | }, 65 | "response": [] 66 | }, 67 | { 68 | "name": "DELETE - Job", 69 | "request": { 70 | "method": "DELETE", 71 | "header": [], 72 | "body": { 73 | "mode": "raw", 74 | "raw": "", 75 | "options": { 76 | "raw": { 77 | "language": "json" 78 | } 79 | } 80 | }, 81 | "url": { 82 | "raw": "http://localhost:8890/api/job/9221690f-618d-4e91-8988-b770fcf1cc6d", 83 | "protocol": "http", 84 | "host": [ 85 | "localhost" 86 | ], 87 | "port": "8890", 88 | "path": [ 89 | "api", 90 | "job", 91 | "9221690f-618d-4e91-8988-b770fcf1cc6d" 92 | ] 93 | } 94 | }, 95 | "response": [] 96 | }, 97 | { 98 | "name": "GET - Job with filters", 99 | "request": { 100 | "method": "GET", 101 | "header": [], 102 | "url": { 103 | "raw": "http://localhost:8890/api/job?category=java&title=job", 104 | "protocol": "http", 105 | "host": [ 106 | "localhost" 107 | ], 108 | "port": "8890", 109 | "path": [ 110 | "api", 111 | "job" 112 | ], 113 | "query": [ 114 | { 115 | "key": "category", 116 | "value": "java" 117 | }, 118 | { 119 | "key": "title", 120 | "value": "job" 121 | } 122 | ] 123 | } 124 | }, 125 | "response": [] 126 | }, 127 | { 128 | "name": "GET - Job", 129 | "request": { 130 | "method": "GET", 131 | "header": [], 132 | "url": { 133 | "raw": "http://localhost:8890/api/job", 134 | "protocol": "http", 135 | "host": [ 136 | "localhost" 137 | ], 138 | "port": "8890", 139 | "path": [ 140 | "api", 141 | "job" 142 | ] 143 | } 144 | }, 145 | "response": [] 146 | } 147 | ], 148 | "protocolProfileBehavior": {} 149 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean test test-clj 2 | 3 | # Based on: https://github.com/den1k/vimsical/blob/master/Makefile 4 | 5 | # 6 | # Deps 7 | # 8 | deps: 9 | lein deps 10 | 11 | # 12 | # Lein 13 | # 14 | clean: 15 | lein clean 16 | 17 | # 18 | # Test 19 | # 20 | test: test-clj 21 | 22 | test-clj: clean 23 | lein test 24 | 25 | # 26 | # Build 27 | # 28 | build: build-clj 29 | 30 | build-clj: clean 31 | lein uberjar 32 | 33 | # 34 | # Setup credentials 35 | # 36 | infra-credentials: 37 | ifeq ($(DATOMIC_LOGIN),) 38 | $(error DATOMIC_LOGIN is undefined) 39 | endif 40 | ifeq ($(DATOMIC_PASSWORD),) 41 | $(error DATOMIC_PASSWORD is undefined) 42 | endif 43 | ifeq ($(DATOMIC_LICENSE_KEY),) 44 | $(error DATOMIC_LICENSE_KEY is undefined) 45 | endif 46 | ifeq ($(DATOMIC_VERSION),) 47 | $(error DATOMIC_VERSION is undefined) 48 | endif 49 | ifeq ($(STORAGE_ADMIN_PASSWORD),) 50 | $(error STORAGE_ADMIN_PASSWORD is undefined) 51 | endif 52 | ifeq ($(STORAGE_DATOMIC_PASSWORD),) 53 | $(error STORAGE_DATOMIC_PASSWORD is undefined) 54 | endif 55 | echo "DATOMIC_LOGIN=${DATOMIC_LOGIN}" >> infra/.env 56 | echo "DATOMIC_PASSWORD=${DATOMIC_PASSWORD}" >> infra/.env 57 | echo "DATOMIC_LICENSE_KEY=${DATOMIC_LICENSE_KEY}" >> infra/.env 58 | echo "DATOMIC_VERSION=${DATOMIC_VERSION}" >> infra/.env 59 | echo "STORAGE_ADMIN_PASSWORD=${STORAGE_ADMIN_PASSWORD}" >> infra/.env 60 | echo "STORAGE_DATOMIC_PASSWORD=${STORAGE_DATOMIC_PASSWORD}" >> infra/.env 61 | echo "{:datomic-secret-password \"${STORAGE_DATOMIC_PASSWORD}\"}" >> .lein-env 62 | 63 | infra-start: infra/.env 64 | cd infra && docker-compose up -d 65 | 66 | infra-logs: 67 | cd infra && docker-compose logs -f 68 | 69 | infra-build: build 70 | cd infra && docker-compose build --no-cache datomic console 71 | 72 | infra-stop: 73 | cd infra && docker-compose down -v --remove-orphans 74 | 75 | infra-run: infra-start infra-logs 76 | 77 | # 78 | # Start Backend and Infra 79 | # 80 | run: infra-start 81 | lein run 82 | 83 | # 84 | # Clean deps, project and run server 85 | # 86 | rebuild: clean deps build run 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Jobs an Clojure Microservice 2 | 3 | This Service was created with the objective of exploring how to code a CRUD application from zero using Clojure language and other tools, like: 4 | 5 | * [Datomic](https://docs.datomic.com/on-prem/getting-started/brief-overview.html) 6 | * [Docker](https://docs.docker.com/get-started/overview/) 7 | * [Pedestal](https://github.com/pedestal/pedestal) 8 | * [Component](https://github.com/stuartsierra/component) 9 | * [Schema](https://github.com/plumatic/schema) 10 | * Hexagonal Architecture 11 | 12 | ### How to run 13 | 14 | 1. First of all, you'll need [lein](https://leiningen.org/) configured 15 | 2. Create a [datomic account](https://my.datomic.com/login) if you still don't have one 16 | 3. Log into your datomic account and go to the [Licenses Page](https://my.datomic.com/account) 17 | 4. Click on the `Send License Key` button. This will send you and email with a license key. We will also use the `Download Key` you can find on this page. 18 | 5. Substitute `${hue}` for your credentials and execute: 19 | ``` 20 | make infra-credentials DATOMIC_LOGIN=${hue} \ 21 | DATOMIC_PASSWORD=${hue} \ 22 | DATOMIC_LICENSE_KEY=${hue} \ 23 | DATOMIC_VERSION=${hue} \ 24 | STORAGE_ADMIN_PASSWORD=${hue} \ 25 | STORAGE_DATOMIC_PASSWORD=${hue} 26 | ``` 27 | - DATOMIC_LOGIN is the email you have used to create your datomic account on step 2 28 | - DATOMIC_PASSWORD is the `Download Key` you can find on step 3. 29 | - DATOMIC_LICENSE_KEY is what was sent to you on step 4 without the `license_key` key and with no line breaks. 30 | - DATOMIC_VERSION should be the same you use on `project.clj` 31 | - STORAGE_ADMIN_PASSWORD and STORAGE_DATOMIC_PASSWORD can be anything you want. 32 | 33 | Example: 34 | ``` 35 | make infra-credentials DATOMIC_LOGIN=your-datomic-account@email.com \ 36 | DATOMIC_PASSWORD=aed2a94a-60e2-11eb-ae93-0242ac130002 \ 37 | DATOMIC_LICENSE_KEY=32hdd9qhd38h33....h3297hd2o23d \ 38 | DATOMIC_VERSION=0.9.6045 \ 39 | STORAGE_ADMIN_PASSWORD=bla \ 40 | STORAGE_DATOMIC_PASSWORD=ble 41 | ``` 42 | 7. Make sure you have maven installed on your machine. If you don't have it, you can install it via `brew install maven` or your preferred method. 43 | 6. Go to [datomic download page](https://my.datomic.com/downloads/pro) and download `datomic-pro-0.9.6045.zip` 44 | 7. Unzip the file you have just downloaded, open a terminal window inside the folder you have just unziped and run: 45 | ```bash 46 | mvn install 47 | ./bin/maven-install 48 | ``` 49 | 4. Execute `make infra-run` 50 | 5. Execute `make deps` 51 | 6. Execute `make run` 52 | 53 | ### How to play 54 | You can import [Postman Collection](https://github.com/thiagozg/GitHubJobs-Clojure-Service/blob/master/GitHub-Jobs.postman_collection.json) to your postman app and make HTTP requests. 55 | -------------------------------------------------------------------------------- /config/config.edn: -------------------------------------------------------------------------------- 1 | {:db-uri "datomic:dev://localhost:4334/github-jobs?password="} -------------------------------------------------------------------------------- /infra/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | volumes: 4 | datomic_data: 5 | driver: local 6 | datomic_log: 7 | driver: local 8 | 9 | services: 10 | 11 | datomic: 12 | build: 13 | context: docker 14 | dockerfile: images/datomic-transactor/Dockerfile 15 | args: 16 | DATOMIC_LOGIN: "$DATOMIC_LOGIN" 17 | DATOMIC_PASSWORD: "$DATOMIC_PASSWORD" 18 | DATOMIC_VERSION: "$DATOMIC_VERSION" 19 | environment: 20 | DB_NAME: "github-jobs" 21 | DATOMIC_LICENSE_KEY: "$DATOMIC_LICENSE_KEY" 22 | STORAGE_ADMIN_PASSWORD: "$STORAGE_ADMIN_PASSWORD" 23 | STORAGE_DATOMIC_PASSWORD: "$STORAGE_DATOMIC_PASSWORD" 24 | volumes: 25 | - "datomic_data:/var/datomic/data" 26 | - "datomic_log:/var/datomic/log" 27 | - "./docker/images/datomic-transactor/config/:/var/datomic/config/" 28 | ports: 29 | - "4334:4334" 30 | - "4335:4335" 31 | - "4336:4336" 32 | - "9999:9999" 33 | 34 | console: 35 | build: 36 | context: docker 37 | dockerfile: images/datomic-console/Dockerfile 38 | args: 39 | DATOMIC_LOGIN: "$DATOMIC_LOGIN" 40 | DATOMIC_PASSWORD: "$DATOMIC_PASSWORD" 41 | DATOMIC_VERSION: "$DATOMIC_VERSION" 42 | STORAGE_DATOMIC_PASSWORD: "$STORAGE_DATOMIC_PASSWORD" 43 | links: 44 | - datomic 45 | command: ["dev", "datomic:dev://datomic:4334/github-jobs?password=$STORAGE_DATOMIC_PASSWORD"] 46 | ports: 47 | - "9000:9000" 48 | -------------------------------------------------------------------------------- /infra/docker/images/datomic-console/Dockerfile: -------------------------------------------------------------------------------- 1 | # Based on: https://github.com/pointslope/docker-datomic-console 2 | # This version uses environment variables for credentials 3 | FROM clojure:lein-2.6.1-alpine 4 | 5 | ARG DATOMIC_LOGIN 6 | ARG DATOMIC_PASSWORD 7 | ARG DATOMIC_VERSION 8 | 9 | ENV DATOMIC_HOME /opt/datomic-pro-$DATOMIC_VERSION 10 | 11 | RUN apk add --no-cache unzip curl 12 | 13 | RUN curl -u $DATOMIC_LOGIN:$DATOMIC_PASSWORD -SL https://my.datomic.com/repo/com/datomic/datomic-pro/$DATOMIC_VERSION/datomic-pro-$DATOMIC_VERSION.zip -o /tmp/datomic.zip \ 14 | && unzip /tmp/datomic.zip -d /opt \ 15 | && rm -f /tmp/datomic.zip 16 | 17 | WORKDIR $DATOMIC_HOME 18 | 19 | ENTRYPOINT ["bin/console", "-p", "9000"] 20 | 21 | EXPOSE 9000 22 | -------------------------------------------------------------------------------- /infra/docker/images/datomic-transactor/Dockerfile: -------------------------------------------------------------------------------- 1 | # Based on: https://github.com/pointslope/docker-datomic 2 | # This version uses environment variables for credentials 3 | FROM clojure:lein-2.6.1-alpine 4 | 5 | ARG DATOMIC_LOGIN 6 | ARG DATOMIC_PASSWORD 7 | ARG DATOMIC_VERSION 8 | ARG MAVEN_VERSION=3.6.3 9 | ARG SHA_MAVEN=c35a1803a6e70a126e80b2b3ae33eed961f83ed74d18fcd16909b2d44d7dada3203f1ffe726c17ef8dcca2dcaa9fca676987befeadc9b9f759967a8cb77181c0 10 | ARG BASE_URL=https://apache.osuosl.org/maven/maven-3/${MAVEN_VERSION}/binaries 11 | 12 | ENV DATOMIC_HOME /opt/datomic-pro-$DATOMIC_VERSION 13 | ENV DATOMIC_DATA /var/datomic/data 14 | ENV DATOMIC_LOG /var/datomic/log 15 | ENV DATOMIC_CONFIG /var/datomic/config 16 | 17 | VOLUME $DATOMIC_DATA 18 | VOLUME $DATOMIC_LOG 19 | VOLUME $DATOMIC_CONFIG 20 | 21 | RUN apk add --no-cache unzip curl 22 | 23 | # Create the directories, download maven, validate the download, install it, remove downloaded file and set links 24 | RUN mkdir -p /usr/share/maven /usr/share/maven/ref \ 25 | && echo "Downloading maven" \ 26 | && curl -fsSL -o /tmp/apache-maven.tar.gz ${BASE_URL}/apache-maven-${MAVEN_VERSION}-bin.tar.gz \ 27 | \ 28 | && echo "Unziping maven" \ 29 | && tar -xzf /tmp/apache-maven.tar.gz -C /usr/share/maven --strip-components=1 \ 30 | \ 31 | && echo "Cleaning and setting links" \ 32 | && rm -f /tmp/apache-maven.tar.gz \ 33 | && ln -s /usr/share/maven/bin/mvn /usr/bin/mvn 34 | 35 | ENV MAVEN_HOME /usr/share/maven 36 | ENV MAVEN_CONFIG "$USER_HOME_DIR/.m2" 37 | 38 | RUN curl -u $DATOMIC_LOGIN:$DATOMIC_PASSWORD -SL https://my.datomic.com/repo/com/datomic/datomic-pro/$DATOMIC_VERSION/datomic-pro-$DATOMIC_VERSION.zip -o /tmp/datomic.zip \ 39 | && unzip /tmp/datomic.zip -d /opt \ 40 | && rm -f /tmp/datomic.zip 41 | 42 | WORKDIR $DATOMIC_HOME 43 | 44 | RUN ./bin/maven-install 45 | ADD ./images/datomic-transactor/start-transactor.sh . 46 | RUN chmod -R 755 start-transactor.sh 47 | CMD ./start-transactor.sh 48 | 49 | EXPOSE 4334 4335 4336 9999 50 | -------------------------------------------------------------------------------- /infra/docker/images/datomic-transactor/config/transactor.properties: -------------------------------------------------------------------------------- 1 | ################################################################### 2 | 3 | protocol=dev 4 | alt-host=0.0.0.0 5 | host=datomic 6 | port=4334 7 | 8 | ################################################################### 9 | ## Healthcheck 10 | 11 | ping-host=datomic 12 | ping-port=9999 13 | 14 | ################################################################### 15 | # See https://docs.datomic.com/on-prem/storage.html 16 | 17 | 18 | ## OPTIONAL ####################################################### 19 | ## The dev: and free: protocols typically use two ports 20 | ## starting with the selected :port, but you can specify the 21 | ## second (h2) port explicitly, e.g. for virtualization environs 22 | ## that do not issue contiguous ports. 23 | 24 | # h2-port=4335 25 | 26 | 27 | 28 | ################################################################### 29 | ## Security settings for embedded storage (free and dev). 30 | 31 | 32 | ## == Passwords == 33 | ## Datomic free/dev has an embedded storage engine with default 34 | ## passwords. You can supply the 'admin' password explicitly with 35 | ## 'storage-admin-password', and rotate that later by moving it to 36 | ## 'old-storage-admin-password', supplying a new 37 | ## 'storage-admin-password'. 38 | # storage-admin-password= 39 | # old-storage-admin-password= 40 | 41 | ## Peers access storage via the 'datomic' user. You can set/rotate 42 | ## the password for 'datomic' using 'storage-datomic-password' and 43 | ## 'old-storage-datomic-password' as per above. 44 | ## NOTE: If you set the password for 'datomic' peers must connect 45 | ## using the same password in the connect URI. 46 | ## See https://docs.datomic.com/on-prem/clojure/index.html#datomic.api/connect. 47 | # storage-datomic-password= 48 | # old-storage-datomic-password= 49 | 50 | ## == Peer access == 51 | ## You can control network access to storage by peers via 52 | ## 'storage-access', options are 'local' (the default) and 'remote'. 53 | ## NOTE: To enable remote access, you must explicitly specify 54 | ## the admin and datomic passwords above. 55 | # storage-access=local 56 | 57 | 58 | 59 | ################################################################### 60 | # See https://docs.datomic.com/on-prem/capacity.html 61 | 62 | 63 | ## Recommended settings for -Xmx4g production usage. 64 | # memory-index-threshold=32m 65 | # memory-index-max=512m 66 | # object-cache-max=1g 67 | 68 | ## Recommended settings for -Xmx1g usage, e.g. dev laptops. 69 | memory-index-threshold=32m 70 | memory-index-max=256m 71 | object-cache-max=128m 72 | 73 | 74 | 75 | ## OPTIONAL ####################################################### 76 | 77 | 78 | ## Set to false to disable SSL between the peers and the transactor. 79 | # Default: true 80 | # encrypt-channel=true 81 | 82 | ## Data directory is used for dev: and free: storage, and 83 | ## as a temporary directory for all storages. 84 | data-dir=/var/datomic/data 85 | 86 | ## Transactor will log here, see bin/logback.xml to configure logging. 87 | log-dir=/var/datomic/log 88 | 89 | ## Transactor will write process pid here on startup 90 | # pid-file=transactor.pid 91 | 92 | 93 | 94 | ## OPTIONAL ####################################################### 95 | # See https://docs.datomic.com/on-prem/valcache.html 96 | ## Valcache configuration. 97 | ## Set these valcache properties to a directory on an SSD to enable valcache 98 | 99 | # valcache-path= 100 | # valcache-max-gb= 101 | 102 | 103 | 104 | ## OPTIONAL ####################################################### 105 | # See https://docs.datomic.com/on-prem/storage.html 106 | ## Memcached configuration. 107 | 108 | # memcached=host:port,host:port,... 109 | # memcached-username=datomic 110 | # memcached-password=datomic 111 | 112 | 113 | 114 | ## OPTIONAL ####################################################### 115 | # See https://docs.datomic.com/on-prem/capacity.html 116 | 117 | 118 | ## Soft limit on the number of concurrent writes to storage. 119 | # Default: 4, Miniumum: 2 120 | # write-concurrency=4 121 | 122 | ## Soft limit on the number of concurrent reads to storage. 123 | # Default: 2 times write-concurrency, Miniumum: 2 124 | # read-concurrency=8 125 | 126 | ## Parallelism in index jobs. 127 | # Default: 1, Maximum: 8 128 | # index-parallelism=1 129 | 130 | 131 | 132 | ## OPTIONAL ####################################################### 133 | # See https://docs.datomic.com/on-prem/aws.html 134 | ## Optional settings for rotating logs to S3 135 | # (Can be auto-generated by bin/datomic ensure-transactor.) 136 | 137 | # aws-s3-log-bucket-id= 138 | 139 | 140 | 141 | ## OPTIONAL ####################################################### 142 | # See https://docs.datomic.com/on-prem/aws.html 143 | ## Optional settings for Cloudwatch metrics. 144 | # (Can be auto-generated by bin/datomic ensure-transactor.) 145 | 146 | # aws-cloudwatch-region= 147 | 148 | ## Pick a unique name to distinguish transactor metrics from different systems. 149 | # aws-cloudwatch-dimension-value=your-system-name 150 | 151 | 152 | 153 | ## OPTIONAL ####################################################### 154 | # See https://docs.datomic.com/on-prem/ha.html 155 | 156 | 157 | ## The transactor will write a heartbeat into storage on this interval. 158 | ## A standby transactor will take over if it sees the heartbeat go 159 | ## unwritten for 2x this interval. If your transactor load leads to 160 | ## long gc pauses, you can increase this number to prevent the standby 161 | ## transactor from unnecessarily taking over during a long gc pause. 162 | # Default: 5000, Miniumum: 5000 163 | # heartbeat-interval-msec=5000 164 | 165 | 166 | 167 | ## OPTIONAL ####################################################### 168 | 169 | 170 | ## The transactor will use this partition for new entities that 171 | ## do not explicitly specify a partition. 172 | # Default: :db.part/user 173 | # default-partition=:db.part/user 174 | 175 | storage-access=remote 176 | -------------------------------------------------------------------------------- /infra/docker/images/datomic-transactor/start-transactor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | PROPERTIES_FILE=bin/transactor.properties 5 | 6 | cp $DATOMIC_CONFIG/transactor.properties bin 7 | 8 | echo "license-key=$DATOMIC_LICENSE_KEY" >> $PROPERTIES_FILE 9 | echo "storage-admin-password=$STORAGE_ADMIN_PASSWORD" >> $PROPERTIES_FILE 10 | echo "storage-datomic-password=$STORAGE_DATOMIC_PASSWORD" >> $PROPERTIES_FILE 11 | 12 | chmod a+x bin/transactor 13 | sh ./bin/transactor $PROPERTIES_FILE 14 | -------------------------------------------------------------------------------- /infra/env.template: -------------------------------------------------------------------------------- 1 | DATOMIC_LOGIN= 2 | DATOMIC_PASSWORD= 3 | DATOMIC_LICENSE_KEY= 4 | DATOMIC_VERSION= 5 | STORAGE_ADMIN_PASSWORD= 6 | STORAGE_DATOMIC_PASSWORD= 7 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject github-jobs "0.0.1-SNAPSHOT" 2 | :description "FIXME: write description" 3 | :url "http://example.com/FIXME" 4 | :repositories {"my.datomic.com" {:url "https://my.datomic.com/repo" 5 | :creds :gpg}} 6 | :dependencies [[org.clojure/clojure "1.10.1"] 7 | [com.stuartsierra/component "1.0.0"] 8 | [com.stuartsierra/component.repl "0.2.0"] 9 | [prismatic/schema "1.1.12"] 10 | [com.datomic/datomic-pro "0.9.6045"] 11 | [io.pedestal/pedestal.service "0.5.8"] 12 | [io.pedestal/pedestal.route "0.5.8"] 13 | [io.pedestal/pedestal.jetty "0.5.8"] 14 | [com.taoensso/timbre "4.10.0"] 15 | [yogthos/config "1.1.7"]] 16 | :jvm-opts ["-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5010"] 17 | :min-lein-version "2.0.0" 18 | :resource-paths ["config", "resources"] 19 | :profiles {:dev {:aliases {"run-dev" ["trampoline" "run" "-m" "github-jobs.server/main"]} 20 | :dependencies [[io.pedestal/pedestal.service-tools "0.5.8"]]} 21 | :uberjar {:aot [github-jobs.server]}} 22 | :main ^{:skip-aot true} github-jobs.server) 23 | -------------------------------------------------------------------------------- /src/github_jobs/adapter.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.adapter 2 | (:require [schema.utils :as s-utils] 3 | [schema.coerce :as coerce] 4 | [io.pedestal.interceptor.helpers :as interceptor] 5 | [io.pedestal.interceptor.error :as error-int] 6 | [taoensso.timbre :as timbre])) 7 | 8 | (defn- log-error 9 | [ex function] 10 | (do (timbre/error ex) function)) 11 | 12 | (def service-error-handler 13 | (error-int/error-dispatch 14 | [context ex] 15 | 16 | [{:exception-type :java.lang.IndexOutOfBoundsException}] 17 | (log-error 18 | ex (assoc context :response {:status 404})) 19 | 20 | [{:exception-type :java.lang.IllegalArgumentException}] 21 | (log-error 22 | ex (assoc context :response {:status 412 23 | :body {:message "The request body does not match with contract..."}})) 24 | 25 | :else 26 | (log-error 27 | ex (assoc context :response {:status 500 28 | :body "Internal Server Error"})))) 29 | 30 | (defn- coerce-and-validate! [schema matcher data] 31 | (let [coercer (coerce/coercer schema matcher) 32 | result (coercer data)] 33 | (if (s-utils/error? result) 34 | (throw (IllegalArgumentException. (format "Value does not match schema: %s" 35 | (s-utils/error-val result)))) 36 | result))) 37 | 38 | (defn coerce-body-request 39 | [schema] 40 | (interceptor/on-request ::payload-request 41 | (fn [{body-params :json-params :as request}] 42 | (dissoc 43 | (->> body-params 44 | (coerce-and-validate! schema coerce/json-coercion-matcher) 45 | (assoc request :payload)) 46 | :json-params)))) 47 | -------------------------------------------------------------------------------- /src/github_jobs/controller.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.controller 2 | (:require [github-jobs.data.job :as db] 3 | [github-jobs.schemata.job :as schemata] 4 | [github-jobs.logic.job :as logic-job] 5 | [schema.core :as s] 6 | [schema.coerce :as coerce])) 7 | 8 | (s/defn get-jobs :- [schemata/JobReference] 9 | [wire 10 | db-conn] 11 | (->> wire 12 | (db/get-jobs! db-conn) 13 | (mapv logic-job/datom-job->wire))) 14 | 15 | (s/defn save-job-async 16 | [wire :- schemata/JobReference 17 | db-conn] 18 | ;; TODO: call topic of #CATEGORY-OF-JOB than save job, force to wait if will be necessary 19 | ;; this is not the best practice for this scenario, it's only something 20 | ;; I did to practice this kind of architecture 21 | 22 | ;; TODO: check return threading and if gets wrong, throw exception 23 | (->> wire 24 | logic-job/wire->new-dto 25 | (db/insert-job! db-conn))) 26 | 27 | (s/defn update-job-async 28 | [{github-id :github-id} 29 | wire :- schemata/JobUpdate 30 | db-conn] 31 | (->> wire 32 | logic-job/wire->update-dto 33 | (db/update-job! db-conn (coerce/string->uuid github-id)))) 34 | 35 | (defn delete-job-async 36 | [{github-id :github-id} 37 | db-conn] 38 | (db/retract-job! db-conn (coerce/string->uuid github-id))) 39 | -------------------------------------------------------------------------------- /src/github_jobs/data/job.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.data.job 2 | (:require [schema.core :as s] 3 | [github-jobs.model.job :as model-job] 4 | [datomic.api :as d])) 5 | 6 | (defn get-jobs! 7 | [conn 8 | {:keys [title 9 | category 10 | github-id]}] 11 | (let [query-base {:query '{:find [[(pull ?job [*]) ...]] 12 | :in [$] 13 | :where [[?job :job/id _]]} 14 | :args [(d/db conn)]}] 15 | (cond-> query-base 16 | 17 | title 18 | (-> 19 | (update-in [:query :in] conj '?title) 20 | (update-in [:query :where] conj '[?job :job/title ?title]) 21 | (update-in [:args] conj title)) 22 | 23 | category 24 | (-> 25 | (update-in [:query :in] conj '?category) 26 | (update-in [:query :where] conj '[?job :job/category ?category]) 27 | (update-in [:args] conj category)) 28 | 29 | github-id 30 | (-> 31 | (update-in [:query :in] conj '?github-id) 32 | (update-in [:query :where] conj '[?job :job/github-id ?github-id]) 33 | (update-in [:args] conj github-id)) 34 | 35 | true d/query))) 36 | 37 | (s/defn insert-job! 38 | [conn 39 | job :- model-job/NewDto] 40 | (d/transact conn [job])) 41 | 42 | (s/defn find-job! 43 | [conn 44 | github-id] 45 | (let [jobs-founded (get-jobs! conn {:github-id github-id})] 46 | (if (not-empty jobs-founded) 47 | (first jobs-founded) 48 | (throw (IndexOutOfBoundsException. 49 | (format "This github id: %s was not founded!" github-id)))))) 50 | 51 | (s/defn add-job-categories! 52 | [conn 53 | github-id :- s/Uuid 54 | categories :- [s/Str]] 55 | (for [category categories] 56 | (d/transact conn [[:db/add [:job/github-id github-id] :job/category category]]))) 57 | 58 | (defn ^:private update-job-cas-datoms! 59 | [conn 60 | github-id 61 | old-datom 62 | datom-key 63 | new-datom] 64 | (d/transact conn [[:db/cas [:job/github-id github-id] datom-key old-datom new-datom]])) 65 | 66 | (defn ^:private mapper-update-job 67 | [old-job conn github-id] 68 | (fn [[key value]] 69 | (let [old-value (get old-job key)] 70 | (if-not (vector? value) 71 | (update-job-cas-datoms! conn github-id old-value key value) 72 | (add-job-categories! conn github-id value))))) 73 | 74 | (s/defn update-job! 75 | [conn 76 | github-id :- s/Uuid 77 | new-job :- model-job/UpdateDto] 78 | (-> (find-job! conn github-id) 79 | (mapper-update-job conn github-id) 80 | (mapv new-job))) 81 | 82 | (s/defn retract-job! 83 | [conn 84 | github-id :- s/Uuid] 85 | (find-job! conn github-id) 86 | (d/transact conn [[:db/retractEntity [:job/github-id github-id]]])) 87 | -------------------------------------------------------------------------------- /src/github_jobs/data/schemas.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.data.schemas 2 | (:require [datomic.api :as d])) 3 | 4 | (def ^:private job-schema 5 | [{:db/ident :job/id 6 | :db/valueType :db.type/uuid 7 | :db/cardinality :db.cardinality/one 8 | :db/unique :db.unique/identity} 9 | {:db/ident :job/github-id 10 | :db/valueType :db.type/uuid 11 | :db/cardinality :db.cardinality/one 12 | :db/doc "GitHub ID reference" 13 | :db/unique :db.unique/value} 14 | {:db/ident :job/title 15 | :db/valueType :db.type/string 16 | :db/cardinality :db.cardinality/one 17 | :db/doc "Job Title"} 18 | {:db/ident :job/url 19 | :db/valueType :db.type/string 20 | :db/cardinality :db.cardinality/one 21 | :db/doc "URL link to Job Description"} 22 | {:db/ident :job/category 23 | :db/valueType :db.type/string ; TODO: change later to "ref" 24 | :db/cardinality :db.cardinality/many 25 | :db/doc "Category choose by user"}]) 26 | 27 | (def ^:private category-schema 28 | [{:db/ident :category/id 29 | :db/valueType :db.type/uuid 30 | :db/cardinality :db.cardinality/one 31 | :db/unique :db.unique/identity} 32 | {:db/ident :category/name 33 | :db/valueType :db.type/string 34 | :db/cardinality :db.cardinality/one 35 | :db/doc "Category Name"}]) 36 | 37 | (defn create 38 | [conn] 39 | (d/transact conn category-schema) 40 | (d/transact conn job-schema)) 41 | -------------------------------------------------------------------------------- /src/github_jobs/di/component.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.di.component 2 | (:require [com.stuartsierra.component :as component] 3 | [github-jobs.di.pedestal :as pedestal] 4 | [github-jobs.di.http-config :as http-config] 5 | [github-jobs.di.context-deps :as context-deps] 6 | [github-jobs.di.datomic :as datomic])) 7 | 8 | (defn start-server [environment] 9 | (component/system-map 10 | :datomic (datomic/provides) 11 | :service-map (http-config/provides environment) 12 | :context-deps (component/using (context-deps/provides) [:datomic]) 13 | :pedestal (component/using (pedestal/provides) [:service-map :context-deps]))) 14 | -------------------------------------------------------------------------------- /src/github_jobs/di/context_deps.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.di.context-deps 2 | (:require [com.stuartsierra.component :as component])) 3 | 4 | (defrecord ContextDeps 5 | [] 6 | component/Lifecycle 7 | (start [this] this) 8 | (stop [this] this)) 9 | 10 | (defn provides 11 | [] 12 | (map->ContextDeps {})) 13 | -------------------------------------------------------------------------------- /src/github_jobs/di/datomic.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.di.datomic 2 | (:require [com.stuartsierra.component :as component] 3 | [github-jobs.data.schemas :as db-schemas] 4 | [datomic.api :as d] 5 | [config.core :refer [env]])) 6 | 7 | (defrecord Datomic 8 | [db-uri] 9 | component/Lifecycle 10 | (start [this] 11 | (d/create-database db-uri) 12 | (let [conn (d/connect db-uri)] 13 | (db-schemas/create conn) 14 | (merge this {:conn conn 15 | :uri db-uri}))) 16 | (stop [this] 17 | (if (:conn this) (d/release (:conn this))) 18 | (dissoc this :uri :conn))) 19 | 20 | (defn provides 21 | [] 22 | (->> 23 | (:datomic-secret-password env) 24 | (str (:db-uri env)) 25 | ->Datomic)) 26 | -------------------------------------------------------------------------------- /src/github_jobs/di/http_config.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.di.http-config 2 | (:require [github-jobs.service :as service] 3 | [io.pedestal.http :as http] 4 | [io.pedestal.http :as server])) 5 | 6 | (defn provides 7 | [environment] 8 | (-> {:env environment 9 | ::http/routes service/routes 10 | ::http/type :jetty 11 | ::http/port 8890 12 | ::http/resource-path "/public" 13 | ::http/join? false 14 | ::http/host "0.0.0.0"} 15 | server/default-interceptors 16 | server/dev-interceptors)) 17 | -------------------------------------------------------------------------------- /src/github_jobs/di/pedestal.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.di.pedestal 2 | (:require [com.stuartsierra.component :as component] 3 | [io.pedestal.http :as http] 4 | [io.pedestal.interceptor :as i])) 5 | 6 | (defn- test? 7 | [service-map] 8 | (= :test (:env service-map))) 9 | 10 | (defn- insert-context-deps-interceptor 11 | "Returns an interceptor which associates context dependencies 12 | in the Pedestal context map." 13 | [context-deps] 14 | (i/interceptor 15 | {:name ::insert-context 16 | :enter (fn [context] 17 | (assoc-in context [:request :context-deps] context-deps))})) 18 | 19 | (defn- add-pedestal-interceptor 20 | "Adds an interceptor to the pedestal which associates the 21 | component into the Pedestal context map. Must be called 22 | before io.pedestal.http/create-server." 23 | [service-map context-deps] 24 | (update-in service-map [::http/interceptors] 25 | #(vec (->> % (cons (insert-context-deps-interceptor context-deps)))))) 26 | 27 | (defrecord Pedestal 28 | [service-map service context-deps] 29 | component/Lifecycle 30 | (start 31 | [this] 32 | (if service 33 | this 34 | (cond-> service-map 35 | true (add-pedestal-interceptor context-deps) 36 | true http/create-server 37 | (not (test? service-map)) http/start 38 | true (partial assoc this :service)))) 39 | 40 | (stop [this] 41 | (when (and service (not (test? service-map))) 42 | (http/stop service)) 43 | (assoc this :service nil))) 44 | 45 | (defn provides 46 | [] 47 | (map->Pedestal {})) 48 | -------------------------------------------------------------------------------- /src/github_jobs/logic/job.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.logic.job 2 | (:require [github-jobs.model.job :as model-job] 3 | [schema.core :as s] 4 | [github-jobs.schemata.job :as schemata] 5 | [schema.coerce :as coerce]) 6 | (:import (java.util UUID))) 7 | 8 | (s/defn datom-job->wire :- schemata/JobReference 9 | [{:job/keys [github-id title url category]}] 10 | {:id (.toString github-id) 11 | :title title 12 | :url url 13 | :category category}) 14 | 15 | (s/defn wire->new-dto :- model-job/NewDto 16 | [{:keys [id title url category]} :- schemata/JobReference] 17 | {:job/id (UUID/randomUUID) 18 | :job/github-id (coerce/string->uuid id) 19 | :job/title title 20 | :job/url url 21 | :job/category category}) 22 | 23 | (s/defn wire->update-dto :- model-job/UpdateDto 24 | [{:keys [title url category]} :- schemata/JobUpdate] 25 | (cond-> {} 26 | title (assoc :job/title title) 27 | url (assoc :job/url url) 28 | category (assoc :job/category category))) 29 | -------------------------------------------------------------------------------- /src/github_jobs/model/category.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.model.category 2 | (:require [schema.core :as s])) 3 | 4 | (s/defschema CategoryDTO 5 | {:category/id s/Uuid 6 | :category/name s/Str}) 7 | -------------------------------------------------------------------------------- /src/github_jobs/model/job.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.model.job 2 | (:require [schema.core :as s])) 3 | 4 | (s/defschema NewDto 5 | {:job/id s/Uuid 6 | :job/github-id s/Uuid 7 | :job/title s/Str 8 | :job/url s/Str 9 | :job/category [s/Str]}) ; TODO: change later to be a "ref" of CategoryDTO 10 | 11 | 12 | (s/defschema UpdateDto 13 | {(s/optional-key :job/title) s/Str 14 | (s/optional-key :job/url) s/Str 15 | (s/optional-key :job/category) [s/Str]}) ; TODO: change later to be a "ref" of CategoryDTO -------------------------------------------------------------------------------- /src/github_jobs/schemata/job.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.schemata.job 2 | (:require [schema.core :as s])) 3 | 4 | (s/defschema JobReference 5 | {:id s/Str ; ID from GitHub Api 6 | :title s/Str 7 | :url s/Str 8 | :category [s/Str]}) 9 | 10 | (s/defschema JobUpdate 11 | {(s/optional-key :title) s/Str 12 | (s/optional-key :url) s/Str 13 | (s/optional-key :category) [s/Str]}) -------------------------------------------------------------------------------- /src/github_jobs/server.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.server 2 | (:gen-class) ; for -main method in uberjar 3 | (:require [com.stuartsierra.component :as component] 4 | [github-jobs.di.component :as di-component] 5 | [schema.core :as s] 6 | [taoensso.timbre :as timbre])) 7 | 8 | (defn -main 9 | "The entry-point for 'lein run'" 10 | [& args] 11 | (timbre/info "Creating server...") 12 | (component/start (di-component/start-server :dev)) 13 | (s/set-fn-validation! true) 14 | (timbre/info "Server started, have fun! ;)") 15 | @(promise)) 16 | -------------------------------------------------------------------------------- /src/github_jobs/service.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.service 2 | (:require [io.pedestal.http :as http] 3 | [io.pedestal.http.route :as route] 4 | [io.pedestal.http.body-params :refer [body-params]] 5 | [github-jobs.controller :as controller] 6 | [github-jobs.schemata.job :as schemata] 7 | [github-jobs.adapter :as adapter] 8 | [ring.util.response :as ring-resp])) 9 | 10 | (defn fetch-jobs 11 | [{:keys [query-params] 12 | {{conn :conn} :datomic} :context-deps}] 13 | (ring-resp/response 14 | (controller/get-jobs query-params conn))) 15 | 16 | (defn save-new-job 17 | [{:keys [payload] 18 | {{conn :conn} :datomic} :context-deps}] 19 | (controller/save-job-async payload conn) 20 | {:status 201}) 21 | 22 | (defn update-job 23 | [{:keys [path-params payload] {{conn :conn} :datomic} :context-deps}] 24 | (controller/update-job-async path-params payload conn) 25 | {:status 200}) 26 | 27 | (defn delete-job 28 | [{:keys [path-params] 29 | {{conn :conn} :datomic} :context-deps}] 30 | (controller/delete-job-async path-params conn) 31 | {:status 200}) 32 | 33 | (def routes 34 | (route/expand-routes 35 | `[[["/api" ^:interceptors [adapter/service-error-handler] 36 | 37 | ["/job" ^:interceptors [(body-params) 38 | http/json-body] 39 | {:get fetch-jobs}] 40 | 41 | ["/job" ^:interceptors [(body-params) 42 | (adapter/coerce-body-request schemata/JobReference) 43 | http/json-body] 44 | {:post save-new-job}] 45 | 46 | ["/job/:github-id" ^:interceptors [(body-params) 47 | (adapter/coerce-body-request schemata/JobUpdate) 48 | http/json-body] 49 | {:put update-job}] 50 | 51 | ["/job/:github-id" 52 | {:delete delete-job}] 53 | 54 | ]]])) 55 | -------------------------------------------------------------------------------- /test/github_jobs/service_test.clj: -------------------------------------------------------------------------------- 1 | (ns github-jobs.service-test 2 | (:require [clojure.test :refer :all] 3 | [io.pedestal.test :refer :all] 4 | [io.pedestal.http :as bootstrap] 5 | [github-jobs.service :as service])) 6 | 7 | (def service 8 | (::bootstrap/service-fn (bootstrap/create-servlet service/service))) 9 | 10 | (deftest home-page-test 11 | (is (= 12 | (:body (response-for service :get "/")) 13 | "Hello World!")) 14 | (is (= 15 | (:headers (response-for service :get "/")) 16 | {"Content-Type" "text/html;charset=UTF-8" 17 | "Strict-Transport-Security" "max-age=31536000; includeSubdomains" 18 | "X-Frame-Options" "DENY" 19 | "X-Content-Type-Options" "nosniff" 20 | "X-XSS-Protection" "1; mode=block" 21 | "X-Download-Options" "noopen" 22 | "X-Permitted-Cross-Domain-Policies" "none" 23 | "Content-Security-Policy" "object-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:;"}))) 24 | 25 | (deftest about-page-test 26 | (is (.contains 27 | (:body (response-for service :get "/about")) 28 | "Clojure 1.9")) 29 | (is (= 30 | (:headers (response-for service :get "/about")) 31 | {"Content-Type" "text/html;charset=UTF-8" 32 | "Strict-Transport-Security" "max-age=31536000; includeSubdomains" 33 | "X-Frame-Options" "DENY" 34 | "X-Content-Type-Options" "nosniff" 35 | "X-XSS-Protection" "1; mode=block" 36 | "X-Download-Options" "noopen" 37 | "X-Permitted-Cross-Domain-Policies" "none" 38 | "Content-Security-Policy" "object-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' https: http:;"}))) 39 | 40 | --------------------------------------------------------------------------------