Merge pull request #1411 from jclouds/dynect_spf_and_sshfp

add SPF and SSHFP records to dynect
This commit is contained in:
Adrian Cole 2013-03-11 20:40:58 -07:00
commit ac5d571b59
10 changed files with 404 additions and 0 deletions

View File

@ -0,0 +1,53 @@
package org.jclouds.dynect.v3.domain.rdata;
import static com.google.common.base.Preconditions.checkNotNull;
import java.beans.ConstructorProperties;
import java.util.Map;
import com.google.common.collect.ForwardingMap;
import com.google.common.collect.ImmutableMap;
/**
* Corresponds to the binary representation of the {@code SPF} (Sender Policy
* Framework) RData
*
* <h4>Example</h4>
*
* <pre>
* import static denominator.model.rdata.SPFData.spf;
* ...
* SPFData rdata = spf("v=spf1 +mx a:colo.example.com/28 -all");
* </pre>
*
* @see <a href="http://tools.ietf.org/html/rfc4408#section-3.1.1">RFC 4408</a>
*/
public class SPFData extends ForwardingMap<String, Object> {
public static SPFData create(String txtdata) {
return new SPFData(txtdata);
}
private final String txtdata;
@ConstructorProperties("txtdata")
private SPFData(String txtdata) {
this.txtdata = checkNotNull(txtdata, "txtdata");
this.delegate = ImmutableMap.<String, Object> of("txtdata", txtdata);
}
/**
* One or more character-strings.
*/
public String getTxtdata() {
return txtdata;
}
// transient to avoid serializing by default, for example in json
private final transient ImmutableMap<String, Object> delegate;
@Override
protected Map<String, Object> delegate() {
return delegate;
}
}

View File

@ -0,0 +1,133 @@
package org.jclouds.dynect.v3.domain.rdata;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import java.beans.ConstructorProperties;
import java.util.Map;
import com.google.common.collect.ForwardingMap;
import com.google.common.collect.ImmutableMap;
/**
* Corresponds to the binary representation of the {@code SSHFP} (SSH
* Fingerprint) RData
*
* <h4>Example</h4>
*
* <pre>
* SSHFPData rdata = SSHFPData.builder().algorithm(2).fptype(1).fingerprint(&quot;123456789abcdef67890123456789abcdef67890&quot;)
* .build();
* // or shortcut
* SSHFPData rdata = SSHFPData.createDSA(&quot;123456789abcdef67890123456789abcdef67890&quot;);
* </pre>
*
* @see <a href="http://www.rfc-editor.org/rfc/rfc4255.txt">RFC 4255</a>
*/
public class SSHFPData extends ForwardingMap<String, Object> {
/**
* @param fingerprint
* {@code DSA} {@code SHA-1} fingerprint
*/
public static SSHFPData createDSA(String fingerprint) {
return builder().algorithm(2).fptype(1).fingerprint(fingerprint).build();
}
/**
* @param fingerprint
* {@code RSA} {@code SHA-1} fingerprint
*/
public static SSHFPData createRSA(String fingerprint) {
return builder().algorithm(1).fptype(1).fingerprint(fingerprint).build();
}
private final int algorithm;
private final int fptype;
private final String fingerprint;
@ConstructorProperties({ "algorithm", "fptype", "fingerprint" })
private SSHFPData(int algorithm, int fptype, String fingerprint) {
checkArgument(algorithm >= 0, "algorithm of %s must be unsigned", fingerprint);
this.algorithm = algorithm;
checkArgument(fptype >= 0, "fptype of %s must be unsigned", fingerprint);
this.fptype = fptype;
this.fingerprint = checkNotNull(fingerprint, "fingerprint");
this.delegate = ImmutableMap.<String, Object> builder()
.put("algorithm", algorithm)
.put("fptype", fptype)
.put("fingerprint", fingerprint).build();
}
/**
* This algorithm number octet describes the algorithm of the public key.
*
* @return most often {@code 1} for {@code RSA} or {@code 2} for {@code DSA}.
*/
public int getAlgorithm() {
return algorithm;
}
/**
* The fingerprint fptype octet describes the message-digest algorithm used
* to calculate the fingerprint of the public key.
*
* @return most often {@code 1} for {@code SHA-1}
*/
public int getType() {
return fptype;
}
/**
* The fingerprint calculated over the public key blob.
*/
public String getFingerprint() {
return fingerprint;
}
public final static class Builder {
private int algorithm;
private int fptype;
private String fingerprint;
/**
* @see SSHFPData#getAlgorithm()
*/
public SSHFPData.Builder algorithm(int algorithm) {
this.algorithm = algorithm;
return this;
}
/**
* @see SSHFPData#getType()
*/
public SSHFPData.Builder fptype(int fptype) {
this.fptype = fptype;
return this;
}
/**
* @see SSHFPData#getFingerprint()
*/
public SSHFPData.Builder fingerprint(String fingerprint) {
this.fingerprint = fingerprint;
return this;
}
public SSHFPData build() {
return new SSHFPData(algorithm, fptype, fingerprint);
}
}
public static SSHFPData.Builder builder() {
return new Builder();
}
// transient to avoid serializing by default, for example in json
private final transient ImmutableMap<String, Object> delegate;
@Override
protected Map<String, Object> delegate() {
return delegate;
}
}

