├── gradle.properties ├── docs ├── images │ ├── APIGateway-ASYNC.png │ ├── APIGateway-SYNC.png │ ├── APIGateway-GETREQ.png │ └── APIGateway-GETRES.png └── Salesforce_API.md ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── notifier.groovy ├── src ├── test │ ├── resources │ │ ├── testdataasync.json │ │ ├── testdata.json │ │ ├── testHipChatPost.json │ │ └── imposter.json │ └── groovy │ │ └── online4m │ │ └── apigateway │ │ ├── func │ │ ├── WeatherApiTest.groovy │ │ ├── HipChatApiTest.groovy │ │ └── ForceDotComApiTest.groovy │ │ └── api │ │ ├── DeleteApiTest.groovy │ │ ├── PatchApiTest.groovy │ │ ├── PutApiTest.groovy │ │ ├── PostAsyncApiTest.groovy │ │ ├── RequestApiTest.groovy │ │ ├── TopLevelApiTest.groovy │ │ ├── GetApiTest.groovy │ │ └── PostApiTest.groovy ├── main │ ├── groovy │ │ └── online4m │ │ │ └── apigateway │ │ │ ├── ds │ │ │ ├── CallerDSModule.groovy │ │ │ └── JedisDS.groovy │ │ │ ├── health │ │ │ └── CallerServiceHealthCheck.groovy │ │ │ ├── si │ │ │ ├── CallerModule.groovy │ │ │ ├── CallerServiceCtx.groovy │ │ │ ├── CallerServiceAsync.groovy │ │ │ ├── Response.groovy │ │ │ ├── QueryServiceAsync.groovy │ │ │ ├── Request.groovy │ │ │ ├── QueryService.groovy │ │ │ ├── Utils.groovy │ │ │ └── CallerService.groovy │ │ │ └── docs │ │ │ └── ApiDocsHandler.groovy │ └── resources │ │ └── log4j2.xml └── ratpack │ ├── ratpack.properties │ ├── templates │ └── index.html │ └── Ratpack.groovy ├── .gitignore ├── gradlew.bat ├── gh-md-toc ├── gradlew └── README.md /gradle.properties: -------------------------------------------------------------------------------- 1 | # turn on gradle daemon 2 | org.gradle.daemon=true 3 | -------------------------------------------------------------------------------- /docs/images/APIGateway-ASYNC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zedar/api-gateway/HEAD/docs/images/APIGateway-ASYNC.png -------------------------------------------------------------------------------- /docs/images/APIGateway-SYNC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zedar/api-gateway/HEAD/docs/images/APIGateway-SYNC.png -------------------------------------------------------------------------------- /docs/images/APIGateway-GETREQ.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zedar/api-gateway/HEAD/docs/images/APIGateway-GETREQ.png -------------------------------------------------------------------------------- /docs/images/APIGateway-GETRES.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zedar/api-gateway/HEAD/docs/images/APIGateway-GETRES.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zedar/api-gateway/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /notifier.groovy: -------------------------------------------------------------------------------- 1 | voice { 2 | enabled = false 3 | name = 'Alex' 4 | } 5 | 6 | notificationCenter { 7 | enabled = true 8 | } 9 | 10 | beep { 11 | enabled = false 12 | count = 5 13 | } 14 | -------------------------------------------------------------------------------- /src/test/resources/testdataasync.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "req_attr1": "alpha", 4 | "req_attr2": "beta" 5 | }, 6 | "format": "JSON", 7 | "method": "POST", 8 | "mode": "ASYNC", 9 | "url": "http://localhost:4545/post_sync_json" 10 | } 11 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Jul 21 19:00:37 CDT 2013 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-2.0-bin.zip 7 | -------------------------------------------------------------------------------- /src/test/resources/testdata.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "id": "1001", 4 | "req_attr1": "alpha", 5 | "req_attr2": "beta" 6 | }, 7 | "format": "JSON", 8 | "method": "DELETE", 9 | "mode": "SYNC", 10 | "url": "http://localhost:4545/delete_cust" 11 | } 12 | -------------------------------------------------------------------------------- /src/main/groovy/online4m/apigateway/ds/CallerDSModule.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.ds 2 | 3 | import com.google.inject.AbstractModule 4 | import com.google.inject.Scopes 5 | 6 | class CallerDSModule extends AbstractModule { 7 | protected void configure() { 8 | bind(JedisDS.class).in(Scopes.SINGLETON) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/ratpack/ratpack.properties: -------------------------------------------------------------------------------- 1 | #ssl.keystore.file= 2 | #ssl.keystore.password= 3 | 4 | # 5 | # Redis configuration 6 | # 7 | # is Redis server mandatory and should be a part of apigateway processing 8 | # for mode=ASYNC redis.on=true is mandatory 9 | other.redis.on=true 10 | # Redis host 11 | other.redis.host=localhost 12 | # Redis port 13 | other.redis.port=6379 14 | -------------------------------------------------------------------------------- /src/test/resources/testHipChatPost.json: -------------------------------------------------------------------------------- 1 | { 2 | "method": "POST", 3 | "mode": "SYNC", 4 | "format": "JSON", 5 | "url": "https://api.hipchat.com/v2/room/online4m.com/notification?auth_token=v54gzlKPiiEEKw6wgbR3waiQklj2lIoCOjVKOeDl", 6 | "data": { 7 | "message": "My test message" 8 | } 9 | } 10 | 11 | 12 | // API v2 13 | // auth_token=v54gzlKPiiEEKw6wgbR3waiQklj2lIoCOjVKOeDl 14 | -------------------------------------------------------------------------------- /src/main/groovy/online4m/apigateway/health/CallerServiceHealthCheck.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.health 2 | 3 | import ratpack.codahale.metrics.NamedHealthCheck 4 | import com.codahale.metrics.health.HealthCheck 5 | 6 | class CallerServiceHealthCheck extends NamedHealthCheck { 7 | protected HealthCheck.Result check() throws Exception { 8 | return HealthCheck.Result.healthy() 9 | } 10 | 11 | String getName() { 12 | return "apigateway" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/*.log 2 | build 3 | **/*.pid 4 | .gradle 5 | out.json 6 | src/test/resources/hipchat.properties 7 | dump.rdb 8 | src/test/resources/sf_get_token.json 9 | src/test/resources/sf_native_new_account.json 10 | src/test/resources/sf_new_account.json 11 | src/test/resources/sf_requests.txt 12 | src/test/resources/sf_update_account.json 13 | src/test/resources/force.com.properties 14 | src/test/resources/sf_delete_account.json 15 | src/test/resources/sf_get_account.json 16 | .DS_Store 17 | -------------------------------------------------------------------------------- /src/main/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/main/groovy/online4m/apigateway/si/CallerModule.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.si 2 | 3 | import com.google.inject.AbstractModule 4 | import com.google.inject.Scopes 5 | import com.google.inject.Provides 6 | import com.google.inject.Singleton 7 | 8 | // Explanation of Guice modules: https://github.com/google/guice/wiki/GettingStarted 9 | 10 | 11 | class CallerModule extends AbstractModule { 12 | protected void configure() { 13 | bind(CallerService.class).in(Scopes.SINGLETON) 14 | bind(CallerServiceAsync.class).in(Scopes.SINGLETON) 15 | bind(QueryService.class).in(Scopes.SINGLETON) 16 | bind(QueryServiceAsync.class).in(Scopes.SINGLETON) 17 | } 18 | 19 | @Provides @Singleton 20 | CallerServiceCtx provideCtx() { 21 | return new CallerServiceCtx() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/groovy/online4m/apigateway/si/CallerServiceCtx.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.si 2 | 3 | import java.net.URI 4 | import java.util.concurrent.atomic.AtomicReference 5 | 6 | import groovy.transform.ToString 7 | import groovy.transform.TupleConstructor 8 | 9 | @ToString @TupleConstructor 10 | class CallerServiceCtx { 11 | private AtomicReference serverUri = new AtomicReference(null) 12 | // serverUrl - protocol, host name, port number 13 | private AtomicReference serverUrl = new AtomicReference(null) 14 | 15 | CallerServiceCtx setServerUri(URI uri) { 16 | this.serverUri.compareAndSet(null, uri) 17 | this.serverUrl.compareAndSet(null, uri.toString()) 18 | return this 19 | } 20 | 21 | String getServerUrl() { 22 | return this.serverUrl.get() 23 | } 24 | 25 | String getHost() { 26 | return this.serverUri.get()?.getHost() 27 | } 28 | 29 | String getPort() { 30 | return this.serverUri.get()?.getPort() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/groovy/online4m/apigateway/si/CallerServiceAsync.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.si 2 | 3 | import groovy.util.logging.* 4 | import com.google.inject.Inject 5 | 6 | // ratpack interface for performing async operations 7 | import ratpack.exec.ExecControl 8 | import ratpack.exec.Promise 9 | 10 | // RxJava 11 | import static ratpack.rx.RxRatpack.observe 12 | import rx.Observable 13 | 14 | @Slf4j 15 | class CallerServiceAsync { 16 | private final ExecControl execControl 17 | private final CallerService callerService 18 | 19 | @Inject 20 | CallerServiceAsync(ExecControl execControl, CallerService callerService) { 21 | this.execControl = execControl 22 | this.callerService = callerService 23 | } 24 | 25 | Promise invoke(String bodyText) { 26 | return execControl.blocking { 27 | return callerService.invoke(bodyText) 28 | } 29 | } 30 | 31 | Observable invokeRx(String bodyText) { 32 | return observe(execControl.blocking { 33 | return callerService.invoke(bodyText) 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/groovy/online4m/apigateway/si/Response.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.si 2 | 3 | import java.util.UUID 4 | 5 | import groovy.util.logging.Slf4j 6 | import groovy.transform.ToString 7 | import groovy.transform.TupleConstructor 8 | 9 | @ToString @TupleConstructor @Slf4j 10 | class Response { 11 | Boolean success = true 12 | String errorCode = "0" 13 | String errorDescr = "" 14 | // statusCode - HTTP status code for external API call 15 | Integer statusCode = 200 16 | // id - correlation UUID. Equals to request.uuid 17 | // has to be private, because overriden set and get 18 | UUID id 19 | // data - JSON object with serialized output of external API call 20 | Object data 21 | // href - link to GET itself 22 | String href = "" 23 | // links - map of links to related entities 24 | Map links = [:] 25 | 26 | void setId(String sid) { 27 | this.id = UUID.fromString(sid) 28 | } 29 | 30 | static Response build(Map data) { 31 | Response response = new Response() 32 | data.inject(response) {res, key, value -> 33 | if (res.hasProperty(key)) { 34 | res."${key}" = value 35 | } 36 | return res 37 | } 38 | return response 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/groovy/online4m/apigateway/si/QueryServiceAsync.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.si 2 | 3 | import javax.inject.Inject 4 | 5 | import groovy.util.logging.Slf4j 6 | 7 | import ratpack.exec.ExecControl 8 | import ratpack.exec.Promise 9 | import rx.Observable 10 | import static ratpack.rx.RxRatpack.observe 11 | 12 | @Slf4j 13 | class QueryServiceAsync { 14 | private final ExecControl execControl 15 | private final QueryService queryService 16 | 17 | @Inject 18 | QueryServiceAsync(ExecControl execControl, QueryService queryService) { 19 | this.execControl = execControl 20 | this.queryService = queryService 21 | } 22 | 23 | Promise getResponse(String sid) { 24 | return execControl.blocking { 25 | return queryService.getResponse(sid) 26 | } 27 | } 28 | 29 | Observable getResponseRx(String sid) { 30 | return observe(execControl.blocking { 31 | return queryService.getResponse(sid) 32 | }) 33 | } 34 | 35 | Promise getRequest(String sid) { 36 | return execControl.blocking { 37 | return queryService.getRequest(sid) 38 | } 39 | } 40 | 41 | Observable getRequestRx(String sid) { 42 | return observe(execControl.blocking { 43 | return queryService.getRequest(sid) 44 | }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/ratpack/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Ratpack: ${model.title} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 |

Ratpack

24 |

a micro web framework for Java & Groovy

25 |
26 | 27 |
28 |

${model.title}

29 |

This is the main page for your Ratpack app.

