跳到主要內容

WireMock - Record Webhook Events

Problem

我有一隻待測程式(SUT)會相依於外部服務(External Service)的webhook 機制,操作流程如下:

  1. SUT會對外部服務特定事件註冊webhook 位置。
  2. 當外部服務發生特定事件時,會發送event到SUT所註冊的位置。
  3. SUT對外部服務反註冊webhook 位置。

WireMock有提供webhook的extension,讓你可以自行編寫程式或mappingfile腳本去做到在“特定操作後,發送webhook操作”,但它並不支援Recording的功能。本篇文章,主要分享如何讓WireMock擁有錄製webhook的功能。

Note. 範例程式碼link可自行取用。這是依照我需求撰寫的,只要弄懂方法,就可以依照你需求自行調整。
Limitation. 如果webhook的反註冊是基於回應內容的URL,目前還沒想到辦法可以根據SUT的IP與情境去做對應的內容回應。

How to?

Pre-notice

由於Solution與整個Record流程息息相關,我必須先說明我所採用的流程。在我的故事中,有四隻程式:
  1. Record Program: 負責控制整個WireMock腳本記錄流程的程式。
  2. SUT: 待測程式,透過REST API提供與External Service相關功能的服務。
  3. WireMock: 負責記錄SUT與External Service互動的過程並產生腳本,提供REST API讓Record Program控制Record流程。
  4. External Service: SUT所互動的外部服務。
詳細流程如下:
  1. Record Program請WireMock開始記錄SUT與External Service互動過程。
  2. Record Program對SUT觸發command,並開始poll command完成結果。
  3. SUT對WireMock做操作。
  4. WireMock將操作轉送給External Service。
  5. Record Program發現command執行完成。
  6. Record Program請WireMock停止記錄。
  7. WireMock產生SUT與External Service互動過程的執行腳本(mappings)。
  8. Record Program針對腳本命名。

Thinking & Analysis

針對Pre-notice的流程,我有幾個問題需要解決:
  1. WireMock如何捕捉External Service發送給SUT的callback event?
  2. 補捉之後如何將這些events放置到腳本的Post中?
  3. 放置到腳本中後,如何在適當的時間由WireMock返回給SUT?
首先討論第三個問題,如果有辦法產生如下方的腳本內容,應該可以解決問題。讓我先針對內容做說明:
  1. webhook callback的內容會被放在postServerActions中,會在WireMock回應SUT後觸發這些動作。
  2. 啟用webhook delay的機制,讓它根據時間延後發送;而這個時間其實是SUT註冊callback URL到WireMock收到callback event的duration。
接著回到第一個問題,我採用的方式是將註冊的callback url改到WireMock身上,流程如下圖。另外會把原本的callback url與註冊時間記錄下來,以用來處理收到callback event後的轉送與計算問題三個delay時間:
延續上圖流程,當收到callback event後,就可以透過上圖記錄的原始callback url,送回給SUT並記錄收到event的時間:
在還不考慮技術與實作細節的情況下,這solution似乎是可行的。讓我們繼續看下去。

Note. 第一個問題,我曾想過2個解決方法。第一個方法是同時註冊SUT與WireMock的callback URL給External Service,讓External Service同時發event給兩者;但由於無法確定event收到的先後順序而影響到步驟6的執行,所以捨棄這個方法。

Design & Implement

要達成前半段分析的流程,這次我們有兩個WireMock extension必須要實作:
  1. RequestFilter: 處理SUT與External Serviec的請求。
  2. StubMappingTransformer: 根據收集到的資訊產生我們要的腳本。

RequestFilter

我實作的ExternalServiceWebHookRequestFilter主要會處理這幾件事情:
  1. 監聽WireMock recording start與stop,用以控制記錄的資料。
  2. 監聽SUT的webhook註冊請求,記錄並將callback URL改為WireMock位置。
  3. 監聽External Service的callback event,記錄並轉送回SUT。
