網頁

2022/4/24

Spring Boot Webhooks簡單實作

在Spring Boot實作簡單的webhooks


範例環境:

  • Java 17
  • Spring Boot 2.6.7
  • Lombok


事前要求

參考「IntelliJ IDEA Community 建立Spring Boot專案教學」建立Spring Boot專案。

application.properties

#context path
server.servlet.context-path=/demo
#port
server.port=8080

注意本範例server的webhooks及註冊webhooks的client皆在同一個Spring Boot專案。


Server Webhhooks

Webhooks需要:

  • 提供client註冊/訂閱webhooks的API endpoint
  • 儲存client註冊webhooks的URL。
  • 提供client設計接收webhooks發送請求的資料格式

建立WebhooksController如下。POST|/register用來給client註冊webhooks的URL。註冊時須提供名稱及URL的JSON request body,由WebhooksRegisterRequest接收然後轉交WebhooksService把註冊資料儲存起來,並返回webhooks的請求格式yaml文件給client。

GET|/register可取得client註冊webhooks的URL清單。

WebhooksController.java

package com.abc.demo.controller;

import com.abc.demo.controller.request.WebhooksRegisterRequest;
import com.abc.demo.service.WebhooksService;
import org.apache.commons.io.IOUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;

@RestController
public class WebhooksController {

    @Autowired
    private WebhooksService webhooksService;

    @Value("classpath:static/webhooks/events.yaml")
    private Resource eventsYaml;

    @PostMapping(value = "/register", produces = "text/yaml")
    public String register(@RequestBody WebhooksRegisterRequest webhooksRegisterRequest) throws IOException {
        webhooksService.save(webhooksRegisterRequest);
        return IOUtils.toString(eventsYaml.getInputStream(), StandardCharsets.UTF_8);
    }

    @GetMapping(value = "/register", produces = MediaType.APPLICATION_JSON_VALUE)
    public List<String> getUrlList() {
        return webhooksService.getRegisteredUrlList();
    }

}

WebhooksRegisterRequest接收client註冊webhooks的JSON資料。

WebhooksRegisterRequest.java

package com.abc.demo.controller.request;

import lombok.Data;

@Data
public class WebhooksRegisterRequest {
    private String name;
    private String url;
}

events.yaml為client呼叫POST|/register註冊webhooks成功時返回的webhooks發送請求格式文件。格式為OpenAPI,可以Swagger Editor開啟。

events.yaml

openapi: 3.0.0
info:
  contact: 
    name: "菜鳥工程師肉豬-Spring Boot Webhooks簡單實作"
    url: "https://matthung0807.blogspot.com/2022/04/spring-boot-webhooks-simple-impl.html"
  title: "Webhooks Events API"
  version: "1.0.0"
paths:
  "/hello":
    post:
      description: Hello
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Hello"
        required: true
      responses:
        "200":
          description: Success
          content:
            text/plain:
              schema:
                type: string
                example: success
      tags:
        - Hello
components:
  schemas:
    Hello:
      type: object
      required:
        - name
      properties:
        name:
          type: string

WebhooksService負責把WebhooksController送來的client註冊請求轉為WebhooksRegisterDto儲存在模擬資料庫的registeredMap成員變數中。

getRegisteredDtoList()方法從registeredMap取得client註冊webhooks的資料用以在觸發webhooks時將請求送給client註冊的URL。

getRegisteredUrlList()方法取得client註冊webhooks的url清單。

WebhooksService.java

package com.abc.demo.service;

import com.abc.demo.controller.request.WebhooksRegisterRequest;
import com.abc.demo.dto.WebhooksRegisterDto;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Service
public class WebhooksService {

    private final Map<String, WebhooksRegisterDto> registeredMap = new ConcurrentHashMap<>();

    public void save(WebhooksRegisterRequest webhooksRegisterRequest) {
        var url = webhooksRegisterRequest.getUrl();
        registeredMap.put(url, new WebhooksRegisterDto(
                webhooksRegisterRequest.getName(), url, LocalDateTime.now()));
    }

    public List<WebhooksRegisterDto> getRegisteredDtoList() {
        return registeredMap.values().stream().toList();
    }

    public List<String> getRegisteredUrlList() {
        return registeredMap.values().stream()
                .toList().stream()
                .map(WebhooksRegisterDto::getUrl).toList();
    }

}

WebhooksRegisterDto為server儲存client註冊webhooks資料的資料物件。

WebhooksRegisterDto.java

package com.abc.demo.dto;

import lombok.AllArgsConstructor;
import lombok.Data;

import java.time.LocalDateTime;

@Data
@AllArgsConstructor
public class WebhooksRegisterDto {
    private String name;
    private String url;
    private LocalDateTime createdAt;
}

GreetingController為server的一簡單服務,執行時會以HttpClient發送請求給client註冊的URL,發送的請求JSON格式即為client註冊webhooks成功時返回的yaml文件。

GreetingController.java

package com.abc.demo.controller;