View File

@ -32,7 +32,9 @@ import org.jclouds.dynect.v3.domain.rdata.CNAMEData;
import org.jclouds.dynect.v3.domain.rdata.MXData; import org.jclouds.dynect.v3.domain.rdata.MXData;
import org.jclouds.dynect.v3.domain.rdata.NSData; import org.jclouds.dynect.v3.domain.rdata.NSData;
import org.jclouds.dynect.v3.domain.rdata.PTRData; import org.jclouds.dynect.v3.domain.rdata.PTRData;
import org.jclouds.dynect.v3.domain.rdata.SPFData;
import org.jclouds.dynect.v3.domain.rdata.SRVData; import org.jclouds.dynect.v3.domain.rdata.SRVData;
import org.jclouds.dynect.v3.domain.rdata.SSHFPData;
import org.jclouds.dynect.v3.domain.rdata.TXTData; import org.jclouds.dynect.v3.domain.rdata.TXTData;
import org.jclouds.javax.annotation.Nullable; import org.jclouds.javax.annotation.Nullable;
@ -184,6 +186,19 @@ public interface RecordApi {
*/ */
SOARecord getSOA(String fqdn, long recordId) throws JobStillRunningException; SOARecord getSOA(String fqdn, long recordId) throws JobStillRunningException;
/**
* Gets the {@link SPFRecord} or null if not present.
*
* @param fqdn
* {@link RecordId#getFQDN()}
* @param recordId
* {@link RecordId#getId()}
* @return null if not found
* @throws JobStillRunningException
* if a different job in the session is still running
*/
Record<SPFData> getSPF(String fqdn, long recordId) throws JobStillRunningException;
/** /**
* Gets the {@link SRVRecord} or null if not present. * Gets the {@link SRVRecord} or null if not present.
* *
@ -197,6 +212,19 @@ public interface RecordApi {
*/ */
Record<SRVData> getSRV(String fqdn, long recordId) throws JobStillRunningException; Record<SRVData> getSRV(String fqdn, long recordId) throws JobStillRunningException;
/**
* Gets the {@link SSHFPRecord} or null if not present.
*
* @param fqdn
* {@link RecordId#getFQDN()}
* @param recordId
* {@link RecordId#getId()}
* @return null if not found
* @throws JobStillRunningException
* if a different job in the session is still running
*/
Record<SSHFPData> getSSHFP(String fqdn, long recordId) throws JobStillRunningException;
/** /**
* Gets the {@link TXTRecord} or null if not present. * Gets the {@link TXTRecord} or null if not present.
* *

View File

@ -49,7 +49,9 @@ import org.jclouds.dynect.v3.domain.rdata.CNAMEData;
import org.jclouds.dynect.v3.domain.rdata.MXData; import org.jclouds.dynect.v3.domain.rdata.MXData;
import org.jclouds.dynect.v3.domain.rdata.NSData; import org.jclouds.dynect.v3.domain.rdata.NSData;
import org.jclouds.dynect.v3.domain.rdata.PTRData; import org.jclouds.dynect.v3.domain.rdata.PTRData;
import org.jclouds.dynect.v3.domain.rdata.SPFData;
import org.jclouds.dynect.v3.domain.rdata.SRVData; import org.jclouds.dynect.v3.domain.rdata.SRVData;
import org.jclouds.dynect.v3.domain.rdata.SSHFPData;
import org.jclouds.dynect.v3.domain.rdata.TXTData; import org.jclouds.dynect.v3.domain.rdata.TXTData;
import org.jclouds.dynect.v3.filters.AlwaysAddContentType; import org.jclouds.dynect.v3.filters.AlwaysAddContentType;
import org.jclouds.dynect.v3.filters.SessionManager; import org.jclouds.dynect.v3.filters.SessionManager;
@ -236,6 +238,16 @@ public interface RecordAsyncApi {
@Fallback(NullOnNotFoundOr404.class) @Fallback(NullOnNotFoundOr404.class)
ListenableFuture<SOARecord> getSOA(@PathParam("fqdn") String fqdn, @PathParam("id") long recordId) throws JobStillRunningException; ListenableFuture<SOARecord> getSOA(@PathParam("fqdn") String fqdn, @PathParam("id") long recordId) throws JobStillRunningException;
/**
* @see RecordApi#getSPF
*/
@Named("GetSPFRecord")
@GET
@Path("/SPFRecord/{zone}/{fqdn}/{id}")
@SelectJson("data")
@Fallback(NullOnNotFoundOr404.class)
ListenableFuture<Record<SPFData>> getSPF(@PathParam("fqdn") String fqdn, @PathParam("id") long recordId) throws JobStillRunningException;
/** /**
* @see RecordApi#getSRV * @see RecordApi#getSRV
*/ */
@ -246,6 +258,16 @@ public interface RecordAsyncApi {
@Fallback(NullOnNotFoundOr404.class) @Fallback(NullOnNotFoundOr404.class)
ListenableFuture<Record<SRVData>> getSRV(@PathParam("fqdn") String fqdn, @PathParam("id") long recordId) throws JobStillRunningException; ListenableFuture<Record<SRVData>> getSRV(@PathParam("fqdn") String fqdn, @PathParam("id") long recordId) throws JobStillRunningException;
/**
* @see RecordApi#getSSHFP
*/
@Named("GetSSHFPRecord")
@GET
@Path("/SSHFPRecord/{zone}/{fqdn}/{id}")
@SelectJson("data")
@Fallback(NullOnNotFoundOr404.class)
ListenableFuture<Record<SSHFPData>> getSSHFP(@PathParam("fqdn") String fqdn, @PathParam("id") long recordId) throws JobStillRunningException;
/** /**
* @see RecordApi#getTXT * @see RecordApi#getTXT
*/ */

View File

@ -41,7 +41,9 @@ import org.jclouds.dynect.v3.parse.GetNSRecordResponseTest;
import org.jclouds.dynect.v3.parse.GetPTRRecordResponseTest; import org.jclouds.dynect.v3.parse.GetPTRRecordResponseTest;
import org.jclouds.dynect.v3.parse.GetRecordResponseTest; import org.jclouds.dynect.v3.parse.GetRecordResponseTest;
import org.jclouds.dynect.v3.parse.GetSOARecordResponseTest; import org.jclouds.dynect.v3.parse.GetSOARecordResponseTest;
import org.jclouds.dynect.v3.parse.GetSPFRecordResponseTest;
import org.jclouds.dynect.v3.parse.GetSRVRecordResponseTest; import org.jclouds.dynect.v3.parse.GetSRVRecordResponseTest;
import org.jclouds.dynect.v3.parse.GetSSHFPRecordResponseTest;
import org.jclouds.dynect.v3.parse.GetTXTRecordResponseTest; import org.jclouds.dynect.v3.parse.GetTXTRecordResponseTest;
import org.jclouds.dynect.v3.parse.ListRecordsResponseTest; import org.jclouds.dynect.v3.parse.ListRecordsResponseTest;
import org.jclouds.http.HttpRequest; import org.jclouds.http.HttpRequest;
@ -246,6 +248,27 @@ public class RecordApiExpectTest extends BaseDynECTApiExpectTest {
assertNull(fail.getRecordApiForZone("jclouds.org").getSOA(soaId.getFQDN(), soaId.getId())); assertNull(fail.getRecordApiForZone("jclouds.org").getSOA(soaId.getFQDN(), soaId.getId()));
} }
HttpRequest getSPF = HttpRequest.builder().method("GET")
.endpoint("https://api2.dynect.net/REST/SPFRecord/jclouds.org/jclouds.org/50976579")
.addHeader("API-Version", "3.3.8")
.addHeader(CONTENT_TYPE, APPLICATION_JSON)
.addHeader("Auth-Token", authToken).build();
HttpResponse spfResponse = HttpResponse.builder().statusCode(200)
.payload(payloadFromResourceWithContentType("/get_record_spf.json", APPLICATION_JSON)).build();
RecordId spfId = recordIdBuilder()
.zone("jclouds.org")
.fqdn("jclouds.org")
.type("SPF")
.id(50976579l).build();
public void testGetSPFWhenResponseIs2xx() {
DynECTApi success = requestsSendResponses(createSession, createSessionResponse, getSPF, spfResponse);
assertEquals(success.getRecordApiForZone("jclouds.org").getSPF(spfId.getFQDN(), spfId.getId()).toString(),
new GetSPFRecordResponseTest().expected().toString());
}
HttpRequest getSRV = HttpRequest.builder().method("GET") HttpRequest getSRV = HttpRequest.builder().method("GET")
.endpoint("https://api2.dynect.net/REST/SRVRecord/jclouds.org/jclouds.org/50976579") .endpoint("https://api2.dynect.net/REST/SRVRecord/jclouds.org/jclouds.org/50976579")
.addHeader("API-Version", "3.3.8") .addHeader("API-Version", "3.3.8")
@ -267,6 +290,27 @@ public class RecordApiExpectTest extends BaseDynECTApiExpectTest {
new GetSRVRecordResponseTest().expected().toString()); new GetSRVRecordResponseTest().expected().toString());
} }
HttpRequest getSSHFP = HttpRequest.builder().method("GET")
.endpoint("https://api2.dynect.net/REST/SSHFPRecord/jclouds.org/jclouds.org/50976579")
.addHeader("API-Version", "3.3.8")
.addHeader(CONTENT_TYPE, APPLICATION_JSON)
.addHeader("Auth-Token", authToken).build();
HttpResponse sshfpResponse = HttpResponse.builder().statusCode(200)
.payload(payloadFromResourceWithContentType("/get_record_sshfp.json", APPLICATION_JSON)).build();
RecordId sshfpId = recordIdBuilder()
.zone("jclouds.org")
.fqdn("jclouds.org")
.type("SSHFP")
.id(50976579l).build();
public void testGetSSHFPWhenResponseIs2xx() {
DynECTApi success = requestsSendResponses(createSession, createSessionResponse, getSSHFP, sshfpResponse);
assertEquals(success.getRecordApiForZone("jclouds.org").getSSHFP(sshfpId.getFQDN(), sshfpId.getId()).toString(),
new GetSSHFPRecordResponseTest().expected().toString());
}
HttpRequest getTXT = HttpRequest.builder().method("GET") HttpRequest getTXT = HttpRequest.builder().method("GET")
.endpoint("https://api2.dynect.net/REST/TXTRecord/jclouds.org/jclouds.org/50976579") .endpoint("https://api2.dynect.net/REST/TXTRecord/jclouds.org/jclouds.org/50976579")
.addHeader("API-Version", "3.3.8") .addHeader("API-Version", "3.3.8")