30 |
31 | 32 |
33 | 34 | 35 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/test/groovy/online4m/apigateway/func/WeatherApiTest.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.si.test 2 | 3 | import groovy.json.JsonSlurper 4 | import groovy.json.JsonOutput 5 | 6 | import ratpack.groovy.test.LocalScriptApplicationUnderTest 7 | import ratpack.test.ApplicationUnderTest 8 | import ratpack.test.http.TestHttpClients 9 | import ratpack.test.http.TestHttpClient 10 | import ratpack.http.client.RequestSpec 11 | import ratpack.test.remote.RemoteControl 12 | 13 | import spock.lang.Specification 14 | import spock.lang.Ignore 15 | 16 | @Ignore 17 | class WeatherApiTest extends Specification { 18 | ApplicationUnderTest aut = new LocalScriptApplicationUnderTest("other.remoteControl.enabled": "true") 19 | @Delegate TestHttpClient client = TestHttpClients.testHttpClient(aut) 20 | RemoteControl remote = new RemoteControl(aut) 21 | 22 | def "success with GET, SYNC, JSON"() { 23 | given: 24 | def inputJson = [ 25 | method: "GET", 26 | mode: "SYNC", 27 | format: "JSON", 28 | url: "http://api.openweathermap.org/data/2.5/weather", 29 | data: [q: "Warsaw"]] 30 | def json = new JsonSlurper() 31 | 32 | when: 33 | requestSpec { RequestSpec requestSpec -> 34 | requestSpec.body.type("application/json") 35 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 36 | } 37 | post("api/call") 38 | 39 | then: 40 | def r = json.parseText(response.body.text) 41 | with(r) { 42 | success == true 43 | errorCode == "0" 44 | data.name == "Warsaw" 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/test/groovy/online4m/apigateway/api/DeleteApiTest.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.api 2 | 3 | import groovy.json.JsonSlurper 4 | import groovy.json.JsonOutput 5 | 6 | import ratpack.groovy.test.LocalScriptApplicationUnderTest 7 | import ratpack.test.ApplicationUnderTest 8 | import ratpack.test.http.TestHttpClients 9 | import ratpack.test.http.TestHttpClient 10 | import ratpack.http.client.RequestSpec 11 | import ratpack.test.remote.RemoteControl 12 | 13 | import spock.lang.Specification 14 | import spock.lang.Ignore 15 | 16 | class DeleteApiTest extends Specification { 17 | ApplicationUnderTest aut = new LocalScriptApplicationUnderTest("other.remoteControl.enabled": "true") 18 | @Delegate TestHttpClient client = TestHttpClients.testHttpClient(aut) 19 | RemoteControl remote = new RemoteControl(aut) 20 | 21 | def "Delete existing resource and get HTTP 204"() { 22 | given: 23 | def inputJson = [ 24 | method: "DELETE", 25 | mode: "SYNC", 26 | format: "JSON", 27 | url: "http://localhost:4545/delete_cust", 28 | data: [ 29 | id: "1001" 30 | ] 31 | ] 32 | def json = new JsonSlurper() 33 | 34 | when: 35 | requestSpec { RequestSpec requestSpec -> 36 | requestSpec.body.type("application/json") 37 | requestSpec.headers.set("Accept", "application/json") 38 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 39 | } 40 | def httpResp = post("api/invoke") 41 | 42 | then: 43 | def r = json.parseText(response.body.text) 44 | with (r) { 45 | success == true 46 | errorCode == "0" 47 | statusCode == 200 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/test/groovy/online4m/apigateway/api/PatchApiTest.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.api 2 | 3 | import groovy.json.JsonSlurper 4 | import groovy.json.JsonOutput 5 | 6 | import ratpack.groovy.test.LocalScriptApplicationUnderTest 7 | import ratpack.test.ApplicationUnderTest 8 | import ratpack.test.http.TestHttpClients 9 | import ratpack.test.http.TestHttpClient 10 | import ratpack.http.client.RequestSpec 11 | import ratpack.test.remote.RemoteControl 12 | 13 | import spock.lang.Specification 14 | import spock.lang.Ignore 15 | 16 | class PatchApiTest extends Specification { 17 | ApplicationUnderTest aut = new LocalScriptApplicationUnderTest("other.remoteControl.enabled": "true") 18 | @Delegate TestHttpClient client = TestHttpClients.testHttpClient(aut) 19 | RemoteControl remote = new RemoteControl(aut) 20 | 21 | def "Patch exisiting resource without content returned (HTTP 204)"() { 22 | given: "request as patch" 23 | def inputJson = [ 24 | method: "PATCH", 25 | mode: "SYNC", 26 | format: "JSON", 27 | url: "http://localhost:4545/patch_json_cust_no_content", 28 | data: [ 29 | mobile: "048900300200", 30 | status: "ACTIVE" 31 | ] 32 | ] 33 | def json = new JsonSlurper() 34 | 35 | when: 36 | requestSpec { RequestSpec request -> 37 | request.body.type("application/json") 38 | request.headers.set("Accept", "application/json") 39 | request.body.text(JsonOutput.toJson(inputJson)) 40 | } 41 | def httpResp = post("api/invoke") 42 | 43 | then: 44 | def r = json.parseText(response.body.text) 45 | with(r) { 46 | success == true 47 | errorCode == "0" 48 | statusCode == 204 49 | data == null 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/groovy/online4m/apigateway/si/Request.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.si 2 | 3 | import java.util.UUID 4 | 5 | import groovy.util.logging.Slf4j 6 | import groovy.transform.ToString 7 | import groovy.transform.TupleConstructor 8 | 9 | enum RequestMethod { 10 | POST, 11 | GET, 12 | PUT, 13 | PATCH, 14 | DELETE 15 | } 16 | 17 | enum RequestMode { 18 | SYNC, ASYNC, EVENT 19 | } 20 | 21 | enum RequestFormat { 22 | // Content-Type: application/json 23 | JSON, 24 | // Content-Type: application/xml 25 | XML, 26 | // Content-Type: application/x-www-form-urlencoded 27 | URLENC 28 | } 29 | 30 | @ToString @TupleConstructor @Slf4j 31 | class Request { 32 | UUID id = UUID.randomUUID() 33 | RequestMethod method 34 | RequestMode mode 35 | RequestFormat format 36 | URL url 37 | // HTTP request headers in form of key-value pairs. For example: 38 | // "Authorization": "Bearer ACCESS_KEY" 39 | Map headers 40 | // HTTP request data to be sent as either query attributes or body content 41 | Object data 42 | // href - link to GET itself 43 | String href = "" 44 | // links - map of links to related entities 45 | Map links = [:] 46 | 47 | public void setId(String sid) { 48 | if (!sid) { 49 | this.id = UUID.randomUUID() 50 | } 51 | else 52 | this.id = UUID.fromString(sid) 53 | } 54 | 55 | public void setUrl(String surl) { 56 | this.url = surl.toURL() 57 | } 58 | 59 | static Request build(Map data) { 60 | Request request = new Request() 61 | data.inject(request) { req, key,value -> 62 | if (req.hasProperty(key)) { 63 | req."${key}" = value 64 | } 65 | return req 66 | } 67 | return request 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/test/groovy/online4m/apigateway/api/PutApiTest.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.api 2 | 3 | import groovy.json.JsonSlurper 4 | import groovy.json.JsonOutput 5 | 6 | import ratpack.groovy.test.LocalScriptApplicationUnderTest 7 | import ratpack.test.ApplicationUnderTest 8 | import ratpack.test.http.TestHttpClients 9 | import ratpack.test.http.TestHttpClient 10 | import ratpack.http.client.RequestSpec 11 | import ratpack.test.remote.RemoteControl 12 | 13 | import spock.lang.Specification 14 | import spock.lang.Ignore 15 | 16 | class PutApiTest extends Specification { 17 | ApplicationUnderTest aut = new LocalScriptApplicationUnderTest("other.remoteControl.enabled": "true") 18 | @Delegate TestHttpClient client = TestHttpClients.testHttpClient(aut) 19 | RemoteControl remote = new RemoteControl(aut) 20 | 21 | def "Put existing resource and get HTTP 200"() { 22 | given: "request in JSON format" 23 | def inputJson = [ 24 | method: "PUT", 25 | mode: "SYNC", 26 | format: "JSON", 27 | url: "http://localhost:4545/put_json_cust", 28 | data: [ 29 | customer: [ 30 | id: "1001", 31 | name1: "Alpha", 32 | name2: "Surname", 33 | mobile: "048601111222", 34 | identities: [ 35 | [ 36 | type: "PID", 37 | value: "12345678" 38 | ], 39 | [ 40 | type: "PASSPORT", 41 | value: "AXX04543543" 42 | ] 43 | ], 44 | status: "INIT" 45 | ] 46 | ] 47 | ] 48 | def json = new JsonSlurper() 49 | 50 | when: 51 | requestSpec { RequestSpec requestSpec -> 52 | requestSpec.body.type("application/json") 53 | requestSpec.headers.set("Accept", "application/json") 54 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 55 | } 56 | def httpResp = post("api/invoke") 57 | 58 | then: 59 | def r = json.parseText(response.body.text) 60 | with (r) { 61 | success == true 62 | errorCode == "0" 63 | data.id == "1001" 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/groovy/online4m/apigateway/ds/JedisDS.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.ds 2 | 3 | import groovy.util.logging.* 4 | 5 | import javax.inject.Inject 6 | 7 | import ratpack.launch.LaunchConfig 8 | 9 | import redis.clients.jedis.Jedis 10 | import redis.clients.jedis.JedisPool 11 | import redis.clients.jedis.JedisPoolConfig 12 | 13 | /** 14 | * Redist data store for requests and responses. 15 | */ 16 | @Slf4j 17 | class JedisDS { 18 | private final LaunchConfig launchConfig 19 | private final boolean on 20 | private final JedisPool jedisPool 21 | 22 | @Inject JedisDS(LaunchConfig launchConfig) { 23 | this.launchConfig = launchConfig 24 | log.debug("other.redis.on=${this.launchConfig.getOther("redis.on", "false")}") 25 | log.debug("other.redis.host=${this.launchConfig.getOther("redis.host", "redis-host-undefined")}") 26 | log.debug("other.redis.port=${this.launchConfig.getOther("redis.port", "redis-port-undefined")}") 27 | this.on = this.launchConfig.getOther("redis.on", "false").toBoolean() 28 | if (this.on) { 29 | JedisPoolConfig config = new JedisPoolConfig() 30 | // Test whether connection is dead when connection 31 | // retrieval method is called 32 | config.setTestOnBorrow(true) 33 | // Test whether connection is dead when returning a 34 | // connection to the pool 35 | config.setTestOnReturn(true) 36 | this.jedisPool = new JedisPool( 37 | config, 38 | this.launchConfig.getOther("redis.host", "localhost"), 39 | this.launchConfig.getOther("redis.port", "6379")?.toInteger()) 40 | } 41 | } 42 | 43 | boolean isOn() { 44 | return this.on 45 | } 46 | 47 | /** 48 | * Get connection to Redis. From the pool. 49 | */ 50 | Jedis getResource() { 51 | if (isOn()) { 52 | return jedisPool.getResource() 53 | } 54 | else { 55 | return null 56 | } 57 | } 58 | 59 | /** 60 | * Return connectin to the pool. 61 | */ 62 | void returnResource(Jedis jedis) { 63 | if (isOn()) { 64 | jedisPool.returnResource(jedis) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/test/groovy/online4m/apigateway/api/PostAsyncApiTest.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.api 2 | 3 | import java.util.concurrent.CountDownLatch 4 | import java.util.concurrent.TimeUnit 5 | 6 | import groovy.json.JsonSlurper 7 | import groovy.json.JsonOutput 8 | 9 | import ratpack.groovy.test.LocalScriptApplicationUnderTest 10 | import ratpack.test.ApplicationUnderTest 11 | import ratpack.test.http.TestHttpClients 12 | import ratpack.test.http.TestHttpClient 13 | import ratpack.http.client.RequestSpec 14 | import ratpack.test.remote.RemoteControl 15 | 16 | import spock.lang.Specification 17 | import spock.lang.Ignore 18 | 19 | class PostAsyncApiTest extends Specification { 20 | ApplicationUnderTest aut = new LocalScriptApplicationUnderTest("other.remoteControl.enabled": "true") 21 | @Delegate TestHttpClient client = TestHttpClients.testHttpClient(aut) 22 | RemoteControl remote = new RemoteControl(aut) 23 | 24 | def "Success: POST ASYNC JSON"() { 25 | given: 26 | def inputJson = [ 27 | method: "POST", 28 | mode: "ASYNC", 29 | format: "JSON", 30 | url: "http://localhost:4545/post_async_json", 31 | data: [ 32 | customer: [ 33 | name1: "Name1", 34 | name2: "Name2", 35 | mobile: "0486009988", 36 | identification: [ 37 | type: "ID", 38 | value: "AXX098765" 39 | ] 40 | ], 41 | status: "INIT" 42 | ] 43 | ] 44 | def json = new JsonSlurper() 45 | def latch = new CountDownLatch(1) 46 | 47 | when: 48 | requestSpec { RequestSpec requestSpec -> 49 | requestSpec.body.type("application/json") 50 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 51 | } 52 | def responseReceived = post("api/invoke") 53 | def rr = json.parseText(response.body.text) 54 | assert rr.success 55 | assert rr.errorCode == "0" 56 | assert rr.statusCode == 202 57 | assert rr.href 58 | 59 | // wait 4 seconds 60 | latch.await(4, TimeUnit.SECONDS) 61 | 62 | inputJson = [:] 63 | requestSpec { RequestSpec requestSpec -> 64 | requestSpec.headers.set("Accept", "application/json") 65 | requestSpec.body.type("application/json") 66 | } 67 | get(rr.href) 68 | 69 | then: 70 | def r = json.parseText(response.body.text) 71 | with(r) { 72 | success == true 73 | errorCode == "0" 74 | statusCode == 201 75 | id == rr.id 76 | data != null 77 | data.id == "101" 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS="-Dorg.apache.logging.log4j.level=DEBUG" 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /gh-md-toc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # Steps: 5 | # 6 | # 1. Download coresponding html file for some README.md: 7 | # curl -s $1 8 | # 9 | # 2. Discard rows where no substring 'user-content-' (github's markup): 10 | # awk '/user-content-/ { ... 11 | # 12 | # 3. Get header level and insert corresponding number of spaces before '*': 13 | # sprintf("%*s", substr($NF, length($NF)-1, 1)*2, " ") 14 | # 15 | # 4. Find head's text and insert it inside "* [ ... ]": 16 | # substr($0, match($0, /a>.*<\/h/)+2, RLENGTH-5) 17 | # 18 | # 5. Find anchor and insert it inside "(...)": 19 | # substr($4, 7) 20 | # 21 | 22 | gh_toc_version="0.2.1" 23 | 24 | # 25 | # Download rendered into html README.md by it's url. 26 | # 27 | # 28 | gh_toc_load() { 29 | local gh_url=$1 30 | local gh_user_agent=$2 31 | 32 | if type curl &>/dev/null; then 33 | curl --user-agent "$gh_user_agent" -s "$gh_url" 34 | elif type wget &>/dev/null; then 35 | wget --user-agent="$gh_user_agent" -qO- "$gh_url" 36 | else 37 | echo "Please, install 'curl' or 'wget' and try again." 38 | exit 1 39 | fi 40 | } 41 | 42 | # 43 | # TOC generator 44 | # 45 | gh_toc(){ 46 | local gh_url=$1 47 | local gh_user_agent=${2:-"gh-md-toc"} 48 | local gh_count=$3 49 | 50 | if [ "$gh_url" = "" ]; then 51 | echo "Please, enter URL for a README.md" 52 | exit 1 53 | fi 54 | 55 | if [ "$gh_count" = "1" ]; then 56 | 57 | echo "Table of Contents" 58 | echo "=================" 59 | echo "" 60 | 61 | gh_toc_load "$gh_url" "$gh_user_agent" | gh_toc_grab "" 62 | else 63 | gh_toc_load "$gh_url" "$gh_user_agent" | gh_toc_grab "$gh_url" 64 | fi 65 | } 66 | 67 | gh_toc_grab() { 68 | awk -v "gh_url=$1" '/user-content-/ { 69 | print sprintf("%*s", substr($NF, length($NF)-1, 1)*2, " ") "* [" substr($0, match($0, /a>.*<\/h/)+2, RLENGTH-5)"](" gh_url substr($4, 7, length($4)-7) ")"}' 70 | } 71 | 72 | # 73 | # Returns filename only from full path or url 74 | # 75 | gh_toc_get_filename() { 76 | echo "${1##*/}" 77 | } 78 | 79 | # 80 | # Options hendlers 81 | # 82 | gh_toc_app() { 83 | local app_name=${0/\.\//} 84 | 85 | if [ "$1" = '--help' ]; then 86 | echo "GitHub TOC generator ($app_name): $gh_toc_version" 87 | echo "" 88 | echo "Usage:" 89 | echo " $app_name Create TOC for passed url of a README file" 90 | echo " $app_name --help Show help" 91 | echo " $app_name --version Show help" 92 | return 93 | fi 94 | 95 | if [ "$1" = '--version' ]; then 96 | echo "$gh_toc_version" 97 | return 98 | fi 99 | 100 | for md in "$@" 101 | do 102 | echo "" 103 | gh_toc "$md" "$app_name" "$#" 104 | done 105 | 106 | echo "" 107 | echo "Created by [gh-md-toc](https://github.com/ekalinin/github-markdown-toc)" 108 | } 109 | 110 | # 111 | # Entry point 112 | # 113 | gh_toc_app "$@" 114 | -------------------------------------------------------------------------------- /src/test/groovy/online4m/apigateway/api/RequestApiTest.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.api 2 | 3 | import groovy.json.JsonSlurper 4 | import groovy.json.JsonOutput 5 | 6 | import ratpack.groovy.test.LocalScriptApplicationUnderTest 7 | import ratpack.test.ApplicationUnderTest 8 | import ratpack.test.http.TestHttpClients 9 | import ratpack.test.http.TestHttpClient 10 | import ratpack.http.client.RequestSpec 11 | import ratpack.test.remote.RemoteControl 12 | 13 | import spock.lang.Specification 14 | import spock.lang.Ignore 15 | 16 | class RequestApiTest extends Specification { 17 | ApplicationUnderTest aut = new LocalScriptApplicationUnderTest("other.remoteControl.enabled": "true") 18 | @Delegate TestHttpClient client = TestHttpClients.testHttpClient(aut) 19 | RemoteControl remote = new RemoteControl(aut) 20 | 21 | def "Invoke API and check if input request is registered"() { 22 | given: 23 | def inputJson = [ 24 | method: "GET", 25 | mode: "SYNC", 26 | format: "JSON", 27 | url: "http://localhost:4545/get_sync_json", 28 | data: [ 29 | req_attr1: "alpha", 30 | req_attr2: "beta" 31 | ] 32 | ] 33 | def json = new JsonSlurper() 34 | 35 | when: 36 | requestSpec { RequestSpec requestSpec -> 37 | requestSpec.headers.set("Accept", "application/json") 38 | requestSpec.body.type("application/json") 39 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 40 | } 41 | def receivedResponse = post("api/invoke") 42 | def rr = json.parseText(receivedResponse.body.text) 43 | assert rr.success 44 | assert rr.links 45 | assert rr.links.request.href 46 | assert rr.id 47 | def rrId = rr.id 48 | get(rr.links.request.href) 49 | 50 | then: 51 | def r = json.parseText(response.body.text) 52 | with(r) { 53 | mode == "SYNC" 54 | format == "JSON" 55 | url == "http://localhost:4545/get_sync_json" 56 | id == rr.id 57 | } 58 | } 59 | 60 | def "Invoke API and check if result response is registered"() { 61 | given: 62 | def inputJson = [ 63 | method: "GET", 64 | mode: "SYNC", 65 | format: "JSON", 66 | url: "http://localhost:4545/get_sync_json", 67 | data: [ 68 | req_attr1: "alpha", 69 | req_attr2: "beta" 70 | ] 71 | ] 72 | def json = new JsonSlurper() 73 | 74 | when: 75 | requestSpec { RequestSpec requestSpec -> 76 | requestSpec.headers.set("Accept", "application/json") 77 | requestSpec.body.type("application/json") 78 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 79 | } 80 | def receivedResponse = post("api/invoke") 81 | def rr = json.parseText(receivedResponse.body.text) 82 | assert rr.success 83 | assert rr.href 84 | assert rr.links 85 | assert rr.links.request.href 86 | assert rr.id 87 | get(rr.href) 88 | 89 | then: 90 | def r = json.parseText(response.body.text) 91 | with(r) { 92 | success 93 | errorCode == "0" 94 | id == rr.id 95 | href == rr.href 96 | links 97 | links.request.href == rr.links.request.href 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/test/groovy/online4m/apigateway/api/TopLevelApiTest.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.api 2 | 3 | import java.util.regex.Pattern 4 | 5 | import groovy.json.JsonSlurper 6 | import groovy.json.JsonOutput 7 | import groovy.util.XmlSlurper 8 | 9 | import ratpack.groovy.test.LocalScriptApplicationUnderTest 10 | import ratpack.test.ApplicationUnderTest 11 | import ratpack.test.http.TestHttpClients 12 | import ratpack.test.http.TestHttpClient 13 | import ratpack.http.client.RequestSpec 14 | import ratpack.test.remote.RemoteControl 15 | 16 | import online4m.apigateway.si.Utils 17 | 18 | import spock.lang.Specification 19 | import spock.lang.Ignore 20 | 21 | class TopLevelApiTest extends Specification { 22 | ApplicationUnderTest aut = new LocalScriptApplicationUnderTest("other.remoteControl.enabled": "true") 23 | @Delegate TestHttpClient client = TestHttpClients.testHttpClient(aut) 24 | RemoteControl remote = new RemoteControl(aut) 25 | 26 | def "Get API endpoints in Json format"() { 27 | given: 28 | def json = new JsonSlurper() 29 | 30 | when: 31 | requestSpec { RequestSpec requestSpec -> 32 | requestSpec.headers.set("Accept", "application/json") 33 | } 34 | get("api") 35 | 36 | then: 37 | response.headers.get("Content-Type") == "application/json" 38 | def r = json.parseText(response.body.text) 39 | with(r) { 40 | href ==~ /.+\/api$/ 41 | title 42 | links 43 | links.size() == 5 44 | links.invoke.href ==~ /.+\/api\/invoke$/ 45 | links.invoke.type == "api" 46 | links.request.href ==~ /.+\/api\/invoke\/\{id\}\/request$/ 47 | links.request.type == "api" 48 | links.response.href ==~ /.+\/api\/invoke\/\{id\}\/response$/ 49 | links.response.type == "api" 50 | } 51 | } 52 | 53 | def "Get API endpoints in vnd.api+json format - json-api.org"() { 54 | given: 55 | def json = new JsonSlurper() 56 | 57 | when: 58 | requestSpec { RequestSpec requestSpec -> 59 | requestSpec.headers.set("Accept", "application/vnd.api+json") 60 | } 61 | get("api") 62 | 63 | then: 64 | response.headers.get("Content-Type") == "application/vnd.api+json" 65 | 66 | def r = json.parseText(response.body.text) 67 | with(r) { 68 | href ==~ /.+\/api$/ 69 | title 70 | links 71 | links.size() == 5 72 | links.invoke.href ==~ /.+\/api\/invoke$/ 73 | links.invoke.type == "api" 74 | links.request.href ==~ /.+\/api\/invoke\/\{id\}\/request$/ 75 | links.request.type == "api" 76 | links.response.href ==~ /.+\/api\/invoke\/\{id\}\/response$/ 77 | links.response.type == "api" 78 | } 79 | } 80 | 81 | def "Get API endpoints in XML format"() { 82 | given: 83 | def xml = new XmlSlurper() 84 | 85 | when: 86 | requestSpec { RequestSpec requestSpec -> 87 | requestSpec.headers.set("Accept", "application/xml") 88 | } 89 | get("api") 90 | 91 | then: 92 | response.headers.get("Content-Type") == "application/xml" 93 | 94 | def x = xml.parseText(response.body.text) 95 | def r = Utils.buildJsonEntity(x) 96 | r.api 97 | with (r.api) { 98 | href ==~ /.+\/api$/ 99 | title 100 | links 101 | links.size() == 5 102 | links.invoke.href ==~ /.+\/api\/invoke$/ 103 | links.invoke.type == "api" 104 | links.request.href ==~ /.+\/api\/invoke\/\{id\}\/request$/ 105 | links.request.type == "api" 106 | links.response.href ==~ /.+\/api\/invoke\/\{id\}\/response$/ 107 | links.response.type == "api" 108 | } 109 | } 110 | } 111 | 112 | -------------------------------------------------------------------------------- /src/test/groovy/online4m/apigateway/func/HipChatApiTest.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.si.test 2 | 3 | import groovy.json.JsonSlurper 4 | import groovy.json.JsonOutput 5 | 6 | import ratpack.groovy.test.LocalScriptApplicationUnderTest 7 | import ratpack.test.ApplicationUnderTest 8 | import ratpack.test.http.TestHttpClients 9 | import ratpack.test.http.TestHttpClient 10 | import ratpack.http.client.RequestSpec 11 | import ratpack.test.remote.RemoteControl 12 | 13 | import spock.lang.Specification 14 | import spock.lang.Ignore 15 | 16 | @Ignore 17 | class HipChatApiTest extends Specification { 18 | ApplicationUnderTest aut = new LocalScriptApplicationUnderTest("other.remoteControl.enabled": "true") 19 | @Delegate TestHttpClient client = TestHttpClients.testHttpClient(aut) 20 | RemoteControl remote = new RemoteControl(aut) 21 | 22 | Properties properties 23 | 24 | def setup() { 25 | 26 | properties = new Properties() 27 | properties.load(getClass().getClassLoader().getResourceAsStream("hipchat.properties")) 28 | } 29 | 30 | def "Send new notification to HipChat/online4m.com room"() { 31 | given: 32 | def inputJson = [ 33 | method: "POST", 34 | mode: "SYNC", 35 | format: "JSON", 36 | url: "https://api.hipchat.com/v2/room/online4m.com/notification?auth_token=${properties.auth_token}", 37 | data: [ 38 | message: "Test message to online4m.com" 39 | ] 40 | ] 41 | def json = new JsonSlurper() 42 | 43 | when: 44 | requestSpec { RequestSpec requestSpec -> 45 | requestSpec.body.type("application/json") 46 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 47 | } 48 | post("api/call") 49 | 50 | then: 51 | def r = json.parseText(response.body.text) 52 | with(r) { 53 | success == true 54 | errorCode == "0" 55 | } 56 | } 57 | 58 | def "Get latest HipChat/online4m.com history"() { 59 | given: 60 | def inputJson = [ 61 | method: "GET", 62 | mode: "SYNC", 63 | format: "JSON", 64 | url: "https://api.hipchat.com/v2/room/online4m.com/history/latest?auth_token=${properties.auth_token}", 65 | data: [ 66 | "max-results": 10 67 | ] 68 | ] 69 | def json = new JsonSlurper() 70 | 71 | when: 72 | requestSpec { RequestSpec requestSpec -> 73 | requestSpec.body.type("application/json") 74 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 75 | } 76 | post("api/call") 77 | 78 | then: 79 | def r = json.parseText(response.body.text) 80 | with(r) { 81 | success == true 82 | errorCode == "0" 83 | data.items 84 | data.items.size() > 0 85 | } 86 | 87 | } 88 | 89 | def "Skip complex parameters"() { 90 | given: 91 | def inputJson = [ 92 | method: "GET", 93 | mode: "SYNC", 94 | format: "JSON", 95 | url: "https://api.hipchat.com/v2/room/online4m.com/history/latest?auth_token=${properties.auth_token}", 96 | data: [ 97 | "complex-attr": [ 98 | "simple-attr": 20 99 | ], 100 | "max-results": 10 101 | ] 102 | ] 103 | def json = new JsonSlurper() 104 | 105 | when: 106 | requestSpec { RequestSpec requestSpec -> 107 | requestSpec.body.type("application/json") 108 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 109 | } 110 | post("api/call") 111 | 112 | then: 113 | def r = json.parseText(response.body.text) 114 | with(r) { 115 | success == true 116 | errorCode == "0" 117 | data.items 118 | data.items.size() > 0 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/main/groovy/online4m/apigateway/si/QueryService.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.si 2 | 3 | import java.util.UUID 4 | import javax.inject.Inject 5 | 6 | import groovy.util.logging.Slf4j 7 | import groovy.json.JsonSlurper 8 | 9 | import redis.clients.jedis.Jedis 10 | import redis.clients.jedis.exceptions.JedisConnectionException 11 | 12 | import online4m.apigateway.ds.JedisDS 13 | 14 | @Slf4j 15 | class QueryService { 16 | // jedisDS - reference to Redis data source connection pool 17 | private final JedisDS jedisDS 18 | // csCtx - common attributes for all service calls 19 | private final CallerServiceCtx csCtx 20 | 21 | @Inject 22 | QueryService(JedisDS jedisDS, CallerServiceCtx csCtx) { 23 | this.jedisDS = jedisDS 24 | this.csCtx = csCtx 25 | } 26 | 27 | /** 28 | * Convert uuid as string to UUID class and call main getResponse() 29 | * @param sid - string representation of UUID 30 | */ 31 | Response getResponse(String sid) { 32 | if (!sid) { 33 | return new Response(false, "SI_ERR_MISSING_UUID", "Missing service call unique identifier (UUID)") 34 | } 35 | UUID uuid = UUID.fromString(sid) 36 | return getResponse(uuid) 37 | } 38 | 39 | /** 40 | * Query for current response from external service call. 41 | * If it was sync call, then return its response. 42 | * It is was async call, then 43 | * if async response is present - return it 44 | * if it is not present yet - return ack with link to query for async response 45 | * @param uuid - unique identifier of request 46 | */ 47 | Response getResponse(UUID uuid) { 48 | Jedis jedis 49 | try { 50 | if (!jedisDS || !jedisDS.isOn()) { 51 | return new Response(false, "SI_ERR_DATA_SOURCE_OFF", "Data source that keeps responses is not available.") 52 | } 53 | jedis = jedisDS.getResource() 54 | String r = jedis.hget("request:${uuid}", "response") 55 | String ra = jedis.hget("request:${uuid}", "responseAsync") 56 | log.debug("RESPONSE: ${r}") 57 | log.debug("RESPONSE ASYNC: ${ra}") 58 | jedisDS.returnResource(jedis) 59 | jedis = null 60 | 61 | def slurper = new JsonSlurper() 62 | Map data = slurper.parseText(ra ?: r) 63 | Response response = Response.build(data) 64 | 65 | String serverUrl = this.csCtx.serverUrl 66 | response.href = serverUrl + "/api/invoke/${response.id.toString()}/response" 67 | response.links["request"] = [ 68 | href: serverUrl + "/api/invoke/${response.id.toString()}/request" 69 | ] 70 | return response 71 | } 72 | catch (JedisConnectionException ex) { 73 | ex.printStackTrace() 74 | return new Response(false, "SI_EXP_REDIS_CONNECTION_EXCEPTION", "Exception: ${ex.getMessage()}") 75 | } 76 | catch (Exception ex) { 77 | ex.printStackTrace() 78 | return new Response(false, "SI_EXCEPTION", "Exception: ${ex.getMessage()}") 79 | } 80 | finally { 81 | if (jedis) { 82 | jedisDS.returnResource(jedis) 83 | jedis = null 84 | } 85 | } 86 | } 87 | 88 | /** 89 | * Get request that initialized external API call. 90 | * Convert string representation of UUID and invoke getResponse(uuid) 91 | * @param sid - string representation of UUID 92 | */ 93 | Request getRequest(String sid) { 94 | if (!sid) { 95 | return null 96 | } 97 | UUID uuid = UUID.fromString(sid) 98 | return getRequest(uuid) 99 | } 100 | 101 | /** 102 | * Get request that initialized external API call. 103 | * @param uuid - unique identifier of request 104 | */ 105 | Request getRequest(UUID id) { 106 | Jedis jedis 107 | try { 108 | if (!jedisDS || !jedisDS.isOn()) { 109 | return null 110 | } 111 | jedis = jedisDS.getResource() 112 | String r = jedis.hget("request:${id}", "request") 113 | log.debug("REQUEST: ${r}") 114 | jedisDS.returnResource(jedis) 115 | jedis = null 116 | 117 | def slurper = new JsonSlurper() 118 | Map data = slurper.parseText(r) 119 | Request request = Request.build(data) 120 | 121 | String serverUrl = this.csCtx.serverUrl 122 | request.href = serverUrl + "/api/invoke/${request.id.toString()}/request" 123 | request.links["response"] = [ 124 | href: serverUrl + "/api/invoke/${request.id.toString()}/response" 125 | ] 126 | return request 127 | } 128 | catch (JedisConnectionException ex) { 129 | ex.printStackTrace() 130 | return null 131 | } 132 | catch (Exception ex) { 133 | ex.printStackTrace() 134 | return null 135 | } 136 | finally { 137 | if (jedis) { 138 | jedisDS.returnResource(jedis) 139 | jedis = null 140 | } 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/ratpack/Ratpack.groovy: -------------------------------------------------------------------------------- 1 | import static ratpack.groovy.Groovy.groovyTemplate 2 | import static ratpack.groovy.Groovy.ratpack 3 | 4 | import ratpack.server.PublicAddress 5 | 6 | import org.slf4j.Logger 7 | import org.slf4j.LoggerFactory 8 | 9 | import groovy.json.JsonBuilder 10 | import groovy.json.JsonOutput 11 | import groovy.xml.MarkupBuilder 12 | 13 | import groovy.json.JsonSlurper 14 | 15 | import groovy.transform.TupleConstructor 16 | 17 | import ratpack.registry.Registries 18 | import ratpack.rx.RxRatpack 19 | import ratpack.codahale.metrics.CodaHaleMetricsModule 20 | import ratpack.perf.incl.* 21 | import ratpack.codahale.metrics.HealthCheckHandler 22 | import com.codahale.metrics.health.HealthCheckRegistry 23 | 24 | import online4m.apigateway.health.CallerServiceHealthCheck 25 | 26 | import online4m.apigateway.si.CallerModule 27 | import online4m.apigateway.si.CallerService 28 | import online4m.apigateway.si.CallerServiceAsync 29 | import online4m.apigateway.si.QueryServiceAsync 30 | import online4m.apigateway.si.Request 31 | import online4m.apigateway.si.Response 32 | import online4m.apigateway.si.Utils 33 | import online4m.apigateway.ds.CallerDSModule 34 | import online4m.apigateway.si.CallerServiceCtx 35 | import online4m.apigateway.docs.ApiDocsHandler 36 | 37 | 38 | final Logger log = LoggerFactory.getLogger(Ratpack.class) 39 | 40 | ratpack { 41 | bindings { 42 | add new CodaHaleMetricsModule().metrics().jvmMetrics().healthChecks().jmx() 43 | bind CallerServiceHealthCheck 44 | add new CallerModule() 45 | add new CallerDSModule() 46 | 47 | init { 48 | RxRatpack.initialize() 49 | } 50 | } 51 | 52 | handlers { 53 | get { 54 | render groovyTemplate("index.html", title: "My Ratpack App") 55 | } 56 | 57 | handler { CallerServiceCtx csCtx, PublicAddress publicAddress -> 58 | // common functionality for all the other REST methods 59 | if (csCtx && !csCtx.serverUrl) { 60 | csCtx.serverUri = publicAddress.getAddress(context) 61 | } 62 | 63 | // call next() to process request 64 | next() 65 | } 66 | 67 | prefix("api-docs") { 68 | handler registry.get(ApiDocsHandler) 69 | } 70 | 71 | prefix("api") { 72 | // get list of available APIs - follow HAL hypertext application language conventions 73 | get { CallerServiceCtx csCtx -> 74 | redirect "api-docs" 75 | } 76 | 77 | // call reactive way - RxJava 78 | post("invoke") { CallerServiceAsync callerServiceAsync -> 79 | callerServiceAsync.invokeRx(request.body.text).single().subscribe() { Response response -> 80 | log.debug "BEFORE JsonOutput.toJson(response)" 81 | //getResponse().status(201) 82 | render JsonOutput.prettyPrint(JsonOutput.toJson(response)) 83 | } 84 | } 85 | 86 | get("invoke/:id/request") { QueryServiceAsync queryService -> 87 | def sid = pathTokens["id"] 88 | queryService.getRequestRx(sid).single().subscribe() { Request request -> 89 | render JsonOutput.prettyPrint(JsonOutput.toJson(request)) 90 | } 91 | } 92 | 93 | get("invoke/:id/response") { QueryServiceAsync queryService -> 94 | def sid = pathTokens["id"] 95 | queryService.getResponseRx(sid).single().subscribe() { Response response -> 96 | render JsonOutput.prettyPrint(JsonOutput.toJson(response)) 97 | } 98 | } 99 | 100 | // call with ratpack promise 101 | post("invoke1") { CallerServiceAsync callerService -> 102 | callerService.invoke(request.body.text).then { 103 | render JsonOutput.toJson(it) 104 | } 105 | } 106 | 107 | // call with ratpack blocking code (it is running in seperate thread) 108 | post("invoke2") {CallerService callerService -> 109 | blocking { 110 | return callerService.invoke(request.body.text) 111 | }.then { 112 | render JsonOutput.toJson(it) // response.toString() 113 | } 114 | } 115 | 116 | post("invoke3") { CallerService callerService -> 117 | Response response = callerService.invoke(request.body.text) 118 | render JsonOutput.toJson(response) 119 | } 120 | 121 | 122 | // get named health check 123 | get("health-check/:name", new HealthCheckHandler()) 124 | 125 | // run all health checks 126 | get("health-checks") { HealthCheckRegistry healthCheckRegistry -> 127 | render healthCheckRegistry.runHealthChecks().toString() 128 | } 129 | 130 | get("bycontent") { 131 | byContent { 132 | json { 133 | // if HTTP header: Accept is not given then first type is returned as default 134 | // so json is default return format 135 | log.debug("RESPOND JSON") 136 | def builder = new JsonBuilder() 137 | builder.root { 138 | type "JSON" 139 | } 140 | render builder.toString() 141 | } 142 | xml { 143 | log.debug("RESPOND XML") 144 | def swriter = new StringWriter() 145 | new MarkupBuilder(swriter).root { 146 | type(a: "A", b: "B", "XML") 147 | } 148 | render swriter.toString() 149 | } 150 | } 151 | } 152 | } 153 | 154 | assets "public" 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/test/groovy/online4m/apigateway/api/GetApiTest.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.test 2 | 3 | import groovy.json.JsonSlurper 4 | import groovy.json.JsonOutput 5 | 6 | import ratpack.groovy.test.LocalScriptApplicationUnderTest 7 | import ratpack.test.ApplicationUnderTest 8 | import ratpack.test.http.TestHttpClients 9 | import ratpack.test.http.TestHttpClient 10 | import ratpack.http.client.RequestSpec 11 | import ratpack.test.remote.RemoteControl 12 | 13 | import spock.lang.Specification 14 | import spock.lang.Ignore 15 | 16 | class GetApiTest extends Specification { 17 | ApplicationUnderTest aut = new LocalScriptApplicationUnderTest("other.remoteControl.enabled": "true") 18 | @Delegate TestHttpClient client = TestHttpClients.testHttpClient(aut) 19 | RemoteControl remote = new RemoteControl(aut) 20 | 21 | def "Missing REQUIRED: method"() { 22 | given: 23 | def inputJson = [ 24 | mode: "SYNC", 25 | format: "JSON", 26 | url: "http://localhost:4545/get_sync_json" 27 | ] 28 | def json = new JsonSlurper() 29 | 30 | when: 31 | requestSpec { RequestSpec requestSpec -> 32 | requestSpec.body.type("application/json") 33 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 34 | } 35 | post("api/invoke") 36 | 37 | then: 38 | def r = json.parseText(response.body.text) 39 | with(r) { 40 | success == false 41 | errorCode == "SI_ERR_MISSING_ATTRS" 42 | errorDescr ==~ /.+\[(.?)+method(.?)+\](.?)+/ 43 | } 44 | } 45 | 46 | def "Missing REQUIRED: method, mode, format, url"() { 47 | given: 48 | def inputJson = [:] 49 | def json = new JsonSlurper() 50 | 51 | when: 52 | requestSpec { RequestSpec requestSpec -> 53 | requestSpec.body.type("application/json") 54 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 55 | } 56 | post("api/invoke") 57 | 58 | then: 59 | def r = json.parseText(response.body.text) 60 | with(r) { 61 | success == false 62 | errorCode == "SI_ERR_MISSING_ATTRS" 63 | errorDescr ==~ /.+\[(.?)+method(.?)+\](.?)+/ 64 | errorDescr ==~ /.+\[(.?)+mode(.?)+\](.?)+/ 65 | errorDescr ==~ /.+\[(.?)+format(.?)+\](.?)+/ 66 | errorDescr ==~ /.+\[(.?)+url(.?)+\](.?)+/ 67 | } 68 | } 69 | 70 | def "Wrong INPUT: data format"() { 71 | given: 72 | def inputJson = [ 73 | ] 74 | def json = new JsonSlurper() 75 | 76 | when: 77 | requestSpec { RequestSpec requestSpec -> 78 | requestSpec.body.type("application/json") 79 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 80 | } 81 | post("api/invoke") 82 | 83 | then: 84 | def r = json.parseText(response.body.text) 85 | with(r) { 86 | success == false 87 | errorCode == "SI_EXP_ILLEGAL_ARGUMENT" 88 | errorDescr ==~ /(.?)+Exception(.?)+/ 89 | } 90 | } 91 | 92 | def "Wrong INPUT: invocation method"() { 93 | given: 94 | def inputJson = [ 95 | method: "GET2", 96 | mode: "SYNC", 97 | format: "JSON", 98 | url: "http://localhost:4545/get_sync_json" 99 | ] 100 | def json = new JsonSlurper() 101 | 102 | when: 103 | requestSpec { RequestSpec requestSpec -> 104 | requestSpec.body.type("application/json") 105 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 106 | } 107 | post("api/invoke") 108 | 109 | then: 110 | def r = json.parseText(response.body.text) 111 | with(r) { 112 | success == false 113 | errorCode == "SI_EXP_ILLEGAL_ARGUMENT" 114 | } 115 | } 116 | 117 | def "Success: GET SYNC JSON"() { 118 | given: 119 | def inputJson = [ 120 | method: "GET", 121 | mode: "SYNC", 122 | format: "JSON", 123 | url: "http://localhost:4545/get_sync_json", 124 | data: [ 125 | req_attr1: "alpha", 126 | req_attr2: "beta", 127 | req_attr3: [ 128 | req_attr3_1: "3_1" 129 | ] 130 | ] 131 | ] 132 | def json = new JsonSlurper() 133 | 134 | when: 135 | requestSpec { RequestSpec requestSpec -> 136 | requestSpec.body.type("application/json") 137 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 138 | } 139 | post("api/invoke") 140 | 141 | then: 142 | def r = json.parseText(response.body.text) 143 | with(r) { 144 | success == true 145 | errorCode == "0" 146 | r.data 147 | r.data.resp_attr1 == "beta" 148 | r.data.resp_attr2 == "alpha" 149 | r.data.resp_attr3.resp_attr3_1 == "3_1" 150 | } 151 | } 152 | 153 | def "Get XML with success"() { 154 | given: 155 | def inputJson = [ 156 | method: "GET", 157 | mode: "SYNC", 158 | format: "XML", 159 | url: "http://localhost:4545/get_xml", 160 | data: [ 161 | req_attr1: "alpha", 162 | req_attr2: "beta", 163 | req_attr3: [ 164 | req_attr3_1: "3_1" 165 | ] 166 | ] 167 | ] 168 | def json = new JsonSlurper() 169 | 170 | when: 171 | requestSpec { RequestSpec requestSpec -> 172 | requestSpec.body.type("application/json") 173 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 174 | } 175 | post("api/invoke") 176 | 177 | then: 178 | def r = json.parseText(response.body.text) 179 | with(r) { 180 | success == true 181 | errorCode == "0" 182 | r.data 183 | r.data.customer.address.street == "Beta" 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/main/groovy/online4m/apigateway/si/Utils.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.si 2 | 3 | import org.slf4j.Logger 4 | import org.slf4j.LoggerFactory 5 | 6 | import groovy.xml.StreamingMarkupBuilder 7 | import groovy.xml.XmlUtil 8 | 9 | import groovy.json.JsonOutput 10 | 11 | import groovy.util.slurpersupport.NodeChild 12 | 13 | class Utils { 14 | final static Logger log = LoggerFactory.getLogger(Utils.class) 15 | 16 | private static final Set simpleTypes = new HashSet([ 17 | Boolean.class, 18 | Character.class, 19 | Byte.class, 20 | Short.class, 21 | Integer.class, 22 | Long.class, 23 | Float.class, 24 | Double.class, 25 | Void.class, 26 | String.class 27 | ]) 28 | 29 | static boolean isSimpleType(Class clazz) { 30 | simpleTypes.contains(clazz) 31 | } 32 | 33 | /** 34 | * Extract query attributes from url and inputData 35 | * @param url - URL given by caller. After this method call url must have query set to null 36 | * @param inputData - map of attributes and its values. If attribute is complex (Map, List) it is automatically skipped 37 | */ 38 | static Map buildQueryAttributesMap(URL url, Map inputData) { 39 | def queryMap = [:] 40 | inputData?.each{key, val -> 41 | if (key instanceof String && Utils.isSimpleType(val.getClass())) { 42 | queryMap[key] = val 43 | } 44 | else { 45 | log.warn("SKIPPING: key=${key}, value=${val}") 46 | } 47 | } 48 | 49 | if (url.getQuery()) { 50 | // extract query attributes from url and merge them qith queryMap 51 | String q = url.getQuery() 52 | String[] params = q.split("&") 53 | for (param in params) { 54 | String key, val 55 | (key, val) = param.split("=") 56 | queryMap[key] = val 57 | } 58 | url.set( 59 | url.getProtocol(), 60 | url.getHost(), 61 | url.getPort(), 62 | url.getAuthority(), 63 | url.getUserInfo(), 64 | url.getPath(), 65 | null, // set null query 66 | url.getRef()) 67 | } 68 | log.debug("URI QUERY: ${queryMap}") 69 | return queryMap 70 | } 71 | 72 | /** 73 | * Build request headers. 74 | * @param requestHeaders - map of headers 75 | * @param headersToSet - map of headers to set 76 | */ 77 | static HashMap buildRequestHeaders(Map requestHeaders, Map headersToSet) { 78 | log.debug("BUILD REQ HEADERS: ${requestHeaders}, ${headersToSet}") 79 | if (requestHeaders == null || headersToSet == null) { 80 | return requestHeaders 81 | } 82 | headersToSet?.each{ key, val -> 83 | log.debug("SETTING HEADER: ${key}, ${val}") 84 | if (key instanceof String && Utils.isSimpleType(val.getClass())) { 85 | requestHeaders[key] = val 86 | } 87 | } 88 | 89 | return requestHeaders 90 | } 91 | 92 | /** 93 | * Generate xml string with groovy MarkupBuilder 94 | * 95 | * @param data - either Map or List 96 | */ 97 | static String buildXmlString(Object data) { 98 | if (!data) { 99 | return null 100 | } 101 | // build body's XML recursivelly 102 | // declare xmlBuilder closure before its definition to be visible inside it content 103 | def xmlbuilder 104 | xmlbuilder = { c -> 105 | if (c instanceof Map) { 106 | c.each{ key, value -> 107 | if (Utils.isSimpleType(value.getClass())) { 108 | "$key"("$value") 109 | } 110 | else { 111 | "$key" { 112 | xmlbuilder(value) 113 | } 114 | } 115 | } 116 | } 117 | else if (c instanceof List) { 118 | c.each{value -> 119 | xmlbuilder(value) 120 | } 121 | } 122 | } 123 | 124 | def xmlString = new StreamingMarkupBuilder().bind { 125 | xmlbuilder.delegate = delegate 126 | xmlbuilder(data) 127 | }.toString() 128 | 129 | return xmlString 130 | } 131 | 132 | /** 133 | * Normalize xml response to JSON compatible object/entity 134 | * @param node - xml root node to convert to json compatible groovy object/entity 135 | */ 136 | static Object buildJsonEntity(NodeChild xml) { 137 | if (!xml) { 138 | return null 139 | } 140 | 141 | def jsonBuilder 142 | jsonBuilder = { node -> 143 | def childNodes = node.childNodes() 144 | if (!childNodes.hasNext()) { 145 | return node.text() 146 | } 147 | else { 148 | def map = [:] 149 | def list = [] 150 | childNodes.each { 151 | if (!map.containsKey(it.name())) { 152 | map[it.name()] = jsonBuilder(it) 153 | } 154 | else { 155 | if (list.size() == 0) { 156 | def e = [:] 157 | e[it.name()] = map[it.name()] 158 | list.add(e) 159 | } 160 | def ed = jsonBuilder(it) 161 | def e = [:] 162 | e[it.name()] = ed 163 | list.add(e) 164 | } 165 | } 166 | if (list.size() && map.size() > 1) { 167 | // unconsistent xml and output json format. 168 | log.error("UNCONSISTENT XML FORMAT. Mix of entities and collections") 169 | return map 170 | } 171 | else if (list.size()) { 172 | return list 173 | } 174 | else { 175 | return map 176 | } 177 | } 178 | } 179 | 180 | def jsonEntity = [ 181 | (xml.name()): jsonBuilder(xml) 182 | ] 183 | 184 | log.debug("RESPONSE jsonEntity: ${jsonEntity.toString()}") 185 | log.debug("RESPONSE TO JSON: ${JsonOutput.toJson(jsonEntity)}") 186 | 187 | return jsonEntity 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # For Cygwin, ensure paths are in UNIX format before anything is touched. 46 | if $cygwin ; then 47 | [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 48 | fi 49 | 50 | # Attempt to set APP_HOME 51 | # Resolve links: $0 may be a link 52 | PRG="$0" 53 | # Need this for relative symlinks. 54 | while [ -h "$PRG" ] ; do 55 | ls=`ls -ld "$PRG"` 56 | link=`expr "$ls" : '.*-> \(.*\)$'` 57 | if expr "$link" : '/.*' > /dev/null; then 58 | PRG="$link" 59 | else 60 | PRG=`dirname "$PRG"`"/$link" 61 | fi 62 | done 63 | SAVED="`pwd`" 64 | cd "`dirname \"$PRG\"`/" >&- 65 | APP_HOME="`pwd -P`" 66 | cd "$SAVED" >&- 67 | 68 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 69 | 70 | # Determine the Java command to use to start the JVM. 71 | if [ -n "$JAVA_HOME" ] ; then 72 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 73 | # IBM's JDK on AIX uses strange locations for the executables 74 | JAVACMD="$JAVA_HOME/jre/sh/java" 75 | else 76 | JAVACMD="$JAVA_HOME/bin/java" 77 | fi 78 | if [ ! -x "$JAVACMD" ] ; then 79 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 80 | 81 | Please set the JAVA_HOME variable in your environment to match the 82 | location of your Java installation." 83 | fi 84 | else 85 | JAVACMD="java" 86 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 87 | 88 | Please set the JAVA_HOME variable in your environment to match the 89 | location of your Java installation." 90 | fi 91 | 92 | # Increase the maximum file descriptors if we can. 93 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 94 | MAX_FD_LIMIT=`ulimit -H -n` 95 | if [ $? -eq 0 ] ; then 96 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 97 | MAX_FD="$MAX_FD_LIMIT" 98 | fi 99 | ulimit -n $MAX_FD 100 | if [ $? -ne 0 ] ; then 101 | warn "Could not set maximum file descriptor limit: $MAX_FD" 102 | fi 103 | else 104 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 105 | fi 106 | fi 107 | 108 | # For Darwin, add options to specify how the application appears in the dock 109 | if $darwin; then 110 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 111 | fi 112 | 113 | # For Cygwin, switch paths to Windows format before running java 114 | if $cygwin ; then 115 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 116 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 158 | function splitJvmOpts() { 159 | JVM_OPTS=("$@") 160 | } 161 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 162 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 163 | 164 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 165 | -------------------------------------------------------------------------------- /src/test/groovy/online4m/apigateway/func/ForceDotComApiTest.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.func 2 | 3 | import groovy.json.JsonSlurper 4 | import groovy.json.JsonOutput 5 | 6 | import ratpack.groovy.test.LocalScriptApplicationUnderTest 7 | import ratpack.test.ApplicationUnderTest 8 | import ratpack.test.http.TestHttpClients 9 | import ratpack.test.http.TestHttpClient 10 | import ratpack.http.client.RequestSpec 11 | import ratpack.test.remote.RemoteControl 12 | 13 | import spock.lang.Specification 14 | import spock.lang.Ignore 15 | import spock.lang.Shared 16 | 17 | class ForceDocComApiTest extends Specification { 18 | ApplicationUnderTest aut = new LocalScriptApplicationUnderTest("other.remoteControl.enabled": "true") 19 | @Delegate TestHttpClient client = TestHttpClients.testHttpClient(aut) 20 | RemoteControl remote = new RemoteControl(aut) 21 | 22 | @Shared Properties envProps 23 | 24 | // run before the first feature method 25 | // Intialization of common resources for all feature methods 26 | def setupSpec() { 27 | envProps = new Properties() 28 | envProps.load(getClass().getClassLoader().getResourceAsStream("force.com.properties")) 29 | } 30 | 31 | def "Force.com resource object lifecycle"() { 32 | given: 33 | def json = new JsonSlurper() 34 | def accessToken 35 | def instanceUrl 36 | def custId 37 | 38 | when: "get access token" 39 | def req = [ 40 | method: "POST", 41 | mode: "SYNC", 42 | format: "URLENC", 43 | url: "https://login.salesforce.com/services/oauth2/token", 44 | data: [ 45 | grant_type: "password", 46 | client_id: "${envProps.customer_key}", 47 | client_secret: "${envProps.customer_secret}", 48 | username: "${envProps.username}", 49 | password: "${envProps.password}" 50 | ] 51 | ] 52 | requestSpec { RequestSpec reqSpec -> 53 | reqSpec.body.text(JsonOutput.toJson(req)) 54 | } 55 | post("api/invoke") 56 | def resp = json.parseText(response.body.text) 57 | 58 | then: "access token is active" 59 | with(resp) { 60 | success == true 61 | statusCode == 200 62 | data.access_token 63 | data.token_type == "Bearer" 64 | data.instance_url 65 | } 66 | 67 | when: 68 | accessToken = resp.data.access_token 69 | instanceUrl = resp.data.instance_url 70 | 71 | then: 72 | accessToken 73 | instanceUrl 74 | 75 | when: "create new account" 76 | req = [ 77 | method: "POST", 78 | mode: "SYNC", 79 | format: "JSON", 80 | url: "${instanceUrl}/services/data/v32.0/sobjects/Account/", 81 | headers: [ 82 | "Authorization": "Bearer ${accessToken}" 83 | ], 84 | data: [ 85 | Name: "Test company name 1" 86 | ] 87 | ] 88 | requestSpec { RequestSpec reqSpec -> 89 | reqSpec.body.text(JsonOutput.toJson(req)) 90 | } 91 | post("api/invoke") 92 | resp = json.parseText(response.body.text) 93 | 94 | then: "new account id is given" 95 | with(resp) { 96 | success == true 97 | errorCode == "0" 98 | statusCode == 201 99 | data.id 100 | data.success == true 101 | } 102 | 103 | when: 104 | custId = resp.data.id 105 | 106 | then: 107 | custId 108 | 109 | when: "update account properties" 110 | req = [ 111 | method: "PATCH", 112 | mode: "SYNC", 113 | format: "JSON", 114 | url: "${instanceUrl}/services/data/v32.0/sobjects/Account/${custId}", 115 | headers: [ 116 | "Authorization": "Bearer ${accessToken}" 117 | ], 118 | data: [ 119 | Name: "New Test Company Name 1", 120 | BillingCity: "Warsaw" 121 | ] 122 | ] 123 | requestSpec { RequestSpec reqSpec -> 124 | reqSpec.body.text(JsonOutput.toJson(req)) 125 | } 126 | post("api/invoke") 127 | resp = json.parseText(response.body.text) 128 | 129 | 130 | then: "no error and empty content is returned" 131 | with(resp) { 132 | success == true 133 | errorCode == "0" 134 | statusCode == 204 135 | data == null 136 | } 137 | 138 | when: "get account properties" 139 | req = [ 140 | method: "GET", 141 | mode: "SYNC", 142 | format: "JSON", 143 | url: "${instanceUrl}/services/data/v32.0/sobjects/Account/${custId}", 144 | headers: [ 145 | "Authorization": "Bearer ${accessToken}" 146 | ], 147 | data: [ 148 | fields: "Name,BillingCity" 149 | ] 150 | ] 151 | requestSpec { RequestSpec reqSpec -> 152 | reqSpec.body.text(JsonOutput.toJson(req)) 153 | } 154 | post("api/invoke") 155 | resp = json.parseText(response.body.text) 156 | 157 | then: "account should have updated properties" 158 | with(resp) { 159 | success == true 160 | errorCode == "0" 161 | statusCode == 200 162 | data.Name == "New Test Company Name 1" 163 | data.BillingCity == "Warsaw" 164 | } 165 | 166 | when: "delete account" 167 | req = [ 168 | method: "DELETE", 169 | mode: "SYNC", 170 | format: "JSON", 171 | url: "${instanceUrl}/services/data/v32.0/sobjects/Account/${custId}", 172 | headers: [ 173 | "Authorization": "Bearer ${accessToken}" 174 | ] 175 | ] 176 | requestSpec { RequestSpec reqSpec -> 177 | reqSpec.body.text(JsonOutput.toJson(req)) 178 | } 179 | post("api/invoke") 180 | resp = json.parseText(response.body.text) 181 | 182 | then: "account is deleted and no content returned" 183 | with(resp) { 184 | success == true 185 | errorCode == "0" 186 | statusCode == 204 187 | !data 188 | } 189 | 190 | when: "get not existing account" 191 | req = [ 192 | method: "GET", 193 | mode: "SYNC", 194 | format: "JSON", 195 | url: "${instanceUrl}/services/data/v32.0/sobjects/Account/${custId}", 196 | headers: [ 197 | "Authorization": "Bearer ${accessToken}" 198 | ], 199 | data: [ 200 | fields: "Name,BillingCity" 201 | ] 202 | ] 203 | requestSpec { RequestSpec reqSpec -> 204 | reqSpec.body.text(JsonOutput.toJson(req)) 205 | } 206 | post("api/invoke") 207 | resp = json.parseText(response.body.text) 208 | 209 | println(JsonOutput.prettyPrint(JsonOutput.toJson(resp))) 210 | 211 | then: "account does not exists" 212 | with(resp) { 213 | success == false 214 | errorCode == "HTTP_ERR_404" 215 | statusCode == 404 216 | } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/test/resources/imposter.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 4545, 3 | "protocol": "http", 4 | "stubs": [ 5 | { 6 | "responses": [ 7 | { 8 | "is": { 9 | "statusCode": "200", 10 | "headers" : { 11 | "Content-Type": "application/json" 12 | }, 13 | "body": "{\"resp_attr1\": \"beta\", \"resp_attr2\": \"alpha\", \"resp_attr3\": {\"resp_attr3_1\": \"3_1\"}}" 14 | } 15 | } 16 | ], 17 | "predicates": [ 18 | { 19 | "equals": { 20 | "method": "GET", 21 | "path": "/get_sync_json", 22 | "query": { 23 | "req_attr1": "alpha", 24 | "req_attr2": "beta" 25 | } 26 | } 27 | } 28 | ] 29 | }, 30 | { 31 | "responses": [{ 32 | "is": { 33 | "statusCode": "204" 34 | } 35 | }], 36 | "predicates": [{ 37 | "equals": { 38 | "method": "POST", 39 | "path": "/empty_post_response" 40 | } 41 | }] 42 | }, 43 | { 44 | "responses": [{ 45 | "is": { 46 | "statusCode": "201", 47 | "headers": { 48 | "Location": "http://localhost:4545/get_sync_json", 49 | "Content-Type": "application/json" 50 | }, 51 | "body": "{\"id\": \"1\"}" 52 | } 53 | }], 54 | "predicates": [{ 55 | "equals": { 56 | "method": "POST", 57 | "path": "/post_sync_json" 58 | } 59 | }] 60 | }, 61 | { 62 | "responses": [{ 63 | "is": { 64 | "statusCode": "201", 65 | "headers": { 66 | "Location": "http://localhost:4545/get_sync_json", 67 | "Content-Type": "application/json" 68 | }, 69 | "body": "{\"id\": \"101\"}", 70 | "_behaviors": { 71 | "wait": 1000 72 | } 73 | } 74 | }], 75 | "predicates": [{ 76 | "equals": { 77 | "method": "POST", 78 | "path": "/post_async_json" 79 | } 80 | }] 81 | }, 82 | { 83 | "responses": [{ 84 | "is": { 85 | "statusCode": "201", 86 | "headers": { 87 | "Content-Type": "application/xml" 88 | }, 89 | "body": "101102" 90 | } 91 | }], 92 | "predicates": [{ 93 | "equals": { 94 | "method": "POST", 95 | "path": "/post_xml" 96 | } 97 | }] 98 | }, 99 | { 100 | "responses": [{ 101 | "is": { 102 | "statusCode": "201", 103 | "headers": { 104 | "Content-Type": "application/xml" 105 | }, 106 | "body": "
Alpha
" 107 | } 108 | }], 109 | "predicates": [{ 110 | "and": [ 111 | { 112 | "equals": { 113 | "method": "POST", 114 | "path": "/post_xml_2" 115 | } 116 | }, 117 | { 118 | "matches": { 119 | "body": "^12" 120 | } 121 | } 122 | ] 123 | }] 124 | }, 125 | { 126 | "responses": [{ 127 | "is": { 128 | "statusCode": "200", 129 | "headers": { 130 | "Content-Type": "application/xml" 131 | }, 132 | "body": "
Beta
" 133 | } 134 | }], 135 | "predicates": [{ 136 | "equals": { 137 | "method": "GET", 138 | "path": "/get_xml" 139 | } 140 | }] 141 | }, 142 | { 143 | "responses": [{ 144 | "is": { 145 | "statusCode": "200", 146 | "headers": { 147 | "Content-Type": "application/json" 148 | }, 149 | "body": "{\"customer\": {\"address\": {\"street\": \"Alpha\"}}}" 150 | } 151 | }], 152 | "predicates": [{ 153 | "and": [ 154 | { 155 | "equals": { 156 | "method": "POST", 157 | "path": "/post_urlenc.json", 158 | "headers": { 159 | "Content-Type": "application/x-www-form-urlencoded" 160 | } 161 | } 162 | }, 163 | { 164 | "matches": { 165 | "body": "^attr_1=attr1val&attr_2=attr2val" 166 | } 167 | } 168 | ] 169 | }] 170 | }, 171 | { 172 | "responses": [{ 173 | "is": { 174 | "statusCode": "200", 175 | "headers": { 176 | "Content-Type": "application/xml" 177 | }, 178 | "body": "
Beta
" 179 | } 180 | }], 181 | "predicates": [{ 182 | "and": [ 183 | { 184 | "equals": { 185 | "method": "POST", 186 | "path": "/post_urlenc.xml", 187 | "headers": { 188 | "Content-Type": "application/x-www-form-urlencoded" 189 | } 190 | } 191 | }, 192 | { 193 | "matches": { 194 | "body": "^attr_1=attr1val&attr_2=attr2val" 195 | } 196 | } 197 | ] 198 | }] 199 | }, 200 | { 201 | "responses": [{ 202 | "is": { 203 | "statusCode": "200", 204 | "headers": { 205 | "Content-Type": "application/unknown" 206 | }, 207 | "body": "customer=Beta" 208 | } 209 | }], 210 | "predicates": [{ 211 | "and": [ 212 | { 213 | "equals": { 214 | "method": "POST", 215 | "path": "/post_urlenc.unknown", 216 | "headers": { 217 | "Content-Type": "application/x-www-form-urlencoded" 218 | } 219 | } 220 | }, 221 | { 222 | "matches": { 223 | "body": "^attr_1=attr1val&attr_2=attr2val" 224 | } 225 | } 226 | ] 227 | }] 228 | }, 229 | { 230 | "responses": [{ 231 | "is": { 232 | "statusCode": "200", 233 | "headers": { 234 | "Content-Type": "application/json" 235 | }, 236 | "body": "{\"id\": \"1001\"}" 237 | } 238 | }], 239 | "predicates": [{ 240 | "equals": { 241 | "method": "PUT", 242 | "path": "/put_json_cust" 243 | } 244 | }] 245 | }, 246 | { 247 | "responses": [{ 248 | "is": { 249 | "statusCode": "204" 250 | } 251 | }], 252 | "predicates": [{ 253 | "equals": { 254 | "method": "PATCH", 255 | "path": "/patch_json_cust_no_content" 256 | } 257 | }] 258 | }, 259 | { 260 | "responses": [{ 261 | "is": { 262 | "statusCode": "200", 263 | "headers": { 264 | "Content-Type": "application/json" 265 | }, 266 | "body": "{\"id\": \"1001\"}" 267 | } 268 | }], 269 | "predicates": [{ 270 | "equals": { 271 | "method": "DELETE", 272 | "path": "/delete_cust", 273 | "query": { 274 | "id": "1001" 275 | } 276 | } 277 | }] 278 | } 279 | ] 280 | } 281 | -------------------------------------------------------------------------------- /src/test/groovy/online4m/apigateway/api/PostApiTest.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.api 2 | 3 | import groovy.json.JsonSlurper 4 | import groovy.json.JsonOutput 5 | 6 | import ratpack.groovy.test.LocalScriptApplicationUnderTest 7 | import ratpack.test.ApplicationUnderTest 8 | import ratpack.test.http.TestHttpClients 9 | import ratpack.test.http.TestHttpClient 10 | import ratpack.http.client.RequestSpec 11 | import ratpack.test.remote.RemoteControl 12 | 13 | import spock.lang.Specification 14 | import spock.lang.Ignore 15 | 16 | class PostApiTest extends Specification { 17 | ApplicationUnderTest aut = new LocalScriptApplicationUnderTest("other.remoteControl.enabled": "true") 18 | @Delegate TestHttpClient client = TestHttpClients.testHttpClient(aut) 19 | RemoteControl remote = new RemoteControl(aut) 20 | 21 | def "EMPTY RESPONSE"() { 22 | given: 23 | def inputJson = [ 24 | method: "POST", 25 | mode: "SYNC", 26 | format: "JSON", 27 | url: "http://localhost:4545/empty_post_response", 28 | data: [ 29 | customer: [ 30 | name1: "Name1", 31 | name2: "Name2", 32 | mobile: "0486009988", 33 | identification: [ 34 | type: "ID", 35 | value: "AXX098765" 36 | ] 37 | ], 38 | status: "INIT" 39 | ] 40 | ] 41 | def json = new JsonSlurper() 42 | 43 | when: 44 | requestSpec { RequestSpec requestSpec -> 45 | requestSpec.body.type("application/json") 46 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 47 | } 48 | post("api/invoke") 49 | 50 | then: 51 | def r = json.parseText(response.body.text) 52 | with(r) { 53 | success == true 54 | errorCode == "0" 55 | data == null 56 | } 57 | } 58 | 59 | def "Success: POST SYNC JSON"() { 60 | given: 61 | def inputJson = [ 62 | method: "POST", 63 | mode: "SYNC", 64 | format: "JSON", 65 | url: "http://localhost:4545/post_sync_json", 66 | data: [ 67 | customer: [ 68 | name1: "Name1", 69 | name2: "Name2", 70 | mobile: "0486009988", 71 | identification: [ 72 | type: "ID", 73 | value: "AXX098765" 74 | ] 75 | ], 76 | status: "INIT" 77 | ] 78 | ] 79 | def json = new JsonSlurper() 80 | 81 | when: 82 | requestSpec { RequestSpec requestSpec -> 83 | requestSpec.body.type("application/json") 84 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 85 | } 86 | post("api/invoke") 87 | 88 | then: 89 | def r = json.parseText(response.body.text) 90 | with(r) { 91 | success == true 92 | errorCode == "0" 93 | data != null 94 | data.id == "1" 95 | } 96 | } 97 | 98 | def "Post XML synchronously with success"() { 99 | given: 100 | def inputJson = [ 101 | method: "POST", 102 | mode: "SYNC", 103 | format: "XML", 104 | url: "http://localhost:4545/post_xml", 105 | data: [ 106 | customer: [ 107 | name1: "Name1", 108 | name2: "Name2", 109 | mobile: "0486009988", 110 | identification: [ 111 | type: "ID", 112 | value: "AXX098765" 113 | ] 114 | ], 115 | status: "INIT" 116 | ] 117 | ] 118 | def json = new JsonSlurper() 119 | 120 | when: 121 | requestSpec { RequestSpec requestSpec -> 122 | requestSpec.body.type("application/json") 123 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 124 | } 125 | post("api/invoke") 126 | 127 | then: 128 | def r = json.parseText(response.body.text) 129 | with(r) { 130 | success == true 131 | errorCode == "0" 132 | data != null 133 | println "data: ${data}, ${data.customer.getClass()}" 134 | data instanceof Map 135 | data.customer instanceof List 136 | data.customer.size() == 2 137 | data.customer[0].id == "101" 138 | data.customer[1].id == "102" 139 | } 140 | } 141 | 142 | def "Post XML with list synchronously and get success"() { 143 | given: 144 | def inputJson = [ 145 | method: "POST", 146 | mode: "SYNC", 147 | format: "XML", 148 | url: "http://localhost:4545/post_xml_2", 149 | data: [ 150 | customer: [ 151 | ids: [ 152 | [id: "1"], 153 | [id: "2"] 154 | ] 155 | ] 156 | ] 157 | ] 158 | def json = new JsonSlurper() 159 | 160 | when: 161 | requestSpec { RequestSpec requestSpec -> 162 | requestSpec.body.type("application/json") 163 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 164 | } 165 | post("api/invoke") 166 | 167 | then: 168 | def r = json.parseText(response.body.text) 169 | with(r) { 170 | success == true 171 | errorCode == "0" 172 | data != null 173 | println "data: ${data}, ${data.customer.getClass()}" 174 | data instanceof Map 175 | data.customer.address.street == "Alpha" 176 | } 177 | } 178 | 179 | def "POST URLENC with API json output and get success"() { 180 | given: 181 | def inputJson = [ 182 | method: "POST", 183 | mode: "SYNC", 184 | format: "URLENC", 185 | url: "http://localhost:4545/post_urlenc.json", 186 | data: [ 187 | "attr_1": "attr1val", 188 | "attr_2": "attr2val", 189 | customer: [ 190 | ids: [ 191 | [id: "1"], 192 | [id: "2"] 193 | ] 194 | ] 195 | ] 196 | ] 197 | def json = new JsonSlurper() 198 | 199 | when: 200 | requestSpec { RequestSpec requestSpec -> 201 | requestSpec.body.type("application/json") 202 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 203 | } 204 | post("api/invoke") 205 | 206 | then: 207 | def r = json.parseText(response.body.text) 208 | with(r) { 209 | success == true 210 | errorCode == "0" 211 | data != null 212 | println "data: ${data}, ${data.customer.getClass()}" 213 | data instanceof Map 214 | data.customer.address.street == "Alpha" 215 | } 216 | } 217 | 218 | def "POST URLENC with API xml output and get success"() { 219 | given: 220 | def inputJson = [ 221 | method: "POST", 222 | mode: "SYNC", 223 | format: "URLENC", 224 | url: "http://localhost:4545/post_urlenc.xml", 225 | data: [ 226 | "attr_1": "attr1val", 227 | "attr_2": "attr2val", 228 | customer: [ 229 | ids: [ 230 | [id: "1"], 231 | [id: "2"] 232 | ] 233 | ] 234 | ] 235 | ] 236 | def json = new JsonSlurper() 237 | 238 | when: 239 | requestSpec { RequestSpec requestSpec -> 240 | requestSpec.body.type("application/json") 241 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 242 | } 243 | post("api/invoke") 244 | 245 | then: 246 | def r = json.parseText(response.body.text) 247 | with(r) { 248 | success == true 249 | errorCode == "0" 250 | data != null 251 | println "data: ${data}, ${data.customer.getClass()}" 252 | data instanceof Map 253 | data.customer.address.street == "Beta" 254 | } 255 | } 256 | 257 | def "POST URLENC with API unknown output and fail"() { 258 | given: 259 | def inputJson = [ 260 | method: "POST", 261 | mode: "SYNC", 262 | format: "URLENC", 263 | url: "http://localhost:4545/post_urlenc.unknown", 264 | data: [ 265 | "attr_1": "attr1val", 266 | "attr_2": "attr2val", 267 | customer: [ 268 | ids: [ 269 | [id: "1"], 270 | [id: "2"] 271 | ] 272 | ] 273 | ] 274 | ] 275 | def json = new JsonSlurper() 276 | 277 | when: 278 | requestSpec { RequestSpec requestSpec -> 279 | requestSpec.body.type("application/json") 280 | requestSpec.body.text(JsonOutput.toJson(inputJson)) 281 | } 282 | post("api/invoke") 283 | 284 | then: 285 | def r = json.parseText(response.body.text) 286 | with(r) { 287 | success == false 288 | errorCode == "SI_UNSUPPORTED_API_CONTENT_TYPE" 289 | } 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/main/groovy/online4m/apigateway/docs/ApiDocsHandler.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.docs 2 | 3 | import javax.inject.Inject 4 | 5 | import groovy.util.logging.Slf4j 6 | 7 | import groovy.json.JsonOutput 8 | 9 | import ratpack.groovy.handling.GroovyHandler 10 | import ratpack.groovy.handling.GroovyContext 11 | 12 | import online4m.apigateway.si.CallerServiceCtx 13 | 14 | @Slf4j 15 | class ApiDocsHandler extends GroovyHandler { 16 | @Inject CallerServiceCtx csCtx 17 | 18 | protected void handle(GroovyContext context) { 19 | def swgr = getSwaggerApi() 20 | log.debug("Swagger array type: ${swgr.getClass()}") 21 | context.with { 22 | byMethod { 23 | get { 24 | byContent { 25 | json { 26 | render JsonOutput.prettyPrint(JsonOutput.toJson(swgr)) 27 | } 28 | } 29 | } 30 | } 31 | } 32 | } 33 | 34 | /** 35 | * Definition of APIGateway's API in Swagger format. 36 | * @return java.util.LinkedHashMap - swagger JSON 37 | */ 38 | private def getSwaggerApi() { 39 | String serverUrl = csCtx.getHost() + (csCtx.getPort() ? ":${csCtx.getPort()}" : "") 40 | def swgr = [ 41 | swagger: "2.0", 42 | info: [ 43 | title: "ApiGateway - unified access to REST/Web services", 44 | description: """ 45 | Single entry point for invocation of diverse services. 46 | (*) all requests are handled asynchronously 47 | (*) unified input and output data format for gatway call 48 | (*) APIGateway is REST API itself 49 | (*) supported invocation modes: SYNC, ASYNC, EVENT (fire and forget) 50 | (*) APIGateway is ratpack.io service 51 | """, 52 | contact: [ 53 | name: "zedar", 54 | url: "https://github.com/zedar" 55 | ], 56 | license: [ 57 | name: "Creative Commons 4.0 International", 58 | url: "http://creativecommons.org/licenses/by/4.0/" 59 | ], 60 | version: "0.1.0" 61 | ], 62 | host: serverUrl, 63 | basePath: "/api", 64 | schemes: ["http", "https"], 65 | paths: [ 66 | "/invoke": [ 67 | post: [ 68 | tags: ["api", "invoke", "route"], 69 | summary: "Invoke external API with unified request format", 70 | consumes: ["application/json"], 71 | produces: ["application/json"], 72 | parameters: [ 73 | [ 74 | name: "body", 75 | description: "API invoke request properties", 76 | in: "body", 77 | required: true, 78 | schema: [ 79 | "\$ref": "#/definitions/Request" 80 | ] 81 | ] 82 | ], 83 | responses: [ 84 | "200": [ 85 | description: "API Gateway successful response", 86 | schema: [ 87 | "\$ref": "#/definitions/Response" 88 | ] 89 | ], 90 | "default": [ 91 | description: "Unexpected error", 92 | schema: [ 93 | "\$ref": "#/definitions/Error" 94 | ] 95 | ] 96 | ] 97 | ] 98 | ], 99 | "/invoke/{id}/request": [ 100 | get: [ 101 | tags: ["api", "invoke", "request"], 102 | summary: "Get request properties for the given unique request {id}", 103 | consumes: ["application/json"], 104 | produces: ["application/json"], 105 | parameters: [ 106 | [ 107 | name: "id", 108 | description: "UUID - unique API invocation ID", 109 | in: "path", 110 | required: true, 111 | type: "string" 112 | ] 113 | ], 114 | responses: [ 115 | "200": [ 116 | description: "API invocation request correlated with the given {id}", 117 | schema: [ 118 | "\$ref": "#/definitions/Request" 119 | ] 120 | ] 121 | ] 122 | ] 123 | ], 124 | "/invoke/{id}/response": [ 125 | get: [ 126 | tags: ["api", "invoke", "response"], 127 | summary: "Get response properties for the given unique request {id}", 128 | consumes: ["application/json"], 129 | produces: ["application/json"], 130 | parameters: [ 131 | [ 132 | name: "id", 133 | description: "UUID - unique API invocation ID", 134 | in: "path", 135 | required: true, 136 | type: "string" 137 | ] 138 | ], 139 | responses: [ 140 | "200": [ 141 | description: "API invocation response correlated with the given {id}", 142 | schema: [ 143 | "\$ref": "#/definitions/Response" 144 | ] 145 | ] 146 | ] 147 | ] 148 | ] 149 | ], 150 | definitions: [ 151 | Request: [ 152 | title: "Request", 153 | description: "API invoke request properties", 154 | type: "object", 155 | properties: [ 156 | id: [ 157 | type: "string", 158 | description: "UUID - Unique request ID" 159 | ], 160 | method: [ 161 | enum: [ 162 | "POST", "GET", "PUT", "PATCH", "DELETE" 163 | ], 164 | description: " API invocation method" 165 | ], 166 | mode: [ 167 | enum: [ 168 | "SYNC", "ASYNC", "EVENT" 169 | ], 170 | description: "SYNC - invoke API and wait for response, ASYNC - invoke API and request for its response, EVENT - invoke API and do not wait for response" 171 | ], 172 | format: [ 173 | enum: [ 174 | "JSON", "XML", "URLENC" 175 | ], 176 | description: "JSON: Content-Type: application/json, XML: Content-Type: application/xml, URLENC: Content-Type: application/x-www-form-urlencoded" 177 | ], 178 | url: [ 179 | type: "string", 180 | description: "URL to invoke the API" 181 | ], 182 | headers: [ 183 | type: "object", 184 | description: "Map (key:value) of headers to be set on API invocation" 185 | ], 186 | data: [ 187 | type: "object", 188 | description: "HTTP request data to be sent as either query attributes or body content" 189 | ], 190 | links: [ 191 | type: "object", 192 | description: "Map (key:value) of links to request, response from the given API invocation flow" 193 | ] 194 | ], 195 | required: [ 196 | "method", "mode", "format", "url" 197 | ] 198 | ], 199 | Response: [ 200 | title: "Response", 201 | description: "API invoke response properties", 202 | type: "object", 203 | properties: [ 204 | success: [ 205 | type: "boolean", 206 | description: "true - if API invocation finished without error, false otherwise" 207 | ], 208 | errorCode: [ 209 | type: "string", 210 | description: "0 - if no error, else otherwise" 211 | ], 212 | errorDescr: [ 213 | type: "string", 214 | description: "Error description" 215 | ], 216 | statusCode: [ 217 | type: "integer", 218 | description: "External API invocation HTTP status code" 219 | ], 220 | id: [ 221 | type: "string", 222 | description: "UUID - unique request ID" 223 | ], 224 | data: [ 225 | type: "object", 226 | description: "JSON object with serialized output from external API invocation" 227 | ], 228 | href: [ 229 | type: "string", 230 | description: "URL to get this response" 231 | ], 232 | links: [ 233 | type: "object", 234 | description: "Map (key:value) of links to request, response from the given API invocation flow" 235 | ] 236 | ] 237 | ], 238 | Error: [ 239 | title: "Error", 240 | description: "API invoke unexpected error properties", 241 | type: "object", 242 | properties: [ 243 | errorCode: [ 244 | type: "string", 245 | description: "0 - if no error, else otherwise" 246 | ], 247 | errorDescr: [ 248 | type: "string", 249 | description: "Error description" 250 | ], 251 | ] 252 | ] 253 | ] 254 | ] 255 | return swgr 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /docs/Salesforce_API.md: -------------------------------------------------------------------------------- 1 | # Example: Salesforce.com Account API 2 | 3 | ## Test case specification 4 | 5 | Whole test for [Force.com](http://force.com) API is defined in 6 | [ForceDotComApiTest](https://github.com/zedar/api-gateway/tree/master/src/test/groovy/online4m/apigateway/func/ForceDotComApiTest.groovy). 7 | 8 | Before running it please look at the [Prerequisites](#prerequisites) section. 9 | Especially you need developer account in Force.com. 10 | 11 | Test case requires properties file. In folder *./src/test/resources* create file *force.com.properties* file with attributes: 12 | 13 | customer_key=Force.com Customer Key 14 | customer_secret=Force.com Customer Secret 15 | username=Force.com User Login (usually email) 16 | password=Force.com password followed by Security Token 17 | 18 | Run just Force.com test by adding *-Dtest.single* attribute. 19 | 20 | $ ./gradlew -Dtest.single=*Force* test 21 | 22 | ## Prerequisites 23 | 24 | ### Salesforce.com developer registration 25 | 26 | Register as developer in Salesforce.com: [https://developer.salesforce.com/signup](https://developer.salesforce.com/signup). 27 | 28 | From top right menu button select option **Me Developer Account**. Next login is required. 29 | 30 | On the left side find **Create** and then **Apps** menu. Under **Connected Apps** click **New** application. Fill out basic information: 31 | 32 | * **Connected App Name**: for example *online4m* 33 | * **API Name**: for example *online4m* 34 | * **Contact Email**: your email 35 | * Check **Enable OAuth Settings** 36 | * Enter **Callback URL**: for example https://localhost:8443/RestTest/oauth/callback. 37 | * Callback is needed only for login with redirection to Salesforce.com. 38 | * In our case we are going to use app to app integration. No user interaction will be necessary. 39 | * From **Selected OAuth Scopes**: select *Full access* and click **Add** button. 40 | * **Save** new application 41 | 42 | As result there are 2 very important values that you can find in application settings: 43 | 44 | * **Consumer Key** 45 | * **Consumer Secret** 46 | 47 | We are going to use them in API to get *access token*. 48 | 49 | There is one more attribute called **Security Token** that has to be connected with consumer password. 50 | If you do not know it, from top menu select *your name* and then **My settings**. 51 | From left side menu sslect **Personal/Reset My security Token**. 52 | Click the button **Reset Security Token**. New token is delivered via email. 53 | 54 | Detailed instructions could be found in [Force.com REST API Developer's Guide](https://www.salesforce.com/us/developer/docs/api_rest/). 55 | 56 | ### Start APIGateway server and its dependencies 57 | 58 | Start runtime dependencies (Redis key/value store should start) 59 | 60 | $ ./gradlew runREnv 61 | 62 | Start APIGateway runtime 63 | 64 | @ ./gradlew run 65 | 66 | ## Get ACCESS TOKEN 67 | 68 | Create new text file called: **sf_get_token.json** with APIGateway's JSON format: 69 | 70 | { 71 | "method": "POST", 72 | "mode": "SYNC", 73 | "format": "URLENC", 74 | "url": "https://login.salesforce.com/services/oauth2/token", 75 | "data": { 76 | "grant_type": "password", 77 | "client_id": PUT CONSUMER KEY HERE, 78 | "client_secret": PUT CONSUMER SECRET HERE, 79 | "username": PUT YOUR Salesforce.com developer username, 80 | "password": PUT YOUR Salesforce.com password SUCCEEDED BY SECURITY TOKEN 81 | } 82 | } 83 | 84 | Invoke APIGateway: 85 | 86 | $ curl -X POST -d@./sf_get_token.json http://localhost:5050/api/invoke 87 | 88 | The **data** attribute in response should contain *access_token* and the other important information: 89 | 90 | "data": { 91 | "access_token": "00D24000000H37Q!AQsAQHIGWI8S_tZV6rE4YejHhizmqfUyCGxrHTxuYqkZIjUhQMCTfbhATP.pIyjVk55GrtxsWK451AnP0I_KaiV27mGtxql1", 92 | "id": "https://login.salesforce.com/id/00D24000000H37QEAS/00524000000Q57wAAC", 93 | "instance_url": "https://eu5.salesforce.com", 94 | "issued_at": "1415233444183", 95 | "signature": "wsDDfB6/HffugIfWjPQIs2X6sPHWj3e1QPKPX7VGats=", 96 | "token_type": "Bearer" 97 | } 98 | 99 | ## Create new ACCOUNT 100 | 101 | Create new text file called: **sf_new_account.json** with APIGateway's JSON format: 102 | 103 | { 104 | "method": "POST", 105 | "mode": "SYNC", 106 | "format": "JSON", 107 | "url": "https://eu5.salesforce.com/services/data/v32.0/sobjects/Account/", 108 | "headers": { 109 | "Authorization": "Bearer 00D24000000H37Q!AQsAQHIGWI8S_tZV6rE4YejHhizmqfUyCGxrHTxuYqkZIjUhQMCTfbhATP.pIyjVk55GrtxsWK451AnP0I_KaiV27mGtxql1" 110 | }, 111 | "data": { 112 | "Name": "Test Company Name" 113 | } 114 | } 115 | 116 | There are two important requirements: 117 | 118 | * *url* attribute has to point to *instance_url* returned in ACCESS TOKEN request; 119 | * *Authorization* header attribute has to contain auth method *Bearer* with *access_token* value returned in ACCESS TOKEN request 120 | 121 | Invoke APIGateway: 122 | 123 | $ curl -X POST -d@./sf_new_account.json http://localhost:5050/api/invoke 124 | 125 | The **data** attribute in response should look like this: 126 | 127 | "data": { 128 | "errors": [ 129 | ], 130 | "id": "00124000001QeXWAA0", 131 | "success": true 132 | } 133 | 134 | ## Change ACCOUNT attributes 135 | 136 | Salesforce uses HTTP PATCH method in order to update record's data. 137 | 138 | Create new text file called: **sf_update_account.json** with APIGateway's JSON format: 139 | 140 | { 141 | "method": "PATCH", 142 | "mode": "SYNC", 143 | "format": "JSON", 144 | "url": "https://eu5.salesforce.com/services/data/v32.0/sobjects/Account/00124000001QeXWAA0", 145 | "headers": { 146 | "Authorization": "Bearer 00D24000000H37Q!AQsAQHIGWI8S_tZV6rE4YejHhizmqfUyCGxrHTxuYqkZIjUhQMCTfbhATP.pIyjVk55GrtxsWK451AnP0I_KaiV27mGtxql1" 147 | }, 148 | "data": { 149 | "Name": "New name for Company 3", 150 | "BillingCity": "Warsaw" 151 | } 152 | } 153 | 154 | There are two important requirements: 155 | 156 | * *method* attribute has to have value *PATCH* 157 | * *url* attribute has to point to *instance_url* returned in ACCESS TOKEN request and at the end contains *id* of previously created ACCOUNT; 158 | * *Authorization* header attribute has to contain auth method *Bearer* with *access_token* value returned in ACCESS TOKEN request 159 | 160 | Salesforce does not return content after successful patching. HTTP 204 is returned. 161 | 162 | Response data looks different in this case. The **data** attribute has null value and **statusCode** has 204 value. 163 | 164 | { 165 | "href": "http://localhost:5050/api/invoke/cc8ffbc1-f8d1-42ba-93eb-e24a1f3348dd/response", 166 | "errorDescr": "", 167 | "id": "cc8ffbc1-f8d1-42ba-93eb-e24a1f3348dd", 168 | "errorCode": "0", 169 | "links": { 170 | "request": { 171 | "href": "http://localhost:5050/api/invoke/cc8ffbc1-f8d1-42ba-93eb-e24a1f3348dd/request" 172 | } 173 | }, 174 | "data": null, 175 | "success": true, 176 | "statusCode": 204 177 | } 178 | 179 | ## Get ACCOUNT attributes 180 | 181 | Create new text file called **sf_get_account.json** with APIGateway's format: 182 | 183 | { 184 | "method": "GET", 185 | "mode": "SYNC", 186 | "format": "JSON", 187 | "url": "https://eu5.salesforce.com/services/data/v32.0/sobjects/Account/00124000001QeXWAA0", 188 | "headers": { 189 | "Authorization": "Bearer 00D24000000H37Q!AQsAQHIGWI8S_tZV6rE4YejHhizmqfUyCGxrHTxuYqkZIjUhQMCTfbhATP.pIyjVk55GrtxsWK451AnP0I_KaiV27mGtxql1" 190 | }, 191 | "data": { 192 | "fields": "Name,AccountNumber,BillingCity" 193 | } 194 | } 195 | 196 | There are two important requirements: 197 | 198 | * *url* attribute has to point to *instance_url* returned in ACCESS TOKEN request and at the end contains *id* of previously created ACCOUNT; 199 | * *Authorization* header attribute has to contain auth method *Bearer* with *access_token* value returned in ACCESS TOKEN request 200 | * *data/fields* attribute contains list of ACCOUNT attributes to be returned from Salesforce 201 | 202 | ## Delete ACCOUNT 203 | 204 | Create new text file called **sf_delete_account.json** with APIGaeway's format: 205 | 206 | { 207 | "method": "DELETE", 208 | "mode": "SYNC", 209 | "format": "JSON", 210 | "url": "https://eu5.salesforce.com/services/data/v32.0/sobjects/Account/00124000001QeXWAA0", 211 | "headers": { 212 | "Authorization": "Bearer 00D24000000H37Q!AQsAQHIGWI8S_tZV6rE4YejHhizmqfUyCGxrHTxuYqkZIjUhQMCTfbhATP.pIyjVk55GrtxsWK451AnP0I_KaiV27mGtxql1" 213 | } 214 | } 215 | 216 | There are two important requirements: 217 | 218 | * *url* attribute has to point to *instance_url* returned in ACCESS TOKEN request and at the end contains *id* of previously created ACCOUNT; 219 | * *Authorization* header attribute has to contain auth method *Bearer* with *access_token* value returned in ACCESS TOKEN request 220 | 221 | Salesforce does not return content after successful delete. HTTP 204 is returned. 222 | 223 | Response data looks as follows. The **data** attribute has null value while **statusCode** has 204 value. 224 | 225 | { 226 | "href": "http://localhost:5050/api/invoke/6ff72ae9-5793-4326-9659-b684f3d21085/response", 227 | "errorDescr": "", 228 | "id": "6ff72ae9-5793-4326-9659-b684f3d21085", 229 | "errorCode": "0", 230 | "links": { 231 | "request": { 232 | "href": "http://localhost:5050/api/invoke/6ff72ae9-5793-4326-9659-b684f3d21085/request" 233 | } 234 | }, 235 | "data": null, 236 | "success": true, 237 | "statusCode": 204 238 | } 239 | 240 | 241 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | API Gateway - unified access to REST or Web Services 2 | ----------------------------- 3 | 4 | API Gateway intents to play a role of single entry point for invocation of diverse services, either REST or WebServices. 5 | Unifies a way of calling APIs. 6 | Primary goals are: 7 | 8 | * all requests are handled asynchronously (no thread blocking); 9 | * input and output data format is unified. Separation of data format between client and callable API; 10 | * it is REST API itself; 11 | * support diverse invocation modes: SYNC, ASYNC, EVENT (fire and forget); 12 | * follow the rules of #microservices. Defined in Sam Newmans's book *Building Microservices*: 13 | * is small and focused on doing one thing well 14 | * is a seperate, independent process 15 | * communicates via language agnostic API 16 | * is highly decoupled 17 | 18 | API Gateway is [*ratpack*](http://www.ratpack.io) based project. 19 | All classes are written in [*Groovy*](http://groovy.codehaus.org). 20 | It uses [*Gradle*](http://www.gradle.org) build subsystem. 21 | 22 | API Gateway tends to follow [Swagger 2.0 JSON API specification](https://github.com/swagger-api/swagger-spec). 23 | 24 | http://localhost:5050/api-docs 25 | 26 | API Gateway microservice is used by [online4m.com](https://www.online4m.com/online4m/info/howItWorks#howitworks) - pragmatic way to evolutionary develop workflow driven applications. 27 | 28 | Table of Contents 29 | ================= 30 | 31 | * [API Gateway usage patterns](#api-gateway-usage-patterns) 32 | * [Invoke external REST API synchronously](#invoke-external-rest-api-synchronously) 33 | * [Invoke external REST API asynchronously](#invoke-external-rest-api-asynchronously) 34 | * [Get Request that initialized invocation of external API](#get-request-that-initialized-invocation-of-external-api) 35 | * [Get ack or final response of external API invocation](#get-ack-or-final-response-of-external-api-invocation) 36 | * [API specification](#api-specification) 37 | * [HTTP headers](#http-headers) 38 | * [Endpoints](#endpoints) 39 | * [/api-docs](#api-docs) 40 | * [/api](#api) 41 | * [/api/invoke](#apiinvoke) 42 | * [/api/invoke/{id}/request](#apiinvokeidrequest) 43 | * [/api/invoke/{id}/response](#apiinvokeidresponse) 44 | * [api/health-checks](#apihealth-checks) 45 | * [api/health-check/:name](#apihealth-checkname) 46 | * [Run Tests](#run-tests) 47 | * [Prerequisites](#prerequisites) 48 | * [Mountebank - for stubbing and mocking](#mountebank---for-stubbing-and-mocking) 49 | * [Redis - for requests persistance and statistics](#redis---for-requests-persistance-and-statistics) 50 | * [Running dependencies](#running-dependencies) 51 | * [Tests](#tests) 52 | * [Key/value data storage](#keyvalue-data-storage) 53 | * [Statistics](#statistics) 54 | * [Usage](#usage) 55 | * [Requests store](#requests-store) 56 | * [Requests log](#requests-log) 57 | * [Example API calls](#example-api-calls) 58 | * [Get API endpoints](#get-api-endpoints) 59 | * [](#example-salesforcecom-account-api) 60 | * [Example: HipChat - get history of chats](#example-hipchat---get-history-of-chats) 61 | * [Example: Twitter query with OAUTH authorization](#example-twitter-query-with-oauth-authorization) 62 | * [Commands to be used while developing](#commands-to-be-used-while-developing) 63 | * [TODO:](#todo) 64 | 65 | # API Gateway usage patterns 66 | 67 | ## Invoke external REST API synchronously 68 | 69 | Invoke external REST API synchronously 70 | 71 | Request in JSON format is a body of POST request to **api/invoke** endpoint. 72 | Response with external API output is a body of this endpoint result. 73 | 74 | Request attributes: 75 | 76 | * mode=SYNC 77 | * method=GET|POST|PUT|PATCH|DELETE 78 | * format=JSON|XML|URLENC 79 | 80 | 81 | ## Invoke external REST API asynchronously 82 | 83 | Invoke external REST API asynchronously 84 | 85 | Request in JSON format is a body of POST request to **api/invoke** endpoint. 86 | Response is acknowledgment only. 87 | External API is called in separate thread and its output could be retrieved by **api/invoke/{id}/response** endpoint. 88 | 89 | Request attributes: 90 | 91 | * mode=ASYNC 92 | * method=GET|POST|PUT|PATH|DELETE 93 | * format=JSON|XML|URLENC 94 | 95 | ## Get Request that initialized invocation of external API 96 | 97 | Get request that initialized invocation of external API 98 | 99 | Get request for invocation defined by {id} UUID. Enrich request with links to itself and to its response. 100 | 101 | ## Get ack or final response of external API invocation 102 | 103 | Get response of external API invocation 104 | 105 | If external API has finished its final response will return. Otherwise acknowledgement response will come. 106 | Enrich response with links to itself and to its request. 107 | 108 | # API specification 109 | 110 | API specification is inline with [Swagger 2.0 JSON API specification](https://github.com/swagger-api/swagger-spec). 111 | 112 | ## HTTP headers 113 | 114 | **GET** 115 | 116 | Accept: application/json 117 | 118 | **POST|PUT|PATCH** 119 | 120 | Content-Type: application/json 121 | Accept: application/json 122 | 123 | ## Endpoints 124 | 125 | ### /api-docs 126 | 127 | Get list of available APIs in Swagger 2.0 format. 128 | 129 | **Method:** GET 130 | **Accept:** application/json 131 | **HTTP return codes:** 132 | 133 | * 200 - OK 134 | 135 | ### /api 136 | 137 | Redirects to ```/api-docs```. 138 | 139 | **Method:** GET 140 | **Accept:** application/json 141 | **HTTP return codes:** 142 | 143 | * 200 - OK 144 | 145 | ### /api/invoke 146 | 147 | Invoke external API either synchronously or asynchronously. 148 | Use diverse HTTP methods and formats for API invocations. 149 | 150 | **Method:** POST 151 | **Content-Type:** application/json 152 | **Accept:** application/json 153 | **HTTP return codes:** 154 | 155 | * 200 - OK 156 | 157 | #### Input message format 158 | 159 | { 160 | "request": { 161 | "id": "Universal Unique Identifier (UUID)", 162 | "method": "GET|POST|PUT|PATCH|DELETE", 163 | "mode": "SYNC|ASYNC|EVENT", 164 | "format": "JSON|XML|URLENC", 165 | "url": "URI OF EXTERNAL ENDPOINT", 166 | "headers": JSON, 167 | "data": JSON 168 | } 169 | } 170 | 171 | where **request** attributes are: 172 | 173 | **method:** 174 | 175 | * HTTP method to be used for external API call 176 | 177 | **mode:** 178 | 179 | * mode=SYNC - call API synchronously, send request and wait for response 180 | * mode=ASYNC - call API asynchronously, send request and do not wait for response. Response might be avilable for caller as: 181 | * callback invocation 182 | * pull request 183 | * mode=EVENT - call API asynchronously without response, send request as notification 184 | 185 | **format:** 186 | 187 | * format=JSON sets header **Content-Type: application/json** 188 | * format=XML sets header **Content-Type: application/xml** 189 | * format=URLENC sets header **Content-Type: application/x-www-form-urlencoded** 190 | 191 | **Important:** URLENC format makes sense only for method=POST. 192 | 193 | **url:** 194 | 195 | * url of target API 196 | * If method==GET query parameters (after "?") are merged with simple attributes from **data** structure. 197 | 198 | **headers:** 199 | 200 | * list of HTTP request headers in the form of key-value pairs 201 | 202 | "headers": { 203 | "Authorization": "Bearer ACCESS_TOKEN" 204 | } 205 | 206 | **data:** 207 | 208 | * JSON, either with list of query parameters or request body content. 209 | 210 | #### Output message format 211 | 212 | { 213 | "response": { 214 | "success": "true|false", 215 | "errorCode": "0 if no error, else otherwise", 216 | "errorDescr": "Error description", 217 | "data": JSON WITH EXTERNAL API OUTPUT, 218 | "statusCode": "HTTP status code from external API invoke", 219 | "id": "Universal Unique Identifier (UUID)", 220 | "href": "http://localhost:5050/api/invoke/{id}/response", 221 | "links": { 222 | "request": { 223 | "href": "http://localhost:5050/api/invoke/{id}/request" 224 | } 225 | } 226 | } 227 | } 228 | 229 | ### /api/invoke/{id}/request 230 | 231 | Get request that started invocation given by {id}. 232 | 233 | **Method:** GET 234 | **Accept:** application/json 235 | **HTTP return codes:** 236 | 237 | * 200 - OK 238 | 239 | #### Output message format 240 | 241 | { 242 | "request": { 243 | "id": "Universal Unique Identifier (UUID)", 244 | "method": "GET|POST|PUT|PATCH|DELETE", 245 | "mode": "SYNC|ASYNC|EVENT", 246 | "format": "JSON|XML|URLENC", 247 | "url": "URI OF EXTERNAL ENDPOINT", 248 | "headers": JSON, 249 | "data": JSON, 250 | "href": "http://localhost:5050/api/invoke/{id}/request", 251 | "links": { 252 | "response": { 253 | "href": "http://localhost:5050/api/invoke/{id}/response" 254 | } 255 | } 256 | } 257 | } 258 | 259 | ### /api/invoke/{id}/response 260 | 261 | Get response from external API invocation given by {id}. 262 | If *mode*=SYNC, response with external API output data is returned inside */api/call* response. 263 | If *mode*=ASYNC, response could be acknowledgment message (when async call has not been finished) or 264 | response from external API call (if async processing has finished). 265 | 266 | **Method:** GET 267 | **Accept:** application/json 268 | **HTTP return codes:** 269 | 270 | * 200 - OK 271 | 272 | #### Output message format 273 | 274 | Response when only acknowlegment is available. 275 | 276 | { 277 | "response": { 278 | "success": "true|false", 279 | "errorCode": "0 if no error, else otherwise", 280 | "errorDescr": "Error description", 281 | "statusCode": "HTTP status code from external API invoke", 282 | "id": "Universal Unique Identifier (UUID)", 283 | "href": "http://localhost:5050/api/invoke/{id}/response", 284 | "links": { 285 | "request": { 286 | "href": "http://localhost:5050/api/invoke/{id}/request" 287 | } 288 | } 289 | } 290 | } 291 | 292 | Response when external API has finished and output is available. 293 | It contains *data* attribute with JSON representation of external API output. 294 | 295 | { 296 | "response": { 297 | "success": "true|false", 298 | "errorCode": "0 if no error, else otherwise", 299 | "errorDescr": "Error description", 300 | "data": JSON WITH EXTERNAL API OUTPUT, 301 | "statusCode": "HTTP status code from external API invoke", 302 | "id": "Universal Unique Identifier (UUID)", 303 | "href": "http://localhost:5050/api/invoke/{id}/response", 304 | "links": { 305 | "request": { 306 | "href": "http://localhost:5050/api/invoke/{id}/request" 307 | } 308 | } 309 | } 310 | } 311 | 312 | ### api/health-checks 313 | 314 | Run all health checks and return their values. 315 | 316 | **Method:** GET 317 | 318 | ### api/health-check/:name 319 | 320 | Run health check defined by the given :name. 321 | 322 | **Method:** GET 323 | 324 | Defined health checks: 325 | 326 | * *apigateway* 327 | 328 | # Run Tests 329 | 330 | ## Prerequisites 331 | 332 | ### Mountebank - for stubbing and mocking 333 | 334 | Unit tests use stubs provided by [mountebank](http://www.mbtest.org) - really nice and practical framework. 335 | 336 | In order to make them working install node.js together with npm package manager. I recommend to use [Node version manager](https://github.com/creationix/nvm). 337 | 338 | $ curl https://raw.githubusercontent.com/creationix/nvm/v0.17.0/install.sh | bash 339 | $ nvm install v0.11.8 340 | $ curl https://www.npmjs.org/install.sh | sh 341 | 342 | Install mountebank globally: 343 | 344 | $ npm install -g mountebank --production 345 | 346 | After that mountebank server should be available with command: 347 | 348 | $ mb 349 | 350 | #### Required FIXes 351 | 352 | 1. [node.js](http://nodejs.org) version greater than v.0.11.8 353 | 354 | v0.11.8 has a bug: request.method is null for HTTP DELETE calls [issue](https://github.com/joyent/node/issues/6461). 355 | 356 | 2. Fix Internal Server Error (500) if predicate attributes have null value. 357 | 358 | [Pull request #53](https://github.com/bbyars/mountebank/pull/53) that solves this issue. 359 | 360 | 3. For load testing fix logging mechanism 361 | 362 | [Required commit](https://github.com/bbyars/mountebank/commit/2f1915702ab9674d08ec8d46a7e6886c8c8b426f) or latest (non production version). 363 | 364 | 365 | ### Redis - for requests persistance and statistics 366 | 367 | API Gateway uses [Redis](http://redis.io) key/value store for persisting requests and their responses and collecting statistics. 368 | There are tests that require Redis to be installed or accessible. 369 | 370 | Install redis in your system: 371 | 372 | $ curl -O http://download.redis.io/releases/redis-2.8.17.tar.gz 373 | $ tar -xvzf redis-2.8.17.tar.gz 374 | $ cd redis-2.8.17.tar.gz 375 | $ make 376 | $ make install 377 | 378 | Then in your system the following commands should be visible: redis-server (start redis server), redis-cli (start redis command line shell). 379 | 380 | ### Running dependencies 381 | 382 | When **./gradlew test** starts it automatically sets up dependencies: *mountebank* and *redis*. There are two gradle tasks: 383 | 384 | * **runEnv** - starts servers required for testing: 385 | * **MounteBank** - external API stubs 386 | * **Redis** - key/value store 387 | * **cleanEnv** - stops servers used for testing 388 | 389 | The assumption is that these servers are available on specific ports. If you change them please look at **stopEnv** task 390 | in *build.gradle* file. There is table of ports in there. 391 | 392 | ## Tests 393 | 394 | Please look at each groovy class from test folder. 395 | Some of them, especially for functional testing with some real services, are annotated with @Ignore annotation (feature of [Spock](https://code.google.com/p/spock/) BDD testing framework). 396 | Remove or comment it out in order to run them while testing. 397 | 398 | Run tests with command: 399 | 400 | $ ./gradlew test 401 | 402 | Above command automatically sets up dependencies: *MounteBank* and *Redis* for testing. 403 | Inside *build.gradle* file there are two helpfull gradle tasks: 404 | 405 | * **runEnv** - starts servers required for testing: 406 | * **MounteBank** - external API stubs 407 | * **Redis** - key/value store 408 | * **cleanEnv** - stops servers used for testing 409 | 410 | The assumption is that these servers are available on specific ports. 411 | If you change them please look at **stopEnv** task definition. There is a table of ports in there. 412 | 413 | task stopEnv(type: FreePorts) { 414 | // port: 2525 - mb (MounteBank), 415 | // port: 6379 - redis-server (Redis data store) 416 | ports = [2525, 6379] 417 | } 418 | 419 | The following tasks from *build.gradle* do the job: 420 | 421 | startMounteBank - start mountebank server with *mb* shell command 422 | initMounteBank - initialize stubs configuration with *./src/test/resources/imposter.json* file. 423 | testFinished - kill spwaned processes attached to mountebank ports 424 | 425 | # Key/value data storage 426 | 427 | API Gateway keeps highly dynamic data in key/value store - [Redis](http://redis.io/). 428 | It is used for: 429 | 430 | * statistics 431 | * requests and their corresponding responses 432 | * if mode=ASYNC, response is stored for future retrival 433 | * logging of top requests and responses 434 | 435 | ## Statistics 436 | 437 | ### Usage 438 | 439 | Collecting number of requests: 440 | 441 | * usage/year:{yyyy} 442 | * usage/year:{yyyy}/month:{mm} 443 | * usage/year:{yyyy}/month:{mm}/day:{dd} 444 | 445 | To get statistic value: 446 | 447 | $ redis-cli> get usage/year:2014 448 | 449 | ### Requests store 450 | 451 | Every request is stored as Redis hash and has structure: 452 | 453 | * key: **request:UUID** - where UUID is unique ID of request 454 | * field: **request**, value: **request serialized to JSON** 455 | * field: **response**, value: **response serialized to JSON** 456 | * field: **aresponse**, value: **async response serialized to JSON** 457 | 458 | If mode=ASYNC, **response** field stores first answer that is result of request registration and request sending. 459 | Then **aresponse** field stores final response from service call. 460 | 461 | If mode=SYNC, **response** field stores final response from service call. 462 | 463 | To get request and response for particular UUID 464 | 465 | $ redis-cli> hget request:UUID request 466 | $ redis-cli> hget request:UUID response 467 | $ redis-cli> hget request:UUID aresponse 468 | 469 | ### Requests log 470 | 471 | Every request's id is stored in sorted set (by timestamp). 472 | 473 | * key: **request-log** 474 | * score: **timestamp** - datetime converted to timestamp 475 | * member: **request:UUID** - where UUID is unique ID of request 476 | 477 | To get log of last 20 requests: 478 | 479 | $ redis-cli> zrange request-log 0 20 480 | 481 | # Example API calls 482 | 483 | ## Get API endpoints 484 | 485 | $ curl -X GET -H "Accept: application/vnd.api+json" http://localhost:5050/api 486 | or 487 | $ curl -X GET -H "Accept: application/json" http://localhost:5050/api 488 | 489 | ## Example: [Salesforce.com Account API](docs/Salesforce_API.md) 490 | 491 | 492 | 493 | ## Example: HipChat - get history of chats 494 | 495 | Example HipChat API call: 496 | 497 | $ curl -X POST -H "Content-Type: application/json" -d '{"method": "GET", "mode": "SYNC", "format": "JSON", "url": "https://api.hipchat.com/v2/room/online4m.com/history/latest?auth_token=YOUR_TOKEN", "data": {"max-results": {"l": 10}}}' -i http://localhost:5050/api/call 498 | 499 | ## Example: Twitter query with OAUTH authorization 500 | 501 | Before any API call you have to register your application in twitter. By doing this you get unique client id and client secret. 502 | These attributes are needed to ask for access token. Access token is used in all subsequent api calls. 503 | 504 | Point your browser to [apps.twitter.com](https://apps.twitter.com), click the button *Create New App* and register your application. 505 | 506 | Next, request for a access token. 507 | 508 | $ curl -X POST -H "Content-Type: application/json" -d '{"method": "POST", "mode": "SYNC", "format": "URLENC", "url": "https://api.twitter.com/oauth2/token", "data": {"grant_type": "client_credentials", "client_id", "YOUR_APP_ID", "client_secret", "YOUR_APP_SECRET"}}' -i http://localhost:5050/api/call 509 | 510 | As result you should get: 511 | 512 | { 513 | "errorCode":"0", 514 | "data": { 515 | "access_token":"ACCESS_TOKEN_URLENCODED", 516 | "token_type":"bearer" 517 | }, 518 | "success":true 519 | } 520 | 521 | Now you are ready to call, for example, twitter's search API. But now to request add headers map: 522 | 523 | "headers": { 524 | "Authorization": "Bearer ACCESS_TOKEN_URLENCODED" 525 | } 526 | 527 | and invocation: 528 | 529 | $ curl -X POST -H "Content-Type: application/json" -d '{"method": "GET", "mode": "SYNC", "format": "JSON", "url": "https://api.twitter.com/1.1/search/tweets.json", "headers": {"Authorization": " Bearer ACCESS_TOKEN_URLENCODED"}, "data": {"q": "ratpackweb"}' -i http://localhost:5050/api/call 530 | 531 | # Commands to be used while developing 532 | 533 | Test synchronous external service invocation: 534 | 535 | curl -X POST -H "Content-Type: application/json" -d@./src/test/resources/testdata.json http://localhost:5050/api/call 536 | 537 | Test asynchronous external service invocation 538 | 539 | curl -X POST -H "Content-Type: application/json" -d@./src/test/resources/testdataasync.json http://localhost:5050/api/call 540 | 541 | Load test. Change **-c** from 1 to more clients. Change **-r** from 1 to more repetition. 542 | 543 | siege -c 1 -r 1 -H 'Content-Type: application/json' 'http://localhost:5050/api/call POST < ./src/test/resources/testdata.json' 544 | 545 | # TODO: 546 | 547 | * Add ASYNC calls with response callbacks and storing responses in local storage 548 | * Add EVENT async calls without waiting for response 549 | 550 | # Project structure 551 | 552 | In this project you get: 553 | 554 | * A Gradle build file with pre-built Gradle wrapper 555 | * A tiny home page at src/ratpack/templates/index.html (it's a template) 556 | * A routing file at src/ratpack/ratpack.groovy 557 | * Reloading enabled in build.gradle 558 | * A standard project structure: 559 | 560 | 561 | | 562 | +- src 563 | | 564 | +- ratpack 565 | | | 566 | | +- ratpack.groovy 567 | | +- ratpack.properties 568 | | +- public // Static assets in here 569 | | | 570 | | +- images 571 | | +- lib 572 | | +- scripts 573 | | +- styles 574 | | 575 | +- main 576 | | | 577 | | +- groovy 578 | | 579 | +- // App classes in here! 580 | | 581 | +- test 582 | | 583 | +- groovy 584 | | 585 | +- // Spock tests in here! 586 | 587 | That's it! You can start the basic app with 588 | 589 | ./gradlew run 590 | 591 | but it's up to you to add the bells, whistles, and meat of the application. 592 | -------------------------------------------------------------------------------- /src/main/groovy/online4m/apigateway/si/CallerService.groovy: -------------------------------------------------------------------------------- 1 | package online4m.apigateway.si 2 | 3 | import java.util.UUID 4 | 5 | import javax.inject.Inject 6 | 7 | import groovy.util.logging.* 8 | 9 | import groovy.json.JsonBuilder 10 | import groovy.json.JsonOutput 11 | import groovy.json.JsonSlurper 12 | 13 | import groovy.util.XmlSlurper 14 | 15 | import groovyx.net.http.* 16 | import static groovyx.net.http.ContentType.* 17 | import static groovyx.net.http.Method.* 18 | 19 | import ratpack.rx.RxRatpack 20 | import ratpack.exec.ExecControl 21 | import ratpack.exec.Execution 22 | import ratpack.func.Action 23 | import static ratpack.rx.RxRatpack.observe 24 | import rx.Observable 25 | 26 | import redis.clients.jedis.Jedis 27 | import redis.clients.jedis.exceptions.JedisConnectionException 28 | 29 | import online4m.apigateway.ds.JedisDS 30 | 31 | /** 32 | * This class is intended to be called as Singleton. It is threadsafe by design. 33 | */ 34 | @Slf4j 35 | class CallerService { 36 | // jedisDS - reference to Redis data source connection pool 37 | private final JedisDS jedisDS 38 | // csCtx - common attrobutes for all service calls 39 | private final CallerServiceCtx csCtx 40 | // execControl - ratpack execution control 41 | private final ExecControl execControl 42 | 43 | 44 | @Inject 45 | CallerService(ExecControl execControl, JedisDS jedisDS, CallerServiceCtx csCtx) { 46 | this.execControl = execControl 47 | this.jedisDS = jedisDS 48 | this.csCtx = csCtx 49 | } 50 | 51 | /** 52 | * Validate input json map if all required attributes are provided. 53 | * @param data - body text converted to json map 54 | */ 55 | Response validate(Map data) { 56 | if (!(data instanceof Map)) { 57 | return new Response(false, "SI_ERR_WRONG_INPUT", "Wrong input data format") 58 | } 59 | 60 | if (!data.method || !data.mode || !data.format || !data.url) { 61 | def missingAttrs = [] 62 | data.method ?: missingAttrs.add("method") 63 | data.mode ?: missingAttrs.add("mode") 64 | data.format ?: missingAttrs.add("format") 65 | data.url ?: missingAttrs.add("url") 66 | log.debug("MISSING ATTRS: ${missingAttrs}") 67 | return new Response(false, "SI_ERR_MISSING_ATTRS", "Missing input attributes: ${missingAttrs}") 68 | } 69 | if (data.headers && !(data.headers instanceof Map)) { 70 | log.debug("Incorrect data type for 'headers' attribute") 71 | return new Response(false, "SI_ERR_WRONG_TYPEOF_HEADERS", 'headers attribute has to be JSON object - "headers": {}') 72 | } 73 | if (data.data && !(data.data instanceof Map)) { 74 | log.debug("Incorrect data type for 'data' attribute") 75 | return new Response(false, "SI_ERR_WRONG_TYPEOF_DATA", 'data attribute has to be JSON object - "data": {}') 76 | } 77 | return new Response(true) 78 | } 79 | 80 | /** 81 | * Invoke external API. Parse input body text to json map. Validate input data. 82 | * Build *Request* object and then call external API. 83 | * @param bodyText - not parsed body text 84 | */ 85 | Response invoke(String bodyText) { 86 | try { 87 | def slurper = new JsonSlurper() 88 | def data = slurper.parseText(bodyText) 89 | log.debug("INPUT DATA TYPE: ${data?.getClass()}") 90 | log.debug("INPUT DATA: ${data}") 91 | Response response = validate(data) 92 | if (response?.success) { 93 | response = invoke(Request.build(data)) 94 | log.debug("INVOKE FINISHED") 95 | } 96 | else if (!response) { 97 | response = new Response() 98 | response.with { 99 | (success, errorCode, errorDescr) = [false, "SI_ERR_EXCEPTION", "Unexpected result from request validation"] 100 | } 101 | } 102 | return response 103 | } 104 | catch (IllegalArgumentException ex) { 105 | ex.printStackTrace() 106 | return new Response(false, "SI_EXP_ILLEGAL_ARGUMENT", "Exception: ${ex.getMessage()}") 107 | } 108 | catch (JedisConnectionException ex) { 109 | ex.printStackTrace() 110 | return new Response(false, "SI_EXP_REDIS_CONNECTION_EXCEPTION", "Exception: ${ex.getMessage()}") 111 | } 112 | catch (Exception ex) { 113 | ex.printStackTrace() 114 | return new Response(false, "SI_EXCEPTION", "Exception: ${ex.getMessage()}") 115 | } 116 | } 117 | 118 | /** 119 | * Call external API defined by *Request* object. Assumes that request object has been validated. 120 | * Supported combinations of attributes: 121 | * SYNC + GET + JSON 122 | * SYNC + POST + JSON 123 | * SYNC + PUT + JSON 124 | * SYNC + GET + XML 125 | * SYNC + POST + XML 126 | * SYNC + PUT + XML 127 | * SYNC + POST + URLENC 128 | * SYNC + PUT + URLENC 129 | * SYNC + DELETE 130 | * ASYNC (with all above methods) 131 | * @param request - request with attributes required to call external API 132 | * @param jedis - Redis connection, unique instance for the given invocation, taken out of JedisPool 133 | */ 134 | Response invoke(Request request) { 135 | log.debug("REQUEST: ${request.getUrl()}") 136 | 137 | if (jedisDS && jedisDS.isOn()) { 138 | Jedis jedis = jedisDS.getResource() 139 | 140 | jedis.hset("request:${request.id}", "request", JsonOutput.toJson(request)) 141 | 142 | Date dt = new Date() 143 | 144 | jedis.zadd("request-log", dt.toTimestamp().getTime(), "request:${request.id}") 145 | 146 | // increment statistics 147 | jedis.incr("usage/year:${dt.getAt(Calendar.YEAR)}") 148 | jedis.incr("usage/year:${dt.getAt(Calendar.YEAR)}/month:${dt.getAt(Calendar.MONTH)+1}") 149 | jedis.incr("usage/year:${dt.getAt(Calendar.YEAR)}/month:${dt.getAt(Calendar.MONTH)+1}/day:${dt.getAt(Calendar.DAY_OF_MONTH)}") 150 | 151 | jedisDS.returnResource(jedis) 152 | } 153 | 154 | Response response = null 155 | 156 | if (request.mode == RequestMode.SYNC) { 157 | response = invokeSync(request) 158 | } 159 | else if (request.mode == RequestMode.ASYNC) { 160 | response = invokeAsync(request) 161 | } 162 | else { 163 | response = new Response() 164 | response.with { 165 | (success, errorCode, errorDescr) = [false, "SI_ERR_NOT_SUPPORTED_INVOCATION", "Not supported invocation mode", request.uuid] 166 | } 167 | } 168 | 169 | if (response) { 170 | response.id = request.id 171 | } 172 | 173 | if (jedisDS && jedisDS.isOn() && response) { 174 | Jedis jedis = jedisDS.getResource() 175 | jedis.hset("request:${request.id}", "response", JsonOutput.toJson(response)) 176 | jedisDS.returnResource(jedis) 177 | 178 | String serverUrl = this.csCtx.serverUrl 179 | response.href = serverUrl + "/api/invoke/${response.id.toString()}/response" 180 | response.links["request"] = [ 181 | href: serverUrl + "/api/invoke/${response.id.toString()}/request" 182 | ] 183 | } 184 | 185 | return response 186 | } 187 | 188 | /** 189 | * Switch to corresponding method that invoces external API synchronously. 190 | * @param request to be send to external API 191 | */ 192 | private Response invokeSync(Request request) { 193 | Response response = null 194 | 195 | if (request.method == RequestMethod.GET && request.format == RequestFormat.JSON) { 196 | response = getJson(request.url, request.headers, request.data) 197 | } 198 | else if ([RequestMethod.POST, RequestMethod.PUT, RequestMethod.PATCH].find{ it == request.method } && 199 | request.format == RequestFormat.JSON) { 200 | response = sendJson(request.method, request.url, request.headers, request.data) 201 | } 202 | else if (request.method == RequestMethod.GET && request.format == RequestFormat.XML) { 203 | response = getXml(request.url, request.headers, request.data) 204 | } 205 | else if ([RequestMethod.POST, RequestMethod.PUT, RequestMethod.PATCH].find { it == request.method } && 206 | request.format == RequestFormat.XML) { 207 | response = sendXml(request.method, request.url, request.headers, request.data) 208 | } 209 | else if ([RequestMethod.POST, RequestMethod.PUT, RequestMethod.PATCH].find{ it == request.method } && 210 | request.format == RequestFormat.URLENC) { 211 | response = sendUrlEncoded(request.method, request.url, request.headers, request.data) 212 | } 213 | else if (request.method == RequestMethod.DELETE) { 214 | response = del(request.url, request.headers, request.data) 215 | } 216 | else { 217 | response = new Response() 218 | response.with { 219 | (success, errorCode, errorDescr) = [false, "SI_ERR_NOT_SUPPORTED_INVOCATION", "Not supported invocation mode", request.uuid] 220 | } 221 | } 222 | 223 | return response 224 | } 225 | 226 | private Response invokeAsync(Request request) { 227 | // Construct response data with location to get final response 228 | if (!this.csCtx) { 229 | Response r = new Response() 230 | r.with { 231 | (success, errorCode, errorDescr) = [false, "SI_NO_ACCESS_TO_PUBLIC_ADDRESS", "Unable to get access to main server's public address"] 232 | } 233 | return r 234 | } 235 | 236 | // Fork further execution 237 | //execControl.fork(new Action() { 238 | execControl.exec().start(new Action() { 239 | public void execute(Execution execution) throws Exception { 240 | ExecControl ec = execution.getControl() 241 | ec.blocking { 242 | // build context for async response 243 | def responseCtx = new Expando() 244 | responseCtx.id = request.id 245 | 246 | request.mode = RequestMode.SYNC 247 | responseCtx.response = invokeSync(request) 248 | 249 | if (!responseCtx.response.id) { 250 | responseCtx.response.id = request.id 251 | } 252 | return responseCtx 253 | } 254 | .then { responseCtx -> 255 | log.debug("POST JSON ASYNC RESPONSE: uuid: ${responseCtx.id}, response: ${responseCtx.response?.toString()}") 256 | // save response in redis 257 | // callback has an access to service context, so jedisDS is visible 258 | if (jedisDS.isOn()) { 259 | Jedis jedis = jedisDS.getResource() 260 | jedis.hset("request:${responseCtx.id}", "responseAsync", JsonOutput.toJson(responseCtx.response)) 261 | jedisDS.returnResource(jedis) 262 | } 263 | } 264 | } 265 | } 266 | ) 267 | 268 | // Prepare confirmation (ack) response 269 | Response r = new Response() 270 | r.with { 271 | (success, statusCode) = [true, 202] 272 | } 273 | return r 274 | } 275 | 276 | /** 277 | * Map internal request method to groovyx.net.http.Method 278 | * @param method - RequestMethod enum 279 | */ 280 | private def mapMethod(RequestMethod method) { 281 | return groovyx.net.http.Method.valueOf(method.name()) 282 | } 283 | 284 | private Response getJson(URL url, Map headersToSet, Map inputData) { 285 | def http = new HTTPBuilder(url) 286 | def result = http.request(GET, JSON) { req -> 287 | headers.Accept = "application/json" 288 | Utils.buildRequestHeaders(headers, headersToSet) 289 | def queryMap = Utils.buildQueryAttributesMap(url, inputData) 290 | 291 | uri.query = queryMap 292 | 293 | log.debug "HEADERS: ${headers}" 294 | log.debug "QUERY: ${queryMap}" 295 | 296 | response.success = { resp -> 297 | if (resp.statusLine.statusCode == 204) { 298 | Response r = new Response() 299 | r.with { 300 | (success, statusCode) = [true, resp.statusLine.statusCode] 301 | } 302 | return r 303 | } 304 | String text = resp.entity?.content?.text 305 | String contentType = resp.headers."Content-Type" 306 | if (contentType?.startsWith("application/json") && text) { 307 | def json = new JsonSlurper().parseText(text) 308 | Map jsonMap = json 309 | Response r = new Response() 310 | r.with { 311 | (success, data, statusCode) = [true, jsonMap, resp.statusLine.statusCode] 312 | } 313 | return r 314 | } 315 | else { 316 | Response r = new Response() 317 | r.with { 318 | (success, errorCode, errorDescr, statusCode) = [ 319 | false, 320 | "SI_ERR_UNSUPPORTED_RESPONSE_CONTENT", 321 | "Content-Type : ${contentType}", 322 | resp.statusLine.statusCode 323 | ] 324 | } 325 | return r 326 | } 327 | } 328 | 329 | // IMPORTANT: there is a bug in #groovylang https://jira.codehaus.org/browse/GROOVY-7132 330 | // Fixed in version 2.3.8 and above. 331 | // TODO: use it if 2.3.8 is available 332 | /* response.success = { resp, json -> */ 333 | /* log.debug("SUCCESS: STATUSCODE=${resp.statusLine.statusCode}") */ 334 | /* log.debug("RESP JSON class: ${json.getClass()}") */ 335 | /* // convert JsonObject to Map interface */ 336 | /* Map jsonMap = json */ 337 | /* Response r = new Response() */ 338 | /* r.with { */ 339 | /* (success, data, statusCode) = [true, jsonMap, resp.statusLine.statusCode] */ 340 | /* } */ 341 | /* return r */ 342 | /* } */ 343 | 344 | response.failure = { resp -> 345 | log.debug("FAILURE: STATUSCODE=${resp.statusLine.statusCode}, ${resp.statusLine.reasonPhrase}") 346 | Response r = new Response() 347 | r.with { 348 | (success, errorCode, errorDescr, statusCode) = [ 349 | false, 350 | "HTTP_ERR_${resp.statusLine.statusCode}", 351 | "${resp.statusLine.reasonPhrase}", 352 | resp.statusLine.statusCode] 353 | } 354 | return r 355 | } 356 | } 357 | 358 | if (result) { 359 | log.debug("RESPONSE: ${result}") 360 | return result 361 | } 362 | else { 363 | Response r = new Response() 364 | r.with { 365 | (success, errorCode, errorDescr) = [true, "SI_ERR_REST_CALL_UNDEFINED", "Response from REST call is undefined"] 366 | } 367 | return r 368 | } 369 | } 370 | 371 | private Response sendJson(RequestMethod method, URL url, Map headersToSet, Map inputData) { 372 | log.debug("method=${mapMethod(method)}") 373 | 374 | def http = new HTTPBuilder(url) 375 | def result = http.request(mapMethod(method) , JSON) { req -> 376 | headers.Accept = "application/json" 377 | Utils.buildRequestHeaders(headers, headersToSet) 378 | body = inputData 379 | 380 | response.success = { resp -> 381 | log.debug("GOT RESPONSE-CODE: ${resp.statusLine}") 382 | // If there is no content returned, so HTTP 204 383 | if (resp.statusLine.statusCode == 204) { 384 | Response r = new Response() 385 | r.with { 386 | (success, statusCode) = [true, resp.statusLine.statusCode] 387 | } 388 | return r 389 | } 390 | String text = resp.entity?.content?.text 391 | log.debug("GOT RESPONSE-SUCCESS: ${text}") 392 | resp.headers?.each { 393 | log.debug("GOT RESPONSE-HEADER: ${it.name} : ${it.value}") 394 | } 395 | String contentType = resp.headers."Content-Type" 396 | if (contentType?.startsWith("application/json") && text) { 397 | def json = new JsonSlurper().parseText(text) 398 | log.debug("GOT RESPONSE-PARSED: ${JsonOutput.prettyPrint(JsonOutput.toJson(json))}") 399 | Map jsonMap = json 400 | Response r = new Response() 401 | r.with { 402 | (success, data, statusCode) = [true, jsonMap, resp.statusLine.statusCode] 403 | } 404 | return r 405 | } 406 | else { 407 | Response r = new Response() 408 | r.with { 409 | (success, errorCode, errorDescr, statusCode) = [ 410 | false, 411 | "SI_ERR_UNSUPPORTED_RESPONSE_CONTENT", 412 | "Content-Type : ${contentType}", 413 | resp.statusLine.statusCode 414 | ] 415 | } 416 | return r 417 | } 418 | } 419 | 420 | // IMPORTANT: there is a bug in #groovylang https://jira.codehaus.org/browse/GROOVY-7132 421 | // Fixed in version 2.3.8 and above. 422 | // TODO: use it if 2.3.8 is available 423 | /* response.success = { resp, json -> */ 424 | /* log.debug("SEND RESPONSE CODE: ${resp.statusLine}") */ 425 | /* log.debug("SEND RESPONSE SUCCESS: ${json}") */ 426 | /* Map jsonMap = json */ 427 | /* Response r = new Response() */ 428 | /* r.with { */ 429 | /* (success, data, statusCode) = [true, jsonMap, resp.statusLine.statusCode] */ 430 | /* } */ 431 | /* return r */ 432 | /* } */ 433 | 434 | response.failure = { resp -> 435 | log.debug("SEND RESPONSE FAILURE") 436 | Response r = new Response() 437 | r.with { 438 | (success, errorCode, errorDescr, statusCode) = [ 439 | false, 440 | "HTTP_ERR_${resp.statusLine.statusCode}", 441 | "${resp.statusLine.reasonPhrase}", 442 | resp.statusLine.statusCode] 443 | } 444 | return r 445 | } 446 | } 447 | 448 | if (result) { 449 | return result 450 | } 451 | else { 452 | Response r = new Response() 453 | r.with { 454 | (success, errorCode, errorDescr) = [true, "SI_ERR_REST_CALL_UNDEFINED", "Response from REST call is undefined"] 455 | } 456 | return r 457 | } 458 | } 459 | 460 | private Response getXml(URL url, Map headersToSet, Map inputData) { 461 | def http = new HTTPBuilder(url) 462 | def result = http.request(GET, XML) { req -> 463 | Utils.buildRequestHeaders(headers, headersToSet) 464 | def queryMap = Utils.buildQueryAttributesMap(url, inputData) 465 | 466 | uri.query = queryMap 467 | 468 | response.success = { resp, xml -> 469 | log.debug("GET XML RESPONSE CODE: ${resp.statusLine}") 470 | log.debug("GET XML RESPONSE TYPE: ${xml.getClass()}") 471 | Response r = new Response() 472 | r.with { 473 | (success, data, statusCode) = [true, Utils.buildJsonEntity(xml), resp.statusLine.statusCode] 474 | } 475 | return r 476 | } 477 | 478 | response.failure = { resp -> 479 | log.debug("FAILURE: STATUSCODE=${resp.statusLine.statusCode}, ${resp.statusLine.reasonPhrase}") 480 | Response r = new Response() 481 | r.with { 482 | (success, errorCode, errorDescr, statusCode) = [ 483 | false, 484 | "HTTP_ERR_${resp.statusLine.statusCode}", 485 | "${resp.statusLine.reasonPhrase}", 486 | resp.statusLine.statusCode] 487 | } 488 | return r 489 | } 490 | } 491 | 492 | if (result) { 493 | log.debug("RESPONSE: ${result}") 494 | return result 495 | } 496 | else { 497 | Response r = new Response() 498 | r.with { 499 | (success, errorCode, errorDescr) = [true, "SI_ERR_REST_CALL_UNDEFINED", "Response from REST call is undefined"] 500 | } 501 | return r 502 | } 503 | } 504 | 505 | private Response sendXml(RequestMethod method, URL url, Map headersToSet, Map inputData) { 506 | def http = new HTTPBuilder(url) 507 | def result = http.request(mapMethod(method), XML) { req -> 508 | Utils.buildRequestHeaders(headers, headersToSet) 509 | body = Utils.buildXmlString(inputData) 510 | 511 | response.success = { resp, xml -> 512 | log.debug("SEND RESPONSE CODE: ${resp.statusLine}") 513 | 514 | // If there is no content returned, so HTTP 204 515 | if (resp.statusLine.statusCode == 204) { 516 | Response r = new Response() 517 | r.with { 518 | (success, statusCode) = [true, resp.statusLine.statusCode] 519 | } 520 | return r 521 | } 522 | 523 | Response r = new Response() 524 | r.with { 525 | (success, data, statusCode) = [true, Utils.buildJsonEntity(xml), resp.statusLine.statusCode] 526 | } 527 | return r 528 | } 529 | 530 | response.failure = { resp -> 531 | log.debug("POST RESPONSE FAILURE") 532 | Response r = new Response() 533 | r.with { 534 | (success, errorCode, errorDescr, statusCode) = [ 535 | false, 536 | "HTTP_ERR_${resp.statusLine.statusCode}", 537 | "${resp.statusLine.reasonPhrase}", 538 | resp.statusLine.statusCode] 539 | } 540 | return r 541 | } 542 | } 543 | } 544 | 545 | private Response sendUrlEncoded(RequestMethod method, URL url, Map headersToSet, Map inputData) { 546 | def http = new HTTPBuilder(url) 547 | def result = http.request(mapMethod(method)) { req -> 548 | Utils.buildRequestHeaders(headers, headersToSet) 549 | def queryMap = Utils.buildQueryAttributesMap(url, inputData) 550 | send URLENC, queryMap 551 | 552 | response.success = { resp -> 553 | // If there is no content returned, so HTTP 204 554 | if (resp.statusLine.statusCode == 204) { 555 | Response r = new Response() 556 | r.with { 557 | (success, statusCode) = [true, resp.statusLine.statusCode] 558 | } 559 | return r 560 | } 561 | 562 | String contentType = resp.headers."Content-Type" 563 | log.debug("CONTENT-TYPE: ${contentType}") 564 | if (contentType?.startsWith("application/json")) { 565 | def json = new JsonSlurper().parseText(resp.entity.content.text) 566 | Map jsonMap = json 567 | Response r = new Response() 568 | r.with { 569 | (success, data, statusCode) = [true, jsonMap, resp.statusLine.statusCode] 570 | } 571 | return r 572 | } 573 | else if (contentType?.startsWith("application/xml")) { 574 | def xml = new XmlSlurper().parseText(resp.entity.content.text) 575 | Response r = new Response() 576 | r.with { 577 | (success, data, statusCode) = [true, Utils.buildJsonEntity(xml), resp.statusLine.statusCode] 578 | } 579 | return r 580 | } 581 | else { 582 | return new Response(false, "SI_UNSUPPORTED_API_CONTENT_TYPE", "Unsupported API content type") 583 | } 584 | } 585 | 586 | response.failure = { resp -> 587 | log.debug("POST RESPONSE FAILURE") 588 | Response r = new Response() 589 | r.with { 590 | (success, errorCode, errorDescr, statusCode) = [ 591 | false, 592 | "HTTP_ERR_${resp.statusLine.statusCode}", 593 | "${resp.statusLine.reasonPhrase}", 594 | resp.statusLine.statusCode] 595 | } 596 | return r 597 | } 598 | } 599 | } 600 | 601 | private Response del(URL url, Map headersToSet, Map inputData) { 602 | def http = new HTTPBuilder(url) 603 | def result = http.request(DELETE) { req -> 604 | Utils.buildRequestHeaders(headers, headersToSet) 605 | def queryMap = Utils.buildQueryAttributesMap(url, inputData) 606 | 607 | uri.query = queryMap 608 | 609 | 610 | log.debug "HEADERS: ${headers}" 611 | log.debug "QUERY: ${queryMap}" 612 | 613 | response.success = { resp, json -> 614 | log.debug("SUCCESS: STATUSCODE=${resp.statusLine.statusCode}") 615 | Response r = new Response() 616 | r.with { 617 | (success, statusCode) = [true, resp.statusLine.statusCode] 618 | } 619 | return r 620 | } 621 | 622 | response.failure = { resp -> 623 | log.debug("FAILURE: STATUSCODE=${resp.statusLine.statusCode}, ${resp.statusLine.reasonPhrase}") 624 | Response r = new Response() 625 | r.with { 626 | (success, errorCode, errorDescr, statusCode) = [ 627 | false, 628 | "HTTP_ERR_${resp.statusLine.statusCode}", 629 | "${resp.statusLine.reasonPhrase}", 630 | resp.statusLine.statusCode] 631 | } 632 | return r 633 | } 634 | } 635 | 636 | if (result) { 637 | log.debug("RESPONSE: ${result}") 638 | return result 639 | } 640 | else { 641 | Response r = new Response() 642 | r.with { 643 | (success, errorCode, errorDescr) = [true, "SI_ERR_REST_CALL_UNDEFINED", "Response from REST call is undefined"] 644 | } 645 | return r 646 | } 647 | } 648 | } 649 | --------------------------------------------------------------------------------