Skip to content

Commit a787660

Browse files
authored
Normalize Fee extension XML tags in EPP response (#2953)
* Normalize Fee extension XML tags in EPP response Nomulus currently supports multiple versions of the Fee extensions. Our current tooling requires that each version must use a unique namespace tag, e.g., fee11, fee12, etc. Some client registrars are sensitive to the tag literal used by the version of the extension they use. For example, a few registrars currently using v0.6 have requested that the `fee` literal be used on the versions they currently use. With registrars upgrading at their own schedule, this kind of requests are impossible to satisfy. This PR instroduces a namespace normalizer class for EPP responses. The key optimization is that each EPP response never mixes multiple versions of a service extension. Therefore we can define a canonical tag for each extension, and change the tag of the extension in use in a response to that. This normalizer only handles Fee extensions right now, but the idea can be extended to others if use cases come up. This normalizer will be applied to all flows in a future PR. * Addressing reviews * A faster implementation with regex. b/478848482
1 parent 4aadcf8 commit a787660

13 files changed

Lines changed: 1361 additions & 0 deletions
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright 2026 The Nomulus Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.package google.registry.flows;
14+
15+
package google.registry.flows;
16+
17+
import static google.registry.model.eppcommon.ProtocolDefinition.ServiceExtension.FEE_0_11;
18+
import static google.registry.model.eppcommon.ProtocolDefinition.ServiceExtension.FEE_0_12;
19+
import static google.registry.model.eppcommon.ProtocolDefinition.ServiceExtension.FEE_0_6;
20+
import static google.registry.model.eppcommon.ProtocolDefinition.ServiceExtension.FEE_1_00;
21+
22+
import com.google.common.annotations.VisibleForTesting;
23+
import com.google.common.collect.ImmutableSet;
24+
import google.registry.model.eppcommon.EppXmlTransformer;
25+
import google.registry.model.eppcommon.ProtocolDefinition.ServiceExtension;
26+
import java.util.Optional;
27+
import java.util.regex.Matcher;
28+
import java.util.regex.Pattern;
29+
import java.util.stream.Collectors;
30+
31+
/**
32+
* Normalizes Fee extension namespace tags in EPP XML response messages.
33+
*
34+
* <p>Nomulus currently supports multiple versions of the Fee extension. With the current XML
35+
* tooling, the namespace of every version is included in each EPP response, and as a result must
36+
* use a unique XML tag. E.g., fee for extension v0.6, and fee12 for extension v0.12.
37+
*
38+
* <p>Some registrars are not XML namespace-aware and rely on the XML tags being specific literals.
39+
* This makes it difficult to perform seamless rollout of new versions: if Nomulus reassigns a tag
40+
* literal to a different version, it effectively forces all these registrars to upgrade at the time
41+
* of the deployment.
42+
*
43+
* <p>This class can be used to normalize the namespace tag in EPP responses. Since every response
44+
* message may use at most one version of the Fee extension, we can remove declared but unused
45+
* versions from the message, thus freeing up the canonical tag ('fee') for the active version.
46+
*/
47+
public class FeeExtensionXmlTagNormalizer {
48+
49+
// So far we only have Fee extensions to process
50+
private static final String CANONICAL_FEE_TAG = "fee";
51+
52+
private static final ImmutableSet<ServiceExtension> FEE_EXTENSIONS =
53+
ImmutableSet.of(FEE_0_6, FEE_0_11, FEE_0_12, FEE_1_00);
54+
55+
private static final Pattern FEE_EXTENSION_IN_USE_PATTERN =
56+
Pattern.compile(feeExtensionInUseRegex());
57+
58+
@VisibleForTesting
59+
static String feeExtensionInUseRegex() {
60+
return FEE_EXTENSIONS.stream()
61+
.map(ServiceExtension::getXmlTag)
62+
.map(tag -> String.format("\\b(%s):", tag))
63+
.collect(Collectors.joining("|"));
64+
}
65+
66+
/**
67+
* Returns a EPP response that uses the canonical tag ({@code fee}) for the fee extension.
68+
*
69+
* <p>This method replaces any versioned tag, e.g., {@code fee12} with the canonical tag. It also
70+
* removes unused namespace declarations and update the tag in the remaining declaration.
71+
*
72+
* <p>The input {@code xml} must be an EPP response message generated by the {@link
73+
* EppXmlTransformer}. With this assumption, we can use regular expressions which is 10X faster
74+
* than XML stream parsers.
75+
*/
76+
public static String normalize(String xml) {
77+
Optional<String> maybeFeeTagInUse = findFeeExtensionInUse(xml);
78+
if (maybeFeeTagInUse.isEmpty()) {
79+
return xml;
80+
}
81+
String feeTagInUse = maybeFeeTagInUse.get();
82+
String normalized = xml;
83+
for (ServiceExtension serviceExtension : FEE_EXTENSIONS) {
84+
if (serviceExtension.getXmlTag().equals(feeTagInUse)) {
85+
normalized = normalizeExtensionInUse(feeTagInUse, serviceExtension.getUri(), normalized);
86+
} else {
87+
normalized =
88+
removeUnusedExtension(
89+
serviceExtension.getXmlTag(), serviceExtension.getUri(), normalized);
90+
}
91+
}
92+
return normalized;
93+
}
94+
95+
static String removeUnusedExtension(String tag, String uri, String xml) {
96+
String declaration = String.format("xmlns:%s=\"%s\"", tag, uri);
97+
// There must be a leading whitespace, and it can be safely removed with the declaration.
98+
return xml.replaceAll(String.format("\\s%s", declaration), "");
99+
}
100+
101+
static String normalizeExtensionInUse(String tagInUse, String uriInUse, String xml) {
102+
if (tagInUse.equals(CANONICAL_FEE_TAG)) {
103+
return xml;
104+
}
105+
// Change the tag in the namespace declaration:
106+
String currentDeclaration = String.format("xmlns:%s=\"%s\"", tagInUse, uriInUse);
107+
String desiredDeclaraion = String.format("xmlns:fee=\"%s\"", uriInUse);
108+
// The new tag at each site of use, with trailing colon:
109+
String newTagWithColon = CANONICAL_FEE_TAG + ":";
110+
return xml.replaceAll(String.format("\\b%s:", tagInUse), newTagWithColon)
111+
.replaceAll(currentDeclaration, desiredDeclaraion);
112+
}
113+
114+
static Optional<String> findFeeExtensionInUse(String xml) {
115+
Matcher matcher = FEE_EXTENSION_IN_USE_PATTERN.matcher(xml);
116+
117+
if (!matcher.find()) {
118+
return Optional.empty();
119+
}
120+
// We know only one extension is in use, so we can return on the first match
121+
for (int i = 1; i <= matcher.groupCount(); i++) {
122+
if (matcher.group(i) != null) {
123+
return Optional.of(matcher.group(i));
124+
}
125+
}
126+
throw new IllegalStateException("Should not reach here. Bad FEE_EXTENSION_IN_USE_PATTERN?");
127+
}
128+
}

core/src/main/java/google/registry/model/eppcommon/ProtocolDefinition.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import static com.google.common.collect.Maps.uniqueIndex;
1919

2020
import com.google.common.annotations.VisibleForTesting;
21+
import com.google.common.base.VerifyException;
2122
import com.google.common.collect.ImmutableMap;
2223
import com.google.common.collect.ImmutableSet;
2324
import google.registry.model.domain.fee06.FeeCheckCommandExtensionV06;
@@ -87,6 +88,7 @@ public enum ServiceExtension {
8788
private final Class<? extends CommandExtension> commandExtensionClass;
8889
private final Class<? extends ResponseExtension> responseExtensionClass;
8990
private final String uri;
91+
private final String xmlTag;
9092
private final ServiceExtensionVisibility visibility;
9193

9294
ServiceExtension(
@@ -96,6 +98,7 @@ public enum ServiceExtension {
9698
this.commandExtensionClass = commandExtensionClass;
9799
this.responseExtensionClass = responseExtensionClass;
98100
this.uri = getCommandExtensionUri(commandExtensionClass);
101+
this.xmlTag = getCommandExtensionXmlTag(commandExtensionClass);
99102
this.visibility = visibility;
100103
}
101104

@@ -111,11 +114,27 @@ public String getUri() {
111114
return uri;
112115
}
113116

117+
public String getXmlTag() {
118+
return xmlTag;
119+
}
120+
114121
/** Returns the namespace URI of the command extension class. */
115122
public static String getCommandExtensionUri(Class<? extends CommandExtension> clazz) {
116123
return clazz.getPackage().getAnnotation(XmlSchema.class).namespace();
117124
}
118125

126+
/** Returns the XML tag for this extension in the response message. */
127+
public static String getCommandExtensionXmlTag(Class<? extends CommandExtension> clazz) {
128+
var xmlSchema = clazz.getPackage().getAnnotation(XmlSchema.class);
129+
var xmlns = xmlSchema.xmlns();
130+
if (xmlns == null || xmlns.length != 1) {
131+
throw new VerifyException(
132+
String.format(
133+
"Expecting exactly one NS declaration in %s", clazz.getPackage().getName()));
134+
}
135+
return xmlns[0].prefix();
136+
}
137+
119138
public boolean isVisible() {
120139
return switch (visibility) {
121140
case ALL -> true;
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright 2026 The Nomulus Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.package google.registry.flows;
14+
15+
package google.registry.flows;
16+
17+
import static com.google.common.truth.Truth.assertThat;
18+
import static google.registry.flows.FeeExtensionXmlTagNormalizer.feeExtensionInUseRegex;
19+
import static google.registry.flows.FeeExtensionXmlTagNormalizer.normalize;
20+
import static google.registry.model.eppcommon.EppXmlTransformer.validateOutput;
21+
import static google.registry.testing.TestDataHelper.loadFile;
22+
23+
import java.util.stream.Stream;
24+
import org.junit.jupiter.api.Test;
25+
import org.junit.jupiter.params.ParameterizedTest;
26+
import org.junit.jupiter.params.provider.Arguments;
27+
import org.junit.jupiter.params.provider.MethodSource;
28+
29+
class FeeExtensionXmlTagNormalizerTest {
30+
31+
@Test
32+
void feeExtensionInUseRegex_correct() {
33+
assertThat(feeExtensionInUseRegex())
34+
.isEqualTo("\\b(fee):|\\b(fee11):|\\b(fee12):|\\b(fee_1_00):");
35+
}
36+
37+
@Test
38+
void normalize_noFeeExtensions() throws Exception {
39+
String xml = loadFile(getClass(), "domain_create.xml");
40+
String normalized = normalize(xml);
41+
assertThat(normalized).isEqualTo(xml);
42+
}
43+
44+
@ParameterizedTest(name = "normalize_withFeeExtension-{0}")
45+
@MethodSource("provideTestCombinations")
46+
@SuppressWarnings("unused") // Parameter 'name' is part of test case name
47+
void normalize_withFeeExtension(String name, String inputXmlFilename, String expectedXmlFilename)
48+
throws Exception {
49+
String original = loadFile(getClass(), inputXmlFilename);
50+
String normalized = normalize(original);
51+
String expected = loadFile(getClass(), expectedXmlFilename);
52+
// Verify that expected xml is syntatically correct.
53+
validateOutput(expected);
54+
55+
assertThat(normalized).isEqualTo(expected);
56+
}
57+
58+
@SuppressWarnings("unused")
59+
static Stream<Arguments> provideTestCombinations() {
60+
return Stream.of(
61+
Arguments.of(
62+
"v06",
63+
"domain_check_fee_response_raw_v06.xml",
64+
"domain_check_fee_response_normalized_v06.xml"),
65+
Arguments.of(
66+
"v11",
67+
"domain_check_fee_response_raw_v11.xml",
68+
"domain_check_fee_response_normalized_v11.xml"),
69+
Arguments.of(
70+
"v12",
71+
"domain_check_fee_response_raw_v12.xml",
72+
"domain_check_fee_response_normalized_v12.xml"),
73+
Arguments.of(
74+
"stdv1",
75+
"domain_check_fee_response_raw_stdv1.xml",
76+
"domain_check_fee_response_normalized_stdv1.xml"));
77+
}
78+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2+
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0" xmlns:domain="urn:ietf:params:xml:ns:domain-1.0" xmlns:contact="urn:ietf:params:xml:ns:contact-1.0" xmlns:fee="urn:ietf:params:xml:ns:epp:fee-1.0" xmlns:rgp="urn:ietf:params:xml:ns:rgp-1.0" xmlns:bulkToken="urn:google:params:xml:ns:bulkToken-1.0" xmlns:launch="urn:ietf:params:xml:ns:launch-1.0" xmlns:secDNS="urn:ietf:params:xml:ns:secDNS-1.1" xmlns:host="urn:ietf:params:xml:ns:host-1.0">
3+
<response>
4+
<result code="1000">
5+
<msg>Command completed successfully</msg>
6+
</result>
7+
<resData>
8+
<domain:chkData>
9+
<domain:cd>
10+
<domain:name avail="false">example.tld</domain:name>
11+
<domain:reason>Reserved; alloc. token required</domain:reason>
12+
</domain:cd>
13+
</domain:chkData>
14+
</resData>
15+
<extension>
16+
<fee:chkData>
17+
<fee:currency>USD</fee:currency>
18+
<fee:cd>
19+
<fee:objID>example.tld</fee:objID>
20+
<fee:class>reserved</fee:class>
21+
<fee:command name="create">
22+
<fee:period unit="y">1</fee:period>
23+
</fee:command>
24+
</fee:cd>
25+
<fee:cd>
26+
<fee:objID>example.tld</fee:objID>
27+
<fee:class>premium</fee:class>
28+
<fee:command name="renew">
29+
<fee:period unit="y">1</fee:period>
30+
<fee:fee description="renew">499.00</fee:fee>
31+
</fee:command>
32+
</fee:cd>
33+
<fee:cd>
34+
<fee:objID>example.tld</fee:objID>
35+
<fee:class>premium</fee:class>
36+
<fee:command name="transfer">
37+
<fee:period unit="y">1</fee:period>
38+
<fee:fee description="renew">499.00</fee:fee>
39+
</fee:command>
40+
</fee:cd>
41+
</fee:chkData>
42+
</extension>
43+
<trID>
44+
<clTRID>ff25dfc7-2025-469a-baec-bedde73e74de</clTRID>
45+
<svTRID>k5VIs5JMR1SRbx3TY6pAxQ==-2c52e</svTRID>
46+
</trID>
47+
</response>
48+
</epp>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2+
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0" xmlns:domain="urn:ietf:params:xml:ns:domain-1.0" xmlns:contact="urn:ietf:params:xml:ns:contact-1.0" xmlns:fee="urn:ietf:params:xml:ns:fee-0.6" xmlns:rgp="urn:ietf:params:xml:ns:rgp-1.0" xmlns:bulkToken="urn:google:params:xml:ns:bulkToken-1.0" xmlns:launch="urn:ietf:params:xml:ns:launch-1.0" xmlns:secDNS="urn:ietf:params:xml:ns:secDNS-1.1" xmlns:host="urn:ietf:params:xml:ns:host-1.0">
3+
<response>
4+
<result code="1000">
5+
<msg>Command completed successfully</msg>
6+
</result>
7+
<resData>
8+
<domain:chkData>
9+
<domain:cd>
10+
<domain:name avail="true">example.tld</domain:name>
11+
</domain:cd>
12+
</domain:chkData>
13+
</resData>
14+
<extension>
15+
<fee:chkData>
16+
<fee:cd>
17+
<fee:name>example.tld</fee:name>
18+
<fee:currency>USD</fee:currency>
19+
<fee:command>create</fee:command>
20+
<fee:period unit="y">1</fee:period>
21+
<fee:fee description="create">10.00</fee:fee>
22+
</fee:cd>
23+
<fee:cd>
24+
<fee:name>example.tld</fee:name>
25+
<fee:currency>USD</fee:currency>
26+
<fee:command>renew</fee:command>
27+
<fee:period unit="y">1</fee:period>
28+
<fee:fee description="renew">12.00</fee:fee>
29+
</fee:cd>
30+
<fee:cd>
31+
<fee:name>example.tld</fee:name>
32+
<fee:currency>USD</fee:currency>
33+
<fee:command>transfer</fee:command>
34+
<fee:period unit="y">1</fee:period>
35+
<fee:fee description="renew">12.00</fee:fee>
36+
</fee:cd>
37+
<fee:cd>
38+
<fee:name>example.tld</fee:name>
39+
<fee:currency>USD</fee:currency>
40+
<fee:command>restore</fee:command>
41+
<fee:period unit="y">1</fee:period>
42+
<fee:fee description="restore">50.00</fee:fee>
43+
</fee:cd>
44+
</fee:chkData>
45+
</extension>
46+
<trID>
47+
<clTRID>trid</clTRID>
48+
<svTRID>PAYQRVV3Q4eeq5B5FMvtmQ==-3406e2</svTRID>
49+
</trID>
50+
</response>
51+
</epp>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2+
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0" xmlns:domain="urn:ietf:params:xml:ns:domain-1.0" xmlns:contact="urn:ietf:params:xml:ns:contact-1.0" xmlns:rgp="urn:ietf:params:xml:ns:rgp-1.0" xmlns:bulkToken="urn:google:params:xml:ns:bulkToken-1.0" xmlns:fee="urn:ietf:params:xml:ns:fee-0.11" xmlns:launch="urn:ietf:params:xml:ns:launch-1.0" xmlns:secDNS="urn:ietf:params:xml:ns:secDNS-1.1" xmlns:host="urn:ietf:params:xml:ns:host-1.0">
3+
<response>
4+
<result code="1000">
5+
<msg>Command completed successfully</msg>
6+
</result>
7+
<resData>
8+
<domain:chkData>
9+
<domain:cd>
10+
<domain:name avail="true">example.tld</domain:name>
11+
</domain:cd>
12+
</domain:chkData>
13+
</resData>
14+
<extension>
15+
<fee:chkData>
16+
<fee:cd avail="true">
17+
<fee:object>
18+
<domain:name>example.tld</domain:name>
19+
</fee:object>
20+
<fee:command>create</fee:command>
21+
<fee:currency>USD</fee:currency>
22+
<fee:period unit="y">1</fee:period>
23+
<fee:fee description="create">59.00</fee:fee>
24+
<fee:class>premium</fee:class>
25+
</fee:cd>
26+
</fee:chkData>
27+
</extension>
28+
<trID>
29+
<svTRID>zpFtnGFRSKi9GbnQgwWvHQ==-398f30</svTRID>
30+
</trID>
31+
</response>
32+
</epp>

0 commit comments

Comments
 (0)