View File

@ -42,7 +42,9 @@ import org.jclouds.dynect.v3.domain.rdata.MXData;
import org.jclouds.dynect.v3.domain.rdata.NSData; import org.jclouds.dynect.v3.domain.rdata.NSData;
import org.jclouds.dynect.v3.domain.rdata.PTRData; import org.jclouds.dynect.v3.domain.rdata.PTRData;
import org.jclouds.dynect.v3.domain.rdata.SOAData; import org.jclouds.dynect.v3.domain.rdata.SOAData;
import org.jclouds.dynect.v3.domain.rdata.SPFData;
import org.jclouds.dynect.v3.domain.rdata.SRVData; import org.jclouds.dynect.v3.domain.rdata.SRVData;
import org.jclouds.dynect.v3.domain.rdata.SSHFPData;
import org.jclouds.dynect.v3.domain.rdata.TXTData; import org.jclouds.dynect.v3.domain.rdata.TXTData;
import org.jclouds.dynect.v3.internal.BaseDynECTApiLiveTest; import org.jclouds.dynect.v3.internal.BaseDynECTApiLiveTest;
import org.testng.annotations.AfterClass; import org.testng.annotations.AfterClass;
@ -93,8 +95,12 @@ public class RecordApiLiveTest extends BaseDynECTApiLiveTest {
record = checkPTRRecord(api.getPTR(recordId.getFQDN(), recordId.getId())); record = checkPTRRecord(api.getPTR(recordId.getFQDN(), recordId.getId()));
} else if ("SOA".equals(recordId.getType())) { } else if ("SOA".equals(recordId.getType())) {
record = checkSOARecord(api.getSOA(recordId.getFQDN(), recordId.getId())); record = checkSOARecord(api.getSOA(recordId.getFQDN(), recordId.getId()));
} else if ("SPF".equals(recordId.getType())) {
record = checkSPFRecord(api.getSPF(recordId.getFQDN(), recordId.getId()));
} else if ("SRV".equals(recordId.getType())) { } else if ("SRV".equals(recordId.getType())) {
record = checkSRVRecord(api.getSRV(recordId.getFQDN(), recordId.getId())); record = checkSRVRecord(api.getSRV(recordId.getFQDN(), recordId.getId()));
} else if ("SSHFP".equals(recordId.getType())) {
record = checkSSHFPRecord(api.getSSHFP(recordId.getFQDN(), recordId.getId()));
} else if ("TXT".equals(recordId.getType())) { } else if ("TXT".equals(recordId.getType())) {
record = checkTXTRecord(api.getTXT(recordId.getFQDN(), recordId.getId())); record = checkTXTRecord(api.getTXT(recordId.getFQDN(), recordId.getId()));
} else { } else {
@ -156,6 +162,12 @@ public class RecordApiLiveTest extends BaseDynECTApiLiveTest {
return record; return record;
} }
private Record<SPFData> checkSPFRecord(Record<SPFData> record) {
SPFData rdata = record.getRData();
checkNotNull(rdata.getTxtdata(), "rdata.txtdata cannot be null for SPFRecord: %s", record);
return record;
}
private Record<SRVData> checkSRVRecord(Record<SRVData> record) { private Record<SRVData> checkSRVRecord(Record<SRVData> record) {
SRVData rdata = record.getRData(); SRVData rdata = record.getRData();
checkNotNull(rdata.getPriority(), "rdata.priority cannot be null for SRVRecord: %s", record); checkNotNull(rdata.getPriority(), "rdata.priority cannot be null for SRVRecord: %s", record);
@ -165,6 +177,14 @@ public class RecordApiLiveTest extends BaseDynECTApiLiveTest {
return record; return record;
} }
private Record<SSHFPData> checkSSHFPRecord(Record<SSHFPData> record) {
SSHFPData rdata = record.getRData();
checkNotNull(rdata.getAlgorithm(), "rdata.algorithm cannot be null for SSHFPRecord: %s", record);
checkNotNull(rdata.getType(), "rdata.type cannot be null for SSHFPRecord: %s", record);
checkNotNull(rdata.getFingerprint(), "rdata.fingerprint cannot be null for SSHFPRecord: %s", record);
return record;
}
private Record<TXTData> checkTXTRecord(Record<TXTData> record) { private Record<TXTData> checkTXTRecord(Record<TXTData> record) {
TXTData rdata = record.getRData(); TXTData rdata = record.getRData();
checkNotNull(rdata.getTxtdata(), "rdata.txtdata cannot be null for TXTRecord: %s", record); checkNotNull(rdata.getTxtdata(), "rdata.txtdata cannot be null for TXTRecord: %s", record);

View File

@ -0,0 +1,53 @@
/**
* Licensed to jclouds, Inc. (jclouds) under one or more
* contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. jclouds licenses this file
* to you 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 org.jclouds.dynect.v3.parse;
import javax.ws.rs.Consumes;
import javax.ws.rs.core.MediaType;
import org.jclouds.dynect.v3.domain.Record;
import org.jclouds.dynect.v3.domain.rdata.SPFData;
import org.jclouds.dynect.v3.internal.BaseDynECTParseTest;
import org.jclouds.rest.annotations.SelectJson;
import org.testng.annotations.Test;
/**
* @author Adrian Cole
*/
@Test(groups = "unit")
public class GetSPFRecordResponseTest extends BaseDynECTParseTest<Record<SPFData>> {
@Override
public String resource() {
return "/get_record_spf.json";
}
@Override
@SelectJson("data")
@Consumes(MediaType.APPLICATION_JSON)
public Record<SPFData> expected() {
return Record.<SPFData> builder()
.zone("adrianc.zone.dynecttest.jclouds.org")
.fqdn("_http._tcp.www.jclouds.org.")
.type("SPF")
.id(50976579l)
.ttl(3600)
.rdata(SPFData.create("v=spf1 a -all")).build();
}
}

View File

@ -0,0 +1,49 @@
/**
* Licensed to jclouds, Inc. (jclouds) under one or more
* contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. jclouds licenses this file
* to you 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 org.jclouds.dynect.v3.parse;
import javax.ws.rs.Consumes;
import javax.ws.rs.core.MediaType;
import org.jclouds.dynect.v3.domain.Record;
import org.jclouds.dynect.v3.domain.rdata.SSHFPData;
import org.jclouds.dynect.v3.internal.BaseDynECTParseTest;
import org.jclouds.rest.annotations.SelectJson;
import org.testng.annotations.Test;
/**
* @author Adrian Cole
*/
@Test(groups = "unit")
public class GetSSHFPRecordResponseTest extends BaseDynECTParseTest<Record<SSHFPData>> {
@Override
public String resource() {
return "/get_record_sshfp.json";
}
@Override
@SelectJson("data")
@Consumes(MediaType.APPLICATION_JSON)
public Record<SSHFPData> expected() {
return Record.<SSHFPData> builder().zone("adrianc.zone.dynecttest.jclouds.org")
.fqdn("_http._tcp.www.jclouds.org.").type("SSHFP").id(50976579l).ttl(3600)
.rdata(SSHFPData.builder().algorithm(2).fptype(1).fingerprint("190E37C5B5DB9A1C455E648A41AF3CC83F99F102").build()).build();
}
}

View File

@ -0,0 +1 @@
{"status": "success", "data": {"zone": "adrianc.zone.dynecttest.jclouds.org", "ttl": 3600, "fqdn": "_http._tcp.www.jclouds.org.", "record_type": "SPF", "rdata": {"txtdata": "v=spf1 a -all"}, "record_id": 50976579}, "job_id": 273523378, "msgs": [{"INFO": "get: Found the record", "SOURCE": "API-B", "ERR_CD": null, "LVL": "INFO"}]}

View File

@ -0,0 +1 @@
{"status": "success", "data": {"zone": "adrianc.zone.dynecttest.jclouds.org", "ttl": 3600, "fqdn": "_http._tcp.www.jclouds.org.", "record_type": "SSHFP", "rdata": {"fptype": 1, "algorithm": 2, "fingerprint": "190E37C5B5DB9A1C455E648A41AF3CC83F99F102"}, "record_id": 50976579}, "job_id": 273523378, "msgs": [{"INFO": "get: Found the record", "SOURCE": "API-B", "ERR_CD": null, "LVL": "INFO"}]}