1 /* 2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package android.adservices.utils; 18 19 import android.util.ArrayMap; 20 import android.util.Pair; 21 22 import androidx.test.core.app.ApplicationProvider; 23 24 import com.android.adservices.LoggerFactory; 25 26 import com.google.common.collect.ImmutableMap; 27 28 import org.json.JSONArray; 29 import org.json.JSONException; 30 import org.json.JSONObject; 31 import org.testng.util.Strings; 32 33 import java.io.BufferedReader; 34 import java.io.IOException; 35 import java.io.InputStream; 36 import java.io.InputStreamReader; 37 import java.util.Map; 38 import java.util.Objects; 39 40 public class ScenarioLoader { 41 42 private static final LoggerFactory.Logger sLogger = LoggerFactory.getFledgeLogger(); 43 public static final String FIELD_MOCKS = "mocks"; 44 public static final String FIELD_REQUEST = "request"; 45 public static final String FIELD_RESPONSE = "response"; 46 public static final String FIELD_VERIFY_CALLED = "verify_called"; 47 public static final String FIELD_VERIFY_NOT_CALLED = "verify_not_called"; 48 public static final String FIELD_BODY = "body"; 49 public static final String FIELD_VALUE_NULL = "null"; 50 public static final String FIELD_BODY_STR = "body_str"; 51 public static final String FIELD_DELAY_SEC = "delay_sec"; 52 public static final String FIELD_SUBSTITUTIONS = "substitutions"; 53 load(String scenarioPath, Map<String, String> substitutionVariables)54 static Scenario load(String scenarioPath, Map<String, String> substitutionVariables) 55 throws JSONException, IOException { 56 // Parsing paths requires server URL upfront. 57 JSONObject json = new JSONObject(ScenarioLoader.loadTextResource(scenarioPath)); 58 Map<String, String> variables = new ArrayMap<>(); 59 // These variables come from two places. First, the user-configured variables supplied in 60 // the JSON config. 61 // Secondly, there are also variables that are specific to the address of the dispatcher, 62 // such as base_url and adtech1_url. These are built-in and provided by the system, and here 63 // are specifically supplied into this method. 64 variables.putAll(substitutionVariables); 65 variables.putAll(ScenarioLoader.parseSubstitutions(json, substitutionVariables)); 66 return ScenarioLoader.parseScenario(json, variables); 67 } 68 parseScenario(JSONObject json, Map<String, String> variables)69 private static Scenario parseScenario(JSONObject json, Map<String, String> variables) 70 throws JSONException { 71 ImmutableMap.Builder<Scenario.Request, Scenario.MockResponse> builder = 72 ImmutableMap.builder(); 73 JSONArray mocks = json.getJSONArray(FIELD_MOCKS); 74 for (int i = 0; i < mocks.length(); i++) { 75 JSONObject mockObject = mocks.getJSONObject(i); 76 Pair<Scenario.Request, Scenario.MockResponse> mock = parseMock(mockObject, variables); 77 builder.put(mock.first, mock.second); 78 } 79 return new Scenario(builder.build()); 80 } 81 parseMock( JSONObject mock, Map<String, String> variables)82 private static Pair<Scenario.Request, Scenario.MockResponse> parseMock( 83 JSONObject mock, Map<String, String> variables) throws JSONException { 84 if (Objects.isNull(mock)) { 85 throw new IllegalArgumentException("mock JSON object is null."); 86 } 87 88 JSONObject requestJson = mock.getJSONObject(FIELD_REQUEST); 89 JSONObject responseJson = mock.getJSONObject(FIELD_RESPONSE); 90 if (Objects.isNull(requestJson) || Objects.isNull(responseJson)) { 91 throw new IllegalArgumentException("request or response JSON object is null."); 92 } 93 94 Scenario.Request request = parseRequest(requestJson, variables); 95 Scenario.MockResponse parsedMock = 96 Scenario.MockResponse.newBuilder() 97 .setShouldVerifyCalled( 98 ScenarioLoader.parseBooleanOptionalOrDefault( 99 FIELD_VERIFY_CALLED, mock)) 100 .setShouldVerifyNotCalled( 101 ScenarioLoader.parseBooleanOptionalOrDefault( 102 FIELD_VERIFY_NOT_CALLED, mock)) 103 .setDefaultResponse(parseResponse(responseJson, variables).build()) 104 .build(); 105 sLogger.v("Setting up mock at path: " + request.getRelativePath()); 106 return Pair.create(request, parsedMock); 107 } 108 parseRequest(JSONObject json, Map<String, String> variables)109 private static Scenario.Request parseRequest(JSONObject json, Map<String, String> variables) { 110 Scenario.Request.Builder builder = Scenario.Request.newBuilder(); 111 try { 112 String rawPath = getStringWithSubstitutions(json.getString("path"), variables); 113 if (!rawPath.startsWith("/")) { 114 throw new IllegalStateException("path should start with '/' prefix: " + rawPath); 115 } 116 builder.setRelativePath(rawPath.substring(1)); 117 } catch (JSONException e) { 118 throw new IllegalArgumentException("could not extract `path` from request", e); 119 } 120 121 try { 122 builder.setHeaders(ScenarioLoader.parseHeaders(json.getJSONObject("header"))); 123 } catch (JSONException e) { 124 builder.setHeaders(ImmutableMap.of()); 125 } 126 127 return builder.build(); 128 } 129 parseResponse( JSONObject json, Map<String, String> variables)130 private static Scenario.Response.Builder parseResponse( 131 JSONObject json, Map<String, String> variables) { 132 Scenario.Response.Builder builder = 133 Scenario.Response.newBuilder().setBody(Scenarios.DEFAULT_RESPONSE_BODY); 134 try { 135 if (json.has(FIELD_BODY)) { 136 String fileName = json.getString(FIELD_BODY); 137 String filePath = Scenarios.SCENARIOS_DATA_JARPATH + fileName; 138 if (!fileName.equals(FIELD_VALUE_NULL)) { 139 builder.setBody(loadTextResourceWithSubstitutions(filePath, variables)); 140 } 141 } else if (json.has(FIELD_BODY_STR)) { 142 builder.setBody(json.getString(FIELD_BODY_STR)); 143 } else { 144 throw new IllegalArgumentException( 145 "response must set `body` or `body_str`: " + json); 146 } 147 } catch (JSONException e) { 148 throw new IllegalArgumentException("could not parse response: " + json); 149 } 150 151 if (json.has("header")) { 152 try { 153 builder.setHeaders(ScenarioLoader.parseHeaders(json.getJSONObject("header"))); 154 } catch (JSONException e) { 155 builder.setHeaders(ImmutableMap.of()); 156 } 157 } else { 158 builder.setHeaders(ImmutableMap.of()); 159 } 160 161 builder.setDelaySeconds( 162 ScenarioLoader.parseIntegerOptionalOrDefault(FIELD_DELAY_SEC, json)); 163 164 return builder; 165 } 166 parseSubstitutions( JSONObject json, Map<String, String> substitutionVariables)167 private static ImmutableMap<String, String> parseSubstitutions( 168 JSONObject json, Map<String, String> substitutionVariables) throws JSONException { 169 ImmutableMap.Builder<String, String> builder = ImmutableMap.builder(); 170 if (!json.has(FIELD_SUBSTITUTIONS)) { 171 return builder.build(); 172 } 173 174 JSONObject substitutions = json.getJSONObject(FIELD_SUBSTITUTIONS); 175 for (String key : substitutions.keySet()) { 176 try { 177 builder.put( 178 key, 179 getStringWithSubstitutions( 180 substitutions.getString(key), substitutionVariables)); 181 } catch (JSONException e) { 182 throw new IllegalArgumentException("could not parse substitution with key: " + key); 183 } 184 } 185 return builder.build(); 186 } 187 parseBooleanOptionalOrDefault(String field, JSONObject json)188 private static boolean parseBooleanOptionalOrDefault(String field, JSONObject json) { 189 try { 190 return json.getBoolean(field); 191 } catch (JSONException e) { 192 return false; 193 } 194 } 195 parseIntegerOptionalOrDefault(String field, JSONObject json)196 private static int parseIntegerOptionalOrDefault(String field, JSONObject json) { 197 try { 198 return json.getInt(field); 199 } catch (JSONException e) { 200 return 0; 201 } 202 } 203 parseHeaders(JSONObject json)204 private static Map<String, String> parseHeaders(JSONObject json) { 205 ImmutableMap.Builder<String, String> builder = ImmutableMap.builder(); 206 for (String key : json.keySet()) { 207 try { 208 builder.put(key, json.getString(key)); 209 } catch (JSONException ignored) { 210 throw new IllegalArgumentException("could not parse header with key: " + key); 211 } 212 } 213 return builder.build(); 214 } 215 loadTextResource(String fileName)216 private static String loadTextResource(String fileName) throws IOException { 217 InputStream is = ApplicationProvider.getApplicationContext().getAssets().open(fileName); 218 StringBuilder builder = new StringBuilder(); 219 try (BufferedReader br = new BufferedReader(new InputStreamReader(is))) { 220 String line; 221 while ((line = br.readLine()) != null) { 222 builder.append(line).append("\n"); 223 } 224 } 225 return builder.toString(); 226 } 227 getStringWithSubstitutions(String string, Map<String, String> variables)228 private static String getStringWithSubstitutions(String string, Map<String, String> variables) { 229 // Apply substitutions to string. 230 for (Map.Entry<String, String> keyValuePair : variables.entrySet()) { 231 string = string.replace(keyValuePair.getKey(), keyValuePair.getValue()); 232 } 233 return string; 234 } 235 loadTextResourceWithSubstitutions( String fileName, Map<String, String> variables)236 private static String loadTextResourceWithSubstitutions( 237 String fileName, Map<String, String> variables) { 238 String responseBody; 239 try { 240 responseBody = ScenarioLoader.loadTextResource(fileName); 241 sLogger.v("loading file: " + fileName); 242 } catch (IOException e) { 243 throw new IllegalArgumentException("failed to load fake response body: " + fileName, e); 244 } 245 246 if (Strings.isNullOrEmpty(responseBody)) { 247 return responseBody; 248 } 249 for (Map.Entry<String, String> keyValuePair : variables.entrySet()) { 250 responseBody = responseBody.replace(keyValuePair.getKey(), keyValuePair.getValue()); 251 } 252 253 return responseBody; 254 } 255 } 256