Add GetServiceState action for MoSAPI service monitoring (#2906)

* Add GetServiceState action for MoSAPI service monitoring

Implements the `/api/mosapi/getServiceState` endpoint to retrieve service health summaries for TLDs from the MoSAPI system.

- Introduces `GetServiceStateAction` to fetch TLD service status.
- Implements `MosApiStateService` to transform raw MoSAPI responses into a curated `ServiceStateSummary`.
- Uses concurrent processing with a fixed thread pool to fetch states for all configured TLDs efficiently while respecting MoSAPI rate limits.

junit test added

* Refactor MoSAPI models to records and address review nits

- Convert model classes to Java records for conciseness and immutability.
- Update unit tests to use Java text blocks for improved JSON readability.
- Simplify service and action layers by removing redundant logic and logging.
- Fix configuration nits regarding primitive types and comment formatting.

* Consolidate MoSAPI models and enhance null-safety

- Moves model records into a single MosApiModels.java file.
- Switches to ImmutableList/ImmutableMap with non-null defaults in constructors.
- Removes redundant pass-through methods in MosApiStateService.
- Updates tests to use Java Text Blocks and non-null collection assertions.

* Improve MoSAPI client error handling and clean up data models

Refactors the MoSAPI monitoring client to be more robust against
infrastructure failures

* Refactor: use nullToEmptyImmutableCopy() for MoSAPI models

Standardize null-handling in model classes by using the Nomulus
`nullToEmptyImmutableCopy()` utility. This ensures consistent API
responses with empty lists instead of omitted fields.
This commit is contained in:
Nilay Shah
2026-01-05 15:44:01 +00:00
committed by GitHub
parent 7e9d4c27d1
commit 81d222e7d6
21 changed files with 1059 additions and 5 deletions
@@ -28,6 +28,7 @@ import google.registry.flows.TlsCredentials.EppTlsModule;
import google.registry.flows.custom.CustomLogicModule;
import google.registry.loadtest.LoadTestModule;
import google.registry.monitoring.whitebox.WhiteboxModule;
import google.registry.mosapi.module.MosApiRequestModule;
import google.registry.rdap.RdapModule;
import google.registry.rde.RdeModule;
import google.registry.reporting.ReportingModule;
@@ -60,6 +61,7 @@ import google.registry.ui.server.console.ConsoleModule;
EppToolModule.class,
IcannReportingModule.class,
LoadTestModule.class,
MosApiRequestModule.class,
RdapModule.class,
RdeModule.class,
ReportingModule.class,
@@ -0,0 +1,98 @@
// Copyright 2025 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.mosapi;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableList;
import com.google.common.net.MediaType;
import com.google.gson.Gson;
import google.registry.mosapi.MosApiModels.AllServicesStateResponse;
import google.registry.mosapi.MosApiModels.ServiceStateSummary;
import google.registry.request.HttpException.ServiceUnavailableException;
import google.registry.testing.FakeResponse;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/** Unit tests for {@link GetServiceStateAction}. */
@ExtendWith(MockitoExtension.class)
public class GetServiceStateActionTest {
@Mock private MosApiStateService stateService;
private final FakeResponse response = new FakeResponse();
private final Gson gson = new Gson();
@Test
void testRun_singleTld_returnsStateForTld() throws Exception {
GetServiceStateAction action =
new GetServiceStateAction(stateService, response, gson, Optional.of("example"));
ServiceStateSummary summary = new ServiceStateSummary("example", "Up", null);
when(stateService.getServiceStateSummary("example")).thenReturn(summary);
action.run();
assertThat(response.getContentType()).isEqualTo(MediaType.JSON_UTF_8);
assertThat(response.getPayload())
.contains(
"""
"overallStatus":"Up"
"""
.trim());
verify(stateService).getServiceStateSummary("example");
}
@Test
void testRun_noTld_returnsStateForAll() {
GetServiceStateAction action =
new GetServiceStateAction(stateService, response, gson, Optional.empty());
AllServicesStateResponse allStates = new AllServicesStateResponse(ImmutableList.of());
when(stateService.getAllServiceStateSummaries()).thenReturn(allStates);
action.run();
assertThat(response.getContentType()).isEqualTo(MediaType.JSON_UTF_8);
assertThat(response.getPayload())
.contains(
"""
"serviceStates":[]
"""
.trim());
verify(stateService).getAllServiceStateSummaries();
}
@Test
void testRun_serviceThrowsException_throwsServiceUnavailable() throws Exception {
GetServiceStateAction action =
new GetServiceStateAction(stateService, response, gson, Optional.of("example"));
doThrow(new MosApiException("Backend error", null))
.when(stateService)
.getServiceStateSummary("example");
ServiceUnavailableException thrown =
assertThrows(ServiceUnavailableException.class, action::run);
assertThat(thrown).hasMessageThat().isEqualTo("Error fetching MoSAPI service state.");
}
}
@@ -11,7 +11,7 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.mosapi.model;
package google.registry.mosapi;
import static com.google.common.truth.Truth.assertThat;
@@ -20,7 +20,6 @@ import google.registry.mosapi.MosApiException.DateOrderInvalidException;
import google.registry.mosapi.MosApiException.EndDateSyntaxInvalidException;
import google.registry.mosapi.MosApiException.MosApiAuthorizationException;
import google.registry.mosapi.MosApiException.StartDateSyntaxInvalidException;
import google.registry.mosapi.model.MosApiErrorResponse;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link MosApiException}. */
@@ -0,0 +1,172 @@
// Copyright 2025 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.mosapi;
import static com.google.common.truth.Truth.assertThat;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import google.registry.mosapi.MosApiModels.AllServicesStateResponse;
import google.registry.mosapi.MosApiModels.IncidentSummary;
import google.registry.mosapi.MosApiModels.ServiceStateSummary;
import google.registry.mosapi.MosApiModels.ServiceStatus;
import google.registry.mosapi.MosApiModels.TldServiceState;
import org.junit.Test;
/** Tests for {@link MosApiModels}. */
public final class MosApiModelsTest {
private static final Gson gson =
new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create();
@Test
public void testAllServicesStateResponse_nullCollection_initializedToEmpty() {
AllServicesStateResponse response = new AllServicesStateResponse(null);
assertThat(response.serviceStates()).isEmpty();
assertThat(response.serviceStates()).isNotNull();
}
@Test
public void testServiceStateSummary_nullCollection_initializedToEmpty() {
ServiceStateSummary summary = new ServiceStateSummary("example", "Up", null);
assertThat(summary.activeIncidents()).isEmpty();
assertThat(summary.activeIncidents()).isNotNull();
}
@Test
public void testServiceStatus_nullCollection_initializedToEmpty() {
ServiceStatus status = new ServiceStatus("Up", 0.0, null);
assertThat(status.incidents()).isEmpty();
assertThat(status.incidents()).isNotNull();
}
@Test
public void testTldServiceState_nullCollection_initializedToEmpty() {
TldServiceState state = new TldServiceState("example", 123456L, "Up", null);
assertThat(state.serviceStatuses()).isEmpty();
assertThat(state.serviceStatuses()).isNotNull();
}
@Test
public void testIncidentSummary_jsonSerialization() {
IncidentSummary incident = new IncidentSummary("inc-123", 1000L, false, "Active", 2000L);
String json = gson.toJson(incident);
// Using Text Blocks to avoid escaping quotes
assertThat(json)
.contains(
"""
"incidentID":"inc-123"
"""
.trim());
assertThat(json)
.contains(
"""
"startTime":1000
"""
.trim());
assertThat(json)
.contains(
"""
"falsePositive":false
"""
.trim());
assertThat(json)
.contains(
"""
"state":"Active"
"""
.trim());
assertThat(json)
.contains(
"""
"endTime":2000
"""
.trim());
}
@Test
public void testServiceStatus_jsonSerialization() {
IncidentSummary incident = new IncidentSummary("inc-1", 1000L, false, "Resolved", null);
ServiceStatus status = new ServiceStatus("Down", 75.5, ImmutableList.of(incident));
String json = gson.toJson(status);
assertThat(json)
.contains(
"""
"status":"Down"
"""
.trim());
assertThat(json)
.contains(
"""
"emergencyThreshold":75.5
"""
.trim());
assertThat(json)
.contains(
"""
"incidents":[
"""
.trim());
}
@Test
public void testTldServiceState_jsonSerialization() {
ServiceStatus dnsStatus = new ServiceStatus("Up", 0.0, ImmutableList.of());
TldServiceState state =
new TldServiceState("app", 1700000000L, "Up", ImmutableMap.of("DNS", dnsStatus));
String json = gson.toJson(state);
assertThat(json)
.contains(
"""
"tld":"app"
"""
.trim());
assertThat(json)
.contains(
"""
"status":"Up"
"""
.trim());
assertThat(json)
.contains(
"""
"testedServices":{"DNS":{
"""
.trim());
}
@Test
public void testAllServicesStateResponse_jsonSerialization() {
ServiceStateSummary summary = new ServiceStateSummary("dev", "Up", ImmutableList.of());
AllServicesStateResponse response = new AllServicesStateResponse(ImmutableList.of(summary));
String json = gson.toJson(response);
assertThat(json)
.contains(
"""
"serviceStates":[
"""
.trim());
assertThat(json)
.contains(
"""
"tld":"dev"
"""
.trim());
}
}
@@ -0,0 +1,130 @@
// Copyright 2025 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.mosapi;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.util.concurrent.MoreExecutors;
import google.registry.mosapi.MosApiModels.AllServicesStateResponse;
import google.registry.mosapi.MosApiModels.IncidentSummary;
import google.registry.mosapi.MosApiModels.ServiceStateSummary;
import google.registry.mosapi.MosApiModels.ServiceStatus;
import google.registry.mosapi.MosApiModels.TldServiceState;
import java.util.concurrent.ExecutorService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/** Unit tests for {@link MosApiStateService}. */
@ExtendWith(MockitoExtension.class)
class MosApiStateServiceTest {
@Mock private ServiceMonitoringClient client;
private final ExecutorService executor = MoreExecutors.newDirectExecutorService();
private MosApiStateService service;
@BeforeEach
void setUp() {
service = new MosApiStateService(client, ImmutableSet.of("tld1", "tld2"), executor);
}
@Test
void testGetServiceStateSummary_upStatus_returnsEmptyIncidents() throws Exception {
TldServiceState rawState = new TldServiceState("tld1", 12345L, "Up", ImmutableMap.of());
when(client.getTldServiceState("tld1")).thenReturn(rawState);
ServiceStateSummary result = service.getServiceStateSummary("tld1");
assertThat(result.tld()).isEqualTo("tld1");
assertThat(result.overallStatus()).isEqualTo("Up");
assertThat(result.activeIncidents()).isEmpty();
}
@Test
void testGetServiceStateSummary_downStatus_filtersActiveIncidents() throws Exception {
IncidentSummary dnsIncident = new IncidentSummary("inc-1", 100L, false, "Open", null);
ServiceStatus dnsService = new ServiceStatus("Down", 50.0, ImmutableList.of(dnsIncident));
ServiceStatus rdapService = new ServiceStatus("Up", 0.0, ImmutableList.of());
TldServiceState rawState =
new TldServiceState(
"tld1", 12345L, "Down", ImmutableMap.of("DNS", dnsService, "RDAP", rdapService));
when(client.getTldServiceState("tld1")).thenReturn(rawState);
ServiceStateSummary result = service.getServiceStateSummary("tld1");
assertThat(result.overallStatus()).isEqualTo("Down");
assertThat(result.activeIncidents()).hasSize(1);
ServiceStatus incidentSummary = result.activeIncidents().get(0);
assertThat(incidentSummary.status()).isEqualTo("DNS");
assertThat(incidentSummary.incidents()).containsExactly(dnsIncident);
}
@Test
void testGetServiceStateSummary_throwsException_whenClientFails() throws Exception {
when(client.getTldServiceState("tld1")).thenThrow(new MosApiException("Network error", null));
assertThrows(MosApiException.class, () -> service.getServiceStateSummary("tld1"));
}
@Test
void testGetAllServiceStateSummaries_success() throws Exception {
TldServiceState state1 = new TldServiceState("tld1", 1L, "Up", ImmutableMap.of());
TldServiceState state2 = new TldServiceState("tld2", 2L, "Up", ImmutableMap.of());
when(client.getTldServiceState("tld1")).thenReturn(state1);
when(client.getTldServiceState("tld2")).thenReturn(state2);
AllServicesStateResponse response = service.getAllServiceStateSummaries();
assertThat(response.serviceStates()).hasSize(2);
assertThat(response.serviceStates().stream().map(ServiceStateSummary::tld))
.containsExactly("tld1", "tld2");
}
@Test
void testGetAllServiceStateSummaries_partialFailure_returnsErrorState() throws Exception {
TldServiceState state1 = new TldServiceState("tld1", 1L, "Up", ImmutableMap.of());
when(client.getTldServiceState("tld1")).thenReturn(state1);
when(client.getTldServiceState("tld2")).thenThrow(new MosApiException("Failure", null));
AllServicesStateResponse response = service.getAllServiceStateSummaries();
assertThat(response.serviceStates()).hasSize(2);
ServiceStateSummary summary1 =
response.serviceStates().stream().filter(s -> s.tld().equals("tld1")).findFirst().get();
assertThat(summary1.overallStatus()).isEqualTo("Up");
ServiceStateSummary summary2 =
response.serviceStates().stream().filter(s -> s.tld().equals("tld2")).findFirst().get();
assertThat(summary2.overallStatus()).isEqualTo("ERROR");
assertThat(summary2.activeIncidents()).isEmpty();
}
}
@@ -0,0 +1,140 @@
// Copyright 2025 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.mosapi;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.google.gson.Gson;
import google.registry.mosapi.MosApiModels.TldServiceState;
import google.registry.tools.GsonUtils;
import okhttp3.MediaType;
import okhttp3.Protocol;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class ServiceMonitoringClientTest {
private static final String TLD = "example";
private static final String ENDPOINT = "v2/monitoring/state";
private final MosApiClient mosApiClient = mock(MosApiClient.class);
private final Gson gson = GsonUtils.provideGson();
private ServiceMonitoringClient client;
@BeforeEach
void beforeEach() {
client = new ServiceMonitoringClient(mosApiClient, gson);
}
@Test
void testGetTldServiceState_success() throws Exception {
String jsonResponse =
"""
{
"tld": "example",
"services": [
{
"service": "DNS",
"status": "OPERATIONAL"
}
]
}
""";
try (Response response = createMockResponse(200, jsonResponse)) {
when(mosApiClient.sendGetRequest(eq(TLD), eq(ENDPOINT), anyMap(), anyMap()))
.thenReturn(response);
TldServiceState result = client.getTldServiceState(TLD);
assertThat(gson.toJson(result)).contains("example");
}
}
@Test
void testGetTldServiceState_apiError_throwsMosApiException() throws Exception {
String errorJson =
"""
{
"resultCode": "2011",
"message": "Invalid duration"
}
""";
try (Response response = createMockResponse(400, errorJson)) {
when(mosApiClient.sendGetRequest(eq(TLD), eq(ENDPOINT), anyMap(), anyMap()))
.thenReturn(response);
MosApiException thrown =
assertThrows(MosApiException.class, () -> client.getTldServiceState(TLD));
assertThat(thrown.getMessage()).contains("2011");
assertThat(thrown.getMessage()).contains("Invalid duration");
}
}
@Test
void testGetTldServiceState_nonJsonError_throwsMosApiException() throws Exception {
String htmlError =
"""
<html>
<body>502 Bad Gateway</body>
</html>
""";
try (Response response = createMockResponse(502, htmlError)) {
when(mosApiClient.sendGetRequest(eq(TLD), eq(ENDPOINT), anyMap(), anyMap()))
.thenReturn(response);
MosApiException thrown =
assertThrows(MosApiException.class, () -> client.getTldServiceState(TLD));
assertThat(thrown.getMessage()).contains("MoSAPI json parsing error (502)");
assertThat(thrown.getMessage()).contains("502 Bad Gateway");
}
}
@Test
void testGetTldServiceState_emptyBody_throwsMosApiException() throws Exception {
Response response =
new Response.Builder()
.request(new Request.Builder().url("http://localhost").build())
.protocol(Protocol.HTTP_1_1)
.code(204)
.message("No Content")
.build();
when(mosApiClient.sendGetRequest(eq(TLD), eq(ENDPOINT), anyMap(), anyMap()))
.thenReturn(response);
MosApiException thrown =
assertThrows(MosApiException.class, () -> client.getTldServiceState(TLD));
assertThat(thrown.getMessage()).contains("returned an empty body");
}
private Response createMockResponse(int code, String body) {
return new Response.Builder()
.request(new Request.Builder().url("http://localhost").build())
.protocol(Protocol.HTTP_1_1)
.code(code)
.message(code == 200 ? "OK" : "Error")
.body(ResponseBody.create(body, MediaType.parse("application/json")))
.build();
}
}
@@ -0,0 +1,57 @@
// Copyright 2025 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.mosapi.module;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Optional;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link MosApiRequestModule}. */
public class MosApiRequestModuleTest {
@Test
void testProvideTld_paramPresent() {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getParameter("tld")).thenReturn("example.tld");
Optional<String> result = MosApiRequestModule.provideTld(req);
assertThat(result).hasValue("example.tld");
}
@Test
void testProvideTld_paramMissing() {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getParameter("tld")).thenReturn(null);
Optional<String> result = MosApiRequestModule.provideTld(req);
assertThat(result).isEmpty();
}
@Test
void testProvideTld_paramEmptyString() {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getParameter("tld")).thenReturn("");
Optional<String> result = MosApiRequestModule.provideTld(req);
assertThat(result).isEmpty();
}
}
@@ -13,6 +13,7 @@ BACKEND /_dr/admin/verifyOte VerifyOteAction
BACKEND /_dr/cron/fanout TldFanoutAction GET y APP ADMIN
BACKEND /_dr/epptool EppToolAction POST n APP ADMIN
BACKEND /_dr/loadtest LoadTestAction POST y APP ADMIN
BACKEND /_dr/mosapi/getServiceState GetServiceStateAction GET n APP ADMIN
BACKEND /_dr/task/brdaCopy BrdaCopyAction POST y APP ADMIN
BACKEND /_dr/task/bsaDownload BsaDownloadAction GET,POST n APP ADMIN
BACKEND /_dr/task/bsaRefresh BsaRefreshAction GET,POST n APP ADMIN