1414
1515package google .registry .reporting .mosapi ;
1616
17+ import com .google .common .base .Splitter ;
18+ import com .google .common .base .Strings ;
1719import com .google .common .collect .ImmutableMap ;
1820import com .google .common .flogger .FluentLogger ;
1921import google .registry .config .RegistryConfig .Config ;
2022import google .registry .util .HttpUtils ;
2123import jakarta .inject .Inject ;
2224import jakarta .inject .Named ;
2325import jakarta .inject .Singleton ;
24- import java .net .CookieManager ;
25- import java .net .CookiePolicy ;
26+ import java .io .IOException ;
27+ import java .net .HttpURLConnection ;
28+ import java .net .URLEncoder ;
2629import java .net .http .HttpClient ;
2730import java .net .http .HttpResponse ;
2831import java .nio .charset .StandardCharsets ;
2932import java .util .Base64 ;
33+ import java .util .Collections ;
34+ import java .util .Map ;
35+ import java .util .Optional ;
36+ import java .util .function .BiFunction ;
3037import java .util .function .Function ;
38+ import java .util .stream .Collectors ;
39+ import javax .annotation .Nullable ;
3140
3241/**
3342 * A client for interacting with the ICANN Monitoring System API (MoSAPI).
@@ -43,10 +52,26 @@ public final class MosApiClient {
4352 private final Function <String , String > usernameProvider ;
4453 private final Function <String , String > passwordProvider ;
4554
46- private final HttpClient httpClient ;
47- private final CookieManager cookieManager ;
55+ // API Endpoints
56+ private static final String LOGIN_PATH = "/login" ;
57+ private static final String LOGOUT_PATH = "/logout" ;
58+ // HTTP Headers
59+ private static final String HEADER_AUTHORIZATION = "Authorization" ;
60+ private static final String HEADER_CONTENT_TYPE = "Content-Type" ;
61+ private static final String HEADER_COOKIE = "Cookie" ;
62+ private static final String HEADER_SET_COOKIE = "Set-Cookie" ;
63+
64+ // HTTP Header Prefixes and Values
65+ private static final String AUTH_BASIC_PREFIX = "Basic " ;
66+ private static final String CONTENT_TYPE_JSON = "application/json" ;
4867
49- private boolean isLoggedIn = false ;
68+ // Cookie Parsing
69+ private static final String COOKIE_ID_PREFIX = "id=" ;
70+ private static final char COOKIE_DELIMITER = ';' ;
71+ private final int RATE_LIMIT = 429 ;
72+
73+ private final HttpClient httpClient ;
74+ private final MosApiSessionCache mosApiSessionCache ;
5075
5176 /**
5277 * Constructs a new MosApiClient.
@@ -61,16 +86,13 @@ public MosApiClient(
6186 @ Config ("mosapiUrl" ) String mosapiUrl ,
6287 @ Config ("entityType" ) String entityType ,
6388 @ Named ("mosapiUsernameProvider" ) Function <String , String > usernameProvider ,
64- @ Named ("mosapiPasswordProvider" ) Function <String , String > passwordProvider ) {
89+ @ Named ("mosapiPasswordProvider" ) Function <String , String > passwordProvider ,
90+ MosApiSessionCache mosApiSessionCache ) {
6591 this .baseUrl = String .format ("%s/%s" , mosapiUrl , entityType );
6692 this .usernameProvider = usernameProvider ;
6793 this .passwordProvider = passwordProvider ;
68- this .cookieManager = new CookieManager ();
69- this .cookieManager .setCookiePolicy (CookiePolicy .ACCEPT_ALL );
70-
71- // Build the final HttpClient using the injected builder and our session-specific
72- // CookieManager.
73- this .httpClient = httpClientBuilder .cookieHandler (cookieManager ).build ();
94+ this .mosApiSessionCache = mosApiSessionCache ;
95+ this .httpClient = httpClientBuilder .build ();
7496 }
7597
7698 /**
@@ -82,7 +104,7 @@ public MosApiClient(
82104 */
83105 public void login (String entityId ) throws MosApiException {
84106
85- String loginUrl = baseUrl + "/" + entityId + "/login" ;
107+ String loginUrl = buildUrl ( entityId , LOGIN_PATH , Collections . emptyMap ()) ;
86108 String username = usernameProvider .apply (entityId );
87109 String password = passwordProvider .apply (entityId );
88110 String auth = username + ":" + password ;
@@ -91,18 +113,26 @@ public void login(String entityId) throws MosApiException {
91113 try {
92114 HttpResponse <String > response =
93115 HttpUtils .sendPostRequest (
94- httpClient , loginUrl , ImmutableMap .of ("Authorization" , "Basic " + encodedAuth ));
116+ httpClient ,
117+ loginUrl ,
118+ ImmutableMap .of (HEADER_AUTHORIZATION , AUTH_BASIC_PREFIX + encodedAuth ));
95119
96120 switch (response .statusCode ()) {
97- case 200 :
98- isLoggedIn = true ;
121+ case HttpURLConnection .HTTP_OK :
122+ Optional <String > setCookieHeader = response .headers ().firstValue (HEADER_SET_COOKIE );
123+ if (setCookieHeader .isEmpty ()) {
124+ throw new MosApiException (
125+ "Login succeeded but server did not return a Set-Cookie header." );
126+ }
127+ String cookieValue = parseCookieValue (setCookieHeader .get ());
128+ mosApiSessionCache .store (entityId , cookieValue );
99129 logger .atInfo ().log ("MoSAPI login successful" );
100130 break ;
101- case 401 :
131+ case HttpURLConnection . HTTP_UNAUTHORIZED :
102132 throw new InvalidCredentialsException (response .body ());
103- case 403 :
133+ case HttpURLConnection . HTTP_FORBIDDEN :
104134 throw new IpAddressNotAllowedException (response .body ());
105- case 429 :
135+ case RATE_LIMIT :
106136 throw new RateLimitExceededException (response .body ());
107137 default :
108138 throw new MosApiException (
@@ -123,23 +153,23 @@ public void login(String entityId) throws MosApiException {
123153 * @throws MosApiException if the logout request fails.
124154 */
125155 public void logout (String entityId ) throws MosApiException {
126- String logoutUrl = baseUrl + "/" + entityId + "/logout" ;
127- if (! isLoggedIn ) {
128- return ; // Already logged out.
129- }
156+ String logoutUrl = buildUrl ( entityId , LOGOUT_PATH , Collections . emptyMap ()) ;
157+ Optional < String > cookie = mosApiSessionCache . get ( entityId );
158+ Map < String , String > headers =
159+ cookie . isPresent () ? ImmutableMap . of ( HEADER_COOKIE , cookie . get ()) : ImmutableMap . of ();
130160
131161 try {
132- HttpResponse <String > response = HttpUtils .sendPostRequest (httpClient , logoutUrl );
162+ HttpResponse <String > response = HttpUtils .sendPostRequest (httpClient , logoutUrl , headers );
133163
134164 switch (response .statusCode ()) {
135- case 200 :
165+ case HttpURLConnection . HTTP_OK :
136166 logger .atInfo ().log ("Logout successful." );
137167 break ;
138- case 401 :
168+ case HttpURLConnection . HTTP_UNAUTHORIZED :
139169 logger .atWarning ().log (
140170 "Warning: %s (Session may have already expired)." , response .body ());
141171 break ;
142- case 403 :
172+ case HttpURLConnection . HTTP_FORBIDDEN :
143173 throw new IpAddressNotAllowedException (response .body ());
144174 default :
145175 throw new MosApiException (
@@ -152,9 +182,214 @@ public void logout(String entityId) throws MosApiException {
152182 } catch (Exception e ) {
153183 throw new MosApiException ("An error occurred during logout." , e );
154184 } finally {
155- isLoggedIn = false ;
156- cookieManager .getCookieStore ().removeAll (); // Clear local cookies.
185+ mosApiSessionCache .clear (entityId );
186+ logger .atInfo ().log ("Cleared session cache for %s" , entityId );
187+ }
188+ }
189+
190+ /**
191+ * Executes a GET request with automatic session handling and re-login.
192+ *
193+ * @param entityId The entityId (e.g., TLD) for this request.
194+ * @param path The API path, e.g., "/monitoring/state".
195+ * @param queryParams A map of query parameters.
196+ * @param additionalHeaders Any custom headers for this specific request (e.g., "Accept").
197+ * @return The response body as a String.
198+ * @throws MosApiException if the request fails.
199+ */
200+ public String executeGetRequest (
201+ String entityId ,
202+ String path ,
203+ Map <String , String > queryParams ,
204+ Map <String , String > additionalHeaders )
205+ throws MosApiException {
206+
207+ String url = buildUrl (entityId , path , queryParams );
208+
209+ BiFunction <String , String , HttpResponse <String >> requestExecutor =
210+ (requestUrl , cookie ) -> {
211+ try {
212+ ImmutableMap .Builder <String , String > headers = ImmutableMap .builder ();
213+ headers .put (HEADER_COOKIE , cookie );
214+ if (additionalHeaders != null ) {
215+ headers .putAll (additionalHeaders );
216+ }
217+ return HttpUtils .sendGetRequest (httpClient , requestUrl , headers .build ());
218+ } catch (IOException | InterruptedException e ) {
219+ throw new RuntimeException (new MosApiException ("HTTP GET request failed" , e ));
220+ }
221+ };
222+
223+ HttpResponse <String > response = executeRequestWithRetry (entityId , url , requestExecutor );
224+
225+ if (response .statusCode () != HttpURLConnection .HTTP_OK ) {
226+ throw new MosApiException (
227+ String .format (
228+ "GET request to %s failed with status code: %d - %s" ,
229+ path , response .statusCode (), response .body ()));
230+ }
231+ return response .body ();
232+ }
233+
234+ /**
235+ * Executes a POST request with automatic session handling and re-login.
236+ *
237+ * @param entityId The entityId (e.g., TLD) for this request.
238+ * @param path The API path, e.g., "/monitoring/incident/123/falsePositive".
239+ * @param body An optional request body (e.g., JSON string). Use null or empty for no body.
240+ * @param additionalHeaders Any custom headers for this specific request.
241+ * @return The response body as a String.
242+ * @throws MosApiException if the request fails.
243+ */
244+ public String executePostRequest (
245+ String entityId , String path , @ Nullable String body , Map <String , String > additionalHeaders )
246+ throws MosApiException {
247+
248+ String url = buildUrl (entityId , path , Collections .emptyMap ());
249+ final String requestBody = Strings .nullToEmpty (body );
250+
251+ // Define the request-executing lambda
252+ BiFunction <String , String , HttpResponse <String >> requestExecutor =
253+ (requestUrl , cookie ) -> {
254+ try {
255+ // Build the headers map
256+ ImmutableMap .Builder <String , String > headers = ImmutableMap .builder ();
257+ headers .put (HEADER_COOKIE , cookie );
258+ if (additionalHeaders != null ) {
259+ headers .putAll (additionalHeaders );
260+ }
261+ if (!requestBody .isEmpty ()) {
262+ headers .put (HEADER_CONTENT_TYPE , CONTENT_TYPE_JSON );
263+ }
264+
265+ return HttpUtils .sendPostRequest (httpClient , requestUrl , headers .build (), requestBody );
266+ } catch (IOException | InterruptedException e ) {
267+ throw new RuntimeException (new MosApiException ("HTTP POST request failed" , e ));
268+ }
269+ };
270+
271+ HttpResponse <String > response = executeRequestWithRetry (entityId , url , requestExecutor );
272+
273+ if (response .statusCode () != HttpURLConnection .HTTP_OK ) {
274+ throw new MosApiException (
275+ String .format (
276+ "POST request to %s failed with status code: %d - %s" ,
277+ path , response .statusCode (), response .body ()));
278+ }
279+ return response .body ();
280+ }
281+
282+ /**
283+ * Executes a request function with automatic session caching and re-login on expiry.
284+ *
285+ * @param entityId The entityId for the request.
286+ * @param url The full URL to request.
287+ * @param requestExecutor A function that takes (URL, CookieString) and returns an HttpResponse.
288+ * @return The HttpResponse from the successful request.
289+ * @throws MosApiException if the request fails permanently.
290+ */
291+ private HttpResponse <String > executeRequestWithRetry (
292+ String entityId , String url , BiFunction <String , String , HttpResponse <String >> requestExecutor )
293+ throws MosApiException {
294+
295+ // 1. Try with existing cookie from cache
296+ Optional <String > cookie = mosApiSessionCache .get (entityId );
297+ if (cookie .isPresent ()) {
298+ try {
299+ HttpResponse <String > response = requestExecutor .apply (url , cookie .get ());
300+ if (isSessionExpiredError (response )) {
301+ logger .atWarning ().log ("Session expired for %s. Re-logging in." , entityId );
302+ } else {
303+ return response ; // Success or other non-session-expired error
304+ }
305+ } catch (RuntimeException e ) {
306+ if (e .getCause () instanceof MosApiException ) {
307+ throw (MosApiException ) e .getCause ();
308+ }
309+ throw new MosApiException ("Request failed" , e );
310+ }
311+ } else {
312+ logger .atInfo ().log ("No session cookie cached for %s. Logging in." , entityId );
313+ }
314+
315+ // 2. If no cookie, or if session was expired, perform login.
316+ try {
317+ login (entityId );
318+ } catch (RateLimitExceededException e ) {
319+ throw new MosApiException ("Try running after some time" , e );
320+ } catch (MosApiException e ) {
321+ throw new MosApiException ("Automatic re-login failed." , e );
322+ }
323+
324+ // 3. Retry the original request with the new cookie
325+ logger .atInfo ().log ("Login successful. Retrying original request for %s." , entityId );
326+ cookie = mosApiSessionCache .get (entityId );
327+ if (cookie .isEmpty ()) {
328+ throw new MosApiException ("Login succeeded but failed to retrieve new session cookie." );
329+ }
330+
331+ try {
332+ HttpResponse <String > response = requestExecutor .apply (url , cookie .get ());
333+ if (isSessionExpiredError (response )) {
334+ throw new MosApiException (
335+ "Authentication failed even after re-login." ,
336+ new InvalidCredentialsException (response .body ()));
337+ }
338+ return response ;
339+ } catch (RuntimeException e ) {
340+ if (e .getCause () instanceof MosApiException ) {
341+ throw (MosApiException ) e .getCause ();
342+ }
343+ throw new MosApiException ("Request failed after re-login" , e );
344+ }
345+ }
346+
347+ /**
348+ * Parses the "id=..." cookie value from the "Set-Cookie" header.
349+ *
350+ * @param setCookieHeader The raw value of the "Set-Cookie" header.
351+ * @return The "id=..." part of the cookie.
352+ * @throws MosApiException if the "id" part cannot be found.
353+ */
354+ private String parseCookieValue (String setCookieHeader ) throws MosApiException {
355+ for (String part : Splitter .on (COOKIE_DELIMITER ).trimResults ().split (setCookieHeader )) {
356+ if (part .startsWith (COOKIE_ID_PREFIX )) {
357+ return part ;
358+ }
359+ }
360+ throw new MosApiException (
361+ String .format ("Could not parse 'id' from Set-Cookie header: %s" , setCookieHeader ));
362+ }
363+
364+ /**
365+ * Checks if an HTTP response indicates an expired session.
366+ *
367+ * @param response The HTTP response.
368+ * @return True if the response is a 401 with the specific session expired message.
369+ */
370+ private boolean isSessionExpiredError (HttpResponse <String > response ) {
371+ return (response .statusCode () == HttpURLConnection .HTTP_UNAUTHORIZED );
372+ }
373+
374+ /**
375+ * Builds the full URL for a request, including the base URL, entityId, path, and query params.
376+ */
377+ private String buildUrl (String entityId , String path , Map <String , String > queryParams ) {
378+ String sanitizedPath = path .startsWith ("/" ) ? path : "/" + path ;
379+ String fullPath = "/" + entityId + sanitizedPath ;
380+
381+ if (queryParams == null || queryParams .isEmpty ()) {
382+ return baseUrl + fullPath ;
157383 }
384+ String queryString =
385+ queryParams .entrySet ().stream ()
386+ .map (
387+ entry ->
388+ entry .getKey ()
389+ + "="
390+ + URLEncoder .encode (entry .getValue (), StandardCharsets .UTF_8 ))
391+ .collect (Collectors .joining ("&" ));
392+ return baseUrl + fullPath + "?" + queryString ;
158393 }
159394
160395 /** Custom exception for MoSAPI client errors. */
0 commit comments