而SUT所註冊request body重點內容如下,Destionation為callback URL,Context為註冊識別號:
{
"Destination": "http://10.146.16.150:18556/callback",
"Context": "tony_test",
..
}
當收到SUT的webhook註冊請求後,會使用EventRecorder將context與destination記錄下來,EventRecorder會自行標記時間;接著將destination的port改為WireMock的HTTP port,並調整請求的Body內容讓WireMock繼續對External Service做註冊動作:
private RequestFilterAction handleSubscribeRequest(Request request, 
				SubscribeRequestBody eventRequestBody) {
	EventRecorder.getInstance().markSubscription(eventRequestBody.getContext(), 
			eventRequestBody.getDestination());
 
	Request wrapRequest = RequestWrapper.create()
			.transformBody(body->new Body(body.asString().replaceAll(":\\d+", ":"+WIREMOCK_HTTP_PORT)))
			.wrap(request);
 
	return RequestFilterAction.continueWith(wrapRequest);
}
在處理完註冊動作後,WireMock就會開始收到callback event。收到這些event後,首先會透過EventRecorder依照context記錄下來,EventRecorder會標記註冊後要delay多少時間才發送給SUT;接著會透過EventRecorder取出原本的callback destination並轉發回去給SUT;最後就是讓這請求到這裡結束並回應給External Service OK,少了這步驟會讓WireMock繼續處理這訊息而導致External Service產生非預期結果:
private RequestFilterAction handlePostRedfishEventsRequest(Request request, CallBackEvent eventRequestBody) {
	String context = eventRequestBody.getEvents().get(0).getContext();
	EventRecorder.getInstance().addEvent(context, request.getBodyAsString());
 
	Optional<String> destinationOpt = EventRecorder.getInstance().getDestination(context);
	destinationOpt.ifPresent(destination->publishEventToSource(destination, request.getBodyAsString()));
	return RequestFilterAction.stopWith(ResponseDefinitionBuilder.okForEmptyJson().build());
}

StubMappingTransformer

有了RequestFilter所記錄下來的資料後,StubMappingTransformer在WireMock收到stop後,就會開始處理腳本的產生。最重要的處理在下圖的步驟二,包含註冊request內容的調整與webhook postServerAction的產生:

首先你要先確定,要處理的StubMapping是屬於SUT註冊webhook的請求,我採用的方式是判斷Request的URL:

@Override
public StubMapping transform(StubMapping stubMapping, FileSource files, Parameters parameters) {
	RequestPattern requestPattern = stubMapping.getRequest();
	if(notSupportedRequest(requestPattern))
		return stubMapping;
 
	removeDestination(requestPattern);
	applyWebHookEvents(stubMapping);
 
	return stubMapping;
}
接著由於註冊的request body中,destination包含著SUT的IP,而SUT會隨著執行環境而改變,這將導致錄製出來的腳本無法萬用。因此我必須將腳本內RequestBody的Destination移除,如果要考慮情境比較複雜可以直接套Library去移除: (註冊的URL就不需要處理了,因為當下的執行前後文是由你的腳本所促成,URL絕對會相同)
private void removeDestination(RequestPattern requestPattern) {
	EqualToJsonPattern bodyPattern = (EqualToJsonPattern)requestPattern.getBodyPatterns().get(0);
	String removedDestBodyString = bodyPattern.getEqualToJson().replaceAll("\"Destination\":.*,", "");
	EqualToJsonPattern  newBodyPattern = new EqualToJsonPattern(removedDestBodyString, true, true);
	requestPattern.getBodyPatterns().clear();
	requestPattern.getBodyPatterns().add(newBodyPattern);
}
最後就是重頭戲:
首先根據context去取得所有對應的callback events,再將這些events轉為webhook的PostServeActionDefinition。Parameters需注意的有兩點,第一個是url的設定,這還是因為destination不會是固定的,因此必須根據當下請求的destination去做回應,在這使用了jsonPath寫法給大家參考;第二個則是delay的type要設定為fixed,代表是固定多久時間後要觸發這個callback,milliseconds則直接從EventRecorder記錄的PostEvent拿了:
private void applyWebHookEvents(StubMapping stubMapping) {
	String context = parseContext(stubMapping);
	if(context == null) return;
	List<Event> postEvents = EventRecorder.getInstance().getEvents(context);
	List<PostServeActionDefinition> postServerActionDefinitions = postEvents.stream()
			.map(this::toParameters)
			.map(parameters->new PostServeActionDefinition("webhook", parameters))
			.collect(Collectors.toList());
 
	stubMapping.setPostServeActions(postServerActionDefinitions);
}
 
private Parameters toParameters(Event postEvent) {
	Parameters parameters = new Parameters();
	parameters.put("method", "POST");
	parameters.put("url", "{{jsonPath originalRequest.body '$.Destination'}}");
	parameters.put("body", postEvent.getRawData());
 
	Map<String, Object> delay = new HashMap<>();
	delay.put("type", "fixed");
	delay.put("milliseconds", postEvent.getDelay());
	parameters.put("delay", delay);
 
	return parameters;
}
完成以上實作並錄製腳本所產生出來的東西,其實我已經在Thinking & Analysis的部分show給大家看過了。

留言

這個網誌中的熱門文章

解決RobotFramework從3.1.2升級到3.2.2之後,Choose File突然會整個Hand住的問題