import com.abc.demo.dto.WebhooksRegisterDto;
import com.abc.demo.service.WebhooksService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.time.Duration;

@RestController
public class GreetingController {

    @Autowired
    private WebhooksService webhooksService;

    @GetMapping("/greeting")
    public void greeting() throws Exception {
        System.out.println("Good day");
        sendEvent();
    }

    private void sendEvent() throws Exception {
        var registeredDtoList = webhooksService.getRegisteredDtoList();
        if (registeredDtoList.isEmpty()) {
            System.out.println("webhooks has no registered urls");
            return;
        }

        var httpClient = HttpClient.newBuilder()
                .version(HttpClient.Version.HTTP_1_1)
                .connectTimeout(Duration.ofSeconds(3))
                .sslContext(disabledSSLContext()) // disable SSL verify
                .build();

        for (WebhooksRegisterDto dto : registeredDtoList) {
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(dto.getUrl()))
                    .POST(HttpRequest.BodyPublishers.ofString("{\"name\": \"" + dto.getName() + "\"}"))
                    .header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
                    .build();

            var response = httpClient.send(
                    request, HttpResponse.BodyHandlers.ofString());
            if ("success".equals(response.body())) {
                System.out.println("send event success");
            }
        }
    }

    private static SSLContext disabledSSLContext() throws KeyManagementException, NoSuchAlgorithmException {
        SSLContext sslContext = SSLContext.getInstance("TLS");
        sslContext.init(
                null,
                new TrustManager[]{
                        new X509TrustManager() {
                            public X509Certificate[] getAcceptedIssuers() {
                                return null;
                            }

                            public void checkClientTrusted(X509Certificate[] certs, String authType) {
                            }

                            public void checkServerTrusted(X509Certificate[] certs, String authType) {
                            }
                        }
                },
                new SecureRandom()
        );
        return sslContext;
    }
}


Client

HelloControllerPOST|/hello為向server註冊的URL,當server的webhooks觸發時會呼叫此endpoints。接收的請求資料HelloRequest格式為client註冊webhooks成功時返回的yaml文件。

HelloController.java

package com.abc.demo.controller;

import com.abc.demo.controller.request.HelloRequest;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @PostMapping(value = "/hello", produces = MediaType.TEXT_PLAIN_VALUE)
    public String hello(@RequestBody HelloRequest helloRequest) {
        System.out.println("Hello " + helloRequest.getName());

        return "success";
    }
}

HelloRequest為接收webhooks請求的資料物件。

HelloRequest.java

package com.abc.demo.controller.request;

import lombok.Data;

@Data
public class HelloRequest {
    private String name;
}

github


測試

完成以上server webhooks及client後,接著以client的角度測試webhooks的效果。

啟動專案。使用curl呼叫server的POST|/register註冊webhooks並以JSON提供註冊的名稱及URL http://localhost:8080/demo/hello。成功後返回webhooks的請求格式文件。

$ curl -X POST "http://localhost:8080/demo/register" \
> -H 'content-type: application/json' \
> -d '{"url": "http://localhost:8080/demo/hello", "name": "john"}'
openapi: 3.0.0
info:
  contact:
    name: "菜鳥工程師肉豬-Spring Boot Webhooks簡單實作"
    url: "https://matthung0807.blogspot.com/2022/04/spring-boot-webhooks-simple-impl.html"
  title: "Webhooks Events API"
  version: "1.0.0"
paths:
  "/hello":
    post:
      description: Hello
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Hello"
        required: true
      responses:
        "200":
          description: Success
          content:
            text/plain:
              schema:
                type: string
                example: success
      tags:
        - Hello
components:
  schemas:
    Hello:
      type: object
      required:
        - name
      properties:
        name:
          type: string

呼叫GET/register即返回剛註冊的http://localhost:8080/demo/hello

$ curl -X GET "http://localhost:8080/demo/register"
["http://localhost:8080/demo/hello"]

呼叫GET/greeting觸發webhooks。

$ curl -X GET "http://localhost:8080/demo/greeting"

此時在console會印出以下。其中Hello john為client GET/hello被server webhooks觸發的結果。

Good day
Hello john
send event success

觸發webhooks循序圖。

               ┌────────┐              ┌────────┐
               │ client │              │ Server │
               └────┬───┘              └────┬───┘
                    │                       │
                                             
                    │     GET|/greeting     │
                   ┌┼─────────────────────►┌┤
                   ││                      ││
                   ││                      ││
                   ││                      │┼─print "Good day"
                   ││                      ││
                   ││                      ││
                   ││                      │┼─retrive webhooks registered urls
                   ││                      ││
                   ││      POST|/hello     ││
                   │┤◄─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┼┼─send webooks event request
                   ││                      ││
Print "Hello john"─┼│                      ││
                   ││                      ││
                   └┤                      └┼─print "send event success"
                    │                       │
                                              
                    │                       │
                    ▼                       ▼

以上即為webhooks的簡單範例,但通常註冊時需要提供token做為client驗證。


沒有留言:

張貼留言