diff --git a/.env b/.env
new file mode 100644
index 0000000..73b12cd
--- /dev/null
+++ b/.env
@@ -0,0 +1,3 @@
+TAG=6.2.3
+ELASTIC_VERSION=6.2.3
+ELASTIC_PASSWORD=changeme
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 015c1ec..52960e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,72 @@
+### Intellij ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff:
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/dictionaries
+
+# Sensitive or high-churn files:
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.xml
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+
+*.gz
+
+# Gradle:
+.idea/**/gradle.xml
+.idea/**/libraries
+.idea/
+
+# CMake
+cmake-build-debug/
+
+# Mongo Explorer plugin:
+.idea/**/mongoSettings.xml
+
+## File-based project format:
+*.iws
+
+## Plugin-specific files:
+
+# IntelliJ
+/out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# Ruby plugin and RubyMine
+/.rakeTasks
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+### Intellij Patch ###
+# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
+
+*.iml
+modules.xml
+.idea/misc.xml
+*.ipr
+
+# Sonarlint plugin
+.idea/sonarlint
+
+### Java ###
# Compiled class file
*.class
@@ -21,15 +90,98 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
-# IntellijIDEA
-.idea/
+### Linux ###
+*~
+
+# temporary files which can be created if a process still has a handle open of a deleted file
+.fuse_hidden*
+
+# KDE directory preferences
+.directory
+
+# Linux trash folder which might appear on any partition or disk
+.Trash-*
+
+# .nfs files are created when an open file is removed but is still being accessed
+.nfs*
+
+### Gradle ###
+.gradle
+**/build/
+
+# Ignore Gradle GUI config
+gradle-app.setting
+
+# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
+!gradle-wrapper.jar
+
+# Cache of project
+.gradletasknamecache
+
+# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
+# gradle/wrapper/gradle-wrapper.properties
+
+
+
+### Eclipse ###
+
+.metadata
+bin/
+tmp/
+*.tmp
+*.bak
+*.swp
+*~.nib
+local.properties
+.settings/
+.loadpath
+.recommenders
+
+# External tool builders
+.externalToolBuilders/
+
+# Locally stored "Eclipse launch configurations"
+*.launch
+
+# PyDev specific (Python IDE for Eclipse)
+*.pydevproject
+
+# CDT-specific (C/C++ Development Tooling)
+.cproject
+
+# Java annotation processor (APT)
+.factorypath
+
+# PDT-specific (PHP Development Tools)
+.buildpath
+
+# sbteclipse plugin
+.target
+
+# Tern plugin
+.tern-project
+
+# TeXlipse plugin
+.texlipse
+
+# STS (Spring Tool Suite)
+.springBeans
+
+# Code Recommenders
+.recommenders/
-# Gradle stuff
-.gradle/
-build/
-out/
-gradle/
+# Scala IDE specific (Scala & Java development for Eclipse)
+.cache-main
+.scala_dependencies
+.worksheet
+### Eclipse Patch ###
+# Eclipse Core
+.project
+# JDT-specific (Eclipse Java Development Tools)
+.classpath
+# Annotation Processing
+.apt_generated
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..f4f6cf6
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,11 @@
+language: java
+before_cache:
+ - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
+ - rm -fr $HOME/.gradle/caches/*/plugin-resolution/
+cache:
+ directories:
+ - $HOME/.gradle/caches/
+ - $HOME/.gradle/wrapper/
+
+after_success:
+ - ./gradlew test jacocoTestReport coveralls
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..3724bcc
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,16 @@
+FROM openjdk:8-jdk-alpine
+EXPOSE 8080
+EXPOSE 9010
+RUN mkdir -p /app/
+ADD build/libs/java-restful-test-0.1.0.jar /app/java-restful-test-0.1.0.jar
+#ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]
+#ENTRYPOINT ["java", "-jar", "/app/java-restful-test-0.1.0.jar"]
+ENTRYPOINT ["java", \
+ "-Dcom.sun.management.jmxremote", \
+ "-Dcom.sun.management.jmxremote.port=9010", \
+ "-Dcom.sun.management.jmxremote.local.only=false", \
+ "-Dcom.sun.management.jmxremote.authenticate=false", \
+ "-Dcom.sun.management.jmxremote.ssl=false",\
+ "-Dspring.data.mongodb.uri=mongodb://mongo_db:27017/users",\
+ "-Djava.security.egd=file:/dev/./urandom",\
+ "-jar","/app/java-restful-test-0.1.0.jar"]
\ No newline at end of file
diff --git a/README.MD b/README.MD
index 9141782..fbfc923 100644
--- a/README.MD
+++ b/README.MD
@@ -1,10 +1,14 @@
Welcome to the Java RESTful API test
====================================
+[](https://travis-ci.org/carlospatinos/Java_RESTful_test)
+[](https://coveralls.io/github/carlospatinos/Java_RESTful_test?branch=master)
-The objetive of this test if to help us evalute your skills with:
+**Why?**
-* Problem Solving
+The objetive of this project is:
+
+* Use some Problem Solving addressing scaling up scenarios
* Web Server API Design
* Request-time data manipulation
* Testing strategies
@@ -13,16 +17,14 @@ The objetive of this test if to help us evalute your skills with:
**Instructions**
* Fork the repo into a private repo.
-* You will need Gradle
+* Install Gradle
* You can import the build.gradle file directly on your preferred IDE
* Spring Boot should be used to complete the test, although, if you feel you want to use something different, feel free
* Implement the required API endpoints
-* Let us know when you have finished.
**Tasks**
-The idea here is for us to see how you design a minimalistic API. This API will be
-used to perform CRUD operations on a model called User.
+The idea here is for us to see how you design a minimalistic API. This API will be used to perform CRUD operations on a model called User.
You're free to design this model as you want, but, at a minimum should have:
@@ -127,3 +129,38 @@ Publish your work in a GitHub repository. Feel free to modify this readme to giv
If you need more than 1 day to do this, you might be overthinking, feel free to add improvement notes in your README file, show-off there,
we prefer better quality code if it takes longer, but you must justify this.
+
+**Improvement Notes**
+* Currently the application does not have profiles (dev, test, prod) to be able to deploy/test the application accordingly.
+With the profile definitions will be good to modify port of the application to use, log level, mongo utl, etc.
+* Also some load testing will be good maybe with jmeter to be able to see how much the application can scale or possible buttle necks.
+* Profiling the application can be also important now that it is running.
+* With the profiles in place, it would be handy to provide the specific profile as a property to the docker file and then to the spring boot application
+this will allow the app to behave differently.
+* Maybe some code conventions will be useful in big project to standarize things a bit, it brings benefits, obviously define pipelines to make sure code is always running, code reviews, etc.
+* Also maybe to have the infrastructure in place for the docker hub to store images and be able to pull them constantly.
+
+
+**How to run**
+To run the code you have to execute.
+```sh
+docker-compose up
+```
+
+This will start mongodb and this service in docker, then you can start sending requests. Then you can use postman or curl to call the end points (url with the format http://host:port). Default port is 8080
+
+You can use postman file provided in the /postman of the project to execute basic calls. Also there is a jemeter project provided which will create 50 users with different coordinates and then call distance 1000 times and then delete everything.
+
+it uses Basic Auth as mechanism for security. User (user) and password (password).
+
+**Answers**
+* Answering the questions, I guess we will have a phone call, mut what I did was basically split the functionality in 3 main parts.
+1. Create all possible combinations of the users. This allows me to have in memory all different combinations of User -> User to I can calculate the distance
+2. Iterate the previous Set of pairs to calculate the distance and store the distance result
+3. Based on the distance now I can do all the simple maths for the average, min, max, etc.
+
+This approach allows me for example to use some sort of map reduce mechanism, where I can for instance, generate an independent micro service to calculare the distance (assuming this is the most CPU intensive calculation), then I can use the results from 1 and invoke that service, since that service is independent, I can scale that up as much as I can to parelalize things and split the load among multiple machines if needed, then collect all the results and reduce that to be able to do the average, min, max, etc.
+
+
+* Normally I dont focus on 100% Test coverage, not really useful to cover setters and getters for instance unless they do something specific.
+I rather focus on business logic coverage as high as possible. Saying that, I am totally flexible, if the company requires 100% code coverage I would not have any issue with that.
diff --git a/build.gradle b/build.gradle
index e711d1c..0fa0fcb 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,23 +1,45 @@
+import com.bmuschko.gradle.docker.tasks.image.DockerBuildImage
+
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:2.0.0.RELEASE")
+ classpath('com.bmuschko:gradle-docker-plugin:3.0.8')
}
}
+plugins {
+ id 'jacoco'
+ id 'com.github.kt3k.coveralls' version '2.6.3'
+}
+
+apply plugin: 'jacoco'
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
+apply plugin: 'com.bmuschko.docker-remote-api'
bootJar {
baseName = 'java-restful-test'
version = '0.1.0'
}
+jacocoTestReport {
+ reports {
+ xml.enabled = true // coveralls plugin depends on xml format report
+ html.enabled = true
+ }
+}
+
+task createDockerImage(type: DockerBuildImage) {
+ inputDir = file('.')
+ tags = ['carlospatinos/java_restful_test']
+}
+
repositories {
mavenCentral()
}
@@ -26,8 +48,23 @@ sourceCompatibility = 1.8
targetCompatibility = 1.8
dependencies {
+ compile("org.springframework.boot:spring-boot-devtools")
+ compile('org.springframework.boot:spring-boot-starter-actuator')
compile("org.springframework.boot:spring-boot-starter-web")
+ compile("org.springframework.boot:spring-boot-starter-data-rest")
+ compile("org.springframework.boot:spring-boot-starter-data-mongodb")
+ compile("com.google.guava:guava:24.1-jre")
+ compile("org.gavaghan:geodesy:1.1.3")
+ compile("org.apache.commons:commons-lang3:3.7")
+ compile("org.springframework.boot:spring-boot-starter-security")
+ //compile("de.flapdoodle.embed:de.flapdoodle.embed.mongo")
+ //testCompile("cz.jirutka.spring:embedmongo-spring:RELEASE")
+ testCompile("org.springframework.security:spring-security-test")
testCompile('org.springframework.boot:spring-boot-starter-test')
testCompile('com.jayway.jsonpath:json-path')
}
+test.doLast {
+ println 'Testing the build from gradle'
+ createDockerImage
+}
\ No newline at end of file
diff --git a/config/elasticsearch.yml b/config/elasticsearch.yml
new file mode 100644
index 0000000..7e270fe
--- /dev/null
+++ b/config/elasticsearch.yml
@@ -0,0 +1,3 @@
+#network.host: localhost
+transport.host: 127.0.0.1
+http.host: 0.0.0.0
\ No newline at end of file
diff --git a/config/httpbeat.yml b/config/httpbeat.yml
new file mode 100644
index 0000000..b78fd4f
--- /dev/null
+++ b/config/httpbeat.yml
@@ -0,0 +1,27 @@
+############################## Httpbeat ########################################
+httpbeat:
+ hosts:
+ # Each - Host endpoints to call. Below are the host endpoint specific configurations
+ -
+ schedule: "@every 30s"
+ url: http://sprinbboot_ws:8080/actuator/health
+ method: get
+ headers:
+ Accept: application/json
+ output_format: json
+ json_dot_mode: replace
+ -
+ schedule: "@every 30s"
+ url: http://sprinbboot_ws:8080/actuator/metrics
+ method: get
+ headers:
+ Accept: application/json
+ output_format: json
+ json_dot_mode: replace
+#================================ General =====================================
+fields:
+ app_id: java-test
+#----------------------------- Logstash output --------------------------------
+output.elasticsearch:
+ hosts: ["elasticsearch:9200"]
+ index: "httpbeat-%{+yyyy.MM.dd}"
\ No newline at end of file
diff --git a/config/kibana.yml b/config/kibana.yml
new file mode 100644
index 0000000..f753379
--- /dev/null
+++ b/config/kibana.yml
@@ -0,0 +1,2 @@
+server.host: "0.0.0.0"
+elasticsearch.url: "http://elasticsearch:9200"
\ No newline at end of file
diff --git a/docker-compose-full.yml b/docker-compose-full.yml
new file mode 100644
index 0000000..72b1655
--- /dev/null
+++ b/docker-compose-full.yml
@@ -0,0 +1,62 @@
+version: '3.1'
+services:
+ sprinbboot_ws:
+ image: . "carlospatinos/java_restful_test"
+ #build: .
+ container_name: sprinbboot_ws
+ ports:
+ - "8080:8080"
+ - "9010:9010"
+ depends_on:
+ - mongo_db
+ links:
+ - mongo_db
+ restart: always
+ environment:
+ SPRING_DATA_MONGODB_URI: mongodb://mongo_db/users
+ mongo_db:
+ image: "mongo"
+ container_name: mongo_db
+ restart: always
+ ports:
+ - "27017:27017"
+# volumes:
+# - ./data:/data/db
+ elasticsearch:
+ image: docker.elastic.co/elasticsearch/elasticsearch:${TAG}
+ container_name: elasticsearch
+ ports:
+ - "9200:9200"
+ environment: ['http.host=0.0.0.0', 'transport.host=127.0.0.1']
+# volumes:
+# - ./config/elasticsearch.yml:/etc/elasticsearch/elasticsearch.yml
+ #'ELASTIC_PASSWORD=${ELASTIC_PASSWORD}'
+ #discovery.type: single-node
+ kibana:
+ image: docker.elastic.co/kibana/kibana:${TAG}
+ container_name: kibana
+ links:
+ - elasticsearch
+ environment:
+ - ELASTICSEARCH_USERNAME=kibana
+ - ELASTICSEARCH_PASSWORD=${ELASTIC_PASSWORD}
+ ports:
+ - "5601:5601"
+ depends_on: ['elasticsearch']
+ volumes:
+ - ./config/kibana.yml:/usr/share/kibana/config/kibana.yml
+ httpbeat:
+ links:
+ - elasticsearch
+ image: evanhoucke/httpbeat
+ container_name: httpbeat
+ environment:
+ ES_HOST: elasticsearch
+ ES_PORT: 9200
+ depends_on: ['elasticsearch']
+ volumes:
+ - ./config/httpbeat.yml:/opt/beats/http.yml
+ #- ./config/httpbeat.yml:/etc/httpbeat/httpbeat.yml
+ #network_mode: bridge
+ restart: always
+# networks: {stack: {}}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..1455410
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,22 @@
+version: '3.1'
+services:
+ sprinbboot_ws:
+ image: "carlospatinos/java_restful_test"
+ ports:
+ - "8080:8080"
+ - "9010:9010"
+ depends_on:
+ - mongo_db
+ links:
+ - mongo_db
+ restart: always
+ environment:
+ SPRING_DATA_MONGODB_URI: mongodb://mongo_db/users
+ mongo_db:
+ image: "mongo"
+ container_name: mongo_db
+ restart: always
+# volumes:
+# - ./data:/data/db
+ ports:
+ - "27017:27017"
\ No newline at end of file
diff --git a/performance-testing/data.csv b/performance-testing/data.csv
new file mode 100644
index 0000000..c2da5b7
--- /dev/null
+++ b/performance-testing/data.csv
@@ -0,0 +1,50 @@
+1,Woodrow,Goodman,53.423933,-7.9406
+2,Cynthia,Bradley,53.4239330,-7.9406900
+3,Monica,Casey,53.4270272,-7.9436082
+4,Albert,Gomez,53.4232937,-7.9455823
+5,Sylvester,Tran,53.4267459,-7.9497451
+6,Shirley,Mccormick,53.4279733,-7.9437370
+7,Brandy,Barker,53.4290728,-7.9299182
+8,Lorena,Becker,53.4316041,-7.9416341
+9,Jessica,Clark,53.4312718,-7.9244251
+10,Rosalie,Shelton,53.4343399,-7.9423637
+11,Maureen,Vaughn,53.4193531,-7.9268659,
+12,Gwendolyn,Hamilton,53.4200692,-7.9229177
+13,Cecilia,Zimmerman,53.4189950,-7.9184545
+14,Misty,Barrett,53.4184324,-7.9140771
+15,Angie,Lane,53.4174093,-7.9089273
+16,Elias,Mcdaniel,53.4197111,-7.9072965
+17,Elisa,Mills,53.4211945,-7.9009451
+18,Joshua,Keller,53.4211945,-7.9087556
+19,Faye,Walsh,53.4218083,-7.9160513
+20,Dexter,Cummings,53.4218594,-7.9200853
+21,Shane,Jackson,53.4221663,-7.9252351
+22,Joan,Silva,53.4182789,-7.9005159
+23,Marlene,Guzman,53.4207341,-7.8990568
+24,Bessie,Tate,53.4236496,-7.9009451
+25,Sandra,Curry,53.4266159,-7.9015459
+26,Erica,Morris,53.4267694,-7.9109872
+27,Patti,Foster,53.4267182,-7.9181112
+28,Francis,Obrien,53.4262068,-7.9254926
+29,Nicholas,Webb,53.4254908,-7.9337324
+30,Sherri,Patrick,53.4274853,-7.9315866
+31,Meredith,Payne,53.4298378,-7.9285825
+32,Benny,Parsons,53.4298889,-7.9235185
+33,Stephen,Flowers,53.4291218,-7.9169954
+34,Gene,Bridges,53.4271273,-7.9113306
+35,Arlene,Love,53.4262579,-7.9027475
+36,Nichole,Wong,53.4253885,-7.8974260
+37,Megan,Taylor,53.4287127,-7.8995718
+38,Eula,Owens,53.4321901,-7.9015459
+39,Terri,Saunders,53.4344400,-7.9041208
+40,Daniel,Hunter,53.4332639,-7.9102148
+41,Kathleen,Thomas,53.4336219,-7.9156221
+42,Gloria,Hodges,53.4325992,-7.9216302
+43,Willis,Kim,53.4325992,-7.9284967
+44,Troy,Sutton,53.4334173,-7.9334749
+45,Harry,Hunt,53.4348490,-7.9408563
+46,Charlene,Graves,53.4337753,-7.9489244
+47,Wanda,Pena,53.4329571,-7.9540742
+48,Tara,Scott,53.4314742,-7.9580225
+49,Elsa,Burke,53.4267182,-7.9587091
+50,Jake,Valdez,53.4252351,-7.9539026
\ No newline at end of file
diff --git a/performance-testing/multiple-users.jmx b/performance-testing/multiple-users.jmx
new file mode 100644
index 0000000..1c454eb
--- /dev/null
+++ b/performance-testing/multiple-users.jmx
@@ -0,0 +1,435 @@
+
+
+
+
+
+ false
+ true
+ true
+
+
+
+
+
+
+
+ continue
+
+ false
+ 1
+
+ 50
+ 1
+ false
+
+
+
+
+
+
+
+ Content-Type
+ application/json
+
+
+
+
+
+
+
+ server
+ localhost
+ =
+
+
+ port
+ 8080
+ =
+
+
+
+
+
+ ,
+
+ ./data.csv
+ false
+ false
+ true
+ shareMode.all
+ false
+ id,name,lastname,lat,lon
+
+
+
+
+
+
+ localhost
+ 8080
+
+
+ /jrt/api/v1.0/users
+ 6
+
+
+
+
+
+
+
+ http://${server}:${port}/jrt/api/v1.0/user
+ user
+ password
+
+
+
+
+
+
+
+ true
+
+
+
+ false
+ {
+ "userId":${id},
+ "firstName":"${name}",
+ "lastName":"${lastname}",
+ "latitude":${lat},
+ "longitude":${lon}
+}
+ =
+
+
+
+ localhost
+ 8080
+
+
+ /jrt/api/v1.0/user
+ POST
+ true
+ false
+ true
+ false
+
+
+
+
+
+
+ false
+
+ saveConfig
+
+
+ true
+ true
+ true
+
+ true
+ true
+ true
+ true
+ false
+ true
+ true
+ false
+ false
+ false
+ true
+ false
+ false
+ false
+ true
+ 0
+ true
+ true
+ true
+ true
+ true
+
+
+
+
+
+
+
+ continue
+
+ false
+ 1
+
+ 10000
+ 1
+ false
+
+
+
+
+
+
+
+ Content-Type
+ application/json
+
+
+
+
+
+
+
+ server
+ localhost
+ =
+
+
+ port
+ 8080
+ =
+
+
+
+
+
+
+
+
+ localhost
+ 8080
+
+
+ /jrt/api/v1.0/distances
+ 6
+
+
+
+
+
+
+
+ http://${server}:${port}/jrt/api/v1.0/distances
+ user
+ password
+
+
+
+
+
+
+
+
+
+
+ localhost
+ 8080
+
+
+ /jrt/api/v1.0/distances
+ GET
+ true
+ false
+ true
+ false
+
+
+
+
+
+
+ false
+
+ saveConfig
+
+
+ true
+ true
+ true
+
+ true
+ true
+ true
+ true
+ false
+ true
+ true
+ false
+ false
+ false
+ true
+ false
+ false
+ false
+ true
+ 0
+ true
+ true
+ true
+ true
+ true
+
+
+
+
+
+
+ false
+
+ saveConfig
+
+
+ true
+ true
+ true
+
+ true
+ true
+ true
+ true
+ false
+ true
+ true
+ false
+ false
+ false
+ true
+ false
+ false
+ false
+ true
+ 0
+ true
+ true
+ true
+ true
+ true
+
+
+
+
+
+
+
+ continue
+
+ false
+ 1
+
+ 1
+ 1
+ false
+
+
+
+
+
+
+
+ Content-Type
+ application/json
+
+
+
+
+
+
+
+ server
+ localhost
+ =
+
+
+ port
+ 8080
+ =
+
+
+
+
+
+
+
+
+ localhost
+ 8080
+
+
+ /jrt/api/v1.0/user
+ 6
+
+
+
+
+
+
+
+ http://${server}:${port}/jrt/api/v1.0/user
+ user
+ password
+
+
+
+
+
+
+
+
+
+
+ localhost
+ 8080
+
+
+ /jrt/api/v1.0/user
+ DELETE
+ true
+ false
+ true
+ false
+
+
+
+
+
+
+ false
+
+ saveConfig
+
+
+ true
+ true
+ true
+
+ true
+ true
+ true
+ true
+ false
+ true
+ true
+ false
+ false
+ false
+ true
+ false
+ false
+ false
+ true
+ 0
+ true
+ true
+ true
+ true
+ true
+
+
+
+
+
+
+
+
+
diff --git a/postman/Java_Restful.postman_collection.json b/postman/Java_Restful.postman_collection.json
new file mode 100644
index 0000000..5e14593
--- /dev/null
+++ b/postman/Java_Restful.postman_collection.json
@@ -0,0 +1,491 @@
+{
+ "info": {
+ "_postman_id": "9c57c8d9-bb02-4d3e-a80b-c1414f7bd3c2",
+ "name": "Java_Restful",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+ },
+ "item": [
+ {
+ "name": "find_users",
+ "request": {
+ "auth": {
+ "type": "basic",
+ "basic": [
+ {
+ "key": "username",
+ "value": "user",
+ "type": "string"
+ },
+ {
+ "key": "password",
+ "value": "password",
+ "type": "string"
+ }
+ ]
+ },
+ "method": "GET",
+ "header": [
+ {
+ "key": "_csrf",
+ "value": "{{_csrf}}"
+ }
+ ],
+ "body": {},
+ "url": {
+ "raw": "{{url}}/jrt/api/v1.0/users",
+ "host": [
+ "{{url}}"
+ ],
+ "path": [
+ "jrt",
+ "api",
+ "v1.0",
+ "users"
+ ]
+ },
+ "description": "Find all users"
+ },
+ "response": []
+ },
+ {
+ "name": "find_distances",
+ "request": {
+ "auth": {
+ "type": "basic",
+ "basic": [
+ {
+ "key": "username",
+ "value": "user",
+ "type": "string"
+ },
+ {
+ "key": "password",
+ "value": "password",
+ "type": "string"
+ }
+ ]
+ },
+ "method": "GET",
+ "header": [
+ {
+ "key": "origin",
+ "value": "localhost:8080"
+ }
+ ],
+ "body": {},
+ "url": {
+ "raw": "{{url}}/jrt/api/v1.0/distances",
+ "host": [
+ "{{url}}"
+ ],
+ "path": [
+ "jrt",
+ "api",
+ "v1.0",
+ "distances"
+ ]
+ },
+ "description": "Find all users"
+ },
+ "response": []
+ },
+ {
+ "name": "delete_users",
+ "request": {
+ "auth": {
+ "type": "basic",
+ "basic": [
+ {
+ "key": "username",
+ "value": "user",
+ "type": "string"
+ },
+ {
+ "key": "password",
+ "value": "password",
+ "type": "string"
+ }
+ ]
+ },
+ "method": "DELETE",
+ "header": [
+ {
+ "key": "origin",
+ "value": "http://localhost:8080",
+ "disabled": true
+ },
+ {
+ "key": "_csrf",
+ "value": "{{_csrf}}",
+ "disabled": true
+ }
+ ],
+ "body": {},
+ "url": {
+ "raw": "{{url}}/jrt/api/v1.0/user",
+ "host": [
+ "{{url}}"
+ ],
+ "path": [
+ "jrt",
+ "api",
+ "v1.0",
+ "user"
+ ]
+ },
+ "description": "Find all users"
+ },
+ "response": []
+ },
+ {
+ "name": "get_user",
+ "request": {
+ "auth": {
+ "type": "basic",
+ "basic": [
+ {
+ "key": "username",
+ "value": "user",
+ "type": "string"
+ },
+ {
+ "key": "password",
+ "value": "password",
+ "type": "string"
+ }
+ ]
+ },
+ "method": "GET",
+ "header": [],
+ "body": {},
+ "url": {
+ "raw": "{{url}}/jrt/api/v1.0/user/1",
+ "host": [
+ "{{url}}"
+ ],
+ "path": [
+ "jrt",
+ "api",
+ "v1.0",
+ "user",
+ "1"
+ ]
+ },
+ "description": "Find all users"
+ },
+ "response": []
+ },
+ {
+ "name": "add_user",
+ "request": {
+ "auth": {
+ "type": "basic",
+ "basic": [
+ {
+ "key": "username",
+ "value": "user",
+ "type": "string"
+ },
+ {
+ "key": "password",
+ "value": "password",
+ "type": "string"
+ }
+ ]
+ },
+ "method": "POST",
+ "header": [
+ {
+ "key": "_csrf",
+ "value": "9b15a3f3-db83-4db6-99cf-d899fcad01b9",
+ "disabled": true
+ },
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\"userId\":1,\"firstName\":\"Luis\",\"lastName\":\"Patino\",\"latitude\":53.423933,\"longitude\":-7.94069}"
+ },
+ "url": {
+ "raw": "{{url}}/jrt/api/v1.0/user",
+ "host": [
+ "{{url}}"
+ ],
+ "path": [
+ "jrt",
+ "api",
+ "v1.0",
+ "user"
+ ]
+ },
+ "description": "Find all users"
+ },
+ "response": []
+ },
+ {
+ "name": "update_user",
+ "request": {
+ "auth": {
+ "type": "basic",
+ "basic": [
+ {
+ "key": "username",
+ "value": "user",
+ "type": "string"
+ },
+ {
+ "key": "password",
+ "value": "password",
+ "type": "string"
+ }
+ ]
+ },
+ "method": "PUT",
+ "header": [
+ {
+ "key": "_csrf",
+ "value": "9b15a3f3-db83-4db6-99cf-d899fcad01b9",
+ "disabled": true
+ },
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\"userId\":1,\"firstName\":\"Otro\",\"lastName\":\"Patino\",\"latitude\":53.423933,\"longitude\":-7.94069}"
+ },
+ "url": {
+ "raw": "{{url}}/jrt/api/v1.0/user/1",
+ "host": [
+ "{{url}}"
+ ],
+ "path": [
+ "jrt",
+ "api",
+ "v1.0",
+ "user",
+ "1"
+ ]
+ },
+ "description": "Find all users"
+ },
+ "response": []
+ },
+ {
+ "name": "get_health",
+ "request": {
+ "auth": {
+ "type": "basic",
+ "basic": [
+ {
+ "key": "username",
+ "value": "user",
+ "type": "string"
+ },
+ {
+ "key": "password",
+ "value": "password",
+ "type": "string"
+ }
+ ]
+ },
+ "method": "GET",
+ "header": [],
+ "body": {},
+ "url": {
+ "raw": "http://localhost:8080/actuator/health",
+ "protocol": "http",
+ "host": [
+ "localhost"
+ ],
+ "port": "8080",
+ "path": [
+ "actuator",
+ "health"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "get_beans",
+ "request": {
+ "auth": {
+ "type": "basic",
+ "basic": [
+ {
+ "key": "username",
+ "value": "user",
+ "type": "string"
+ },
+ {
+ "key": "password",
+ "value": "password",
+ "type": "string"
+ }
+ ]
+ },
+ "method": "GET",
+ "header": [],
+ "body": {},
+ "url": {
+ "raw": "http://localhost:8080/actuator/beans",
+ "protocol": "http",
+ "host": [
+ "localhost"
+ ],
+ "port": "8080",
+ "path": [
+ "actuator",
+ "beans"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "get_logfile",
+ "request": {
+ "auth": {
+ "type": "basic",
+ "basic": [
+ {
+ "key": "username",
+ "value": "user",
+ "type": "string"
+ },
+ {
+ "key": "password",
+ "value": "password",
+ "type": "string"
+ }
+ ]
+ },
+ "method": "GET",
+ "header": [],
+ "body": {},
+ "url": {
+ "raw": "http://localhost:8080/actuator/logfile",
+ "protocol": "http",
+ "host": [
+ "localhost"
+ ],
+ "port": "8080",
+ "path": [
+ "actuator",
+ "logfile"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "get_env",
+ "request": {
+ "auth": {
+ "type": "basic",
+ "basic": [
+ {
+ "key": "username",
+ "value": "user",
+ "type": "string"
+ },
+ {
+ "key": "password",
+ "value": "password",
+ "type": "string"
+ }
+ ]
+ },
+ "method": "GET",
+ "header": [],
+ "body": {},
+ "url": {
+ "raw": "http://localhost:8080/actuator/env",
+ "protocol": "http",
+ "host": [
+ "localhost"
+ ],
+ "port": "8080",
+ "path": [
+ "actuator",
+ "env"
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "get_metrics",
+ "request": {
+ "auth": {
+ "type": "basic",
+ "basic": [
+ {
+ "key": "username",
+ "value": "user",
+ "type": "string"
+ },
+ {
+ "key": "password",
+ "value": "password",
+ "type": "string"
+ }
+ ]
+ },
+ "method": "GET",
+ "header": [],
+ "body": {},
+ "url": {
+ "raw": "http://localhost:8080/actuator/metrics",
+ "protocol": "http",
+ "host": [
+ "localhost"
+ ],
+ "port": "8080",
+ "path": [
+ "actuator",
+ "metrics"
+ ]
+ }
+ },
+ "response": []
+ }
+ ],
+ "event": [
+ {
+ "listen": "prerequest",
+ "script": {
+ "id": "721af903-acd1-4002-a019-99f69a4f4bb3",
+ "type": "text/javascript",
+ "exec": [
+ ""
+ ]
+ }
+ },
+ {
+ "listen": "test",
+ "script": {
+ "id": "9bbd3e27-8181-445e-b2ea-3f9f30380432",
+ "type": "text/javascript",
+ "exec": [
+ ""
+ ]
+ }
+ }
+ ],
+ "variable": [
+ {
+ "id": "7864d84b-867c-420a-bbfa-e9261bafdf8b",
+ "key": "url",
+ "value": "http://localhost:8081",
+ "type": "string",
+ "description": ""
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/main/java/user/ApplicationError.java b/src/main/java/user/ApplicationError.java
new file mode 100644
index 0000000..94cd20a
--- /dev/null
+++ b/src/main/java/user/ApplicationError.java
@@ -0,0 +1,13 @@
+package user;
+
+public class ApplicationError {
+ private String error;
+
+ public ApplicationError(String err){
+ this.error = err;
+ }
+
+ public String getError() {
+ return error;
+ }
+}
diff --git a/src/main/java/user/DoubleStatistics.java b/src/main/java/user/DoubleStatistics.java
new file mode 100644
index 0000000..e461906
--- /dev/null
+++ b/src/main/java/user/DoubleStatistics.java
@@ -0,0 +1,59 @@
+package user;
+
+import java.util.DoubleSummaryStatistics;
+import java.util.stream.Collector;
+
+/**
+ * Not the author of this class. It seems there was a proposal to integrate this in the DoubleSummaryStatistics as part of jdk
+ * but it was decided not do it.I was using the standard then I discovered it did not have std deviation.
+ */
+public class DoubleStatistics extends DoubleSummaryStatistics {
+
+ private double sumOfSquare = 0.0d;
+ private double sumOfSquareCompensation; // Low order bits of sum
+ private double simpleSumOfSquare; // Used to compute right sum for
+ // non-finite inputs
+
+ @Override
+ public void accept(double value) {
+ super.accept(value);
+ double squareValue = value * value;
+ simpleSumOfSquare += squareValue;
+ sumOfSquareWithCompensation(squareValue);
+ }
+
+ public DoubleStatistics combine(DoubleStatistics other) {
+ super.combine(other);
+ simpleSumOfSquare += other.simpleSumOfSquare;
+ sumOfSquareWithCompensation(other.sumOfSquare);
+ sumOfSquareWithCompensation(other.sumOfSquareCompensation);
+ return this;
+ }
+
+ private void sumOfSquareWithCompensation(double value) {
+ double tmp = value - sumOfSquareCompensation;
+ double velvel = sumOfSquare + tmp; // Little wolf of rounding error
+ sumOfSquareCompensation = (velvel - sumOfSquare) - tmp;
+ sumOfSquare = velvel;
+ }
+
+ public double getSumOfSquare() {
+ double tmp = sumOfSquare + sumOfSquareCompensation;
+ if (Double.isNaN(tmp) && Double.isInfinite(simpleSumOfSquare)) {
+ return simpleSumOfSquare;
+ }
+ return tmp;
+ }
+
+ public final double getStandardDeviation() {
+ long count = getCount();
+ double sumOfSquare = getSumOfSquare();
+ double average = getAverage();
+ return count > 0 ? Math.sqrt((sumOfSquare - count * Math.pow(average, 2)) / (count - 1)) : 0.0d;
+ }
+
+ public static Collector collector() {
+ return Collector.of(DoubleStatistics::new, DoubleStatistics::accept, DoubleStatistics::combine);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/user/PairOfIdsToDistance.java b/src/main/java/user/PairOfIdsToDistance.java
new file mode 100644
index 0000000..69eaf66
--- /dev/null
+++ b/src/main/java/user/PairOfIdsToDistance.java
@@ -0,0 +1,27 @@
+package user;
+
+public class PairOfIdsToDistance{
+ private String fromToIds;
+ private Double distance;
+
+ public PairOfIdsToDistance(String fromToIds, double distance) {
+ this.fromToIds = fromToIds;
+ this.distance = distance;
+ }
+
+ public String getFromToIds() {
+ return fromToIds;
+ }
+
+ public void setFromToIds(String fromToIds) {
+ this.fromToIds = fromToIds;
+ }
+
+ public Double getDistance() {
+ return distance;
+ }
+
+ public void setDistance(Double distance) {
+ this.distance = distance;
+ }
+}
diff --git a/src/main/java/user/User.java b/src/main/java/user/User.java
index f49d789..f046207 100644
--- a/src/main/java/user/User.java
+++ b/src/main/java/user/User.java
@@ -1,25 +1,55 @@
package user;
-import java.nio.DoubleBuffer;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.mapping.Document;
+import javax.validation.constraints.*;
+import javax.validation.constraints.Size;
+
+@Document(collection = "users")
public class User {
- private final long id;
+ private static final int MAX_LENGTH_NAME = 30;
+ private static final int MIN_LENGTH_NAME = 2;
+ private static final int INTEGERS_IN_GEOPOSITION = 5;
+ private static final int DECIMALS_IN_GEOPOSITION = 20;
+
+
+ @Id
+ private int userId;
+
+ @NotNull
+ @NotBlank
+ @Size(min=MIN_LENGTH_NAME, max=MAX_LENGTH_NAME)
private String firstName;
+
+ @NotNull
+ @NotBlank
+ @Size(min=MIN_LENGTH_NAME, max=MAX_LENGTH_NAME)
private String lastName;
+
+ @Digits(integer=INTEGERS_IN_GEOPOSITION,fraction=DECIMALS_IN_GEOPOSITION)
private Double latitude;
+
+ @Digits(integer=INTEGERS_IN_GEOPOSITION,fraction=DECIMALS_IN_GEOPOSITION)
private Double longitude;
- public User(long id, String firstName, String lastName, Double latitude, Double longitude) {
- this.id = id;
+ public User(){
+ }
+
+ public User(String firstName, String lastName, Double latitude, Double longitude) {
this.firstName = firstName;
this.lastName = lastName;
this.latitude = latitude;
this.longitude = longitude;
}
- public long getId() {
- return id;
+ public User(int userId, String firstName, String lastName, Double latitude, Double longitude) {
+ this.userId = userId;
+ this.firstName = firstName;
+ this.lastName = lastName;
+ this.latitude = latitude;
+ this.longitude = longitude;
}
public String getFirstName() { return this.firstName; }
@@ -27,4 +57,31 @@ public long getId() {
public Double getLatitude() { return this.latitude; }
public Double getLongitude() { return this.longitude; }
+ public int getUserId() {
+ return userId;
+ }
+
+ public void setUserId(int userId) {
+ this.userId = userId;
+ }
+ public void setFirstName(String firstName) {
+ this.firstName = firstName;
+ }
+
+ public void setLastName(String lastName) {
+ this.lastName = lastName;
+ }
+
+ public void setLatitude(Double latitude) {
+ this.latitude = latitude;
+ }
+
+ public void setLongitude(Double longitude) {
+ this.longitude = longitude;
+ }
+
+ @Override
+ public String toString() {
+ return this.firstName;
+ }
}
diff --git a/src/main/java/user/UserController.java b/src/main/java/user/UserController.java
index f58cad4..514d9bf 100644
--- a/src/main/java/user/UserController.java
+++ b/src/main/java/user/UserController.java
@@ -1,71 +1,150 @@
package user;
-import java.util.Collection;
-import java.util.concurrent.atomic.AtomicLong;
-
+import com.google.common.collect.Sets;
+import org.apache.commons.lang3.math.NumberUtils;
+import org.gavaghan.geodesy.Ellipsoid;
+import org.gavaghan.geodesy.GeodeticCalculator;
+import org.gavaghan.geodesy.GlobalPosition;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.web.bind.annotation.*;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import javax.validation.Valid;
+import java.util.*;
+import java.util.stream.Collectors;
+
@RestController
-public class UserController {
+public class UserController extends WebSecurityConfigurerAdapter {
+ private GeodeticCalculator geoCalc = new GeodeticCalculator();
+ private Ellipsoid reference = Ellipsoid.WGS84;
+
+ @Override
+ protected void configure (HttpSecurity http) throws Exception {
+ http.authorizeRequests().anyRequest().fullyAuthenticated();
+ http.httpBasic();
+ http.csrf().disable();
+ }
+
+ @Autowired
+ UserRepository userRepository;
@RequestMapping(method = RequestMethod.GET, value="/jrt/api/v1.0/users")
- public Collection users() {
- /**
- * Update this to return a json stream defining a listing of the users
- * Note: Always return the appropriate response for the action requested.
- *
- */
- //TODO: Implement this
- return null;
+ public ResponseEntity> users() {
+ Collection users = userRepository.findAll();
+ if (users.isEmpty()) {
+ return new ResponseEntity(users, HttpStatus.NO_CONTENT);
+ }
+ return new ResponseEntity>(users, HttpStatus.OK);
}
- @RequestMapping(method = RequestMethod.GET, value="/jrt/api/v1.0/user")
+ @RequestMapping(method = RequestMethod.GET, value="/jrt/api/v1.0/user/{userId}")
public ResponseEntity> get_user(@PathVariable String userId) {
- //TODO: Implement this
- return null;
+ if (!NumberUtils.isCreatable(userId)) {
+ return new ResponseEntity(new ApplicationError("Invalid user id: " + userId
+ + "."), HttpStatus.BAD_REQUEST);
+ }
+ User user = userRepository.findUserByUserId(Integer.valueOf(userId));
+ if (null == user) {
+ return new ResponseEntity(new ApplicationError("User with id " + userId
+ + " not found"), HttpStatus.NOT_FOUND);
+ }
+ return new ResponseEntity(user, HttpStatus.OK);
}
- @RequestMapping(method = RequestMethod.POST, value="/jrt/api/v1.0/user")
- public ResponseEntity> add_user(@RequestBody User input) {
- /**
- * Should add a new user to the users collection, with validation
- * note: Always return the appropriate response for the action requested.
- */
- //TODO: Implement this
- return null;
+ @RequestMapping(method = RequestMethod.POST, value="/jrt/api/v1.0/user", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity> add_user(@Valid @RequestBody User input) {
+ User userFound = userRepository.findUserByUserId(input.getUserId());
+ if(userFound != null){
+ return new ResponseEntity(new ApplicationError("Unable to create user. Record already exist"),HttpStatus.CONFLICT);
+ }
+ User user = userRepository.save(input);
+ HttpHeaders headers = new HttpHeaders();
+ headers.setLocation(UriComponentsBuilder.fromPath("/jrt/api/v1.0/user/{userId}").buildAndExpand(user.getUserId()).toUri());
+ return new ResponseEntity(headers, HttpStatus.CREATED);
}
- @RequestMapping(method = RequestMethod.PUT, value="/jrt/api/v1.0/user")
+ @RequestMapping(method = RequestMethod.PUT, value="/jrt/api/v1.0/user/{userId}")
public ResponseEntity> update_user(@PathVariable String userId, @RequestBody User input) {
- /**
- * Update user specified with user ID and return updated user contents
- * Note: Always return the appropriate response for the action requested.
- */
- //TODO: Implement this
- return null;
+ User user = userRepository.findUserByUserId(Integer.valueOf(userId));
+ if (null == user) {
+ return new ResponseEntity(new ApplicationError("User not found."),HttpStatus.NOT_FOUND);
+ }
+ if(input.getFirstName() != null){
+ user.setFirstName(input.getFirstName());
+ }
+ if(input.getLastName() != null){
+ user.setLastName(input.getLastName());
+ }
+ if(input.getLatitude() != null){
+ user.setLatitude(input.getLatitude());
+ }
+ if(input.getLongitude() != null){
+ user.setLongitude(input.getLongitude());
+ }
+ User updatedUser = userRepository.save(user);
+ return new ResponseEntity(updatedUser, HttpStatus.OK);
}
- @RequestMapping(method = RequestMethod.DELETE, value="/jrt/api/v1.0/user")
+ @RequestMapping(method = RequestMethod.DELETE, value="/jrt/api/v1.0/user/{userId}")
public ResponseEntity> delete_user(@PathVariable String userId) {
- /**
- * Delete user specified with user ID and return updated user contents
- * Note: Always return the appropriate response for the action requested.
- */
- //TODO: Implement this
- return null;
+ User user = userRepository.findUserByUserId(Integer.valueOf(userId));
+ if (null == user) {
+ return new ResponseEntity(new ApplicationError("User not found."),HttpStatus.NOT_FOUND);
+ }
+ userRepository.delete(user);
+ return new ResponseEntity(user, HttpStatus.OK);
+ }
+
+ @RequestMapping(method = RequestMethod.DELETE, value="/jrt/api/v1.0/user")
+ public ResponseEntity> delete_all() {
+ userRepository.deleteAll();
+ return new ResponseEntity(HttpStatus.OK);
}
+ /**
+ * Distance verified manually using https://www.movable-type.co.uk/scripts/latlong.html.
+ * Some profiling or performance tuning may be needed here based on the architecture of the computer,
+ * number of cores, thread pool best suited for the use case we are running.
+ */
@RequestMapping(method = RequestMethod.GET, value="/jrt/api/v1.0/distances")
- public String distances() {
+ public ResponseEntity> distances() {
/**
* Each user has a lat/lon associated with them. Determine the distance
* between each user pair, and provide the min/max/average/std as a json response.
* This should be GET only.
*
*/
- //TODO: Implement this
- return null;
+ // In a real case scenario you dont really map everything against everything, maybe the search can be narrowed per country,
+ // or per block and so on. Then we can optimize this call, instead of find all.
+ Set set = new HashSet(userRepository.findAll());
+ if(set == null || set.size() < 2) {
+ return new ResponseEntity(new ApplicationError("Not enough records to generate output"), HttpStatus.NOT_FOUND);
+ }
+
+ Map mapOfIdsAndDistances = Sets.combinations(set, 2).parallelStream().map(s -> {
+ List list = new ArrayList(s);
+ User userA = list.get(0);
+ User userB = list.get(1);
+ GlobalPosition pointA = new GlobalPosition(userA.getLatitude(), userA.getLongitude(), 0.0);
+ GlobalPosition pointB = new GlobalPosition(userB.getLatitude(), userB.getLongitude(), 0.0);
+ double distance = geoCalc.calculateGeodeticCurve(reference, pointB, pointA).getEllipsoidalDistance();
+
+ StringBuilder sb = new StringBuilder();
+ sb.append(userA.getUserId()).append("-").append(userB.getUserId());
+
+ return new PairOfIdsToDistance(sb.toString(), distance);
+ }).collect(Collectors.toMap(PairOfIdsToDistance::getFromToIds, PairOfIdsToDistance::getDistance));
+
+ DoubleStatistics stats = mapOfIdsAndDistances.values().stream().collect(DoubleStatistics.collector());
+ return new ResponseEntity(stats, HttpStatus.OK);
}
+
}
diff --git a/src/main/java/user/UserRepository.java b/src/main/java/user/UserRepository.java
new file mode 100644
index 0000000..d907e1f
--- /dev/null
+++ b/src/main/java/user/UserRepository.java
@@ -0,0 +1,20 @@
+package user;
+
+import org.springframework.data.mongodb.repository.MongoRepository;
+import org.springframework.data.mongodb.repository.Query;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+
+@Repository
+public interface UserRepository extends MongoRepository {
+ @Query("{ 'userId' : ?0 }")
+ User findUserByUserId(int userId);
+
+ long countByLastName(String lastName);
+
+ @Query(value="{ 'firstName' : ?0 }", fields="{ 'firstName' : 1, 'lastName' : 1}")
+ List findByTheUserFirstName(String firstName);
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
new file mode 100644
index 0000000..254c2e0
--- /dev/null
+++ b/src/main/resources/application.properties
@@ -0,0 +1,25 @@
+#Mongo
+spring.data.mongodb.database=users
+spring.data.mongodb.host=mongo_db
+#spring.data.mongodb.host=localhost
+spring.data.mongodb.port=27017
+
+server.port=8080
+spring.application.name=users
+
+#loging
+logging.level.org.springframework.web=WARN
+logging.file=users.log
+
+management.security.enabled=true
+security.basic.enabled=true
+
+management.endpoints.web.expose=*
+management.endpoints.web.exposure.include=*
+management.endpoint.metrics.enabled=true
+spring.security.user.name=user
+spring.security.user.password=password
+spring.security.user.roles=USER
+
+
+
diff --git a/src/test/java/user/UserControllerTests.java b/src/test/java/user/UserControllerTests.java
index 635f706..cfe252b 100644
--- a/src/test/java/user/UserControllerTests.java
+++ b/src/test/java/user/UserControllerTests.java
@@ -3,6 +3,7 @@
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
+ * you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
@@ -15,19 +16,38 @@
*/
package user;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
-import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.After;
+import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.json.JacksonTester;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+import static java.util.Collections.singletonList;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.number.IsCloseTo.closeTo;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.springframework.http.MediaType.APPLICATION_JSON;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@@ -36,10 +56,223 @@ public class UserControllerTests {
@Autowired
private MockMvc mockMvc;
+ @Autowired
+ ObjectMapper objectMapper;
+
+ @Autowired
+ private UserController userController;
+
+ @MockBean
+ private UserRepository userRepository;
+
+ private JacksonTester jsonTester;
+
+ @Before
+ public void setup() {
+ JacksonTester.initFields(this, objectMapper);
+ }
+
+ @After
+ public void cleanup(){
+ userController.delete_all();
+ }
+
@Test
- public void someUnitTest() throws Exception {
- //TODO: Do something meaninful here
- assert(true);
+ public void getUserWithAnNonAuthorizedConsumer() throws Exception {
+ mockMvc.perform(get("/jrt/api/v1.0/users"))
+ .andExpect(status().is(HttpStatus.UNAUTHORIZED.value()));
}
+ @Test
+ @WithMockUser(username = "user", password = "password", roles = "USER")
+ public void getNonExistentUser() throws Exception {
+ given(userRepository.findUserByUserId(1)).willReturn(null);
+ mockMvc.perform(get("/jrt/api/v1.0/user/{userId}", 1).with(csrf()))
+ .andExpect(status().is(HttpStatus.NOT_FOUND.value()));
+ }
+
+ @Test
+ @WithMockUser(username = "user", password = "password", roles = "USER")
+ public void getUserInvalidIdFormat() throws Exception {
+ given(userRepository.findUserByUserId(1)).willReturn(null);
+ mockMvc.perform(get("/jrt/api/v1.0/user/{userId}", "X").with(csrf()))
+ .andExpect(status().is(HttpStatus.BAD_REQUEST.value()));
+ }
+
+
+ @Test
+ @WithMockUser(username = "user", password = "password", roles = "USER")
+ public void getExistentUser() throws Exception {
+ User user = new User(5, "Carlos", "Patino", 53.4239330, -7.9406900);
+ given(userRepository.findUserByUserId(1)).willReturn(user);
+ mockMvc.perform(get("/jrt/api/v1.0/user/{userId}", 1).with(csrf()))
+ .andExpect(status().is(HttpStatus.OK.value()))
+ .andExpect(jsonPath("$.firstName", is(user.getFirstName())))
+ .andExpect(jsonPath("$.lastName", is(user.getLastName())))
+ .andExpect(jsonPath("$.latitude", is(user.getLatitude())))
+ .andExpect(jsonPath("$.longitude", is(user.getLongitude())));
+ }
+
+ @Test
+ @WithMockUser(username = "user", password = "password", roles = "USER")
+ public void addUserWhenPreviousUserAlreadyExists() throws Exception {
+ User user = new User(5, "Carlos", "Patino", 53.4239330, -7.9406900);
+ final String userJson = jsonTester.write(user).getJson();
+
+ given(userRepository.findUserByUserId(user.getUserId())).willReturn(user);
+ mockMvc.perform(post("/jrt/api/v1.0/user").with(csrf())
+ .contentType(APPLICATION_JSON)
+ .content(userJson))
+ .andExpect(status().is(HttpStatus.CONFLICT.value()));
+ }
+
+ @Test
+ @WithMockUser(username = "user", password = "password", roles = "USER")
+ public void addUserSuccessfully() throws Exception {
+ User user = new User(5, "Carlos", "Patino", 53.4239330, -7.9406900);
+ final String userJson = jsonTester.write(user).getJson();
+ given(userRepository.findUserByUserId(user.getUserId())).willReturn(null);
+ given(userRepository.save(any(User.class))).willReturn(user);
+ mockMvc.perform(post("/jrt/api/v1.0/user").with(csrf())
+ .contentType(APPLICATION_JSON)
+ .content(userJson))
+ .andExpect(status().is(HttpStatus.CREATED.value()));
+ }
+
+ @Test
+ @WithMockUser(username = "user", password = "password", roles = "USER")
+ public void addUserInvalidDetails() throws Exception {
+ User user = new User(5, "Very long name that should fail since this is not a valid name otherwise this person will be in the guiness record", "Patino", 53.4239330, -7.9406900);
+ final String userJson = jsonTester.write(user).getJson();
+
+ given(userRepository.findUserByUserId(user.getUserId())).willReturn(null);
+ given(userRepository.save(any(User.class))).willReturn(user);
+ mockMvc.perform(post("/jrt/api/v1.0/user").with(csrf())
+ .contentType(APPLICATION_JSON)
+ .content(userJson))
+ .andExpect(status().is(HttpStatus.BAD_REQUEST.value()));
+ }
+
+ @Test
+ @WithMockUser(username = "user", password = "password", roles = "USER")
+ public void updateUserSuccessfully() throws Exception {
+ User user = new User(5, "Carlos", "Patino", 53.4239330, -7.9406900);
+ User userUpdated = new User(5, "Luis", "Hernandez", 53.4239330, -7.9406900);
+ final String userJson = jsonTester.write(userUpdated).getJson();
+
+ given(userRepository.findUserByUserId(user.getUserId())).willReturn(user);
+ given(userRepository.save(any(User.class))).willReturn(user);
+ mockMvc.perform(put("/jrt/api/v1.0/user/{userId}", user.getUserId()).with(csrf())
+ .contentType(APPLICATION_JSON)
+ .content(userJson))
+ .andExpect(status().is(HttpStatus.OK.value()))
+ .andExpect(jsonPath("$.firstName", is(userUpdated.getFirstName())))
+ .andExpect(jsonPath("$.lastName", is(userUpdated.getLastName())))
+ .andExpect(jsonPath("$.latitude", is(userUpdated.getLatitude())))
+ .andExpect(jsonPath("$.longitude", is(userUpdated.getLongitude())));
+ }
+
+ @Test
+ @WithMockUser(username = "user", password = "password", roles = "USER")
+ public void updateNonExistentUser() throws Exception {
+ User user = new User(5, "Carlos", "Patino", 53.4239330, -7.9406900);
+ final String userJson = jsonTester.write(user).getJson();
+ given(userRepository.findUserByUserId(user.getUserId())).willReturn(null);
+ mockMvc.perform(put("/jrt/api/v1.0/user/{userId}", user.getUserId()).with(csrf())
+ .contentType(APPLICATION_JSON).content(userJson))
+ .andExpect(status().is(HttpStatus.NOT_FOUND.value()));
+ }
+
+
+ @Test
+ @WithMockUser(username = "user", password = "password", roles = "USER")
+ public void getUsers() throws Exception {
+ User user = new User(1, "Carlos", "Patino", 53.4239330, -7.9406900);
+ Collectionusers = singletonList(user);
+
+ given(userRepository.findAll()).willReturn((List) users);
+
+ mockMvc.perform(get("/jrt/api/v1.0/users").with(csrf())
+ .contentType(APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(1)))
+ .andExpect(jsonPath("$[0].firstName", is(user.getFirstName())))
+ .andExpect(jsonPath("$[0].lastName", is(user.getLastName())))
+ .andExpect(jsonPath("$[0].latitude", is(user.getLatitude())))
+ .andExpect(jsonPath("$[0].longitude", is(user.getLongitude())));
+ }
+
+ @Test
+ @WithMockUser(username = "user", password = "password", roles = "USER")
+ public void getUsersEmpty() throws Exception {
+ Collectionusers = new ArrayList<>();
+ given(userRepository.findAll()).willReturn((List) users);
+
+ mockMvc.perform(get("/jrt/api/v1.0/users").with(csrf())
+ .contentType(APPLICATION_JSON))
+ .andExpect(status().is(HttpStatus.NO_CONTENT.value()))
+ .andExpect(jsonPath("$", hasSize(0)));
+ }
+
+ @Test
+ @WithMockUser(username = "user", password = "password", roles = "USER")
+ public void deleteUserSuccessfully() throws Exception {
+ User user = new User(5, "", "", 0.0, 0.0);
+ final String userJson = jsonTester.write(user).getJson();
+
+ given(userRepository.findUserByUserId(user.getUserId())).willReturn(user);
+ mockMvc.perform(delete("/jrt/api/v1.0/user/{userId}", user.getUserId()).with(csrf())
+ .contentType(APPLICATION_JSON)
+ .content(userJson))
+ .andExpect(status().is(HttpStatus.OK.value()));
+ }
+
+ @Test
+ @WithMockUser(username = "user", password = "password", roles = "USER")
+ public void deleteAllUsers() throws Exception {
+ mockMvc.perform(delete("/jrt/api/v1.0/user").with(csrf())
+ .contentType(APPLICATION_JSON))
+ .andExpect(status().is(HttpStatus.OK.value()));
+ }
+
+ @Test
+ @WithMockUser(username = "user", password = "password", roles = "USER")
+ public void deleteNonExistentUser() throws Exception {
+ User user = new User(5, "", "", 0.0, 0.0);
+ final String userJson = jsonTester.write(user).getJson();
+
+ given(userRepository.findUserByUserId(user.getUserId())).willReturn(null);
+ mockMvc.perform(delete("/jrt/api/v1.0/user/{userId}", user.getUserId()).with(csrf())
+ .contentType(APPLICATION_JSON).content(userJson))
+ .andExpect(status().is(HttpStatus.NOT_FOUND.value()));
+ }
+
+ @Test
+ @WithMockUser(username = "user", password = "password", roles = "USER")
+ public void getDistance() throws Exception {
+ User uno = new User(1, "Carlos", "", 53.421543, -7.942274);
+ User dos = new User(2, "Luis", "", 53.422303, -7.942306 );
+ User tres = new User(3, "Pedro", "", 53.422287, -7.942070 );
+ User cuatro = new User(4, "Jose", "", 53.422156, -7.942016 );
+ List users = Arrays.asList(uno, dos, tres, cuatro);
+ given(userRepository.findAll()).willReturn(users);
+ mockMvc.perform(get("/jrt/api/v1.0/distances").with(csrf()))
+ .andExpect(status().is(HttpStatus.OK.value()))
+ .andExpect(jsonPath("$.sum", closeTo(294, 2)))
+ .andExpect(jsonPath("$.min", closeTo(15, 2)))
+ .andExpect(jsonPath("$.average", closeTo(49, 2)))
+ .andExpect(jsonPath("$.max", closeTo(84, 2)));
+ }
+
+
+ @Test
+ @WithMockUser(username = "user", password = "password", roles = "USER")
+ public void getDistanceWithoutEnoughRecords() throws Exception {
+ User uno = new User(1, "Carlos", "", 53.421543, -7.942274);
+ List users = Arrays.asList(uno);
+ given(userRepository.findAll()).willReturn(users);
+ mockMvc.perform(get("/jrt/api/v1.0/distances").with(csrf()))
+ .andExpect(status().is(HttpStatus.NOT_FOUND.value()))
+ .andExpect(jsonPath("$.error", is("Not enough records to generate output")));
+ }
}
diff --git a/src/test/java/user/UserRepositoryTest.java b/src/test/java/user/UserRepositoryTest.java
new file mode 100644
index 0000000..8266d21
--- /dev/null
+++ b/src/test/java/user/UserRepositoryTest.java
@@ -0,0 +1,45 @@
+package user;
+
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.junit4.SpringRunner;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+@RunWith(SpringRunner.class)
+@SpringBootTest
+@Ignore
+// Test designed to verify operations against mongo. Will fail without mongo running and configuration updated.
+public class UserRepositoryTest {
+
+ @Autowired
+ private UserRepository repository;
+
+ private User carlos, pedro, luis;
+
+ @Before
+ public void setup(){
+ repository.deleteAll();
+ carlos = repository.save(new User(1, "Carlos", "Patino", 53.4239330, -7.9406900));
+ pedro = repository.save(new User(2, "Pedro", "Rodriguez", 53.4239330, -7.9406900));
+ luis = repository.save(new User(3, "Luis", "Garcia", 53.4239330, -7.9406900));
+ }
+
+ @Test
+ public void setsIdOnSave() {
+ User david = repository.save(new User("Dave", "Matthews", 53.4239330, -7.9406900));
+ assertNotNull(david.getUserId());
+ }
+
+ @Test
+ public void findByUserId() {
+ User david = repository.save(new User(4, "David", "Martinez", 53.4239330, -7.9406900));
+ User founded = repository.findUserByUserId(david.getUserId());
+ assertEquals(david.getFirstName(), founded.getFirstName());
+ }
+}
\ No newline at end of file