考慮到自動測試環境的維護,我們很久以前就使用java去執行robot framework。前陣子開始處理從3.1.2升級到3.2.2的事情,主要先把明確的runtime語法錯誤與deprecate item處理好,這部分內容可以參考: link 。 直到最近才發現,透過SeleniumLibrary執行Choose File去上傳檔案的動作,會導致測試案例timeout。本篇文章主要分享心路歷程與解決方法,我也送了一條issue給robot framework: link 。 我的環境如下: RobotFramework: 3.2.2 Selenium: 3.141.0 SeleniumLibrary: 3.3.1 Remote Selenium Version: selenium-server-standalone-3.141.59 首先並非所有Choose File的動作都會hang住,有些測試案例是可以執行的,但是上傳一個作業系統ISO檔案一定會發生問題。後來我透過wireshark去比對新舊版本的上傳動作,因為我使用 Remote Selenium ,所以Selenium會先把檔案透過REST API發送到Remote Selenium Server上。從下圖我們可以發現,在3.2.2的最後一個TCP封包,比3.1.2大概少了500個bytes。 於是就開始了我trace code之路。包含SeleniumLibrary產生要送給Remote Selenium Server的request內容,還有HTTP Content-Length的計算,我都確認過沒有問題。 最後發現問題是出在socket API的使用上,就是下圖的這支code: 最後發現可能因為開始使用nio的方式送資料,但沒處理到尚未送完的資料內容,而導致發生問題。加一個loop去做計算就可以解決了。 最後我有把解法提供給robot framework官方,在他們出新的版本之前,我是將改完的_socket.py放在我們自己的Lib底下,好讓我們測試可以正常進行。(shutil.py應該也是為了解某個bug而產生的樣子..)

Show NIC selection when setting the network command with the device option

 Problem  在answer file中設定網卡名稱後,安裝時會停在以下畫面: 所使用的command參數如下: network --onboot = yes --bootproto =dhcp --ipv6 =auto --device =eth1 Diagnostic Result 這樣的參數,以前試驗過是可以安裝完成的。因此在發生這個問題後,我檢查了它的debug console: 從console得知,eth1可能是沒有連接網路線或者是網路太慢而導致的問題。後來和Ivy再三確認,有問題的是有接網路線的網卡,且問題是發生在activate階段: Solution 我想既然有retry應該就有次數或者timeout限制,因此發現在Anaconda的說明文件中( link ),有提到dhcptimeout這個boot參數。看了一些人的使用範例,應該是可以直接串在isolinux.cfg中,如下: default linux ksdevice = link ip =dhcp ks =cdrom: / ks.cfg dhcptimeout = 90 然而我在RHEL/CentOS 6.7與6.8試驗後都無效。 因此我就拿了顯示的錯誤字串,問問Google大師,想找一下Anaconda source code來看一下。最後找到別人根據Anaconda code修改的版本: link ,關鍵在於setupIfaceStruct函式中的setupIfaceStruct與readNetConfig: setupIfaceStruct: 會在dhcp時設定dhcptimeout。 readNetConfig: 在writeEnabledNetInfo將timeout寫入dhclient config中;在wait_for_iface_activation內會根據timeout做retry。 再來從log與code可以得知,它讀取的檔案是answer file而不是boot command line。因此我接下來的測試,就是在answer file的network command上加入dhcptimeout: network --onboot = yes --bootproto =dhcp --ipv6 =auto --device =eth1 --...

Robot Framework - Evaluate該怎麼用?

Evaluate該怎麼用? 前言 Builtin的RobotFramework Library提供了Evaluate Keyword。它所提供的功能是「執行Python描述句」。但實際上到底有什麼用途呢?原本我僅僅拿來將string轉為int的功用,經過一些查詢與試驗,我將心得整理給大家。 Builtin Builtin的function可以參考Library Doc for Evaluate。我以有使用過的function做說明。 數字轉換 Python提供了int、long、float與complex等function讓你可以將字串轉為數字,也可以透過它們做四則運算。首先以字串轉數字為例,我將8設於${num_str}中,再透過Evaluate+int轉為數字。這裡必須注意的是: 「int()中放變數必須以單引號'括起」。否則,假如你設定的數字為08,在轉換int時會出現Syntax Error。 ${num_str} | Set Variable | 8 ${num} | Evaluate | int('${num_str}') 其中int與long的第二個參數為base,這是根據你的input所決定: Comment | num = 9 ${num} | Evaluate | int('11', 8) Comment | num = 11 ${num} | Evaluate | int('11', 10) Comment | num = 17 ${num} | Evaluate | int('11', 16) 其它還有像bin、oct、hex,可以將整數轉為2、8、16進位。 運算 四則運算: 直接將運算子加上即可: ${num} | Evaluate | int('${hour}')*60 + int('${min}') 指數: 可以用pow。以下面兩個例子來說,第一個是2的3次方為8,第二個是2的3次方再mod 7為1。需注意的是: 「傳入值必須是數字不可為字串」。 ${num} | Evaluate | pow(2,3) ${num} | Evaluate | pow(2,3,7) 取最大最小值: 使用max/min,可以選擇丟一個array的方式...