+ * Supplying your own {@code JSch} lets you pre‑configure global settings
+ * like proxies or identity repositories before a {@link Device} is built.
+ *
+ * @param sshClient pre‑configured {@link JSch} instance (must not be {@code null})
+ * @return this {@code Builder} for fluent chaining
+ * @throws NullPointerException if {@code sshClient} is {@code null}
+ */
+ public Builder sshClient(JSch sshClient) {
+ this.sshClient = Objects.requireNonNull(sshClient);
+ return this;
}
- this.keyBasedAuthentication = (keyBasedAuthentication != null) ? keyBasedAuthentication : false;
- this.pemKeyFile = pemKeyFile;
+ /**
+ * Sets the DNS host name or IP address of the Netconf server.
+ *
+ * @param hostName server host name or IP
+ * @return this {@code Builder} for fluent chaining
+ */
+ public Builder hostName(String hostName) {
+ this.hostName = hostName;
+ return this;
+ }
- if (this.keyBasedAuthentication && pemKeyFile == null) {
- throw new NetconfException("key based authentication requires setting the pemKeyFile");
+ /**
+ * Specifies the TCP port on which the Netconf SSH subsystem listens.
+ *
+ * Defaults to {@code 830} if not set.
+ *
+ * @param port TCP port number
+ * @return this {@code Builder} for fluent chaining
+ */
+ public Builder port(int port) {
+ this.port = port;
+ return this;
}
- this.strictHostKeyChecking = (strictHostKeyChecking != null) ? strictHostKeyChecking : true;
- this.hostKeysFileName = hostKeysFileName;
+ /**
+ * Sets a default timeout (in milliseconds) that is used when a more
+ * specific {@link #connectionTimeout(int)} or {@link #commandTimeout(int)}
+ * value has not been provided.
+ *
+ * @param ms timeout in milliseconds
+ * @return this {@code Builder} for fluent chaining
+ */
+ public Builder timeout(int ms) {
+ this.timeout = ms;
+ return this;
+ }
- if (this.strictHostKeyChecking && hostKeysFileName == null) {
- throw new NetconfException("Strict Host Key checking requires setting the hostKeysFileName");
+ /**
+ * Overrides the default SSH connection timeout.
+ *
+ * @param ms timeout in milliseconds for establishing the SSH session
+ * @return this {@code Builder} for fluent chaining
+ */
+ public Builder connectionTimeout(int ms) {
+ this.connectionTimeout = ms;
+ return this;
}
- DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
- try {
- builder = factory.newDocumentBuilder();
- } catch (ParserConfigurationException e) {
- throw new NetconfException(String.format("Error creating XML Parser: %s", e.getMessage()), e);
+ /**
+ * Sets the per‑command timeout that applies to individual NETCONF RPCs
+ * (distinct from the SSH connection timeout).
+ *
+ * @param ms timeout in milliseconds
+ * @return this {@code Builder} for fluent chaining
+ */
+ public Builder commandTimeout(int ms) {
+ this.commandTimeout = ms;
+ return this;
+ }
+
+ /**
+ * Specifies the login user name for the SSH/NETCONF session.
+ *
+ * @param userName user name string
+ * @return this {@code Builder} for fluent chaining
+ */
+ public Builder userName(String userName) {
+ this.userName = userName;
+ return this;
+ }
+
+ /**
+ * Sets the password used for password‑based SSH authentication.
+ *
+ * Ignored if {@link #keyBasedAuth(String)} is used instead.
+ *
+ * @param password login password
+ * @return this {@code Builder} for fluent chaining
+ */
+ public Builder password(String password) {
+ this.password = password;
+ return this;
+ }
+
+ /**
+ * Enables key‑based SSH authentication and sets the path to the PEM
+ * private‑key file.
+ *
+ * @param pem absolute or relative path to the PEM‑formatted private key
+ * @return this {@code Builder} for fluent chaining
+ */
+ public Builder keyBasedAuth(String pem) {
+ this.keyAuth = true;
+ this.pemKeyFile = pem;
+ return this;
+ }
+
+ /**
+ * Specifies the path to the PEM‑formatted private key that will be used
+ * for key‑based SSH authentication.
+ *
+ * Note: calling this method alone does not switch the builder
+ * to key‑authentication mode; be sure to also invoke
+ * {@link #keyBasedAuth(String)} or set {@link #keyAuth} explicitly.
+ *
+ * @param pemKeyFile absolute or relative path to the PEM key file
+ * @return this {@code Builder} for fluent chaining
+ */
+ public Builder pemKeyFile(String pemKeyFile) {
+ this.pemKeyFile = pemKeyFile;
+ return this;
+ }
+
+ /**
+ * Disables strict host‑key checking so every host key is trusted
+ * (equivalent to {@code StrictHostKeyChecking=no} in OpenSSH).
+ *
+ * Security note: Accepting all host keys makes the
+ * connection vulnerable to man‑in‑the‑middle attacks. Use this only in
+ * development or other low‑risk environments.
+ *
+ * @return this {@code Builder} for fluent chaining
+ */
+ public Builder trustAllHostKeys() {
+ this.strictHostKeyChecking = false;
+ return this;
+ }
+
+ /**
+ * Enables or disables strict host‑key checking for the SSH session.
+ *
+ * When set to {@code true} the underlying JSch session will verify the
+ * server's host key against the known‑hosts file and refuse the
+ * connection if it is unknown. When {@code false} the connection will
+ * proceed even if the server's host key is not listed.
+ *
+ * @param strictHostKeyChecking whether to enforce host‑key checking
+ * @return this {@code Builder} for fluent chaining
+ */
+ public Builder strictHostKeyChecking(boolean strictHostKeyChecking) {
+ this.strictHostKeyChecking = strictHostKeyChecking;
+ return this;
}
- this.netconfCapabilities = (netconfCapabilities != null) ? netconfCapabilities : getDefaultClientCapabilities();
- helloRpc = createHelloRPC(this.netconfCapabilities);
+ /**
+ * Specifies the path to the SSH known‑hosts file used when
+ * {@link #strictHostKeyChecking(boolean)} is enabled.
+ *
+ * @param hostKeysFileName absolute or relative path to the known‑hosts file
+ * @return this {@code Builder} for fluent chaining
+ */
+ public Builder hostKeysFileName(String hostKeysFileName) {
+ this.hostKeysFileName = hostKeysFileName;
+ return this;
+ }
+
+ /**
+ * Replaces the default list of client‑side Netconf capabilities that
+ * will be advertised in the initial {@code <hello>} message.
+ *
+ * The supplied list is defensively copied and wrapped in an
+ * unmodifiable view, so subsequent modifications to the original list
+ * do not affect the builder.
+ *
+ * @param caps list of capability URIs; must not be {@code null}
+ * @return this {@code Builder} for fluent chaining
+ * @throws NullPointerException if {@code caps} is {@code null}
+ */
+ public Builder netconfCapabilities(List caps) {
+ this.netconfCapabilities = java.util.Collections.unmodifiableList(new java.util.ArrayList<>(caps));
+ return this;
+ }
+
+ /**
+ * Validates all required fields and constructs an immutable {@link Device}.
+ *
+ * Mandatory parameters include host name, user credentials (or key), and
+ * host‑key settings when strict checking is enabled. If any of these are
+ * missing or inconsistent, a {@link NetconfException} is thrown.
+ *
+ * @return a fully‑configured {@link Device} instance
+ * @throws NetconfException if validation fails or if an internal error
+ * occurs while initialising auxiliary resources
+ */
+ public Device build() throws NetconfException {
+ // Validation logic moved from Device constructor
+ if (hostName == null) throw new NetconfException("hostName is required");
+ if (userName == null) throw new NetconfException("userName is required");
+ if (!keyAuth && password == null)
+ throw new NetconfException("Password is required for password auth");
+ if (strictHostKeyChecking && hostKeysFileName == null)
+ throw new NetconfException("hostKeysFileName required when strictHostKeyChecking=true");
+ if (keyAuth && pemKeyFile == null)
+ throw new NetconfException("pemKeyFile required when keyAuth=true");
+ try {
+ return new Device(this);
+ } catch (NetconfException e) {
+ throw e;
+ }
+ }
+ }
+
+ /* ------------------------------------------------------------------
+ * Private constructor used by Builder
+ * ------------------------------------------------------------------ */
+ private Device(Builder b) throws NetconfException {
+ this.sshClient = b.sshClient;
+ this.hostName = b.hostName;
+ this.port = b.port;
+ this.connectionTimeout = b.connectionTimeout != null ? b.connectionTimeout : b.timeout;
+ this.commandTimeout = b.commandTimeout != null ? b.commandTimeout : b.timeout;
+
+ this.userName = b.userName;
+ this.password = b.password;
+ this.keyBasedAuthentication = b.keyAuth;
+ this.pemKeyFile = b.pemKeyFile;
+ this.strictHostKeyChecking = b.strictHostKeyChecking;
+ this.hostKeysFileName = b.hostKeysFileName;
+
+ this.netconfCapabilities = b.netconfCapabilities;
+
+ try {
+ this.xmlBuilder = javax.xml.parsers.DocumentBuilderFactory.newInstance().newDocumentBuilder();
+ } catch (javax.xml.parsers.ParserConfigurationException e) {
+ throw new NetconfException("Cannot create XML Parser", e);
+ }
- this.sshClient = (sshClient != null) ? sshClient : new JSch();
+ this.helloRpc = createHelloRPC(this.netconfCapabilities);
}
+
/**
* Get the client capabilities that are advertised to the Netconf server by default.
* RFC 6241 describes the standard netconf capabilities.
- * https://tools.ietf.org/html/rfc6241#section-8
+ * ...
*
* @return List of default client capabilities.
*/
- private List getDefaultClientCapabilities() {
+ protected List getDefaultClientCapabilities() {
return DEFAULT_CLIENT_CAPABILITIES;
}
/**
* Given a list of netconf capabilities, generate the netconf hello rpc message.
- * https://tools.ietf.org/html/rfc6241#section-8.1
+ * ...
*
* @param capabilities A list of netconf capabilities
* @return the hello RPC that represents those capabilities.
@@ -215,10 +436,10 @@ private NetconfSession createNetconfSession() throws NetconfException {
try {
sshChannel = (ChannelSubsystem) sshSession.openChannel("subsystem");
sshChannel.setSubsystem("netconf");
- return new NetconfSession(sshChannel, connectionTimeout, commandTimeout, helloRpc, builder);
+ return new NetconfSession(sshChannel, connectionTimeout, commandTimeout, helloRpc, xmlBuilder);
} catch (JSchException | IOException e) {
throw new NetconfException("Failed to create Netconf session:" +
- e.getMessage(), e);
+ e.getMessage(), e);
}
}
@@ -232,7 +453,7 @@ private Session loginWithUserPass(int timeoutMilliSeconds) throws NetconfExcepti
return session;
} catch (JSchException e) {
throw new NetconfException(String.format("Error connecting to host: %s - Error: %s",
- hostName, e.getMessage()), e);
+ hostName, e.getMessage()), e);
}
}
@@ -245,7 +466,7 @@ private Session loginWithPrivateKey(int timeoutMilliSeconds) throws NetconfExcep
return session;
} catch (JSchException e) {
throw new NetconfException(String.format("Error using key pair file: %s to connect to host: %s - Error: %s",
- pemKeyFile, hostName, e.getMessage()), e);
+ pemKeyFile, hostName, e.getMessage()), e);
}
}
@@ -256,9 +477,9 @@ private Session loginWithPrivateKey(int timeoutMilliSeconds) throws NetconfExcep
*/
public void connect() throws NetconfException {
if (hostName == null || userName == null || (password == null &&
- pemKeyFile == null)) {
+ pemKeyFile == null)) {
throw new NetconfException("Login parameters of Device can't be " +
- "null.");
+ "null.");
}
netconfSession = this.createNetconfSession();
}
@@ -280,11 +501,17 @@ private void loadPrivateKey() throws NetconfException {
public String reboot() throws IOException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.reboot();
}
+ /**
+ * Indicates whether both the SSH {@link Session} and {@link ChannelSubsystem}
+ * are currently connected.
+ *
+ * @return {@code true} if the device is connected
+ */
public boolean isConnected() {
return (isChannelConnected() && isSessionConnected());
}
@@ -370,7 +597,7 @@ public String runShellCommand(String command) throws IOException {
* @throws IOException if there are issues communicating with the Netconf server.
*/
public BufferedReader runShellCommandRunning(String command)
- throws IOException {
+ throws IOException {
if (!isConnected()) {
throw new IOException("Could not find open connection");
}
@@ -385,9 +612,9 @@ public BufferedReader runShellCommandRunning(String command)
}
/**
- * Send an RPC(as String object) over the default Netconf session and get
- * the response as an XML object.
- *
+ * Send an RPC(as String object) over the default Netconf session and get the
+ * response as an XML object.
+ *
Convenience overload for raw‑string payloads.
*
* @param rpcContent RPC content to be sent. For example, to send an rpc
* <rpc><get-chassis-inventory/></rpc>, the
@@ -402,7 +629,7 @@ public BufferedReader runShellCommandRunning(String command)
public XML executeRPC(String rpcContent) throws SAXException, IOException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return netconfSession.executeRPC(rpcContent);
}
@@ -410,7 +637,7 @@ public XML executeRPC(String rpcContent) throws SAXException, IOException {
/**
* Send an RPC(as XML object) over the Netconf session and get the response
* as an XML object.
- *
+ *
Use when the payload is already assembled as an {@link XML} helper object.
*
* @param rpc RPC to be sent. Use the XMLBuilder to create RPC as an
* XML object.
@@ -421,7 +648,7 @@ public XML executeRPC(String rpcContent) throws SAXException, IOException {
public XML executeRPC(XML rpc) throws SAXException, IOException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.executeRPC(rpc);
}
@@ -429,7 +656,8 @@ public XML executeRPC(XML rpc) throws SAXException, IOException {
/**
* Send an RPC(as Document object) over the Netconf session and get the
* response as an XML object.
- *
+ *
Accepts a DOM {@link org.w3c.dom.Document} that represents the full
+ * <rpc> element.
*
* @param rpcDoc RPC content to be sent, as a org.w3c.dom.Document object.
* @return RPC reply sent by Netconf server
@@ -439,31 +667,24 @@ public XML executeRPC(XML rpc) throws SAXException, IOException {
public XML executeRPC(Document rpcDoc) throws SAXException, IOException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.executeRPC(rpcDoc);
}
/**
- * Send an RPC(as String object) over the default Netconf session and get
- * the response as a BufferedReader.
- *
+ * Sends an RPC (as a raw XML string) over the default Netconf session and
+ * returns a {@link BufferedReader} for streaming the reply.
*
- * @param rpcContent RPC content to be sent. For example, to send an rpc
- * <rpc><get-chassis-inventory/></rpc>, the
- * String to be passed can be
- * "<get-chassis-inventory/>" OR
- * "get-chassis-inventory" OR
- * "<rpc><get-chassis-inventory/></rpc>"
- * @return RPC reply sent by Netconf server as a BufferedReader. This is
- * useful if we want continuous stream of output, rather than wait
- * for whole output till rpc execution completes.
- * @throws java.io.IOException if there are errors communicating with the Netconf server.
+ * @param rpcContent XML payload to send (content of the <rpc> element)
+ * @return RPC reply as a {@link BufferedReader}
+ * @throws IOException if communication with the server fails
+ * @throws IllegalStateException if no Netconf connection exists
*/
public BufferedReader executeRPCRunning(String rpcContent) throws IOException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.executeRPCRunning(rpcContent);
}
@@ -471,7 +692,7 @@ public BufferedReader executeRPCRunning(String rpcContent) throws IOException {
/**
* Send an RPC(as XML object) over the Netconf session and get the response
* as a BufferedReader.
- *
+ *
Streams the reply incrementally, suitable for large responses.
*
* @param rpc RPC to be sent. Use the XMLBuilder to create RPC as an
* XML object.
@@ -483,26 +704,34 @@ public BufferedReader executeRPCRunning(String rpcContent) throws IOException {
public BufferedReader executeRPCRunning(XML rpc) throws IOException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.executeRPCRunning(rpc);
}
/**
- * Send an RPC(as Document object) over the Netconf session and get the
- * response as a BufferedReader.
+ * Sends an RPC (as a DOM {@link Document}) over the active NETCONF session
+ * and returns a {@link BufferedReader} for streaming the reply.
*
+ * Use this variant when you need to consume the server response
+ * incrementally — for example, when the RPC produces a
+ * large dataset or when you want to start processing output before the
+ * device finishes sending the final ]]>]]> prompt.
+ *
*
- * @param rpcDoc RPC content to be sent, as a org.w3c.dom.Document object.
- * @return RPC reply sent by Netconf server as a BufferedReader. This is
- * useful if we want continuous stream of output, rather than wait
- * for whole output till command execution completes.
- * @throws java.io.IOException If there are errors communicating with the Netconf server.
+ * @param rpcDoc the complete <rpc> element encoded as a DOM
+ * {@link Document}; must not be {@code null}
+ *
+ * @return a {@link BufferedReader} connected to the server’s reply stream
+ *
+ * @throws IOException if an I/O error occurs while sending the
+ * request or reading the reply
+ * @throws IllegalStateException if no NETCONF connection is established
*/
public BufferedReader executeRPCRunning(Document rpcDoc) throws IOException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.executeRPCRunning(rpcDoc);
}
@@ -516,7 +745,7 @@ public BufferedReader executeRPCRunning(Document rpcDoc) throws IOException {
public String getSessionId() {
if (netconfSession == null) {
throw new IllegalStateException("Cannot get session ID, you need " +
- "to establish a connection first.");
+ "to establish a connection first.");
}
return this.netconfSession.getSessionId();
}
@@ -532,7 +761,7 @@ public String getSessionId() {
public boolean hasError() throws SAXException, IOException {
if (netconfSession == null) {
throw new IllegalStateException("No RPC executed yet, you need to" +
- " establish a connection first.");
+ " establish a connection first.");
}
return this.netconfSession.hasError();
}
@@ -547,7 +776,7 @@ public boolean hasError() throws SAXException, IOException {
public boolean hasWarning() throws SAXException, IOException {
if (netconfSession == null) {
throw new IllegalStateException("No RPC executed yet, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.hasWarning();
}
@@ -562,7 +791,7 @@ public boolean hasWarning() throws SAXException, IOException {
public boolean isOK() {
if (netconfSession == null) {
throw new IllegalStateException("No RPC executed yet, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.isOK();
}
@@ -578,7 +807,7 @@ public boolean isOK() {
public boolean lockConfig() throws IOException, SAXException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.lockConfig();
}
@@ -593,7 +822,7 @@ public boolean lockConfig() throws IOException, SAXException {
public boolean unlockConfig() throws IOException, SAXException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.unlockConfig();
}
@@ -610,10 +839,10 @@ public boolean unlockConfig() throws IOException, SAXException {
* @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
*/
public void loadXMLConfiguration(String configuration, String loadType)
- throws IOException, SAXException {
+ throws IOException, SAXException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
this.netconfSession.loadXMLConfiguration(configuration, loadType);
}
@@ -634,10 +863,10 @@ public void loadXMLConfiguration(String configuration, String loadType)
* @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
*/
public void loadTextConfiguration(String configuration, String loadType)
- throws IOException, SAXException {
+ throws IOException, SAXException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
this.netconfSession.loadTextConfiguration(configuration, loadType);
}
@@ -655,11 +884,11 @@ public void loadTextConfiguration(String configuration, String loadType)
* @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
*/
public void loadSetConfiguration(String configuration) throws
- IOException,
- SAXException {
+ IOException,
+ SAXException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
this.netconfSession.loadSetConfiguration(configuration);
}
@@ -675,10 +904,10 @@ public void loadSetConfiguration(String configuration) throws
* @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
*/
public void loadXMLFile(String configFile, String loadType)
- throws IOException, SAXException {
+ throws IOException, SAXException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
this.netconfSession.loadXMLFile(configFile, loadType);
}
@@ -694,10 +923,10 @@ public void loadXMLFile(String configFile, String loadType)
* @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
*/
public void loadTextFile(String configFile, String loadType)
- throws IOException, SAXException {
+ throws IOException, SAXException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
this.netconfSession.loadTextFile(configFile, loadType);
}
@@ -713,10 +942,10 @@ public void loadTextFile(String configFile, String loadType)
* @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
*/
public void loadSetFile(String configFile) throws
- IOException, SAXException {
+ IOException, SAXException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
this.netconfSession.loadSetFile(configFile);
}
@@ -731,7 +960,7 @@ public void loadSetFile(String configFile) throws
public void commit() throws CommitException, IOException, SAXException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
this.netconfSession.commit();
}
@@ -747,10 +976,10 @@ public void commit() throws CommitException, IOException, SAXException {
* @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
*/
public void commitConfirm(long seconds) throws CommitException, IOException,
- SAXException {
+ SAXException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
this.netconfSession.commitConfirm(seconds);
}
@@ -766,7 +995,7 @@ public void commitConfirm(long seconds) throws CommitException, IOException,
public void commitFull() throws CommitException, IOException, SAXException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
this.netconfSession.commitFull();
}
@@ -778,10 +1007,10 @@ public void commitFull() throws CommitException, IOException, SAXException {
* @param configFile Path name of file containing configuration,in text/xml format,
* to be loaded. For example,
* "system {
- * services {
- * ftp;
- * }
- * }"
+ * services {
+ * ftp;
+ * }
+ * }"
* will load 'ftp' under the 'systems services' hierarchy.
* OR
* "<configuration><system><services><ftp/><
@@ -793,10 +1022,10 @@ public void commitFull() throws CommitException, IOException, SAXException {
* @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
*/
public void commitThisConfiguration(String configFile, String loadType)
- throws CommitException, IOException, SAXException {
+ throws CommitException, IOException, SAXException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
this.netconfSession.commitThisConfiguration(configFile, loadType);
}
@@ -812,10 +1041,10 @@ public void commitThisConfiguration(String configFile, String loadType)
* @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
*/
public XML getCandidateConfig(String configTree) throws SAXException,
- IOException {
+ IOException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.getCandidateConfig(configTree);
}
@@ -831,10 +1060,10 @@ public XML getCandidateConfig(String configTree) throws SAXException,
* @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
*/
public XML getRunningConfig(String configTree) throws SAXException,
- IOException {
+ IOException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.getRunningConfig(configTree);
}
@@ -842,7 +1071,7 @@ public XML getRunningConfig(String configTree) throws SAXException,
/**
* Retrieve the running configuration, or part of the configuration.
*
- * @param xpathFilter example
+ * @param xpathFilter example {@code <filter xmlns:model='urn:path:for:my:model' select='/model:*' />}
* @return configuration data as XML object.
* @throws java.io.IOException If there are errors communicating with the netconf server.
* @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
@@ -850,27 +1079,28 @@ public XML getRunningConfig(String configTree) throws SAXException,
public XML getRunningConfigAndState(String xpathFilter) throws IOException, SAXException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.getRunningConfigAndState(xpathFilter);
}
/**
- * Run the call to netconf server and retrieve data as an XML.
+ * Run the {@code <get-data>} call to netconf server and retrieve data as an XML.
*
- * @param xpathFilter example
- * @param datastore running, startup, candidate, or operational
+ * @param xpathFilter example {@code <filter xmlns:model='urn:path:for:my:model' select='/model:*' />}
+ * @param datastore running, startup, candidate, or operational
* @return configuration data as XML object.
* @throws java.io.IOException If there are errors communicating with the netconf server.
* @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
*/
- public XML getData(String xpathFilter, @NonNull Datastore datastore) throws IOException, SAXException {
+ public XML getData(String xpathFilter, Datastore datastore) throws IOException, SAXException {
+ if (datastore == null) throw new NullPointerException("datastore must not be null");
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
- return this.netconfSession.getData(xpathFilter, datastore);
+ return this.netconfSession.getData(xpathFilter, datastore);
}
@@ -884,7 +1114,7 @@ public XML getData(String xpathFilter, @NonNull Datastore datastore) throws IOEx
public XML getCandidateConfig() throws SAXException, IOException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.getCandidateConfig();
}
@@ -899,7 +1129,7 @@ public XML getCandidateConfig() throws SAXException, IOException {
public XML getRunningConfig() throws SAXException, IOException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.getRunningConfig();
}
@@ -914,7 +1144,7 @@ public XML getRunningConfig() throws SAXException, IOException {
public boolean validate() throws IOException, SAXException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.validate();
}
@@ -931,7 +1161,7 @@ public boolean validate() throws IOException, SAXException {
public String runCliCommand(String command) throws IOException, SAXException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.runCliCommand(command);
}
@@ -948,7 +1178,7 @@ public String runCliCommand(String command) throws IOException, SAXException {
public BufferedReader runCliCommandRunning(String command) throws IOException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
return this.netconfSession.runCliCommandRunning(command);
}
@@ -964,7 +1194,7 @@ public BufferedReader runCliCommandRunning(String command) throws IOException {
public void openConfiguration(String mode) throws IOException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
netconfSession.openConfiguration(mode);
}
@@ -978,7 +1208,7 @@ public void openConfiguration(String mode) throws IOException {
public void closeConfiguration() throws IOException {
if (netconfSession == null) {
throw new IllegalStateException("Cannot execute RPC, you need to " +
- "establish a connection first.");
+ "establish a connection first.");
}
netconfSession.closeConfiguration();
}
@@ -1008,6 +1238,7 @@ public void createRPCAttribute(String name, String value) {
/**
* Removes an RPC attribute from the default rpc xml envelope used in all rpc executions.
*
+ * @param name the attribute name to remove
* @return The value of the removed attribute.
* @throws NullPointerException If the device connection has not been made yet.
*/
@@ -1024,4 +1255,289 @@ public void clearRPCAttributes() {
netconfSession.removeAllRPCAttributes();
}
+ /**
+ * Convenience alias for {@link #getStrictHostKeyChecking()}.
+ *
+ * @return {@code true} if strict host-key checking is enabled
+ */
+ public boolean isStrictHostKeyChecking() {
+ return this.strictHostKeyChecking;
+ }
+ // Getters for fields
+
+ /**
+ * Returns the {@link JSch} instance that backs this {@code Device}.
+ *
+ * Note – the returned object is the live instance
+ * used for all SSH operations; creating a defensive copy is not feasible,
+ * so callers must not modify its global state (e.g. changing
+ * the identity repository or host‑key repository) once the {@code Device}
+ * has been built.
+ *
+ * @return the underlying {@link JSch} SSH client
+ */
+ @SuppressWarnings("EI_EXPOSE_REP") // Defensive copy not feasible for JSch
+ public JSch getSshClient() {
+ // Defensive copy not possible; document that caller must not modify
+ return sshClient;
+ }
+
+ /**
+ * Returns the DNS host name or IP address of the NETCONF server to which
+ * this {@code Device} instance will attempt to connect.
+ *
+ * @return host name or IP string
+ */
+ public String getHostName() {
+ return hostName;
+ }
+
+ /**
+ * Returns the TCP port on which the remote device’s NETCONF SSH subsystem
+ * is listening. The default is {@code 830} unless explicitly overridden
+ * in the builder.
+ *
+ * @return NETCONF port number
+ */
+ public int getPort() {
+ return port;
+ }
+
+ /**
+ * Returns the maximum time, in milliseconds, to wait while establishing
+ * the underlying SSH transport connection before giving up.
+ *
+ * @return SSH connection timeout (milliseconds)
+ */
+ public int getConnectionTimeout() {
+ return connectionTimeout;
+ }
+
+ /**
+ * Returns the per‑command timeout configured for individual NETCONF RPCs.
+ *
+ * @return timeout in milliseconds
+ */
+ public int getCommandTimeout() {
+ return commandTimeout;
+ }
+
+ /**
+ * Returns the user name that will be used to authenticate the SSH session.
+ *
+ * @return login user name
+ */
+ public String getUserName() {
+ return userName;
+ }
+
+ /**
+ * Returns the password associated with {@link #getUserName()}, or
+ * {@code null} if key‑based authentication is configured.
+ *
+ * @return password string, or {@code null}
+ */
+ public String getPassword() {
+ return password;
+ }
+
+ /**
+ * Indicates whether key‑based SSH authentication is configured instead
+ * of password‑based authentication.
+ *
+ * @return {@code true} if key‑based authentication is configured
+ */
+ public boolean isKeyBasedAuthentication() {
+ return keyBasedAuthentication;
+ }
+
+ /**
+ * Returns the path to the PEM‑formatted private‑key file that will be
+ * used for key‑based authentication.
+ *
+ * @return PEM key file path, or {@code null} if password auth is used
+ */
+ public String getPemKeyFile() {
+ return pemKeyFile;
+ }
+
+ /**
+ * Returns whether strict host-key checking is enabled for this device.
+ *
+ * @return {@code true} if strict host-key checking is on
+ */
+ public boolean getStrictHostKeyChecking() {
+ return strictHostKeyChecking;
+ }
+
+ /**
+ * Returns the path to the SSH known-hosts file used for host-key checking.
+ *
+ * @return known-hosts file path, or {@code null} if none was provided
+ */
+ public String getHostKeysFileName() {
+ return hostKeysFileName;
+ }
+
+ /**
+ * Returns the DocumentBuilder used for XML parsing.
+ *
+ * Defensive copy not possible; caller must not modify the returned instance.
+ *
+ * @return the {@link DocumentBuilder} in use
+ */
+ @SuppressWarnings("EI_EXPOSE_REP") // Defensive copy not feasible for DocumentBuilder
+ public DocumentBuilder getXmlBuilder() {
+ // Defensive copy not possible; document that caller must not modify
+ return xmlBuilder;
+ }
+
+ /**
+ * Returns an immutable copy of the capability URIs that this client
+ * advertises in its initial {@code <hello>} exchange.
+ *
+ * @return list of Netconf capability URIs
+ */
+ public List getNetconfCapabilities() {
+ return new java.util.ArrayList<>(netconfCapabilities);
+ }
+
+ /**
+ * Returns the pre‑built NETCONF {@code <hello>} RPC payload that
+ * will be sent when a session is established.
+ *
+ * @return initial NETCONF {@code <hello>} RPC string
+ */
+ public String getHelloRpc() {
+ return helloRpc;
+ }
+
+ /**
+ * Returns the active SSH {@link ChannelSubsystem} used for NETCONF
+ * communication, or {@code null} if not yet connected.
+ *
+ * @return active SSH subsystem channel, or {@code null}
+ */
+ @SuppressWarnings("EI_EXPOSE_REP") // Defensive copy not feasible for active channel
+ public ChannelSubsystem getSshChannel() {
+ return sshChannel; // Defensive copy not feasible; caller must not modify
+ }
+
+ /**
+ * Returns the underlying SSH {@link Session}, or {@code null} if not
+ * connected.
+ *
+ * @return SSH session, or {@code null}
+ */
+ @SuppressWarnings("EI_EXPOSE_REP") // Defensive copy not feasible for active session
+ public Session getSshSession() {
+ return sshSession; // Defensive copy not feasible; caller must not modify
+ }
+
+ /**
+ * Returns the current {@link NetconfSession}, or {@code null} if no
+ * session has been established.
+ *
+ * @return active Netconf session, or {@code null}
+ */
+ @SuppressWarnings("EI_EXPOSE_REP") // Defensive copy not feasible for active session
+ public NetconfSession getNetconfSession() {
+ return netconfSession; // Defensive copy not feasible; caller must not modify
+ }
+
+ // Setters for mutable fields (if needed)
+
+ /**
+ * Sets the SSH channel subsystem.
+ *
+ * Defensive copy not feasible; ensures non-null reference.
+ * The caller must not reuse or externally share the provided object after setting.
+ *
+ * @param sshChannel the SSH channel subsystem (must not be null)
+ * @throws NullPointerException if sshChannel is null
+ */
+ @SuppressWarnings("EI_EXPOSE_REP2") // Defensive copy not feasible for active channel
+ public void setSshChannel(ChannelSubsystem sshChannel) {
+ this.sshChannel = (ChannelSubsystem) Objects.requireNonNull(sshChannel);
+ }
+
+ /**
+ * Sets the SSH session.
+ *
+ * Defensive copy not feasible; ensures non-null reference.
+ * The caller must not reuse or externally share the provided object after setting.
+ *
+ * @param sshSession the SSH session (must not be null)
+ * @throws NullPointerException if sshSession is null
+ */
+ @SuppressWarnings("EI_EXPOSE_REP2") // Defensive copy not feasible for active session
+ public void setSshSession(Session sshSession) {
+ this.sshSession = Objects.requireNonNull(sshSession);
+ }
+
+ /**
+ * Sets the Netconf session.
+ *
+ * Defensive copy not feasible; ensures non-null reference.
+ * The caller must not reuse or externally share the provided object after setting.
+ *
+ * @param netconfSession the Netconf session (must not be null)
+ * @throws NullPointerException if netconfSession is null
+ */
+ @SuppressWarnings("EI_EXPOSE_REP2") // Defensive copy not feasible for active session
+ public void setNetconfSession(NetconfSession netconfSession) {
+ this.netconfSession = Objects.requireNonNull(netconfSession);
+ }
+
+ // equals(), hashCode(), toString()
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Device device = (Device) o;
+ return port == device.port &&
+ connectionTimeout == device.connectionTimeout &&
+ commandTimeout == device.commandTimeout &&
+ keyBasedAuthentication == device.keyBasedAuthentication &&
+ strictHostKeyChecking == device.strictHostKeyChecking &&
+ java.util.Objects.equals(sshClient, device.sshClient) &&
+ java.util.Objects.equals(hostName, device.hostName) &&
+ java.util.Objects.equals(userName, device.userName) &&
+ java.util.Objects.equals(password, device.password) &&
+ java.util.Objects.equals(pemKeyFile, device.pemKeyFile) &&
+ java.util.Objects.equals(hostKeysFileName, device.hostKeysFileName) &&
+ java.util.Objects.equals(xmlBuilder, device.xmlBuilder) &&
+ java.util.Objects.equals(netconfCapabilities, device.netconfCapabilities) &&
+ java.util.Objects.equals(helloRpc, device.helloRpc);
+ }
+
+ @Override
+ public int hashCode() {
+ return java.util.Objects.hash(
+ sshClient, hostName, port, connectionTimeout, commandTimeout,
+ userName, password, keyBasedAuthentication, pemKeyFile,
+ strictHostKeyChecking, hostKeysFileName, xmlBuilder,
+ netconfCapabilities, helloRpc
+ );
+ }
+
+ @Override
+ public String toString() {
+ return "Device{" +
+ "sshClient=" + sshClient +
+ ", hostName='" + hostName + '\'' +
+ ", port=" + port +
+ ", connectionTimeout=" + connectionTimeout +
+ ", commandTimeout=" + commandTimeout +
+ ", userName='" + userName + '\'' +
+ ", password='" + (password != null ? "***" : null) + '\'' +
+ ", keyBasedAuthentication=" + keyBasedAuthentication +
+ ", pemKeyFile='" + pemKeyFile + '\'' +
+ ", strictHostKeyChecking=" + strictHostKeyChecking +
+ ", hostKeysFileName='" + hostKeysFileName + '\'' +
+ ", xmlBuilder=" + xmlBuilder +
+ ", netconfCapabilities=" + netconfCapabilities +
+ ", helloRpc='" + helloRpc + '\'' +
+ '}';
+ }
}
\ No newline at end of file
diff --git a/src/main/java/net/juniper/netconf/LoadException.java b/src/main/java/net/juniper/netconf/LoadException.java
index e11108a..253d012 100644
--- a/src/main/java/net/juniper/netconf/LoadException.java
+++ b/src/main/java/net/juniper/netconf/LoadException.java
@@ -3,19 +3,50 @@
All Rights Reserved
Use is subject to license terms.
-
*/
package net.juniper.netconf;
import java.io.IOException;
-/**
- * Describes exceptions related to load operation
+/**
+ * Exception thrown when a load RPC returns <rpc-error> or otherwise
+ * fails to complete successfully.
+ *
+ *
Three convenient constructors are provided so callers can supply:
+ *
+ *
a human‑readable message only,
+ *
a message and root cause, or
+ *
just the root cause.
+ *
*/
public class LoadException extends IOException {
- LoadException(String msg) {
- super(msg);
+ /**
+ * Creates a {@code LoadException} with the supplied message.
+ *
+ * @param message description of the load failure
+ */
+ public LoadException(String message) {
+ super(message);
+ }
+
+ /**
+ * Creates a {@code LoadException} with a message and a root cause.
+ *
+ * @param message description of the load failure
+ * @param cause underlying exception that triggered the failure
+ */
+ public LoadException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ /**
+ * Creates a {@code LoadException} that wraps an underlying cause.
+ *
+ * @param cause underlying exception that triggered the failure
+ */
+ public LoadException(Throwable cause) {
+ super(cause);
}
}
diff --git a/src/main/java/net/juniper/netconf/NetconfConstants.java b/src/main/java/net/juniper/netconf/NetconfConstants.java
index 6baa28e..c664540 100644
--- a/src/main/java/net/juniper/netconf/NetconfConstants.java
+++ b/src/main/java/net/juniper/netconf/NetconfConstants.java
@@ -1,38 +1,70 @@
package net.juniper.netconf;
/**
+ * Centralised collection of string literals and protocol constants used
+ * throughout the NETCONF client library.
+ *
+ * The class is {@code final} and has a private constructor – it cannot be
+ * instantiated or extended. All members are {@code public static final}
+ * to encourage direct use without additional indirection.
+ *
+ *
* @author Jonas Glass
*/
public class NetconfConstants {
- private NetconfConstants() {
- }
+ /* ------------------------------------------------------------------
+ * Framing protocol
+ * ------------------------------------------------------------------ */
/**
- * Device prompt for the framing protocol.
- * https://tools.ietf.org/html/rfc6242#section-4.1
+ * Device prompt used by the NETCONF chunked framing protocol.
+ *
+ * @see RFC 6242 §4.1
*/
public static final String DEVICE_PROMPT = "]]>]]>";
+ /* ------------------------------------------------------------------
+ * XML preamble & namespaces
+ * ------------------------------------------------------------------ */
+
/**
- * XML Schema prefix.
+ * XML declaration emitted at the top of NETCONF messages.
*/
public static final String XML_VERSION = "";
/**
- * XML Namespace for NETCONF Base 1.0
- * https://tools.ietf.org/html/rfc6241#section-8.1
+ * XML namespace for NETCONF Base 1.0
+ *
+ * @see RFC 6241 §8.1
*/
public static final String URN_XML_NS_NETCONF_BASE_1_0 = "urn:ietf:params:xml:ns:netconf:base:1.0";
/**
- * URN for NETCONF Base 1.0
- * https://tools.ietf.org/html/rfc6241#section-8.1
+ * URI form of the NETCONF Base 1.0 capability identifier.
+ *
+ * @see RFC 6241 §8.1
*/
public static final String URN_IETF_PARAMS_NETCONF_BASE_1_0 = "urn:ietf:params:netconf:base:1.0";
+ /* ------------------------------------------------------------------
+ * Misc helpers
+ * ------------------------------------------------------------------ */
+
+ /** Empty line helper constant. */
public static final String EMPTY_LINE = "";
+
+ /** Line feed (Unix‑style newline). */
public static final String LF = "\n";
+
+ /** Carriage return (use with {@code LF} for CRLF sequences). */
public static final String CR = "\r";
+ /** UTF‑8 charset literal used throughout the library. */
+ public static final String CHARSET_UTF8 = "utf-8";
+
+ /**
+ * Not instantiable – utility holder only.
+ */
+ private NetconfConstants() { /* no‑op */ }
}
diff --git a/src/main/java/net/juniper/netconf/NetconfException.java b/src/main/java/net/juniper/netconf/NetconfException.java
index 7120d2d..bc50a69 100644
--- a/src/main/java/net/juniper/netconf/NetconfException.java
+++ b/src/main/java/net/juniper/netconf/NetconfException.java
@@ -14,10 +14,22 @@
* Describes exceptions related to establishing Netconf session.
*/
public class NetconfException extends IOException {
+ /**
+ * Constructs a {@code NetconfException} with the specified detail message.
+ *
+ * @param msg the detail message that describes the exception
+ */
public NetconfException(String msg) {
super(msg);
}
+ /**
+ * Constructs a {@code NetconfException} with the specified detail message
+ * and underlying cause.
+ *
+ * @param msg the detail message
+ * @param t the throwable that caused this exception
+ */
public NetconfException(String msg, Throwable t) {
super(msg, t);
}
diff --git a/src/main/java/net/juniper/netconf/NetconfSession.java b/src/main/java/net/juniper/netconf/NetconfSession.java
index 72c87ef..5b25835 100644
--- a/src/main/java/net/juniper/netconf/NetconfSession.java
+++ b/src/main/java/net/juniper/netconf/NetconfSession.java
@@ -12,8 +12,6 @@
import com.google.common.base.Charsets;
import com.jcraft.jsch.Channel;
import com.jcraft.jsch.JSchException;
-import lombok.NonNull;
-import lombok.extern.slf4j.Slf4j;
import net.juniper.netconf.element.Datastore;
import net.juniper.netconf.element.Hello;
import net.juniper.netconf.element.RpcReply;
@@ -44,24 +42,24 @@
import java.util.concurrent.TimeUnit;
/**
- * A NetconfSession object is used to call the Netconf driver
- * methods.
- * This is derived by creating a Device first,
- * and calling createNetconfSession().
+ * A {@code NetconfSession} is obtained by first building a
+ * {@link Device} and then calling {@link Device#connect()}.
*
* Typically, one
*
- *
creates a Device object.
- *
calls the createNetconfSession() method to get a NetconfSession
- * object.
- *
perform operations on the NetconfSession object.
- *
finally, one must close the NetconfSession and release resources with
- * the {@link #close() close()} method.
+ *
Build a {@link Device} using its fluent {@code builder()}.
+ *
Invoke {@link Device#connect()} to establish transport and receive a
+ * {@code NetconfSession}.
+ *
Perform RPC operations via the session
+ * (e.g. {@link #executeRPC(String)}, {@link #killSession(String)},
+ * {@link #commitConfirm(long, String)}, {@link #cancelCommit(String)}).
+ *
Call {@link #close()} when finished to free resources.
*
*/
-@Slf4j
public class NetconfSession {
+ private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(NetconfSession.class);
+
private final Channel netconfChannel;
private String serverCapability;
private Hello serverHello;
@@ -78,7 +76,12 @@ public class NetconfSession {
private String rpcAttributes;
private int messageId = 0;
- // Bigger than inner buffer in BufferReader class
+ /**
+ * Size (in characters) of the temporary read buffer used when collecting
+ * RPC replies from the device. Set larger than the internal buffer in
+ * {@link java.io.BufferedReader} so that large replies are less likely to
+ * require multiple passes.
+ */
public static final int BUFFER_SIZE = 9 * 1024;
private static final String CANDIDATE_CONFIG = "candidate";
@@ -226,9 +229,16 @@ public void loadXMLConfiguration(String configuration, String loadType) throws I
"" +
"" +
NetconfConstants.DEVICE_PROMPT;
- setLastRpcReply(getRpcReply(rpc));
- if (hasError() || !isOK())
+ try {
+ setLastRpcReply(getRpcReply(rpc));
+ } catch (NetconfException e) {
+ // Propagate as a LoadException so the caller knows this happened
+ throw new LoadException("Load operation returned error.", e);
+ }
+
+ if (hasError() || !isOK()) {
throw new LoadException("Load operation returned error.");
+ }
}
private void setHelloReply(final String reply) throws IOException {
@@ -282,9 +292,15 @@ public void loadTextConfiguration(String configuration, String loadType) throws
"" +
"" +
NetconfConstants.DEVICE_PROMPT;
- setLastRpcReply(getRpcReply(rpc));
- if (hasError() || !isOK())
+ try {
+ setLastRpcReply(getRpcReply(rpc));
+ } catch (NetconfException e) {
+ throw new LoadException("Load operation returned error.", e);
+ }
+
+ if (hasError() || !isOK()) {
throw new LoadException("Load operation returned error");
+ }
}
private String getConfig(String configTree) throws IOException {
@@ -304,6 +320,16 @@ private String getConfig(String configTree) throws IOException {
return lastRpcReply;
}
+ /**
+ * Executes a NETCONF {@code <get>} operation against the device’s running
+ * datastore and returns the configuration **and** operational state data.
+ *
+ * @param xpathFilter optional XPath filter to limit the returned subtree;
+ * pass {@code null} to retrieve the entire running state
+ * @return an {@link XML} object representing the server’s {@code <rpc-reply>}
+ * @throws IOException if communication with the device fails
+ * @throws SAXException if the reply cannot be parsed into valid XML
+ */
public XML getRunningConfigAndState(String xpathFilter) throws IOException, SAXException {
String rpc = "" +
"" +
@@ -315,9 +341,24 @@ public XML getRunningConfigAndState(String xpathFilter) throws IOException, SAXE
return convertToXML(lastRpcReply);
}
- public XML getData(String xpathFilter, @NonNull Datastore datastore)
+ /**
+ * Executes the YANG NMDA {@code <get-data>} operation against the specified
+ * {@link Datastore}.
+ *
+ * @param xpathFilter optional XPath filter that narrows the returned data;
+ * may be {@code null} for an unfiltered request
+ * @param datastore the target datastore (e.g., {@code operational}, {@code running});
+ * must not be {@code null}
+ * @return an {@link XML} object containing the server’s reply
+ * @throws IllegalArgumentException if {@code datastore} is {@code null}
+ * @throws IOException if communication with the device fails
+ * @throws SAXException if the reply XML is malformed
+ */
+ public XML getData(String xpathFilter, Datastore datastore)
throws IOException, SAXException {
-
+ if (datastore == null) {
+ throw new IllegalArgumentException("Datastore argument must not be null");
+ }
String rpc = "" +
"" +
"ds:" + datastore + "" +
@@ -355,7 +396,8 @@ public String getServerCapability() {
}
/**
- * Returns the <hello> message received from the server. See https://datatracker.ietf.org/doc/html/rfc6241#section-8.1
+ * Returns the <hello> message received from the server.
+ * See ...
* @return the <hello> message received from the server.
*/
public Hello getServerHello() {
@@ -363,30 +405,34 @@ public Hello getServerHello() {
}
/**
- * Send an RPC(as String object) over the default Netconf session and get
- * the response as an XML object.
+ * Sends a raw RPC string, waits for the {@code <rpc-reply>}, and converts the
+ * response to an {@link XML} object.
*
+ * If the server includes any {@code <rpc-error>} elements, the call
+ * now throws a {@link NetconfException}. This makes error handling
+ * symmetrical with other high‑level helpers (e.g. {@code load*()},
+ * {@code commit()}).
*
- * @param rpcContent RPC content to be sent. For example, to send an rpc
- * <rpc><get-chassis-inventory/></rpc>, the
- * String to be passed can be
- * "<get-chassis-inventory/>" OR
- * "get-chassis-inventory" OR
- * "<rpc><get-chassis-inventory/></rpc>"
- * @return RPC reply sent by Netconf server
- * @throws org.xml.sax.SAXException If the XML Reply cannot be parsed.
- * @throws java.io.IOException If there are issues communicating with the netconf server.
+ * @param rpcContent the RPC payload (with or without <rpc> wrapper)
+ * @return parsed {@link XML} representation of the reply
+ * @throws NetconfException if the reply contains one or more
+ * {@code <rpc-error>} elements
+ * @throws SAXException if the reply cannot be parsed as XML
+ * @throws IOException on transport errors
*/
public XML executeRPC(String rpcContent) throws SAXException, IOException {
String rpcReply = getRpcReply(fixupRpc(rpcContent));
setLastRpcReply(rpcReply);
+
+ if (hasError()) {
+ throw new NetconfException("RPC returned error: " + rpcReply);
+ }
return convertToXML(rpcReply);
}
/**
- * Send an RPC(as XML object) over the Netconf session and get the response
+ * Send an RPC (as XML object) over the Netconf session and get the response
* as an XML object.
- *
*
* @param rpc RPC to be sent. Use the XMLBuilder to create RPC as an
* XML object.
@@ -399,9 +445,8 @@ public XML executeRPC(XML rpc) throws SAXException, IOException {
}
/**
- * Send an RPC(as Document object) over the Netconf session and get the
+ * Send an RPC (as Document object) over the Netconf session and get the
* response as an XML object.
- *
*
* @param rpcDoc RPC content to be sent, as a org.w3c.dom.Document object.
* @return RPC reply sent by Netconf server
@@ -417,14 +462,14 @@ public XML executeRPC(Document rpcDoc) throws SAXException, IOException {
/**
* Given an RPC command, wrap it in RPC tags.
- * https://tools.ietf.org/html/rfc6241#section-4.1
+ * ...
*
* @param rpcContent an RPC command that may or may not be wrapped in < or >
* @return a string of the RPC command wrapped in <rpc>< ></rpc>
* @throws IllegalArgumentException if null is passed in as the rpcContent.
*/
@VisibleForTesting
- static String fixupRpc(@NonNull String rpcContent) throws IllegalArgumentException {
+ static String fixupRpc(String rpcContent) throws IllegalArgumentException {
if (rpcContent == null) {
throw new IllegalArgumentException("Null RPC");
}
@@ -440,9 +485,8 @@ static String fixupRpc(@NonNull String rpcContent) throws IllegalArgumentExcepti
/**
- * Send an RPC(as String object) over the default Netconf session and get
+ * Send an RPC (as String object) over the default Netconf session and get
* the response as a BufferedReader.
- *
*
* @param rpcContent RPC content to be sent. For example, to send an rpc
* <rpc><get-chassis-inventory/></rpc>, the
@@ -460,9 +504,8 @@ public BufferedReader executeRPCRunning(String rpcContent) throws IOException {
}
/**
- * Send an RPC(as XML object) over the Netconf session and get the response
+ * Send an RPC (as XML object) over the Netconf session and get the response
* as a BufferedReader.
- *
*
* @param rpc RPC to be sent. Use the XMLBuilder to create RPC as an
* XML object.
@@ -476,9 +519,8 @@ public BufferedReader executeRPCRunning(XML rpc) throws IOException {
}
/**
- * Send an RPC(as Document object) over the Netconf session and get the
+ * Send an RPC (as Document object) over the Netconf session and get the
* response as a BufferedReader.
- *
*
* @param rpcDoc RPC content to be sent, as a org.w3c.dom.Document object.
* @return RPC reply sent by Netconf server as a BufferedReader. This is
@@ -520,10 +562,8 @@ public void close() throws IOException {
* Check if the last RPC reply returned from Netconf server has any error.
*
* @return true if any errors are found in last RPC reply.
- * @throws org.xml.sax.SAXException If the XML Reply cannot be parsed.
- * @throws java.io.IOException If there are issues communicating with the netconf server.
*/
- public boolean hasError() throws SAXException, IOException {
+ public boolean hasError() {
return lastRpcReplyObject.hasErrors();
}
@@ -531,10 +571,8 @@ public boolean hasError() throws SAXException, IOException {
* Check if the last RPC reply returned from Netconf server has any warning.
*
* @return true if any errors are found in last RPC reply.
- * @throws org.xml.sax.SAXException If the XML Reply cannot be parsed.
- * @throws java.io.IOException If there are issues communicating with the netconf server.
*/
- public boolean hasWarning() throws SAXException, IOException {
+ public boolean hasWarning() {
return lastRpcReplyObject.hasWarnings();
}
@@ -545,7 +583,7 @@ public boolean hasWarning() throws SAXException, IOException {
* @return true if <ok/> tag is found in last RPC reply.
*/
public boolean isOK() {
- return lastRpcReplyObject.isOk();
+ return lastRpcReplyObject.isOK();
}
/**
@@ -572,10 +610,9 @@ public boolean lockConfig() throws IOException, SAXException {
* Unlocks the candidate configuration.
*
* @return true if successful.
- * @throws org.xml.sax.SAXException If the XML Reply cannot be parsed.
* @throws java.io.IOException If there are issues communicating with the netconf server.
*/
- public boolean unlockConfig() throws IOException, SAXException {
+ public boolean unlockConfig() throws IOException {
String rpc = "" +
"" +
"" +
@@ -588,6 +625,35 @@ public boolean unlockConfig() throws IOException, SAXException {
return !hasError() && isOK();
}
+ /**
+ * Terminates another active NETCONF session on the server
+ * (RFC 6241 §7.9).
+ *
+ * The server will abort any operations in progress for that session,
+ * release resources and locks, and close the connection.
+ *
+ * @param sessionId the session identifier to terminate; must not be {@code null} or empty
+ * @return {@code true} if the operation succeeded (no <rpc-error> and an <ok/> was returned)
+ * @throws IllegalArgumentException if {@code sessionId} is {@code null} or empty
+ * @throws IOException if communication with the device fails
+ * @throws SAXException if the reply cannot be parsed
+ */
+ public boolean killSession(String sessionId) throws IOException, SAXException {
+ if (sessionId == null || sessionId.isEmpty()) {
+ throw new IllegalArgumentException("sessionId must not be null or empty");
+ }
+
+ String rpc = "" +
+ "" +
+ "" + sessionId + "" +
+ "" +
+ "" +
+ NetconfConstants.DEVICE_PROMPT;
+
+ setLastRpcReply(getRpcReply(rpc));
+ return !hasError() && isOK();
+ }
+
/**
* Loads the candidate configuration, Configuration should be in set
* format.
@@ -597,10 +663,9 @@ public boolean unlockConfig() throws IOException, SAXException {
* "set system services ftp"
* will load 'ftp' under the 'systems services' hierarchy.
* To load multiple set statements, separate them by '\n' character.
- * @throws org.xml.sax.SAXException If there are issues parsing the config file.
* @throws java.io.IOException If there are issues reading the config file.
*/
- public void loadSetConfiguration(String configuration) throws IOException, SAXException {
+ public void loadSetConfiguration(String configuration) throws IOException {
String rpc = "" +
"" +
"" +
@@ -650,7 +715,7 @@ private void validateLoadType(String loadType) throws IllegalArgumentException {
*/
private String readConfigFile(String configFile) throws IOException {
try {
- return new String(Files.readAllBytes(Paths.get(configFile)), Charset.defaultCharset().name());
+ return Files.readString(Paths.get(configFile), Charset.defaultCharset());
} catch (FileNotFoundException e) {
throw new FileNotFoundException("The system cannot find the configuration file specified: " + configFile);
}
@@ -679,11 +744,10 @@ public void loadTextFile(String configFile, String loadType) throws IOException,
*
* @param configFile Path name of file containing configuration,in set format,
* to be loaded.
- * @throws org.xml.sax.SAXException If there are issues parsing the config file.
* @throws java.io.IOException If there are issues reading the config file.
*/
public void loadSetFile(String configFile) throws
- IOException, SAXException {
+ IOException {
loadSetConfiguration(readConfigFile(configFile));
}
@@ -748,26 +812,79 @@ public void commit() throws IOException, SAXException {
}
/**
- * Commit the candidate configuration, temporarily. This is equivalent of
- * 'commit confirm'
+ * Sends a *confirmed* <commit> request that follows the original
+ * RFC 6241 §8.4 semantics (confirmed‑commit **1.0**).
+ *
+ * The server will roll back the candidate configuration unless a
+ * non‑confirmed <commit> is issued from **the same session**
+ * within the given timeout period.
+ *
*
- * @param seconds Time in seconds, after which the previous active configuration
- * is reverted back to.
- * @throws java.io.IOException If there are errors communicating with the netconf server.
- * @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
+ * @param seconds the <confirm-timeout> in seconds; if {@code seconds
+ * <= 0} the device’s default (600 s) is used
+ * @throws IOException if communication with the device fails
+ *
+ * @deprecated Prefer {@link #commitConfirm(long, String)} which adds
+ * the <persist> / <persist-id>
+ * parameters required by the
+ * :confirmed-commit:1.1 capability.
*/
- public void commitConfirm(long seconds) throws IOException, SAXException {
- String rpc = "" +
- "" +
- "" +
- "" + seconds + "" +
- "" +
- "" +
- NetconfConstants.DEVICE_PROMPT;
- setLastRpcReply(getRpcReply(rpc));
- if (hasError() || !isOK())
- throw new CommitException("Commit operation returned " +
- "error.");
+ @Deprecated
+ public void commitConfirm(long seconds) throws IOException {
+ commitConfirm(seconds, null);
+ }
+
+ /**
+ * Issues a confirmed commit as defined by the
+ *
+ * :confirmed-commit:1.1 capability.
+ *
+ * @param seconds confirm‑timeout (600 s default). If {@code seconds <= 0}
+ * the timeout element is omitted and the server default
+ * is used.
+ * @param persistToken optional token for cross‑session confirmation; if
+ * {@code null} the commit can only be confirmed from
+ * the same session.
+ * @throws IOException if communication with the device fails
+ */
+ public void commitConfirm(long seconds, String persistToken) throws IOException {
+ StringBuilder rpc = new StringBuilder();
+ rpc.append("");
+ if (seconds > 0) {
+ rpc.append("").append(seconds).append("");
+ }
+ if (persistToken != null) {
+ rpc.append("").append(persistToken).append("");
+ }
+ rpc.append("").append(NetconfConstants.DEVICE_PROMPT);
+
+ setLastRpcReply(getRpcReply(rpc.toString()));
+ if (hasError() || !isOK()) {
+ throw new CommitException("Confirmed-commit operation returned error.");
+ }
+ }
+
+ /**
+ * Cancels a pending confirmed commit.
+ *
+ * @param persistId optional token that matches the <persist> used
+ * in the original confirmed commit; may be {@code null}
+ * if no token was supplied.
+ * @return {@code true} if the server replied <ok/>
+ * @throws IOException if communication with the device fails
+ * @throws SAXException if the reply cannot be parsed
+ */
+ public boolean cancelCommit(String persistId) throws IOException, SAXException {
+ StringBuilder rpc = new StringBuilder();
+ rpc.append("");
+ if (persistId != null) {
+ rpc.insert(rpc.length() - "".length(),
+ "" + persistId + "");
+ }
+ rpc.append("").append(NetconfConstants.DEVICE_PROMPT);
+
+ setLastRpcReply(getRpcReply(rpc.toString()));
+ return !hasError() && isOK();
}
/**
@@ -775,9 +892,8 @@ public void commitConfirm(long seconds) throws IOException, SAXException {
*
* @throws net.juniper.netconf.CommitException if there is an error committing the config.
* @throws java.io.IOException If there are errors communicating with the netconf server.
- * @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
*/
- public void commitFull() throws CommitException, IOException, SAXException {
+ public void commitFull() throws CommitException, IOException {
String rpc = "" +
"" +
"" +
@@ -847,9 +963,8 @@ public XML getRunningConfig() throws SAXException, IOException {
*
* @return true if validation successful.
* @throws java.io.IOException If there are errors communicating with the netconf server.
- * @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
*/
- public boolean validate() throws IOException, SAXException {
+ public boolean validate() throws IOException {
String rpc = "" +
"" +
diff --git a/src/main/java/net/juniper/netconf/XML.java b/src/main/java/net/juniper/netconf/XML.java
index b77ec30..1db7974 100644
--- a/src/main/java/net/juniper/netconf/XML.java
+++ b/src/main/java/net/juniper/netconf/XML.java
@@ -7,6 +7,7 @@
*/
package net.juniper.netconf;
+import java.util.logging.Logger;
import com.google.common.base.Preconditions;
@@ -47,9 +48,22 @@
*/
public class XML {
+ private static final Logger logger = Logger.getLogger(XML.class.getName());
private final Element activeElement;
private final Document ownerDoc;
+ /**
+ * Creates a new {@code XML} wrapper with the supplied DOM {@link Element} as its
+ * initial active element.
+ *
+ * This constructor is intentionally protected; normal clients are
+ * expected to obtain {@code XML} instances via {@link XMLBuilder} rather than
+ * constructing them directly. Keeping the constructor non‑public ensures the
+ * internal DOM is manipulated through the provided fluent API.
+ *
+ * @param active the DOM element that will serve as the current active node;
+ * must not be {@code null}
+ */
protected XML(Element active) {
this.activeElement = active;
ownerDoc = active.getOwnerDocument();
@@ -65,12 +79,14 @@ private String trim(String str) {
return st;
}
+
+
/**
* Get the owner Document for the XML object.
* @return The org.w3c.dom.Document object for the XML
*/
public Document getOwnerDocument() {
- return this.ownerDoc;
+ return (Document) ownerDoc.cloneNode(true); // deep copy
}
/**
@@ -212,15 +228,21 @@ public void addSiblings(String element, String[] text) {
* text value as the key value.
*/
public void addSiblings(Map map) {
- List keyList = new ArrayList<>(map.keySet());
- activeElement.getParentNode();
- for (Object element : keyList) {
- String elementName = (String) element;
- String text = map.get(element);
- Element newElement = ownerDoc.createElement(elementName);
- Node textNode = ownerDoc.createTextNode(text);
- newElement.appendChild(textNode);
- activeElement.appendChild(newElement); }
+ if (map == null || map.isEmpty()) {
+ return;
+ }
+
+ Node parent = activeElement.getParentNode(); // get the parent once
+ if (parent == null) {
+ throw new IllegalStateException(
+ "Cannot add siblings: active element has no parent");
+ }
+
+ for (Map.Entry entry : map.entrySet()) {
+ Element e = ownerDoc.createElement(entry.getKey());
+ e.appendChild(ownerDoc.createTextNode(entry.getValue()));
+ parent.appendChild(e); // add to the *parent*
+ }
}
/**
@@ -274,6 +296,15 @@ public void setText(String text) {
}
}
+ /**
+ * Sets the text content of the active XML element.
+ *
+ * @param text The text content to set on the current active element.
+ */
+ public void setTextContent(String text) {
+ activeElement.setTextContent(text);
+ }
+
/**
* Sets the attribute ("delete","delete") for the active element of XML
* object.
@@ -321,86 +352,89 @@ public void junosInsert(String before, String name) {
}
/**
- * Find the text value of an element.
+ * Finds the text value of an element in the XML hierarchy specified by the list.
+ *
* @param list
- * The String based list of elements which determine the hierarchy.
- * For example, for the below XML:
- * <rpc-reply>
- * <environment-information>
+ * A list of strings representing the XML path. Each entry corresponds to an XML tag.
+ * To apply a text filter, use "tag~text" syntax (e.g., "name~FPC 0 CPU").
+ * For example, to extract <temperature> from:
+ * <rpc-reply>
+ * <environment-information>
* <environment-item>
- * <name>FPC 0 CPU</name>
- * <temperature>
- * To find out the text value of temperature node, the list
- * should be- {"environment-information","environment-item",
- * "name~FPC 0 CPU","temperature"}
- * @return The text value of the element.
+ * <name>FPC 0 CPU</name>
+ * <temperature>55</temperature>
+ * the list should be:
+ * {"environment-information", "environment-item", "name~FPC 0 CPU", "temperature"}
+ *
+ * @return The trimmed text value of the target element,
+ * or {@code null} if the path is invalid, the list is empty/null,
+ * or if the element has no text content.
*/
public String findValue(List list) {
+ if (list == null || list.isEmpty()) {
+ return null;
+ }
+
Element nextElement = ownerDoc.getDocumentElement();
boolean nextElementFound;
for (int k=0; k
- * ge-1/0/0
- *
- * ....
- * In this case, the list passed to findValue function
- * should contain (..,"physical-interface","name~ge-1/0/0",
- * "logical-interface",..)
- * This will fetch me the required element.
+ if (!nextElementName.contains("~")) {
+ NodeList nextElementList =
+ nextElement != null
+ ? nextElement.getElementsByTagName(nextElementName)
+ : null;
+ if (nextElementList == null || nextElementList.getLength() == 0) {
+ logger.fine("Element '" + nextElementName + "' not found in findValue()");
+ return null;
+ }
+
+ /* If the next‑to‑next (n2n) element is a filter based on
+ * text value, then do the required filtering.
+ */
+ String n2nString = null;
+ if (k findNodes(List list) {
nextElementFound = false;
String nextElementName = list.get(k);
if (!nextElementName.contains("~")) {
- try {
- NodeList nextElementList = nextElement.
- getElementsByTagName(nextElementName);
- /* If the next to next(n2n) element is a filter based on
- * text value,
- * then do the required filtering.
- * For example,
- * ....
- *
- * ge-1/0/0
- *
- * ....
- * In this case, the list passed to findValue function
- * should contain (..,"physical-interface","name~ge-1/0/0",
- * "logical-interface",..)
- * This will fetch me the required element.
+ NodeList nextElementList =
+ nextElement != null
+ ? nextElement.getElementsByTagName(nextElementName)
+ : null;
+ if (nextElementList == null || nextElementList.getLength() == 0) {
+ logger.fine("Element '" + nextElementName + "' not found in findNodes()");
+ return null;
+ }
+ /* If the next to next(n2n) element is a filter based on
+ * text value,
+ * then do the required filtering.
+ * For example,
+ * ....
+ *
+ * ge-1/0/0
+ *
+ * ....
+ * In this case, the list passed to findValue function
+ * should contain (..,"physical-interface","name~ge-1/0/0",
+ * "logical-interface",..)
+ * This will fetch me the required element.
+ */
+ String n2nString = null;
+ if (kXMLBuilder is used to create an XML object.This is useful to
+ * An XMLBuilder is used to create an XML object.This is useful to
* create XML RPC's and configurations.
*
* As an example, one
@@ -26,25 +27,42 @@
*
*/
public class XMLBuilder {
-
+
private DOMImplementation impl;
private DocumentBuilder builder;
-
+ /** Monotonic counter used to populate the mandatory {@code message-id} attribute on elements. */
+ private static final AtomicLong MSG_ID_GEN = new AtomicLong(1);
+ /**
+ * Creates an empty <rpc> root element with the NETCONF base namespace
+ * and an auto‑incremented {@code message-id} attribute as required by
+ * RFC 6241 §4.1.
+ */
+ private Document createRpcRoot() {
+ // Create in the NETCONF base namespace so the xmlns attribute is correct
+ Document doc = impl.createDocument(
+ "urn:ietf:params:xml:ns:netconf:base:1.0",
+ "rpc",
+ null);
+ Element root = doc.getDocumentElement();
+ root.setAttribute("message-id", String.valueOf(MSG_ID_GEN.getAndIncrement()));
+ return doc;
+ }
+
/**
* Prepares a new <code><XMLBuilder</code> object.
* @throws ParserConfigurationException if there are issues parsing the configuration.
*/
public XMLBuilder() throws ParserConfigurationException {
- DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance() ;
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance() ;
builder = factory.newDocumentBuilder() ;
impl = builder.getDOMImplementation();
}
-
+
/**
* Create a new configuration(single-level hierarchy) as an XML object.
- *
+ *
Convenience method for creating a single‑level configuration.
* @param elementLevelOne
- * The element at the top-most hierarchy. For example, to create a
+ * The element at the top-most hierarchy. For example, to create a
* configuration,
* "<configuration><system/></configuration>" the
* String to be passed is "system".
@@ -57,12 +75,12 @@ public XML createNewConfig(String elementLevelOne) {
rootElement.appendChild(elementOne);
return new XML(elementOne);
}
-
+
/**
* Create a new configuration(two-level hierarchy) as an XML object.
- *
+ *
Creates a configuration with two nested hierarchy levels.
* @param elementLevelOne
- * The element at the top-most hierarchy.
+ * The element at the top-most hierarchy.
* @param elementLevelTwo
* The element at level-2 hierarchy.
* @return XML object.
@@ -79,9 +97,9 @@ public XML createNewConfig(String elementLevelOne, String elementLevelTwo) {
/**
* Create a new configuration(three-level hierarchy) as an XML object.
- *
+ *
Creates a configuration with three nested hierarchy levels.
* @param elementLevelOne
- * The element at the top-most hierarchy.
+ * The element at the top-most hierarchy.
* @param elementLevelTwo
* The element at level-2 hierarchy.
* @param elementLevelThree
@@ -100,12 +118,12 @@ public XML createNewConfig(String elementLevelOne, String elementLevelTwo,
rootElement.appendChild(elementOne);
return new XML(elementThree);
}
-
+
/**
* Create a new configuration(four-level hierarchy) as an XML object.
- *
+ *
Creates a configuration with four nested hierarchy levels.
* @param elementLevelOne
- * The element at the top-most hierarchy.
+ * The element at the top-most hierarchy.
* @param elementLevelTwo
* The element at level-2 hierarchy.
* @param elementLevelThree
@@ -128,12 +146,12 @@ public XML createNewConfig(String elementLevelOne, String elementLevelTwo,
rootElement.appendChild(elementOne);
return new XML(elementFour);
}
-
+
/**
* Create a new configuration as an XML object.
- *
+ *
Creates a configuration with a variable-depth hierarchy defined by the supplied list.
* @param elementList
- * The list of elements to be included in the XML. For example,
+ * The list of elements to be included in the XML. For example,
* to create a configuration,
* "<configuration><system><services><ftp/>
* </services></system></configuration>" the
@@ -141,7 +159,7 @@ public XML createNewConfig(String elementLevelOne, String elementLevelTwo,
* @return XML object.
*/
public XML createNewConfig(List elementList) {
- if (elementList.size() == 0)
+ if (elementList.isEmpty())
return null;
Document doc = impl.createDocument(null, "configuration", null);
Element rootElement = doc.getDocumentElement();
@@ -156,33 +174,33 @@ public XML createNewConfig(List elementList) {
rootElement.appendChild(last);
return new XML(elementLevelLast);
}
-
+
/**
- * Create a new RPC(single-level hierarchy) as an XML object.
- *
+ *
Convenience method for a single‑level RPC. The builder automatically sets the mandatory {@code message-id} attribute and the NETCONF base namespace.
* @param elementLevelOne
- * The element at the top-most hierarchy.
+ * The element at the top-most hierarchy.
* @return XML object.
*/
public XML createNewRPC(String elementLevelOne) {
- Document doc = impl.createDocument(null, "rpc", null);
+ Document doc = createRpcRoot();
Element rootElement = doc.getDocumentElement();
Element elementOne = doc.createElement(elementLevelOne);
rootElement.appendChild(elementOne);
return new XML(elementOne);
}
-
+
/**
* Create a new RPC(two-level hierarchy) as an XML object.
- *
+ *
Creates an RPC with two nested hierarchy levels.
+ * The {@code message-id} attribute and base namespace are filled in automatically.
* @param elementLevelOne
- * The element at the top-most hierarchy.
+ * The element at the top-most hierarchy.
* @param elementLevelTwo
* The element at level-2 hierarchy.
* @return XML object.
*/
public XML createNewRPC(String elementLevelOne, String elementLevelTwo) {
- Document doc = impl.createDocument(null, "rpc", null);
+ Document doc = createRpcRoot();
Element rootElement = doc.getDocumentElement();
Element elementOne = doc.createElement(elementLevelOne);
Element elementTwo = doc.createElement(elementLevelTwo);
@@ -190,12 +208,13 @@ public XML createNewRPC(String elementLevelOne, String elementLevelTwo) {
rootElement.appendChild(elementOne);
return new XML(elementTwo);
}
-
+
/**
* Create a new RPC(three-level hierarchy) as an XML object.
- *
+ *
Creates an RPC with three nested hierarchy levels.
+ * The {@code message-id} attribute and base namespace are filled in automatically.
* @param elementLevelOne
- * The element at the top-most hierarchy.
+ * The element at the top-most hierarchy.
* @param elementLevelTwo
* The element at level-2 hierarchy.
* @param elementLevelThree
@@ -204,7 +223,7 @@ public XML createNewRPC(String elementLevelOne, String elementLevelTwo) {
*/
public XML createNewRPC(String elementLevelOne, String elementLevelTwo,
String elementLevelThree) {
- Document doc = impl.createDocument(null, "rpc", null);
+ Document doc = createRpcRoot();
Element rootElement = doc.getDocumentElement();
Element elementOne = doc.createElement(elementLevelOne);
Element elementTwo = doc.createElement(elementLevelTwo);
@@ -214,12 +233,13 @@ public XML createNewRPC(String elementLevelOne, String elementLevelTwo,
rootElement.appendChild(elementOne);
return new XML(elementThree);
}
-
+
/**
* Create a new RPC(four-level hierarchy) as an XML object.
- *
+ *
Creates an RPC with four nested hierarchy levels.
+ * The {@code message-id} attribute and base namespace are filled in automatically.
* @param elementLevelOne
- * The element at the top-most hierarchy.
+ * The element at the top-most hierarchy.
* @param elementLevelTwo
* The element at level-2 hierarchy.
* @param elementLevelThree
@@ -230,7 +250,7 @@ public XML createNewRPC(String elementLevelOne, String elementLevelTwo,
*/
public XML createNewRPC(String elementLevelOne, String elementLevelTwo,
String elementLevelThree, String elementLevelFour) {
- Document doc = impl.createDocument(null, "rpc", null);
+ Document doc = createRpcRoot();
Element rootElement = doc.getDocumentElement();
Element elementOne = doc.createElement(elementLevelOne);
Element elementTwo = doc.createElement(elementLevelTwo);
@@ -242,10 +262,11 @@ public XML createNewRPC(String elementLevelOne, String elementLevelTwo,
rootElement.appendChild(elementOne);
return new XML(elementFour);
}
-
+
/**
* Create a new RPC as an XML object.
- *
+ *
Creates an RPC with hierarchy defined by the supplied element list.
+ * The {@code message-id} attribute and base namespace are filled in automatically.
* @param elementList
* The list of elements to be included in the XML. For example, the
* list {"get-interface-information","terse"} will create the RPC-
@@ -254,9 +275,9 @@ public XML createNewRPC(String elementLevelOne, String elementLevelTwo,
* @return XML object.
*/
public XML createNewRPC(List elementList) {
- if (elementList.size() == 0)
+ if (elementList.isEmpty())
return null;
- Document doc = impl.createDocument(null, "rpc", null);
+ Document doc = createRpcRoot();
Element rootElement = doc.getDocumentElement();
Element elementLevelLast = doc.createElement(elementList.
get(elementList.size()-1));
@@ -269,12 +290,12 @@ public XML createNewRPC(List elementList) {
rootElement.appendChild(last);
return new XML(elementLevelLast);
}
-
+
/**
* Create a new xml(one-level hierarchy) as an XML object.
- *
+ *
Convenience method for a single‑level XML document.
* @param elementLevelOne
- * The element at the top-most hierarchy.
+ * The element at the top-most hierarchy.
* @return XML object.
*/
public XML createNewXML(String elementLevelOne) {
@@ -282,12 +303,12 @@ public XML createNewXML(String elementLevelOne) {
Element rootElement = doc.getDocumentElement();
return new XML(rootElement);
}
-
+
/**
* Create a new xml(two-level hierarchy) as an XML object.
- *
+ *
Creates an XML document with two nested hierarchy levels.
* @param elementLevelOne
- * The element at the top-most hierarchy.
+ * The element at the top-most hierarchy.
* @param elementLevelTwo
* The element at level-2 hierarchy.
* @return XML object.
@@ -299,12 +320,12 @@ public XML createNewXML(String elementLevelOne, String elementLevelTwo) {
rootElement.appendChild(elementTwo);
return new XML(elementTwo);
}
-
+
/**
* Create a new xml(three-level hierarchy) as an XML object.
- *
+ *
Creates an XML document with three nested hierarchy levels.
* @param elementLevelOne
- * The element at the top-most hierarchy.
+ * The element at the top-most hierarchy.
* @param elementLevelTwo
* The element at level-2 hierarchy.
* @param elementLevelThree
@@ -321,12 +342,12 @@ public XML createNewXML(String elementLevelOne, String elementLevelTwo,
rootElement.appendChild(elementTwo);
return new XML(elementThree);
}
-
+
/**
* Create a new xml(four-level hierarchy) as an XML object.
- *
+ *
Creates an XML document with four nested hierarchy levels.
* @param elementLevelOne
- * The element at the top-most hierarchy.
+ * The element at the top-most hierarchy.
* @param elementLevelTwo
* The element at level-2 hierarchy.
* @param elementLevelThree
@@ -347,10 +368,10 @@ public XML createNewXML(String elementLevelOne, String elementLevelTwo,
rootElement.appendChild(elementTwo);
return new XML(elementFour);
}
-
+
/**
* Create a new xml as an XML object.
- *
+ *
Creates an XML document with hierarchy defined by the supplied element list.
* @param elementList
* The list of elements to be included in the XML. For example, the
* list {"firstElement","secondElement"} will create the xml-
@@ -358,7 +379,7 @@ public XML createNewXML(String elementLevelOne, String elementLevelTwo,
* @return XML object.
*/
public XML createNewXML(List elementList) {
- if (elementList.size() == 0)
+ if (elementList.isEmpty())
return null;
String elementLevelOne = elementList.get(0);
Document doc = impl.createDocument(null, elementLevelOne, null);
diff --git a/src/main/java/net/juniper/netconf/element/AbstractNetconfElement.java b/src/main/java/net/juniper/netconf/element/AbstractNetconfElement.java
index 0148a37..51f7084 100644
--- a/src/main/java/net/juniper/netconf/element/AbstractNetconfElement.java
+++ b/src/main/java/net/juniper/netconf/element/AbstractNetconfElement.java
@@ -1,9 +1,5 @@
package net.juniper.netconf.element;
-import lombok.EqualsAndHashCode;
-import lombok.ToString;
-import lombok.Value;
-import lombok.experimental.NonFinal;
import net.juniper.netconf.NetconfConstants;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
@@ -20,22 +16,67 @@
import static java.lang.String.format;
-@Value
-@NonFinal
+/**
+ * Base class for all model objects that represent NETCONF XML fragments
+ * (e.g. {@code }, {@code }).
+ *
+ * Each subclass wraps a {@link org.w3c.dom.Document} so that:
+ *
+ *
The DOM is immutable from the caller’s perspective—
+ * getters return defensive copies.
+ *
An on‑demand, pre‑rendered XML {@link String} is cached for fast
+ * {@link #equals(Object)}, {@link #hashCode()}, and logging.
+ *
+ * Common XML helper methods live here so builders and parsers can share a
+ * single, RFC 6241‑aware implementation.
+ *
+ * @author Juniper Networks
+ */
public abstract class AbstractNetconfElement {
- @ToString.Exclude
- @EqualsAndHashCode.Exclude
- Document document;
-
- @ToString.Exclude
- String xml;
+ private final Document document;
+ private final String xml;
+ /**
+ * Wraps the supplied DOM {@link Document} and pre‑computes its XML string
+ * representation for fast equality checks and logging.
+ *
+ * @param document a fully‑formed NETCONF XML document; must not be {@code null}
+ * @throws NullPointerException if {@code document} is {@code null}
+ */
protected AbstractNetconfElement(final Document document) {
this.document = document;
this.xml = createXml(document);
}
+ /**
+ * Returns a defensive deep copy of the underlying DOM
+ * {@link Document} so callers cannot mutate the internal state.
+ *
+ * @return a cloned {@link Document} representing this element
+ */
+ public Document getDocument() {
+ return (Document) document.cloneNode(true); // deep copy
+ }
+
+ /**
+ * Returns the cached XML string representation of the wrapped document.
+ *
+ * @return XML string with no declaration (UTF‑8 assumed)
+ */
+ public String getXml() {
+ return xml;
+ }
+
+ /**
+ * Creates an empty, namespace‑aware DOM {@link Document}.
+ *
+ * Internally delegates to {@link #createDocumentBuilderFactory()} to ensure
+ * all security features are applied consistently.
+ *
+ * @return a brand‑new, empty {@link Document}
+ * @throws IllegalStateException if the platform’s XML parser cannot be configured
+ */
protected static Document createBlankDocument() {
try {
return createDocumentBuilderFactory().newDocumentBuilder().newDocument();
@@ -44,12 +85,30 @@ protected static Document createBlankDocument() {
}
}
+ /**
+ * Returns a pre‑configured {@link DocumentBuilderFactory} with
+ * namespace awareness enabled. Additional hardening options
+ * (e.g. disallowing DTDs) can be added here centrally so every
+ * NETCONF element parser benefits.
+ *
+ * @return a namespace‑aware {@link DocumentBuilderFactory}
+ */
protected static DocumentBuilderFactory createDocumentBuilderFactory() {
final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
documentBuilderFactory.setNamespaceAware(true);
return documentBuilderFactory;
}
+ /**
+ * Serialises a DOM {@link Document} to its XML string representation.
+ *
+ * The XML declaration is omitted because NETCONF frames are always UTF‑8
+ * and the declaration is not required on the wire.
+ *
+ * @param document the document to serialise; must not be {@code null}
+ * @return XML string (no declaration)
+ * @throws IllegalStateException if a {@link TransformerException} occurs
+ */
protected static String createXml(final Document document) {
try {
final TransformerFactory transformerFactory = TransformerFactory.newInstance();
@@ -63,10 +122,28 @@ protected static String createXml(final Document document) {
}
}
+ /**
+ * Convenience helper that builds an XPath expression matching a NETCONF
+ * element in the base 1.0 namespace with the specified local‑name.
+ *
+ * @param elementName local name (e.g. {@code "rpc-reply"})
+ * @return an XPath string scoped to the NETCONF base namespace
+ */
protected static String getXpathFor(final String elementName) {
return format("/*[namespace-uri()='urn:ietf:params:xml:ns:netconf:base:1.0' and local-name()='%s']", elementName);
}
+ /**
+ * Appends a child element (with optional text content) to the given parent,
+ * using the NETCONF base 1.0 namespace and the provided prefix.
+ *
+ * @param document owner document
+ * @param parentElement element to which the new child is appended
+ * @param namespacePrefix namespace prefix to set on the new element
+ * @param elementName local name of the child element
+ * @param text text content; if {@code null} the element is skipped
+ * @return the newly created element, or {@code null} if {@code text} was {@code null}
+ */
protected static Element appendElementWithText(
final Document document,
final Element parentElement,
@@ -85,6 +162,14 @@ protected static Element appendElementWithText(
}
}
+ /**
+ * Safely retrieves an attribute value from the supplied DOM {@link Element}.
+ *
+ * @param element the element to query; may be {@code null}
+ * @param attributeName the local name of the attribute
+ * @return the attribute value if the element is non‑null and the attribute
+ * exists; otherwise {@code null}
+ */
protected static String getAttribute(final Element element, final String attributeName) {
if (element != null && element.hasAttribute(attributeName)) {
return element.getAttribute(attributeName);
@@ -93,6 +178,13 @@ protected static String getAttribute(final Element element, final String attribu
}
}
+ /**
+ * Returns the trimmed text content of a DOM {@link Element}.
+ *
+ * @param element the element whose {@code getTextContent()} should be read;
+ * may be {@code null}
+ * @return trimmed text or {@code null} if the element is {@code null}
+ */
protected static String getTextContent(final Element element) {
if (element == null) {
return null;
@@ -101,7 +193,32 @@ protected static String getTextContent(final Element element) {
}
}
+ /**
+ * Convenience null‑safe {@link String#trim()} wrapper.
+ *
+ * @param string the input string; may be {@code null}
+ * @return a trimmed copy of {@code string}, or {@code null} if the input
+ * was {@code null}
+ */
protected static String trim(final String string) {
return string == null ? null : string.trim();
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ AbstractNetconfElement that = (AbstractNetconfElement) o;
+ return xml.equals(that.xml);
+ }
+
+ @Override
+ public int hashCode() {
+ return xml.hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "{}";
+ }
}
diff --git a/src/main/java/net/juniper/netconf/element/Datastore.java b/src/main/java/net/juniper/netconf/element/Datastore.java
index 3042d68..35a5746 100644
--- a/src/main/java/net/juniper/netconf/element/Datastore.java
+++ b/src/main/java/net/juniper/netconf/element/Datastore.java
@@ -6,13 +6,60 @@
* Datastore
*
* As defined by RFC-8342.
- * See https://datatracker.ietf.org/doc/html/rfc8342#section-5
+ * See ...
*/
public enum Datastore {
- RUNNING, CANDIDATE, STARTUP, INTENDED, OPERATIONAL;
+ /**
+ * The running configuration datastore as defined by RFC-8342.
+ */
+ RUNNING("running"),
+ /**
+ * The candidate configuration datastore as defined by RFC-8342.
+ */
+ CANDIDATE("candidate"),
+ /**
+ * The startup configuration datastore as defined by RFC-8342.
+ */
+ STARTUP("startup"),
+ /**
+ * The intended configuration datastore as defined by RFC-8342.
+ */
+ INTENDED("intended"),
+ /**
+ * The operational state datastore as defined by RFC-8342.
+ */
+ OPERATIONAL("operational");
+ private final String xmlName;
+
+ Datastore(String xmlName) {
+ this.xmlName = xmlName.toLowerCase(Locale.US);
+ }
+
+ /**
+ * Returns the XML name (lowercase) for this datastore.
+ */
@Override
public String toString() {
- return this.name().toLowerCase(Locale.US);
+ return xmlName;
+ }
+
+ /**
+ * Returns the Datastore enum constant corresponding to the given XML name (case-insensitive).
+ * @param name the XML name to lookup
+ * @return the Datastore enum constant
+ * @throws IllegalArgumentException if no matching constant exists
+ */
+ public static Datastore fromXmlName(String name) {
+ if (name == null) {
+ throw new IllegalArgumentException("Datastore XML name cannot be null");
+ }
+ String nameLc = name.toLowerCase(Locale.US);
+ for (Datastore ds : values()) {
+ if (ds.xmlName.equals(nameLc)) {
+ return ds;
+ }
+ }
+ throw new IllegalArgumentException("Unknown Datastore XML name: " + name);
}
}
diff --git a/src/main/java/net/juniper/netconf/element/Hello.java b/src/main/java/net/juniper/netconf/element/Hello.java
index 9395d95..2bf8927 100644
--- a/src/main/java/net/juniper/netconf/element/Hello.java
+++ b/src/main/java/net/juniper/netconf/element/Hello.java
@@ -1,11 +1,5 @@
package net.juniper.netconf.element;
-import lombok.Builder;
-import lombok.EqualsAndHashCode;
-import lombok.Singular;
-import lombok.ToString;
-import lombok.Value;
-import lombok.extern.slf4j.Slf4j;
import net.juniper.netconf.NetconfConstants;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
@@ -21,29 +15,188 @@
import javax.xml.xpath.XPathFactory;
import java.io.IOException;
import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
+import java.util.Objects;
+import java.util.logging.Logger;
/**
- * Class to represent a NETCONF hello element - https://datatracker.ietf.org/doc/html/rfc6241#section-8.1
+ * Class to represent a NETCONF hello element - ...
*/
-@Slf4j
-@Value
-@ToString(callSuper = true)
-@EqualsAndHashCode(callSuper = true)
public class Hello extends AbstractNetconfElement {
+ private static final Logger log = Logger.getLogger(Hello.class.getName());
+
+ /**
+ * Validates that the supplied string is a syntactically correct URI.
+ *
+ * @param uri the capability string
+ * @throws IllegalArgumentException if the string is not a valid URI
+ */
+ private static void assertValidUri(String uri) {
+ try {
+ new java.net.URI(uri);
+ } catch (java.net.URISyntaxException e) {
+ throw new IllegalArgumentException("Capability MUST be a valid URI per RFC 3986: " + uri, e);
+ }
+ }
+
private static final String XPATH_HELLO = getXpathFor("hello");
private static final String XPATH_HELLO_SESSION_ID = XPATH_HELLO + getXpathFor("session-id");
private static final String XPATH_HELLO_CAPABILITIES = XPATH_HELLO + getXpathFor("capabilities");
private static final String XPATH_HELLO_CAPABILITIES_CAPABILITY = XPATH_HELLO_CAPABILITIES + getXpathFor("capability");
- String sessionId;
+ private final String sessionId;
+ private final List capabilities;
+
+ /**
+ * Constructs an immutable {@code Hello} element.
+ *
+ * @param namespacePrefix optional XML namespace prefix, may be {@code null}
+ * @param originalDocument original DOM document to wrap or {@code null} to build a new one
+ * @param sessionId session-id presented by the NETCONF peer (may be {@code null} for client hello)
+ * @param capabilities list of capability URIs; a defensive copy is taken
+ */
+ public Hello(final String namespacePrefix, final Document originalDocument, final String sessionId, final List capabilities) {
+ super(getDocument(originalDocument, namespacePrefix, sessionId, capabilities));
+ this.sessionId = sessionId;
+ this.capabilities = capabilities != null ? List.copyOf(capabilities) : Collections.emptyList();
+ }
+
+ /**
+ * Returns the NETCONF session-id conveyed in this <hello>.
+ *
+ * @return session-id or {@code null} if absent
+ */
+ public String getSessionId() {
+ return sessionId;
+ }
- @Singular("capability")
- List capabilities;
+ /**
+ * Returns an immutable copy of the capability URIs advertised in this hello.
+ *
+ * @return list of capability strings (never {@code null})
+ */
+ public List getCapabilities() {
+ return new ArrayList<>(capabilities);
+ }
+ /**
+ * Checks whether the given capability is present in this hello.
+ *
+ * @param capability capability URI to test
+ * @return {@code true} if the capability list contains the URI
+ */
public boolean hasCapability(final String capability) {
- return capabilities.contains(capability);
+ return capability != null && capabilities.contains(capability);
+ }
+
+ /**
+ * Returns a new {@link Builder} for programmatically constructing a {@code Hello}.
+ *
+ * @return fresh {@link Builder}
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Fluent builder for creating {@link Hello} objects.
+ */
+ public static class Builder {
+ /**
+ * Creates an empty {@code Builder}.
+ */
+ public Builder() {
+ }
+ private String sessionId;
+ private List capabilities = new java.util.ArrayList<>();
+ private String namespacePrefix;
+
+ /**
+ * Sets the {@code session-id} to embed in the hello.
+ *
+ * @param sessionId session identifier
+ * @return this {@code Builder}
+ */
+ public Builder sessionId(String sessionId) {
+ this.sessionId = sessionId;
+ return this;
+ }
+
+ /**
+ * Replaces the entire capability list with the supplied collection.
+ *
+ * A defensive copy is made, so subsequent modifications to the
+ * caller‑supplied {@code List} will not affect the builder.
+ * Each capability URI is validated against RFC 3986; an
+ * {@link IllegalArgumentException} is thrown if any entry is malformed.
+ *
+ * @param capabilities list of capability URIs, or {@code null} to clear
+ * the current list
+ * @return this {@code Builder} for fluent chaining
+ * @throws IllegalArgumentException if any capability is not a valid URI
+ */
+ public Builder capabilities(List capabilities) {
+ if (capabilities != null) {
+ for (String cap : capabilities) {
+ assertValidUri(cap);
+ }
+ this.capabilities = new java.util.ArrayList<>(capabilities); // defensive copy
+ }
+ return this;
+ }
+
+ /**
+ * Adds a single capability URI to the list.
+ *
+ * @param capability capability URI to add; ignored if {@code null}
+ * @return this {@code Builder}
+ */
+ public Builder capability(String capability) {
+ if (capability != null) {
+ assertValidUri(capability);
+ this.capabilities.add(capability);
+ }
+ return this;
+ }
+
+ /**
+ * Sets the XML namespace prefix to use when generating elements.
+ *
+ * @param namespacePrefix prefix string, e.g., "nc"
+ * @return this {@code Builder}
+ */
+ public Builder namespacePrefix(String namespacePrefix) {
+ this.namespacePrefix = namespacePrefix;
+ return this;
+ }
+
+ /**
+ * Builds a new immutable {@link Hello} instance using the configured values.
+ *
+ * @return constructed {@link Hello}
+ */
+ public Hello build() {
+ // RFC 6241 § 8.1 — each peer MUST advertise at least the base 1.1 capability
+ final String BASE_11 = "urn:ietf:params:netconf:base:1.1";
+
+ List caps = capabilities == null
+ ? new java.util.ArrayList<>()
+ : new java.util.ArrayList<>(capabilities);
+
+ if (!caps.contains(BASE_11)) {
+ caps.add(BASE_11);
+ }
+
+ return new Hello(
+ namespacePrefix, // prefix (may be null)
+ null, // originalDocument (none when building)
+ sessionId,
+ caps
+ );
+ }
}
/**
@@ -59,44 +212,39 @@ public boolean hasCapability(final String capability) {
public static Hello from(final String xml)
throws ParserConfigurationException, IOException, SAXException, XPathExpressionException {
+ // RFC 6241 §3.2: Reject XML that contains a DOCTYPE declaration
+ if (xml.contains(" capabilities = new ArrayList<>();
+ for (int i = 0; i < capabilitiesNodes.getLength(); i++) {
+ final Node node = capabilitiesNodes.item(i);
+ final String capability = node.getTextContent();
+ assertValidUri(capability);
+ capabilities.add(capability);
}
- final Hello hello = builder.build();
- log.info("hello is: {}", hello.getXml());
+ final Hello hello = new Hello(
+ null, // namespacePrefix
+ document, // originalDocument
+ sessionId,
+ capabilities
+ );
+ log.info("hello is: " + hello.getXml());
return hello;
}
- @Builder
- private Hello(
- final Document originalDocument,
- final String namespacePrefix,
- final String sessionId,
- @Singular("capability") final List capabilities) {
- super(getDocument(originalDocument, namespacePrefix, sessionId, capabilities));
- this.sessionId = sessionId;
- this.capabilities = capabilities;
- }
-
private static Document getDocument(
final Document originalDocument,
final String namespacePrefix,
final String sessionId,
final List capabilities) {
- if (originalDocument != null) {
- return originalDocument;
- } else {
- return createDocument(namespacePrefix, sessionId, capabilities);
- }
+ return Objects.requireNonNullElseGet(originalDocument, () -> createDocument(namespacePrefix, sessionId, capabilities));
}
private static Document createDocument(
@@ -114,13 +262,15 @@ private static Document createDocument(
final Element capabilitiesElement
= createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "capabilities");
capabilitiesElement.setPrefix(namespacePrefix);
- capabilities.forEach(capability -> {
- final Element capabilityElement =
- createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "capability");
- capabilityElement.setTextContent(capability);
- capabilityElement.setPrefix(namespacePrefix);
- capabilitiesElement.appendChild(capabilityElement);
- });
+ if (capabilities != null) {
+ for (String capability : capabilities) {
+ final Element capabilityElement =
+ createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "capability");
+ capabilityElement.setTextContent(capability);
+ capabilityElement.setPrefix(namespacePrefix);
+ capabilitiesElement.appendChild(capabilityElement);
+ }
+ }
helloElement.appendChild(capabilitiesElement);
if (sessionId != null) {
@@ -133,4 +283,25 @@ private static Document createDocument(
return createdDocument;
}
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof Hello hello)) return false;
+ if (!super.equals(o)) return false;
+ return Objects.equals(sessionId, hello.sessionId) &&
+ Objects.equals(capabilities, hello.capabilities);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(super.hashCode(), sessionId, capabilities);
+ }
+
+ @Override
+ public String toString() {
+ return "Hello{" +
+ "sessionId='" + sessionId + '\'' +
+ ", capabilities=" + capabilities +
+ "} " + super.toString();
+ }
}
diff --git a/src/main/java/net/juniper/netconf/element/RpcError.java b/src/main/java/net/juniper/netconf/element/RpcError.java
index 81f9ebf..c649fbd 100644
--- a/src/main/java/net/juniper/netconf/element/RpcError.java
+++ b/src/main/java/net/juniper/netconf/element/RpcError.java
@@ -1,34 +1,85 @@
package net.juniper.netconf.element;
-import lombok.AccessLevel;
-import lombok.Builder;
-import lombok.Getter;
-import lombok.RequiredArgsConstructor;
-import lombok.Value;
+import java.util.Objects;
/**
- * Class to represent a NETCONF rpc-error element - https://datatracker.ietf.org/doc/html/rfc6241#section-4.3
+ * Represents a NETCONF RPC error with structured details as per RFC standards.
+ *
+ * @param errorType The type of error (e.g., transport, rpc, protocol, application).
+ * @param errorTag The error tag indicating the nature of the error (e.g., unknown-element, bad-attribute).
+ * @param errorSeverity The severity of the error (e.g., error or warning).
+ * @param errorPath The path to the node where the error occurred.
+ * @param errorMessage The human-readable message describing the error.
+ * @param errorMessageLanguage The language of the error message.
+ * @param errorInfo Additional structured error information.
*/
-@Value
-@Builder
-@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
-public class RpcError {
-
- ErrorType errorType;
- ErrorTag errorTag;
- ErrorSeverity errorSeverity;
- String errorPath;
- String errorMessage;
- String errorMessageLanguage;
- RpcErrorInfo errorInfo;
-
- @Getter
- @RequiredArgsConstructor
+public record RpcError(net.juniper.netconf.element.RpcError.ErrorType errorType,
+ net.juniper.netconf.element.RpcError.ErrorTag errorTag,
+ net.juniper.netconf.element.RpcError.ErrorSeverity errorSeverity, String errorPath,
+ String errorMessage, String errorMessageLanguage,
+ net.juniper.netconf.element.RpcError.RpcErrorInfo errorInfo) {
+
+ @Override
+ public String toString() {
+ return "RpcError{" +
+ "errorType=" + errorType +
+ ", errorTag=" + errorTag +
+ ", errorSeverity=" + errorSeverity +
+ ", errorPath='" + errorPath + '\'' +
+ ", errorMessage='" + errorMessage + '\'' +
+ ", errorMessageLanguage='" + errorMessageLanguage + '\'' +
+ ", errorInfo=" + errorInfo +
+ '}';
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof RpcError)) return false;
+ RpcError rpcError = (RpcError) o;
+ return errorType == rpcError.errorType &&
+ errorTag == rpcError.errorTag &&
+ errorSeverity == rpcError.errorSeverity &&
+ Objects.equals(errorPath, rpcError.errorPath) &&
+ Objects.equals(errorMessage, rpcError.errorMessage) &&
+ Objects.equals(errorMessageLanguage, rpcError.errorMessageLanguage) &&
+ Objects.equals(errorInfo, rpcError.errorInfo);
+ }
+
+ /**
+ * Enum representing the type of NETCONF error.
+ */
public enum ErrorType {
- TRANSPORT("transport"), RPC("rpc"), PROTOCOL("protocol"), APPLICATION("application");
+ /** Transport layer error. */
+ TRANSPORT("transport"),
+ /** Error occurred in the NETCONF RPC layer. */
+ RPC("rpc"),
+ /** Protocol layer error. */
+ PROTOCOL("protocol"),
+ /** Application layer error. */
+ APPLICATION("application");
private final String textContent;
+ ErrorType(String textContent) {
+ this.textContent = textContent;
+ }
+
+ /**
+ * Returns the string representation of the error type.
+ *
+ * @return the text content of the error type
+ */
+ public String getTextContent() {
+ return textContent;
+ }
+
+ /**
+ * Converts a string to the corresponding ErrorType enum.
+ *
+ * @param textContent the string representation of the error type
+ * @return the corresponding ErrorType, or null if no match is found
+ */
public static ErrorType from(final String textContent) {
for (final ErrorType errorType : ErrorType.values()) {
if (errorType.textContent.equals(textContent)) {
@@ -39,30 +90,68 @@ public static ErrorType from(final String textContent) {
}
}
- @Getter
- @RequiredArgsConstructor
+ /**
+ * Enum representing the tag categorizing the NETCONF error.
+ */
public enum ErrorTag {
+ /** The request or response references an in-use resource. */
IN_USE("in-use"),
+ /** A value in the request is not valid. */
INVALID_VALUE("invalid-value"),
+ /** The request is too large to be processed. */
TOO_BIG("too-big"),
+ /** A required attribute is missing from the request. */
MISSING_ATTRIBUTE("missing-attribute"),
+ /** An attribute value is not correct. */
BAD_ATTRIBUTE("bad-attribute"),
+ /** An unknown or unexpected attribute is present. */
UNKNOWN_ATTRIBUTE("unknown-attribute"),
+ /** A required element is missing from the request. */
MISSING_ELEMENT("missing-element"),
+ /** An element's value is invalid or unexpected. */
BAD_ELEMENT("bad-element"),
+ /** An unknown or unexpected element is present. */
UNKNOWN_ELEMENT("unknown-element"),
+ /** The specified namespace is not recognized. */
UNKNOWN_NAMESPACE("unknown-namespace"),
+ /** The request was denied due to insufficient access rights. */
ACCESS_DENIED("access-denied"),
+ /** The requested lock could not be obtained. */
LOCK_DENIED("lock-denied"),
+ /** The data item already exists and cannot be created again. */
DATA_EXISTS("data-exists"),
+ /** The requested data item does not exist. */
DATA_MISSING("data-missing"),
+ /** The operation is not supported by the server or resource. */
OPERATION_NOT_SUPPORTED("operation-not-supported"),
+ /** The operation failed for an unspecified reason. */
OPERATION_FAILED("operation-failed"),
+ /** The operation was only partially completed. */
PARTIAL_OPERATION("partial-operation"),
+ /** The message is not well-formed or violates syntax rules. */
MALFORMED_MESSAGE("malformed-message");
private final String textContent;
+ ErrorTag(String textContent) {
+ this.textContent = textContent;
+ }
+
+ /**
+ * Returns the string representation of the error tag.
+ *
+ * @return the text content of the error tag
+ */
+ public String getTextContent() {
+ return textContent;
+ }
+
+ /**
+ * Converts a string to the corresponding ErrorTag enum.
+ *
+ * @param textContent the string representation of the error tag
+ * @return the corresponding ErrorTag, or null if no match is found
+ */
public static ErrorTag from(final String textContent) {
for (final ErrorTag errorTag : ErrorTag.values()) {
if (errorTag.textContent.equals(textContent)) {
@@ -73,13 +162,36 @@ public static ErrorTag from(final String textContent) {
}
}
- @Getter
- @RequiredArgsConstructor
+ /**
+ * Enum representing the severity level of the NETCONF error.
+ */
public enum ErrorSeverity {
- ERROR("error"), WARNING("warning");
+ /** An error that causes the operation to fail. */
+ ERROR("error"),
+ /** A warning that does not stop the operation. */
+ WARNING("warning");
private final String textContent;
+ ErrorSeverity(String textContent) {
+ this.textContent = textContent;
+ }
+
+ /**
+ * Returns the string representation of the error severity.
+ *
+ * @return the text content of the error severity
+ */
+ public String getTextContent() {
+ return textContent;
+ }
+
+ /**
+ * Converts a string to the corresponding ErrorSeverity enum.
+ *
+ * @param textContent the string representation of the error severity
+ * @return the corresponding ErrorSeverity, or null if no match is found
+ */
public static ErrorSeverity from(final String textContent) {
for (final ErrorSeverity errorSeverity : ErrorSeverity.values()) {
if (errorSeverity.textContent.equals(textContent)) {
@@ -91,20 +203,366 @@ public static ErrorSeverity from(final String textContent) {
}
/**
- * Class to represent a NETCONF rpc error-info element - https://datatracker.ietf.org/doc/html/rfc6241#section-4.3
+ * Represents detailed NETCONF error information contained within the rpc-error's error-info element.
*/
- @Value
- @Builder
- @RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public static class RpcErrorInfo {
- String badAttribute;
- String badElement;
- String badNamespace;
- String sessionId;
- String okElement;
- String errElement;
- String noOpElement;
+ private final String badAttribute;
+ private final String badElement;
+ private final String badNamespace;
+ private final String sessionId;
+ private final String okElement;
+ private final String errElement;
+ private final String noOpElement;
+ /**
+ * Constructs a new RpcErrorInfo instance with detailed fields.
+ *
+ * @param badAttribute the name of the attribute that caused the error
+ * @param badElement the name of the element that caused the error
+ * @param badNamespace the namespace involved in the error
+ * @param sessionId the session ID where the error occurred
+ * @param okElement the XML element indicating successful operation
+ * @param errElement the XML element indicating error operation
+ * @param noOpElement the XML element indicating no-operation
+ */
+ public RpcErrorInfo(String badAttribute, String badElement, String badNamespace, String sessionId, String okElement, String errElement, String noOpElement) {
+ this.badAttribute = badAttribute;
+ this.badElement = badElement;
+ this.badNamespace = badNamespace;
+ this.sessionId = sessionId;
+ this.okElement = okElement;
+ this.errElement = errElement;
+ this.noOpElement = noOpElement;
+ }
+
+ /**
+ * Returns the bad attribute associated with the error.
+ *
+ * @return the bad attribute string
+ */
+ public String getBadAttribute() {
+ return badAttribute;
+ }
+
+ /**
+ * Returns the bad element associated with the error.
+ *
+ * @return the bad element string
+ */
+ public String getBadElement() {
+ return badElement;
+ }
+
+ /**
+ * Returns the bad namespace associated with the error.
+ *
+ * @return the bad namespace string
+ */
+ public String getBadNamespace() {
+ return badNamespace;
+ }
+
+ /**
+ * Returns the session ID related to the error.
+ *
+ * @return the session ID string
+ */
+ public String getSessionId() {
+ return sessionId;
+ }
+
+ /**
+ * Returns the ok element related to the error.
+ *
+ * @return the ok element string
+ */
+ public String getOkElement() {
+ return okElement;
+ }
+
+ /**
+ * Returns the error element related to the error.
+ *
+ * @return the error element string
+ */
+ public String getErrElement() {
+ return errElement;
+ }
+
+ /**
+ * Returns the no-op element related to the error.
+ *
+ * @return the no-op element string
+ */
+ public String getNoOpElement() {
+ return noOpElement;
+ }
+
+ @Override
+ public String toString() {
+ return "RpcErrorInfo{" +
+ "badAttribute='" + badAttribute + '\'' +
+ ", badElement='" + badElement + '\'' +
+ ", badNamespace='" + badNamespace + '\'' +
+ ", sessionId='" + sessionId + '\'' +
+ ", okElement='" + okElement + '\'' +
+ ", errElement='" + errElement + '\'' +
+ ", noOpElement='" + noOpElement + '\'' +
+ '}';
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof RpcErrorInfo)) return false;
+ RpcErrorInfo that = (RpcErrorInfo) o;
+ return Objects.equals(badAttribute, that.badAttribute) &&
+ Objects.equals(badElement, that.badElement) &&
+ Objects.equals(badNamespace, that.badNamespace) &&
+ Objects.equals(sessionId, that.sessionId) &&
+ Objects.equals(okElement, that.okElement) &&
+ Objects.equals(errElement, that.errElement) &&
+ Objects.equals(noOpElement, that.noOpElement);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(badAttribute, badElement, badNamespace, sessionId, okElement, errElement, noOpElement);
+ }
+
+ /**
+ * Returns a new builder for constructing RpcErrorInfo instances.
+ *
+ * @return a new RpcErrorInfo.Builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Fluent builder for constructing immutable {@link RpcErrorInfo} instances.
+ *
+ * Call the setter‑style methods to populate fields, then invoke {@link #build()}
+ * to obtain a ready‑to‑use object. The builder is not thread‑safe.
+ */
+ public static class Builder {
+ private String badAttribute;
+ private String badElement;
+ private String badNamespace;
+ private String sessionId;
+ private String okElement;
+ private String errElement;
+ private String noOpElement;
+
+ /**
+ * Creates a new builder instance with all fields initialized to null.
+ */
+ public Builder() {
+ // Default constructor - all fields start as null
+ }
+
+ /**
+ * Sets the name of the attribute that caused the error.
+ *
+ * @param badAttribute the attribute name
+ * @return this {@code Builder} for chaining
+ */
+ public Builder badAttribute(String badAttribute) {
+ this.badAttribute = badAttribute;
+ return this;
+ }
+
+ /**
+ * Sets the name of the element that caused the error.
+ *
+ * @param badElement the element name
+ * @return this {@code Builder}
+ */
+ public Builder badElement(String badElement) {
+ this.badElement = badElement;
+ return this;
+ }
+
+ /**
+ * Sets the namespace involved in the error.
+ *
+ * @param badNamespace the namespace URI
+ * @return this {@code Builder}
+ */
+ public Builder badNamespace(String badNamespace) {
+ this.badNamespace = badNamespace;
+ return this;
+ }
+
+ /**
+ * Sets the NETCONF session ID related to the error.
+ *
+ * @param sessionId the session ID
+ * @return this {@code Builder}
+ */
+ public Builder sessionId(String sessionId) {
+ this.sessionId = sessionId;
+ return this;
+ }
+
+ /**
+ * Sets the <ok-element> string.
+ *
+ * @param okElement element indicating success
+ * @return this {@code Builder}
+ */
+ public Builder okElement(String okElement) {
+ this.okElement = okElement;
+ return this;
+ }
+
+ /**
+ * Sets the <err-element> string.
+ *
+ * @param errElement element indicating error
+ * @return this {@code Builder}
+ */
+ public Builder errElement(String errElement) {
+ this.errElement = errElement;
+ return this;
+ }
+
+ /**
+ * Sets the <noop-element> string.
+ *
+ * @param noOpElement element indicating no‑operation
+ * @return this {@code Builder}
+ */
+ public Builder noOpElement(String noOpElement) {
+ this.noOpElement = noOpElement;
+ return this;
+ }
+
+ /**
+ * Builds the corresponding RpcErrorInfo instance.
+ *
+ * @return a new RpcErrorInfo object
+ */
+ public RpcErrorInfo build() {
+ return new RpcErrorInfo(badAttribute, badElement, badNamespace, sessionId, okElement, errElement, noOpElement);
+ }
+ }
+ }
+
+ /**
+ * Builder for assembling {@link RpcError} objects with optional fields.
+ *
+ * Populate the desired attributes via the fluent setters and finish with
+ * {@link #build()} to create an immutable {@code RpcError}.
+ */
+ public static class Builder {
+ /**
+ * Creates an empty {@code Builder} instance.
+ */
+ public Builder() {
+ }
+ private ErrorType errorType;
+ private ErrorTag errorTag;
+ private ErrorSeverity errorSeverity;
+ private String errorPath;
+ private String errorMessage;
+ private String errorMessageLanguage;
+ private RpcErrorInfo errorInfo;
+
+ /**
+ * Sets the NETCONF error type.
+ *
+ * @param errorType the error type
+ * @return this {@code Builder}
+ */
+ public Builder errorType(ErrorType errorType) {
+ this.errorType = errorType;
+ return this;
+ }
+
+ /**
+ * Sets the NETCONF error tag.
+ *
+ * @param errorTag the error tag
+ * @return this {@code Builder}
+ */
+ public Builder errorTag(ErrorTag errorTag) {
+ this.errorTag = errorTag;
+ return this;
+ }
+
+ /**
+ * Sets the severity of the error.
+ *
+ * @param errorSeverity severity level
+ * @return this {@code Builder}
+ */
+ public Builder errorSeverity(ErrorSeverity errorSeverity) {
+ this.errorSeverity = errorSeverity;
+ return this;
+ }
+
+ /**
+ * Sets the XPath path to the node where the error occurred.
+ *
+ * @param errorPath NETCONF error-path value
+ * @return this {@code Builder}
+ */
+ public Builder errorPath(String errorPath) {
+ this.errorPath = errorPath;
+ return this;
+ }
+
+ /**
+ * Sets the human‑readable error message.
+ *
+ * @param errorMessage descriptive message
+ * @return this {@code Builder}
+ */
+ public Builder errorMessage(String errorMessage) {
+ this.errorMessage = errorMessage;
+ return this;
+ }
+
+ /**
+ * Sets the language tag of the error message (e.g., "en").
+ *
+ * @param errorMessageLanguage IETF language tag
+ * @return this {@code Builder}
+ */
+ public Builder errorMessageLanguage(String errorMessageLanguage) {
+ this.errorMessageLanguage = errorMessageLanguage;
+ return this;
+ }
+
+ /**
+ * Attaches structured {@link RpcErrorInfo} details to the error.
+ *
+ * @param errorInfo additional structured info
+ * @return this {@code Builder}
+ */
+ public Builder errorInfo(RpcErrorInfo errorInfo) {
+ this.errorInfo = errorInfo;
+ return this;
+ }
+
+ /**
+ * Builds the corresponding RpcError instance.
+ *
+ * @return a new RpcError object
+ */
+ public RpcError build() {
+ return new RpcError(errorType, errorTag, errorSeverity, errorPath, errorMessage, errorMessageLanguage, errorInfo);
+ }
+ }
+
+ /**
+ * Returns a new builder for constructing RpcError instances.
+ *
+ * @return a new RpcError.Builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
}
}
diff --git a/src/main/java/net/juniper/netconf/element/RpcReply.java b/src/main/java/net/juniper/netconf/element/RpcReply.java
index 9d35543..bf15051 100644
--- a/src/main/java/net/juniper/netconf/element/RpcReply.java
+++ b/src/main/java/net/juniper/netconf/element/RpcReply.java
@@ -1,12 +1,5 @@
package net.juniper.netconf.element;
-import lombok.Builder;
-import lombok.EqualsAndHashCode;
-import lombok.Singular;
-import lombok.ToString;
-import lombok.Value;
-import lombok.experimental.NonFinal;
-import lombok.extern.slf4j.Slf4j;
import net.juniper.netconf.NetconfConstants;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
@@ -23,58 +16,325 @@
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
+import java.util.Objects;
import static java.util.Optional.ofNullable;
/**
- * Class to represent a NETCONF rpc-reply element - https://datatracker.ietf.org/doc/html/rfc6241#section-4.2
+ * Represents a NETCONF rpc-reply element, including its message ID, status (ok or error),
+ * any errors returned, session ID, and capabilities.
+ * Based on RFC 6241 section 4.2.
*/
-@Slf4j
-@Value
-@NonFinal
-@ToString(callSuper = true)
-@EqualsAndHashCode(callSuper = true)
public class RpcReply extends AbstractNetconfElement {
+ /** XPath for the root <rpc-reply> element. */
protected static final String XPATH_RPC_REPLY = getXpathFor("rpc-reply");
+
+ /** XPath for the <ok> element inside an <rpc-reply>. */
private static final String XPATH_RPC_REPLY_OK = XPATH_RPC_REPLY + getXpathFor("ok");
+
+ /** XPath for any <rpc-error> elements inside the reply. */
private static final String XPATH_RPC_REPLY_ERROR = XPATH_RPC_REPLY + getXpathFor("rpc-error");
+
+ /** XPath for the <error-type> child of an <rpc-error>. */
private static final String XPATH_RPC_REPLY_ERROR_TYPE = getXpathFor("error-type");
+
+ /** XPath for the <error-tag> child of an <rpc-error>. */
private static final String XPATH_RPC_REPLY_ERROR_TAG = getXpathFor("error-tag");
+
+ /** XPath for the <error-severity> child of an <rpc-error>. */
private static final String XPATH_RPC_REPLY_ERROR_SEVERITY = getXpathFor("error-severity");
+
+ /** XPath for the <error-path> child of an <rpc-error>. */
private static final String XPATH_RPC_REPLY_ERROR_PATH = getXpathFor("error-path");
+
+ /** XPath for the <error-message> child of an <rpc-error>. */
private static final String XPATH_RPC_REPLY_ERROR_MESSAGE = getXpathFor("error-message");
+
+ /** XPath for the <error-info> section inside an <rpc-error>. */
private static final String XPATH_RPC_REPLY_ERROR_INFO = getXpathFor("error-info");
+
+ /** XPath for the <bad-attribute> field within <error-info>. */
private static final String XPATH_RPC_REPLY_ERROR_INFO_BAD_ATTRIBUTE = XPATH_RPC_REPLY_ERROR_INFO + getXpathFor("bad-attribute");
+
+ /** XPath for the <bad-element> field within <error-info>. */
private static final String XPATH_RPC_REPLY_ERROR_INFO_BAD_ELEMENT = XPATH_RPC_REPLY_ERROR_INFO + getXpathFor("bad-element");
+
+ /** XPath for the <bad-namespace> field within <error-info>. */
private static final String XPATH_RPC_REPLY_ERROR_INFO_BAD_NAMESPACE = XPATH_RPC_REPLY_ERROR_INFO + getXpathFor("bad-namespace");
+
+ /** XPath for the <session-id> field within <error-info>. */
private static final String XPATH_RPC_REPLY_ERROR_INFO_SESSION_ID = XPATH_RPC_REPLY_ERROR_INFO + getXpathFor("session-id");
+
+ /** XPath for the <ok-element> field within <error-info>. */
private static final String XPATH_RPC_REPLY_ERROR_INFO_OK_ELEMENT = XPATH_RPC_REPLY_ERROR_INFO + getXpathFor("ok-element");
+
+ /** XPath for the <err-element> field within <error-info>. */
private static final String XPATH_RPC_REPLY_ERROR_INFO_ERR_ELEMENT = XPATH_RPC_REPLY_ERROR_INFO + getXpathFor("err-element");
+
+ /** XPath for the <noop-element> field within <error-info>. */
private static final String XPATH_RPC_REPLY_ERROR_INFO_NO_OP_ELEMENT = XPATH_RPC_REPLY_ERROR_INFO + getXpathFor("noop-element");
- String messageId;
- boolean ok;
- List errors;
+ private final String messageId;
+ private final boolean ok;
+ private final List errors;
+ /** Optional prefix (e.g. "nc") to apply to elements when we generate XML. */
+ private final String namespacePrefix;
+
+ private final String sessionId;
+ private final List capabilities;
+
+ /* ---------------------------------------------------------------------
+ * Builder
+ * ------------------------------------------------------------------- */
+
+ /**
+ * Creates and returns a new {@link Builder} for constructing {@code RpcReply} instances.
+ *
+ * The builder pattern allows callers to set only the fields relevant to their use‑case
+ * and then call {@link Builder#build()} to obtain an immutable {@code RpcReply}.
+ *
+ * @return a fresh {@code Builder} instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Fluent builder for creating immutable {@link RpcReply} instances.
+ *
+ * Configure the desired fields with the provided setter‑style methods
+ * and finish by calling {@link #build()} to obtain a fully‑constructed
+ * {@code RpcReply}. The builder is not thread‑safe; use a separate
+ * instance per construction sequence.
+ */
+ public static final class Builder {
+ private Document originalDocument;
+ private String namespacePrefix;
+ private String messageId;
+ private boolean ok;
+ private List errors = new ArrayList<>();
+ private String sessionId;
+ private List capabilities = new ArrayList<>();
+
+ private Builder() { }
+
+ /**
+ * Sets the original {@link Document} this reply was parsed from.
+ *
+ * @param originalDocument the source DOM {@link Document}; may be {@code null}
+ * @return this {@code Builder} instance for method chaining
+ */
+ public Builder originalDocument(Document originalDocument) {
+ this.originalDocument = originalDocument;
+ return this;
+ }
+
+ /**
+ * Sets the XML namespace prefix to apply when generating new XML.
+ *
+ * @param namespacePrefix optional namespace prefix (e.g. "nc"); may be {@code null}
+ * @return this {@code Builder} for chaining
+ */
+ public Builder namespacePrefix(String namespacePrefix) {
+ this.namespacePrefix = namespacePrefix;
+ return this;
+ }
+
+ /**
+ * Sets the NETCONF message-id attribute for the reply.
+ *
+ * @param messageId the message ID string
+ * @return this {@code Builder}
+ */
+ public Builder messageId(String messageId) {
+ this.messageId = messageId;
+ return this;
+ }
+
+ /**
+ * Indicates whether the reply contains an <ok/> element.
+ *
+ * @param ok {@code true} if the operation succeeded
+ * @return this {@code Builder}
+ */
+ public Builder ok(boolean ok) {
+ this.ok = ok;
+ return this;
+ }
+
+ /**
+ * Replaces the current list of errors with the provided list.
+ *
+ * @param errors list of {@link RpcError}; if {@code null} an empty list is used
+ * @return this {@code Builder}
+ */
+ public Builder errors(List errors) {
+ this.errors = errors != null ? errors : new ArrayList<>();
+ return this;
+ }
+
+ /**
+ * Adds a single {@link RpcError} to the reply.
+ *
+ * @param error the error to add; ignored if {@code null}
+ * @return this {@code Builder}
+ */
+ public Builder addError(RpcError error) {
+ if (error != null) {
+ this.errors.add(error);
+ }
+ return this;
+ }
+
+ /**
+ * Sets the NETCONF session-id associated with the reply.
+ *
+ * @param sessionId the session ID
+ * @return this {@code Builder}
+ */
+ public Builder sessionId(String sessionId) {
+ this.sessionId = sessionId;
+ return this;
+ }
+
+ /**
+ * Replaces the current capability list.
+ *
+ * @param capabilities list of capability URIs; if {@code null} an empty list is used
+ * @return this {@code Builder}
+ */
+ public Builder capabilities(List capabilities) {
+ this.capabilities = capabilities != null ? capabilities : new ArrayList<>();
+ return this;
+ }
+
+ /**
+ * Adds a single NETCONF capability URI to the list.
+ *
+ * @param capability capability URI to add; ignored if {@code null}
+ * @return this {@code Builder}
+ */
+ public Builder addCapability(String capability) {
+ if (capability != null) {
+ this.capabilities.add(capability);
+ }
+ return this;
+ }
+
+ /**
+ * Builds an immutable {@link RpcReply} instance using the currently configured parameters.
+ *
+ * @return a new {@link RpcReply}
+ */
+ public RpcReply build() {
+ return new RpcReply(
+ namespacePrefix,
+ originalDocument,
+ messageId,
+ ok,
+ errors,
+ sessionId,
+ capabilities
+ );
+ }
+ }
+
+ /**
+ * Returns the message ID associated with this rpc-reply.
+ *
+ * @return the message ID
+ */
+ public String getMessageId() {
+ return messageId;
+ }
+
+ /**
+ * Returns true if the rpc-reply indicates success (i.e., contains an <ok/> element).
+ *
+ * @return true if the reply is OK, false otherwise
+ */
+ public boolean isOK() {
+ return ok;
+ }
+ /**
+ * Returns the list of errors contained in this rpc-reply.
+ *
+ * @return list of RpcError
+ */
+ public List getErrors() {
+ return new ArrayList<>(errors);
+ }
+
+ /**
+ * Returns true if the rpc-reply contains any errors or warnings.
+ *
+ * @return true if errors or warnings are present
+ */
public boolean hasErrorsOrWarnings() {
return !errors.isEmpty();
}
+ /**
+ * Returns true if any of the errors in the rpc-reply are of severity "error".
+ *
+ * @return true if there are error-severity issues
+ */
public boolean hasErrors() {
- return errors.stream().anyMatch(error -> error.getErrorSeverity() == RpcError.ErrorSeverity.ERROR);
+ return errors.stream().anyMatch(error -> error.errorSeverity() == RpcError.ErrorSeverity.ERROR);
}
+ /**
+ * Returns true if any of the errors in the rpc-reply are of severity "warning".
+ *
+ * @return true if there are warning-severity issues
+ */
public boolean hasWarnings() {
- return errors.stream().anyMatch(error -> error.getErrorSeverity() == RpcError.ErrorSeverity.WARNING);
+ return errors.stream().anyMatch(error -> error.errorSeverity() == RpcError.ErrorSeverity.WARNING);
}
+ /**
+ * Returns the session ID associated with this rpc-reply.
+ *
+ * @return the session ID, or null if not present
+ */
+ public String getSessionId() {
+ return sessionId;
+ }
+
+ /**
+ * Returns the list of NETCONF capabilities reported in the rpc-reply.
+ *
+ * @return list of capability URIs
+ */
+ public List getCapabilities() {
+ return new ArrayList<>(capabilities);
+ }
+
+ /** Removes the RFC 6242 ']]>]]>' delimiter from the tail of a frame. */
+ private static String stripEomDelimiter(String xml) {
+ return xml.replaceFirst("\\Q]]>]]>\\E\\s*$", "");
+ }
+
+ /**
+ * Parses the given NETCONF XML string into an {@code RpcReply} (or subtype).
+ *
+ * @param the concrete subtype of {@link AbstractNetconfElement} to return
+ * @param xml the NETCONF XML string to parse
+ * @return an {@code RpcReply} (or subclass) instance
+ * @throws ParserConfigurationException if a parser cannot be configured
+ * @throws IOException if the input cannot be read
+ * @throws SAXException if the XML is not well-formed
+ * @throws XPathExpressionException if the XPath lookup fails
+ */
@SuppressWarnings("unchecked")
public static T from(final String xml)
throws ParserConfigurationException, IOException, SAXException, XPathExpressionException {
+ String cleaned = stripEomDelimiter(xml);
final Document document = createDocumentBuilderFactory().newDocumentBuilder()
- .parse(new InputSource(new StringReader(xml)));
+ .parse(new InputSource(new StringReader(cleaned)));
final XPath xPath = XPathFactory.newInstance().newXPath();
final Element loadConfigResultsElement = (Element) xPath.evaluate(RpcReplyLoadConfigResults.XPATH_RPC_REPLY_LOAD_CONFIG_RESULT, document, XPathConstants.NODE);
@@ -86,16 +346,60 @@ public static T from(final String xml)
final Element rpcReplyOkElement = (Element) xPath.evaluate(XPATH_RPC_REPLY_OK, document, XPathConstants.NODE);
final List errorList = getRpcErrors(document, xPath, XPATH_RPC_REPLY_ERROR);
- final RpcReply rpcReply = RpcReply.builder()
- .messageId(getAttribute(rpcReplyElement, "message-id"))
- .ok(rpcReplyOkElement != null)
- .errors(errorList)
- .originalDocument(document)
- .build();
- log.info("rpc-reply is: {}", rpcReply.getXml());
+ final RpcReply rpcReply = new RpcReply(
+ null, // no explicit prefix in parsed XML
+ document,
+ getAttribute(rpcReplyElement, "message-id"),
+ rpcReplyOkElement != null,
+ errorList,
+ extractSessionId(document, xPath),
+ extractCapabilities(document, xPath)
+ );
return (T) rpcReply;
}
+ /**
+ * Extracts the session ID from an rpc-reply XML document.
+ *
+ * @param document the parsed XML document
+ * @param xPath the XPath instance to use
+ * @return the session ID, or null if not found
+ * @throws XPathExpressionException if the XPath lookup fails
+ */
+ private static String extractSessionId(Document document, XPath xPath) throws XPathExpressionException {
+ Element sessionIdElement = (Element) xPath.evaluate(XPATH_RPC_REPLY_ERROR_INFO_SESSION_ID, document, XPathConstants.NODE);
+ if (sessionIdElement != null) {
+ return getTextContent(sessionIdElement);
+ }
+ return null;
+ }
+
+ /**
+ * Extracts the list of capabilities from an rpc-reply XML document.
+ *
+ * @param document the parsed XML document
+ * @param xPath the XPath instance to use
+ * @return list of capability URIs
+ * @throws XPathExpressionException if the XPath lookup fails
+ */
+ private static List extractCapabilities(Document document, XPath xPath) throws XPathExpressionException {
+ List caps = new ArrayList<>();
+ NodeList capNodes = (NodeList) xPath.evaluate("//capability", document, XPathConstants.NODESET);
+ for (int i = 0; i < capNodes.getLength(); i++) {
+ caps.add(capNodes.item(i).getTextContent());
+ }
+ return caps;
+ }
+
+ /**
+ * Extracts all <rpc-error> elements from the XML document.
+ *
+ * @param document the parsed XML document
+ * @param xPath the XPath instance to use
+ * @param xpathQuery the XPath expression for locating <rpc-error> elements
+ * @return a list of RpcError objects
+ * @throws XPathExpressionException if any XPath lookup fails
+ */
protected static List getRpcErrors(final Document document, final XPath xPath, final String xpathQuery)
throws XPathExpressionException {
final NodeList errors = (NodeList) xPath.evaluate(xpathQuery, document, XPathConstants.NODESET);
@@ -107,7 +411,7 @@ protected static List getRpcErrors(final Document document, final XPat
final String errorSeverity = xPath.evaluate(expressionPrefix + XPATH_RPC_REPLY_ERROR_SEVERITY, document);
final Element errorMessageElement = (Element) xPath.evaluate(expressionPrefix + XPATH_RPC_REPLY_ERROR_MESSAGE, document, XPathConstants.NODE);
final Element errorPathElement = (Element) xPath.evaluate(expressionPrefix + XPATH_RPC_REPLY_ERROR_PATH, document, XPathConstants.NODE);
- final RpcError.RpcErrorBuilder errorBuilder = RpcError.builder()
+ final RpcError.Builder errorBuilder = RpcError.builder()
.errorType(RpcError.ErrorType.from(errorType))
.errorTag(RpcError.ErrorTag.from(errorTag))
.errorSeverity(RpcError.ErrorSeverity.from(errorSeverity))
@@ -141,32 +445,63 @@ protected static List getRpcErrors(final Document document, final XPat
return errorList;
}
- @Builder
- protected RpcReply(
- final Document originalDocument,
- final String namespacePrefix,
- final String messageId,
- final boolean ok,
- @Singular("error") final List errors) {
+ /**
+ * Full constructor for RpcReply.
+ *
+ * @param namespacePrefix optional XML namespace prefix, may be {@code null}
+ * @param originalDocument the original Document, or {@code null} to build a new one
+ * @param messageId the message id
+ * @param ok whether the reply is ok
+ * @param errors the list of {@link RpcError} (must not be {@code null})
+ * @param sessionId the session id
+ * @param capabilities the list of capabilities
+ */
+ public RpcReply(
+ final String namespacePrefix,
+ final Document originalDocument,
+ final String messageId,
+ final boolean ok,
+ final List errors,
+ final String sessionId,
+ final List capabilities
+ ) {
super(getDocument(originalDocument, namespacePrefix, messageId, ok, errors));
+ this.namespacePrefix = namespacePrefix;
this.messageId = messageId;
this.ok = ok;
this.errors = errors;
+ this.sessionId = sessionId;
+ this.capabilities = capabilities;
}
+ /**
+ * Returns the XML Document for the reply, using the original or generating a new one.
+ *
+ * @param originalDocument the original XML document, or null
+ * @param namespacePrefix optional XML namespace prefix
+ * @param messageId the message ID
+ * @param ok true if reply is ok
+ * @param errors list of RpcError
+ * @return a valid Document object
+ */
private static Document getDocument(
final Document originalDocument,
final String namespacePrefix,
final String messageId,
final boolean ok,
final List errors) {
- if (originalDocument != null) {
- return originalDocument;
- } else {
- return createDocument(namespacePrefix, messageId, ok, errors);
- }
+ return Objects.requireNonNullElseGet(originalDocument, () -> createDocument(namespacePrefix, messageId, ok, errors));
}
+ /**
+ * Creates a new XML Document representing the rpc-reply.
+ *
+ * @param namespacePrefix optional XML namespace prefix
+ * @param messageId the message ID
+ * @param ok true if reply is ok
+ * @param errors list of RpcError
+ * @return a new Document instance
+ */
private static Document createDocument(
final String namespacePrefix,
final String messageId,
@@ -175,12 +510,16 @@ private static Document createDocument(
final Document createdDocument = createBlankDocument();
final Element rpcReplyElement = createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "rpc-reply");
- rpcReplyElement.setPrefix(namespacePrefix);
+ if (namespacePrefix != null) {
+ rpcReplyElement.setPrefix(namespacePrefix);
+ }
rpcReplyElement.setAttribute("message-id", messageId);
createdDocument.appendChild(rpcReplyElement);
if (ok) {
final Element okElement = createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "ok");
- okElement.setPrefix(namespacePrefix);
+ if (namespacePrefix != null) {
+ okElement.setPrefix(namespacePrefix);
+ }
rpcReplyElement.appendChild(okElement);
}
appendErrors(namespacePrefix, errors, createdDocument, rpcReplyElement);
@@ -188,24 +527,36 @@ private static Document createDocument(
return createdDocument;
}
+ /**
+ * Appends error elements to the rpc-reply XML structure.
+ *
+ * @param namespacePrefix optional XML namespace prefix
+ * @param errors list of RpcError
+ * @param createdDocument the Document to which elements are added
+ * @param parentElement the parent element to append to
+ */
protected static void appendErrors(final String namespacePrefix, final List errors, final Document createdDocument, final Element parentElement) {
errors.forEach(error -> {
final Element errorElement = createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "rpc-error");
- errorElement.setPrefix(namespacePrefix);
+ if (namespacePrefix != null) {
+ errorElement.setPrefix(namespacePrefix);
+ }
parentElement.appendChild(errorElement);
- ofNullable(error.getErrorType())
+ ofNullable(error.errorType())
.ifPresent(errorType-> appendElementWithText(createdDocument, errorElement, namespacePrefix, "error-type", errorType.getTextContent()));
- ofNullable(error.getErrorTag())
+ ofNullable(error.errorTag())
.ifPresent(errorTag -> appendElementWithText(createdDocument, errorElement, namespacePrefix, "error-tag", errorTag.getTextContent()));
- ofNullable(error.getErrorSeverity())
+ ofNullable(error.errorSeverity())
.ifPresent(errorSeverity -> appendElementWithText(createdDocument, errorElement, namespacePrefix, "error-severity", errorSeverity.getTextContent()));
- appendElementWithText(createdDocument, errorElement, namespacePrefix, "error-path", error.getErrorPath());
- final Element errorMessageElement = appendElementWithText(createdDocument, errorElement, namespacePrefix, "error-message", error.getErrorMessage());
- ofNullable(error.getErrorMessageLanguage())
+ appendElementWithText(createdDocument, errorElement, namespacePrefix, "error-path", error.errorPath());
+ final Element errorMessageElement = appendElementWithText(createdDocument, errorElement, namespacePrefix, "error-message", error.errorMessage());
+ ofNullable(error.errorMessageLanguage())
.ifPresent(errorMessageLanguage -> errorMessageElement.setAttribute("xml:lang", errorMessageLanguage));
- ofNullable(error.getErrorInfo()).ifPresent(errorInfo -> {
+ ofNullable(error.errorInfo()).ifPresent(errorInfo -> {
final Element errorInfoElement = createdDocument.createElementNS(NetconfConstants.URN_XML_NS_NETCONF_BASE_1_0, "error-info");
- errorInfoElement.setPrefix(namespacePrefix);
+ if (namespacePrefix != null) {
+ errorInfoElement.setPrefix(namespacePrefix);
+ }
errorElement.appendChild(errorInfoElement);
appendElementWithText(createdDocument, errorInfoElement, namespacePrefix, "bad-attribute", errorInfo.getBadAttribute());
appendElementWithText(createdDocument, errorInfoElement, namespacePrefix, "bad-element", errorInfo.getBadElement());
@@ -217,4 +568,32 @@ protected static void appendErrors(final String namespacePrefix, final List errorList = getRpcErrors(document, xPath, XPATH_RPC_REPLY_LOAD_CONFIG_RESULT_ERROR);
return RpcReplyLoadConfigResults.loadConfigResultsBuilder()
+ .originalDocument(document)
+ .namespacePrefix(null)
.messageId(getAttribute(rpcReplyElement, "message-id"))
.action(getAttribute(loadConfigResultsElement, "action"))
.ok(rpcReplyOkElement != null)
.errors(errorList)
- .originalDocument(document)
.build();
}
- @Builder(builderMethodName = "loadConfigResultsBuilder")
private RpcReplyLoadConfigResults(
- final Document originalDocument,
final String namespacePrefix,
+ final Document originalDocument,
final String messageId,
final String action,
final boolean ok,
- @Singular("error") final List errors) {
- super(getDocument(originalDocument, namespacePrefix, messageId, action, ok, errors),
- namespacePrefix, messageId, ok, errors);
+ final List errors) {
+ super(
+ namespacePrefix,
+ getDocument(originalDocument, namespacePrefix, messageId, action, ok, errors),
+ messageId,
+ ok,
+ errors,
+ null,
+ null
+ );
this.action = action;
}
+ /**
+ * Returns a new {@link Builder} for constructing
+ * {@code RpcReplyLoadConfigResults} objects.
+ *
+ * @return a fresh {@link Builder}
+ */
+ public static Builder loadConfigResultsBuilder() {
+ return new Builder();
+ }
+
+ /**
+ * Returns the value of the {@code action} attribute found in the
+ * <load-configuration-results> element.
+ *
+ * @return action attribute string
+ */
+ public String getAction() {
+ return action;
+ }
+
+ /**
+ * Builder for {@link RpcReplyLoadConfigResults}.
+ * Use this builder to construct immutable instances of RpcReplyLoadConfigResults.
+ */
+ public static class Builder {
+ /**
+ * Creates an empty {@code Builder}.
+ */
+ public Builder() {
+ }
+ private Document originalDocument;
+ private String namespacePrefix;
+ private String messageId;
+ private String action;
+ private boolean ok;
+ private List errors = new java.util.ArrayList<>();
+
+ /**
+ * Sets the original XML Document for the reply.
+ * @param originalDocument the XML Document, must not be null
+ * @return this Builder
+ * @throws NullPointerException if originalDocument is null
+ */
+ public Builder originalDocument(Document originalDocument) {
+ this.originalDocument = Objects.requireNonNull(originalDocument, "originalDocument must not be null");
+ return this;
+ }
+
+ /**
+ * Sets the namespace prefix for the reply.
+ * @param namespacePrefix the prefix, may be null
+ * @return this Builder
+ */
+ public Builder namespacePrefix(String namespacePrefix) {
+ this.namespacePrefix = namespacePrefix;
+ return this;
+ }
+
+ /**
+ * Sets the message-id for the reply.
+ * @param messageId the message id, must not be null
+ * @return this Builder
+ * @throws NullPointerException if messageId is null
+ */
+ public Builder messageId(String messageId) {
+ this.messageId = Objects.requireNonNull(messageId, "messageId must not be null");
+ return this;
+ }
+
+ /**
+ * Sets the action attribute for the reply.
+ * @param action the action, must not be null
+ * @return this Builder
+ * @throws NullPointerException if action is null
+ */
+ public Builder action(String action) {
+ this.action = Objects.requireNonNull(action, "action must not be null");
+ return this;
+ }
+
+ /**
+ * Sets the ok flag for the reply.
+ * @param ok true if reply is ok, false otherwise
+ * @return this Builder
+ */
+ public Builder ok(boolean ok) {
+ this.ok = ok;
+ return this;
+ }
+
+ /**
+ * Sets the list of errors for the reply.
+ * @param errors the list of errors, must not be null
+ * @return this Builder
+ * @throws NullPointerException if errors is null
+ */
+ public Builder errors(List errors) {
+ this.errors = new java.util.ArrayList<>(Objects.requireNonNull(errors, "errors list must not be null"));
+ return this;
+ }
+
+ /**
+ * Adds a single error to the reply.
+ * @param error the error to add, ignored if null
+ * @return this Builder
+ */
+ public Builder addError(RpcError error) {
+ if (error != null) {
+ this.errors.add(error);
+ }
+ return this;
+ }
+
+ /**
+ * Builds the immutable RpcReplyLoadConfigResults instance.
+ * @return the built RpcReplyLoadConfigResults
+ */
+ public RpcReplyLoadConfigResults build() {
+ return new RpcReplyLoadConfigResults(
+ namespacePrefix, // 1) prefix
+ originalDocument, // 2) document
+ messageId, // 3) message-id
+ action, // 4) action
+ ok, // 5) ok flag
+ errors // 6) error list
+ );
+ }
+ }
+
private static Document getDocument(
final Document originalDocument,
final String namespacePrefix,
@@ -106,4 +248,36 @@ private static Document createDocument(
return createdDocument;
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof RpcReplyLoadConfigResults)) return false;
+ if (!super.equals(o)) return false;
+ RpcReplyLoadConfigResults that = (RpcReplyLoadConfigResults) o;
+ return Objects.equals(action, that.action);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(super.hashCode(), action);
+ }
+
+ @Override
+ public String toString() {
+ return "RpcReplyLoadConfigResults{" +
+ "action='" + action + '\'' +
+ "} " + super.toString();
+ }
+
+ /**
+ * Returns a defensive copy of the errors list to avoid exposing internal
+ * representation.
+ *
+ * @return copy of the error list
+ */
+ @SuppressWarnings("unchecked") // parent class returns raw List
+ public List getErrors() {
+ return new java.util.ArrayList<>((List) super.getErrors());
+ }
}
\ No newline at end of file
diff --git a/src/test/java/net/juniper/netconf/CommitExceptionTest.java b/src/test/java/net/juniper/netconf/CommitExceptionTest.java
index e265a27..ff0ccf5 100644
--- a/src/test/java/net/juniper/netconf/CommitExceptionTest.java
+++ b/src/test/java/net/juniper/netconf/CommitExceptionTest.java
@@ -1,11 +1,9 @@
package net.juniper.netconf;
-import org.junit.Test;
-import org.junit.experimental.categories.Category;
+import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
-@Category(Test.class)
public class CommitExceptionTest {
private static final String TEST_MESSAGE = "test message";
diff --git a/src/test/java/net/juniper/netconf/DatastoreTest.java b/src/test/java/net/juniper/netconf/DatastoreTest.java
index b673e5f..59f1cdb 100644
--- a/src/test/java/net/juniper/netconf/DatastoreTest.java
+++ b/src/test/java/net/juniper/netconf/DatastoreTest.java
@@ -1,18 +1,16 @@
package net.juniper.netconf;
import net.juniper.netconf.element.Datastore;
-import org.junit.Test;
-
-import static org.hamcrest.Matchers.is;
-import static org.junit.Assert.assertThat;
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
public class DatastoreTest {
@Test
public void testDatastoreName() {
- assertThat(Datastore.OPERATIONAL.toString(), is("operational"));
- assertThat(Datastore.RUNNING.toString(), is("running"));
- assertThat(Datastore.CANDIDATE.toString(), is("candidate"));
- assertThat(Datastore.STARTUP.toString(), is("startup"));
- assertThat(Datastore.INTENDED.toString(), is("intended"));
+ assertThat(Datastore.OPERATIONAL.toString()).isEqualTo("operational");
+ assertThat(Datastore.RUNNING.toString()).isEqualTo("running");
+ assertThat(Datastore.CANDIDATE.toString()).isEqualTo("candidate");
+ assertThat(Datastore.STARTUP.toString()).isEqualTo("startup");
+ assertThat(Datastore.INTENDED.toString()).isEqualTo("intended");
}
}
diff --git a/src/test/java/net/juniper/netconf/DeviceTest.java b/src/test/java/net/juniper/netconf/DeviceTest.java
index c72d2db..505038a 100644
--- a/src/test/java/net/juniper/netconf/DeviceTest.java
+++ b/src/test/java/net/juniper/netconf/DeviceTest.java
@@ -5,9 +5,8 @@
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.experimental.categories.Category;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
import org.xmlunit.assertj.XmlAssert;
import java.io.ByteArrayInputStream;
@@ -15,11 +14,10 @@
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
+import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNull;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
@@ -29,31 +27,37 @@
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
-@Category(Test.class)
public class DeviceTest {
private static final String TEST_HOSTNAME = "hostname";
private static final String TEST_USERNAME = "username";
private static final String TEST_PASSWORD = "password";
private static final int DEFAULT_NETCONF_PORT = 830;
+ private static final int OTHER_NETCONF_PORT = 990;
private static final int DEFAULT_TIMEOUT = 5000;
+ private static final int OTHER_TIMEOUT = 1000;
+ private static final String TEST_FILENAME = "TEST_FILENAME";
private static final String SUBSYSTEM = "subsystem";
- private static final String HELLO_WITH_DEFAULT_CAPABILITIES = ""
- + "\n"
- + "\n"
- + "urn:ietf:params:netconf:base:1.0\n"
- + "urn:ietf:params:netconf:base:1.0#candidate\n"
- + "urn:ietf:params:netconf:base:1.0#confirmed-commit\n"
- + "urn:ietf:params:netconf:base:1.0#validate\n"
- + "urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file\n"
- + "\n"
- + "";
- private static final String HELLO_WITH_BASE_CAPABILITIES = ""
- + "\n"
- + "\n"
- + "urn:ietf:params:netconf:base:1.0\n"
- + "\n"
- + "";
+ private static final String HELLO_WITH_DEFAULT_CAPABILITIES = """
+ \
+
+
+ urn:ietf:params:netconf:base:1.0
+ urn:ietf:params:netconf:base:1.0#candidate
+ urn:ietf:params:netconf:base:1.0#confirmed-commit
+ urn:ietf:params:netconf:base:1.0#validate
+ urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file
+ urn:ietf:params:netconf:base:1.1
+
+ """;
+ private static final String HELLO_WITH_BASE_CAPABILITIES = """
+ \
+
+
+ urn:ietf:params:netconf:base:1.0
+ urn:ietf:params:netconf:base:1.1
+
+ """;
private ByteArrayOutputStream outputStream;
@@ -66,7 +70,7 @@ private Device createTestDevice() throws NetconfException {
.build();
}
- @Before
+ @BeforeEach
public void setUp() {
outputStream = new ByteArrayOutputStream();
}
@@ -80,9 +84,36 @@ public void GIVEN_requiredParameters_THEN_createDevice() throws NetconfException
assertThat(device.getPort()).isEqualTo(DEFAULT_NETCONF_PORT);
assertThat(device.getConnectionTimeout()).isEqualTo(DEFAULT_TIMEOUT);
assertThat(device.getCommandTimeout()).isEqualTo(DEFAULT_TIMEOUT);
- assertFalse(device.isKeyBasedAuthentication());
- assertNull(device.getPemKeyFile());
- assertNull(device.getHostKeysFileName());
+ assertThat(device.isKeyBasedAuthentication()).isFalse();
+ assertThat(device.getPemKeyFile()).isNull();
+ assertThat(device.getHostKeysFileName()).isNull();
+ }
+
+ @Test
+ public void GIVEN_deviceBuilder_THEN_buildDevice() throws NetconfException {
+ Device device = Device.builder()
+ .hostName(TEST_HOSTNAME)
+ .userName(TEST_USERNAME)
+ .password(TEST_PASSWORD)
+ .port(OTHER_NETCONF_PORT)
+ .pemKeyFile(TEST_FILENAME)
+ .connectionTimeout(OTHER_TIMEOUT)
+ .commandTimeout(OTHER_TIMEOUT)
+ .keyBasedAuth(TEST_FILENAME)
+ .hostKeysFileName(TEST_FILENAME)
+ .build();
+ assertThat(device.getHostName()).isEqualTo(TEST_HOSTNAME);
+ assertThat(device.getUserName()).isEqualTo(TEST_USERNAME);
+ assertThat(device.getPassword()).isEqualTo(TEST_PASSWORD);
+ assertThat(device.getPort()).isEqualTo(OTHER_NETCONF_PORT);
+ assertThat(device.getConnectionTimeout()).isEqualTo(OTHER_TIMEOUT);
+ assertThat(device.getCommandTimeout()).isEqualTo(OTHER_TIMEOUT);
+ assertThat(device.isKeyBasedAuthentication()).isTrue();
+ assertThat(device.getPemKeyFile()).isEqualTo(TEST_FILENAME);
+ assertThat(device.getHostKeysFileName()).isEqualTo(TEST_FILENAME);
+ List caps = device.getDefaultClientCapabilities();
+ assertThat(caps).isNotNull();
+ assertThat(caps).contains("urn:ietf:params:netconf:base:1.0");
}
@Test
@@ -139,21 +170,21 @@ public void GIVEN_sshAvailableNetconfNot_THEN_closeDevice() throws Exception {
@Test
public void GIVEN_newDevice_WHEN_withNullUserName_THEN_throwsException() {
assertThatThrownBy(() -> Device.builder().hostName("foo").build())
- .isInstanceOf(NullPointerException.class)
- .hasMessage("userName is marked non-null but is null");
+ .isInstanceOf(NetconfException.class)
+ .hasMessage("userName is required");
}
@Test
public void GIVEN_newDevice_WHEN_withHostName_THEN_throwsException() {
assertThatThrownBy(() -> Device.builder().userName("foo").build())
- .isInstanceOf(NullPointerException.class)
- .hasMessage("hostName is marked non-null but is null");
+ .isInstanceOf(NetconfException.class)
+ .hasMessage("hostName is required");
}
@Test
public void GIVEN_newDevice_WHEN_checkIfConnected_THEN_returnFalse() throws NetconfException {
Device device = createTestDevice();
- assertFalse(device.isConnected());
+ assertThat(device.isConnected()).isFalse();
}
@Test
@@ -170,7 +201,7 @@ public void GIVEN_newDevice_WHEN_connect_THEN_sendHelloWithDefaultCapabilities()
.build();
device.connect();
- final String message = outputStream.toString();
+ final String message = outputStream.toString(StandardCharsets.UTF_8);
assertThat(message).endsWith(NetconfConstants.DEVICE_PROMPT);
final String hello = message.substring(0, message.length() - NetconfConstants.DEVICE_PROMPT.length());
XmlAssert.assertThat(hello)
@@ -194,7 +225,7 @@ public void GIVEN_newDevice_WHEN_connect_THEN_sendHelloWithCustomCapabilities()
.build();
device.connect();
- final String message = outputStream.toString();
+ final String message = outputStream.toString(StandardCharsets.UTF_8);
assertThat(message).endsWith(NetconfConstants.DEVICE_PROMPT);
final String hello = message.substring(0, message.length() - NetconfConstants.DEVICE_PROMPT.length());
XmlAssert.assertThat(hello)
diff --git a/src/test/java/net/juniper/netconf/LoadExceptionTest.java b/src/test/java/net/juniper/netconf/LoadExceptionTest.java
index f4755b8..3b71409 100644
--- a/src/test/java/net/juniper/netconf/LoadExceptionTest.java
+++ b/src/test/java/net/juniper/netconf/LoadExceptionTest.java
@@ -1,10 +1,8 @@
package net.juniper.netconf;
-import org.junit.Test;
-import org.junit.experimental.categories.Category;
+import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
-@Category(Test.class)
public class LoadExceptionTest {
private static final String TEST_MESSAGE = "test message";
diff --git a/src/test/java/net/juniper/netconf/NetconfExceptionTest.java b/src/test/java/net/juniper/netconf/NetconfExceptionTest.java
index d007171..7270e85 100644
--- a/src/test/java/net/juniper/netconf/NetconfExceptionTest.java
+++ b/src/test/java/net/juniper/netconf/NetconfExceptionTest.java
@@ -1,11 +1,9 @@
package net.juniper.netconf;
-import org.junit.Test;
-import org.junit.experimental.categories.Category;
+import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
-@Category(Test.class)
public class NetconfExceptionTest {
private static final String TEST_MESSAGE = "test message";
diff --git a/src/test/java/net/juniper/netconf/NetconfSessionTest.java b/src/test/java/net/juniper/netconf/NetconfSessionTest.java
index fc495d1..25730b7 100644
--- a/src/test/java/net/juniper/netconf/NetconfSessionTest.java
+++ b/src/test/java/net/juniper/netconf/NetconfSessionTest.java
@@ -2,14 +2,15 @@
import com.google.common.base.Charsets;
import com.jcraft.jsch.Channel;
-import lombok.extern.slf4j.Slf4j;
import net.juniper.netconf.element.RpcError;
import net.juniper.netconf.element.RpcReply;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.experimental.categories.Category;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.xmlunit.assertj.XmlAssert;
import javax.xml.parsers.DocumentBuilder;
@@ -27,29 +28,41 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.junit.Assert.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
-@Slf4j
-@Category(Test.class)
public class NetconfSessionTest {
+ private static final Logger log = LoggerFactory.getLogger(NetconfSessionTest.class);
+
public static final int CONNECTION_TIMEOUT = 2000;
public static final int COMMAND_TIMEOUT = 5000;
private static final String FAKE_HELLO = "fake hello";
+ /** Automatically closes Mockito mocks opened in {@link #setUp()}. */
+ private AutoCloseable closeable;
+
private static final String DEVICE_PROMPT = "]]>]]>";
- private static final byte[] DEVICE_PROMPT_BYTE = DEVICE_PROMPT.getBytes();
+ private static final byte[] DEVICE_PROMPT_BYTE = DEVICE_PROMPT.getBytes(StandardCharsets.UTF_8);
private static final String FAKE_RPC_REPLY = "fakedata";
+ private static final String OK_RPC_REPLY =
+ "";
+ private static final String ERROR_RPC_REPLY =
+ "" +
+ " " +
+ " protocol" +
+ " operation-failed" +
+ " error" +
+ " " +
+ "";
private static final String NETCONF_SYNTAX_ERROR_MSG_FROM_DEVICE = "netconf error: syntax error";
@Mock
@@ -63,9 +76,9 @@ public class NetconfSessionTest {
private PipedOutputStream outPipe;
private PipedInputStream inPipe;
- @Before
+ @BeforeEach
public void setUp() throws IOException {
- MockitoAnnotations.initMocks(this);
+ closeable = MockitoAnnotations.openMocks(this);
inPipe = new PipedInputStream(8096);
outPipe = new PipedOutputStream(inPipe);
@@ -76,84 +89,75 @@ public void setUp() throws IOException {
when(mockChannel.getOutputStream()).thenReturn(out);
}
+ @AfterEach
+ public void tearDown() throws Exception {
+ if (closeable != null) {
+ closeable.close();
+ }
+ }
+
@Test
- public void GIVEN_getCandidateConfig_WHEN_syntaxError_THEN_throwNetconfException() throws Exception {
+ public void getCandidateConfigThrowsNetconfExceptionOnSyntaxError() throws Exception {
when(mockNetconfSession.getCandidateConfig()).thenCallRealMethod();
when(mockNetconfSession.getRpcReply(anyString())).thenReturn(NETCONF_SYNTAX_ERROR_MSG_FROM_DEVICE);
assertThatThrownBy(mockNetconfSession::getCandidateConfig)
- .isInstanceOf(NetconfException.class)
- .hasMessage("Invalid message from server: netconf error: syntax error");
+ .isInstanceOf(NetconfException.class)
+ .hasMessage("Invalid message from server: netconf error: syntax error");
}
@Test
- public void GIVEN_getRunningConfig_WHEN_syntaxError_THEN_throwNetconfException() throws Exception {
+ public void getRunningConfigThrowsNetconfExceptionOnSyntaxError() throws Exception {
when(mockNetconfSession.getRunningConfig()).thenCallRealMethod();
when(mockNetconfSession.getRpcReply(anyString())).thenReturn(NETCONF_SYNTAX_ERROR_MSG_FROM_DEVICE);
assertThatThrownBy(mockNetconfSession::getRunningConfig)
- .isInstanceOf(NetconfException.class)
- .hasMessage("Invalid message from server: netconf error: syntax error");
+ .isInstanceOf(NetconfException.class)
+ .hasMessage("Invalid message from server: netconf error: syntax error");
}
@Test
- public void GIVEN_createSession_WHEN_timeoutExceeded_THEN_throwSocketTimeoutException() {
+ public void createSessionThrowsSocketTimeoutExceptionWhenTimeoutExceeded() {
Thread thread = new Thread(() -> {
try {
- outPipe.write(FAKE_RPC_REPLY.getBytes());
- for (int i = 0; i < 7; i++) {
- outPipe.write(FAKE_RPC_REPLY.getBytes());
- Thread.sleep(200);
- outPipe.flush();
- }
- Thread.sleep(200);
- outPipe.close();
+ writeDataWithDelay();
} catch (IOException | InterruptedException e) {
- log.error("error =", e);
+ log.error("Error in background thread", e);
}
});
thread.start();
assertThatThrownBy(() -> createNetconfSession(1000))
- .isInstanceOf(SocketTimeoutException.class)
- .hasMessage("Command timeout limit was exceeded: 1000");
+ .isInstanceOf(SocketTimeoutException.class)
+ .hasMessage("Command timeout limit was exceeded: 1000");
}
@Test
- public void GIVEN_createSession_WHEN_connectionClose_THEN_throwSocketTimeoutException() {
+ public void createSessionThrowsNetconfExceptionWhenConnectionCloses() {
Thread thread = new Thread(() -> {
try {
- outPipe.write(FAKE_RPC_REPLY.getBytes());
- Thread.sleep(200);
- outPipe.flush();
- Thread.sleep(200);
- outPipe.close();
+ writeDataAndClose();
} catch (IOException | InterruptedException e) {
- log.error("error =", e);
+ log.error("Error in background thread", e);
}
});
thread.start();
assertThatThrownBy(() -> createNetconfSession(COMMAND_TIMEOUT))
- .isInstanceOf(NetconfException.class)
- .hasMessage("Input Stream has been closed during reading.");
+ .isInstanceOf(NetconfException.class)
+ .hasMessage("Input Stream has been closed during reading.");
}
@Test
- public void GIVEN_createSession_WHEN_devicePromptWithoutLF_THEN_correctResponse() throws Exception {
+ public void createSessionHandlesDevicePromptWithoutLineFeed() throws Exception {
when(mockChannel.getInputStream()).thenReturn(inPipe);
when(mockChannel.getOutputStream()).thenReturn(out);
Thread thread = new Thread(() -> {
try {
- outPipe.write(FAKE_RPC_REPLY.getBytes());
- outPipe.write(DEVICE_PROMPT_BYTE);
- Thread.sleep(200);
- outPipe.flush();
- Thread.sleep(200);
- outPipe.close();
+ writeValidResponse();
} catch (IOException | InterruptedException e) {
- log.error("error =", e);
+ log.error("Error in background thread", e);
}
});
thread.start();
@@ -162,26 +166,16 @@ public void GIVEN_createSession_WHEN_devicePromptWithoutLF_THEN_correctResponse(
}
@Test
- public void GIVEN_executeRPC_WHEN_lldpRequest_THEN_correctResponse() throws Exception {
+ public void executeRpcReturnsCorrectResponseForLldpRequest() throws Exception {
byte[] lldpResponse = Files.readAllBytes(TestHelper.getSampleFile("responses/lldpResponse.xml").toPath());
String expectedResponse = new String(lldpResponse, Charsets.UTF_8)
- .replaceAll(NetconfConstants.CR, NetconfConstants.EMPTY_LINE) + NetconfConstants.LF;
+ .replaceAll(NetconfConstants.CR, NetconfConstants.EMPTY_LINE) + NetconfConstants.LF;
Thread thread = new Thread(() -> {
try {
- outPipe.write(FAKE_RPC_REPLY.getBytes());
- outPipe.write(DEVICE_PROMPT_BYTE);
- outPipe.flush();
- Thread.sleep(800);
- outPipe.write(lldpResponse);
- outPipe.flush();
- Thread.sleep(700);
- outPipe.write(DEVICE_PROMPT_BYTE);
- outPipe.flush();
- Thread.sleep(1900);
- outPipe.close();
+ writeLldpResponse(lldpResponse);
} catch (IOException | InterruptedException e) {
- log.error("error =", e);
+ log.error("Error in background thread", e);
}
});
thread.start();
@@ -197,215 +191,302 @@ public void GIVEN_executeRPC_WHEN_lldpRequest_THEN_correctResponse() throws Exce
}
@Test
- public void GIVEN_executeRPC_WHEN_syntaxError_THEN_throwNetconfException() throws Exception {
+ public void executeRpcThrowsNetconfExceptionOnSyntaxError() throws Exception {
when(mockNetconfSession.executeRPC(eq(TestConstants.LLDP_REQUEST))).thenCallRealMethod();
when(mockNetconfSession.getRpcReply(anyString())).thenReturn(NETCONF_SYNTAX_ERROR_MSG_FROM_DEVICE);
assertThatThrownBy(() -> mockNetconfSession.executeRPC(TestConstants.LLDP_REQUEST))
- .isInstanceOf(NetconfException.class)
- .hasMessage("Invalid message from server: netconf error: syntax error");
+ .isInstanceOf(NetconfException.class)
+ .hasMessage("Invalid message from server: netconf error: syntax error");
}
@Test
- public void GIVEN_stringWithoutRPC_fixupRPC_THEN_returnStringWrappedWithRPCTags() {
+ public void fixupRpcWrapsStringWithoutRpcTags() {
assertThat(NetconfSession.fixupRpc("fake string"))
- .isEqualTo("" + DEVICE_PROMPT);
+ .isEqualTo("" + DEVICE_PROMPT);
}
@Test
- public void GIVEN_stringWithRPCTags_fixupRPC_THEN_returnWrappedString() {
+ public void fixupRpcPreservesExistingRpcTags() {
assertThat(NetconfSession.fixupRpc("fake string"))
- .isEqualTo("fake string" + DEVICE_PROMPT);
+ .isEqualTo("fake string" + DEVICE_PROMPT);
}
@Test
- public void GIVEN_stringWithTag_fixupRPC_THEN_returnWrappedString() {
+ public void fixupRpcWrapsTaggedString() {
assertThat(NetconfSession.fixupRpc(""))
- .isEqualTo("" + DEVICE_PROMPT);
+ .isEqualTo("" + DEVICE_PROMPT);
}
@Test
- public void GIVEN_nullString_WHEN_fixupRPC_THEN_throwException() {
+ public void fixupRpcThrowsExceptionForNullString() {
//noinspection ConstantConditions
assertThatThrownBy(() -> NetconfSession.fixupRpc(null))
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessage("Null RPC");
- }
-
- private NetconfSession createNetconfSession(int commandTimeout) throws IOException {
- DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
- try {
- builder = factory.newDocumentBuilder();
- } catch (ParserConfigurationException e) {
- throw new NetconfException(String.format("Error creating XML Parser: %s", e.getMessage()));
- }
-
- return new NetconfSession(mockChannel, CONNECTION_TIMEOUT, commandTimeout, FAKE_HELLO, builder);
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Null RPC");
}
@Test
- public void WHEN_instantiated_THEN_fetchHelloFromServer() throws Exception {
-
- final String hello = ""
- + "\n"
- + " \n"
- + " urn:ietf:params:netconf:base:1.0\n"
- + " urn:ietf:params:netconf:base:1.0#candidate\n"
- + " urn:ietf:params:netconf:base:1.0#confirmed-commit\n"
- + " urn:ietf:params:netconf:base:1.0#validate\n"
- + " urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file\n"
- + " \n"
- + " 27700\n"
- + "";
-
+ public void instantiationFetchesHelloFromServer() throws Exception {
+ final String hello = createHelloMessage();
final ByteArrayInputStream is = new ByteArrayInputStream(
- (hello + NetconfConstants.DEVICE_PROMPT)
- .getBytes(StandardCharsets.UTF_8));
- when(mockChannel.getInputStream())
- .thenReturn(is);
+ (hello + NetconfConstants.DEVICE_PROMPT).getBytes(StandardCharsets.UTF_8));
+
+ when(mockChannel.getInputStream()).thenReturn(is);
final NetconfSession netconfSession = createNetconfSession(100);
- assertThat(netconfSession.getSessionId())
- .isEqualTo("27700");
+
+ assertThat(netconfSession.getSessionId()).isEqualTo("27700");
assertThat(netconfSession.getServerHello().hasCapability(NetconfConstants.URN_IETF_PARAMS_NETCONF_BASE_1_0))
.isTrue();
-
}
- private static void mockResponse(final InputStream is, final String message) throws IOException {
- final String messageWithTerminator = message + NetconfConstants.DEVICE_PROMPT;
- doAnswer(invocationOnMock -> {
- final byte[] buffer = (byte[])invocationOnMock.getArguments()[0];
- final int offset = (int)invocationOnMock.getArguments()[1];
- final int bufferLength = (int)invocationOnMock.getArguments()[2];
- final byte[] messageBytes = messageWithTerminator.getBytes(StandardCharsets.UTF_8);
- if(messageBytes.length > bufferLength ) {
- throw new IllegalArgumentException("Requires more work for long messages");
- }
- System.arraycopy(messageBytes, 0, buffer, offset, messageBytes.length);
- return messageBytes.length;
- }).when(is).read(any(), anyInt(), anyInt());
+ @Test
+ public void loadTextConfigurationSucceedsWithOkResponse() throws Exception {
+ doCallRealMethod().when(mockNetconfSession)
+ .loadTextConfiguration(anyString(), anyString());
+ when(mockNetconfSession.getRpcReply(anyString())).thenReturn(OK_RPC_REPLY);
+ when(mockNetconfSession.hasError()).thenReturn(false);
+ when(mockNetconfSession.isOK()).thenReturn(true);
+
+ // should complete without throwing
+ mockNetconfSession.loadTextConfiguration("some config", "some type");
}
@Test
- public void loadTextConfigurationWillSucceedIfResponseIsOk() throws Exception {
+ public void loadTextConfigurationFailsWithNotOkResponse() throws Exception {
+ final String helloMessage = createHelloMessage();
+ final RpcReply rpcReply = RpcReply.builder()
+ .ok(false)
+ .messageId("1")
+ .build();
+
+ final String combinedMessage = helloMessage + NetconfConstants.DEVICE_PROMPT +
+ rpcReply.getXml() + NetconfConstants.DEVICE_PROMPT;
+
+ final InputStream combinedStream = new ByteArrayInputStream(combinedMessage.getBytes(StandardCharsets.UTF_8));
+ when(mockChannel.getInputStream()).thenReturn(combinedStream);
- final InputStream is = mock(InputStream.class);
- when(mockChannel.getInputStream())
- .thenReturn(is);
- mockResponse(is, "");
final NetconfSession netconfSession = createNetconfSession(100);
+ assertThrows(LoadException.class,
+ () -> netconfSession.loadTextConfiguration("some config", "some type"));
+ }
+
+ @Test
+ public void loadTextConfigurationFailsWithOkResponseButErrors() throws Exception {
+ final String helloMessage = createHelloMessage();
final RpcReply rpcReply = RpcReply.builder()
.ok(true)
+ .addError(RpcError.builder().errorSeverity(RpcError.ErrorSeverity.ERROR).build())
+ .messageId("1")
.build();
- mockResponse(is, rpcReply.getXml());
-
- netconfSession.loadTextConfiguration("some config", "some type");
- verify(is, times(2)).read(any(), anyInt(), anyInt());
- }
+ final String combinedMessage = helloMessage + NetconfConstants.DEVICE_PROMPT +
+ rpcReply.getXml() + NetconfConstants.DEVICE_PROMPT;
- @Test
- public void loadTextConfigurationWillFailIfResponseIsNotOk() throws Exception {
+ final InputStream combinedStream = new ByteArrayInputStream(combinedMessage.getBytes(StandardCharsets.UTF_8));
+ when(mockChannel.getInputStream()).thenReturn(combinedStream);
- final InputStream is = mock(InputStream.class);
- when(mockChannel.getInputStream())
- .thenReturn(is);
- mockResponse(is, "");
final NetconfSession netconfSession = createNetconfSession(100);
- final RpcReply rpcReply = RpcReply.builder()
- .ok(false)
- .build();
- mockResponse(is, rpcReply.getXml());
-
assertThrows(LoadException.class,
() -> netconfSession.loadTextConfiguration("some config", "some type"));
+ }
- verify(is, times(2)).read(any(), anyInt(), anyInt());
+ @Test
+ public void loadXmlConfigurationSucceedsWithOkResponse() throws Exception {
+ doCallRealMethod().when(mockNetconfSession)
+ .loadXMLConfiguration(anyString(), anyString());
+ when(mockNetconfSession.getRpcReply(anyString())).thenReturn(OK_RPC_REPLY);
+ when(mockNetconfSession.hasError()).thenReturn(false);
+ when(mockNetconfSession.isOK()).thenReturn(true);
+
+ // should complete without throwing
+ mockNetconfSession.loadXMLConfiguration("some config", "merge");
}
@Test
- public void loadTextConfigurationWillFailIfResponseIsOkWithErrors() throws Exception {
+ public void loadXmlConfigurationFailsWithNotOkResponse() throws Exception {
+ final String helloMessage = createHelloMessage();
+ final RpcReply rpcReply = RpcReply.builder()
+ .ok(false)
+ .messageId("1")
+ .build();
+
+ final String combinedMessage = helloMessage + NetconfConstants.DEVICE_PROMPT +
+ rpcReply.getXml() + NetconfConstants.DEVICE_PROMPT;
+
+ final InputStream combinedStream = new ByteArrayInputStream(combinedMessage.getBytes(StandardCharsets.UTF_8));
+ when(mockChannel.getInputStream()).thenReturn(combinedStream);
- final InputStream is = mock(InputStream.class);
- when(mockChannel.getInputStream())
- .thenReturn(is);
- mockResponse(is, "");
final NetconfSession netconfSession = createNetconfSession(100);
+ assertThrows(LoadException.class,
+ () -> netconfSession.loadXMLConfiguration("some config", "merge"));
+ }
+
+ @Test
+ public void loadXmlConfigurationFailsWithOkResponseButErrors() throws Exception {
+ final String helloMessage = createHelloMessage();
final RpcReply rpcReply = RpcReply.builder()
.ok(true)
- .error(RpcError.builder().errorSeverity(RpcError.ErrorSeverity.ERROR).build())
+ .addError(RpcError.builder().errorSeverity(RpcError.ErrorSeverity.ERROR).build())
+ .messageId("1")
.build();
- mockResponse(is, rpcReply.getXml());
- assertThrows(LoadException.class,
- () -> netconfSession.loadTextConfiguration("some config", "some type"));
+ final String combinedMessage = helloMessage + NetconfConstants.DEVICE_PROMPT +
+ rpcReply.getXml() + NetconfConstants.DEVICE_PROMPT;
+
+ final InputStream combinedStream = new ByteArrayInputStream(combinedMessage.getBytes(StandardCharsets.UTF_8));
+ when(mockChannel.getInputStream()).thenReturn(combinedStream);
+
+ final NetconfSession netconfSession = createNetconfSession(100);
- verify(is, times(2)).read(any(), anyInt(), anyInt());
+ assertThrows(LoadException.class,
+ () -> netconfSession.loadXMLConfiguration("some config", "merge"));
}
+ /**
+ * RFC 6241 §7.9 – a successful <kill-session> returns <ok/>.
+ */
@Test
- public void loadXmlConfigurationWillSucceedIfResponseIsOk() throws Exception {
+ public void killSessionReturnsTrueOnOkReply() throws Exception {
+ when(mockNetconfSession.killSession("42")).thenCallRealMethod();
+ when(mockNetconfSession.getRpcReply(anyString())).thenReturn(OK_RPC_REPLY);
+ when(mockNetconfSession.hasError()).thenReturn(false);
+ when(mockNetconfSession.isOK()).thenReturn(true);
- final InputStream is = mock(InputStream.class);
- when(mockChannel.getInputStream())
- .thenReturn(is);
- mockResponse(is, "");
- final NetconfSession netconfSession = createNetconfSession(100);
+ assertThat(mockNetconfSession.killSession("42")).isTrue();
+ }
- final RpcReply rpcReply = RpcReply.builder()
- .ok(true)
- .build();
- mockResponse(is, rpcReply.getXml());
+ /**
+ * RFC 6241 §7.9 – if the server returns <rpc-error>, killSession() should return false.
+ */
+ @Test
+ public void killSessionReturnsFalseOnErrorReply() throws Exception {
+ when(mockNetconfSession.killSession("99")).thenCallRealMethod();
+ when(mockNetconfSession.getRpcReply(anyString())).thenReturn(ERROR_RPC_REPLY);
+ when(mockNetconfSession.hasError()).thenReturn(true);
+ when(mockNetconfSession.isOK()).thenReturn(false);
- netconfSession.loadXMLConfiguration("some config", "merge");
+ assertThat(mockNetconfSession.killSession("99")).isFalse();
+ }
+
+ /* =========================================================
+ * :confirmed-commit:1.1 tests
+ * ========================================================= */
- verify(is, times(2)).read(any(), anyInt(), anyInt());
+ @Test
+ public void commitConfirmWithPersistCompletesSuccessfullyOnOkReply() throws Exception {
+ doCallRealMethod().when(mockNetconfSession).commitConfirm(600, "abc");
+ when(mockNetconfSession.getRpcReply(anyString())).thenReturn(OK_RPC_REPLY);
+ when(mockNetconfSession.hasError()).thenReturn(false);
+ when(mockNetconfSession.isOK()).thenReturn(true);
+
+ // should complete without throwing CommitException
+ mockNetconfSession.commitConfirm(600, "abc");
}
@Test
- public void loadXmlConfigurationWillFailIfResponseIsNotOk() throws Exception {
+ public void commitConfirmWithPersistThrowsCommitExceptionOnErrorReply() throws Exception {
+ doCallRealMethod().when(mockNetconfSession).commitConfirm(600, "xyz");
+ when(mockNetconfSession.getRpcReply(anyString())).thenReturn(ERROR_RPC_REPLY);
+ when(mockNetconfSession.hasError()).thenReturn(true);
+ when(mockNetconfSession.isOK()).thenReturn(false);
+
+ assertThatThrownBy(() -> mockNetconfSession.commitConfirm(600, "xyz"))
+ .isInstanceOf(CommitException.class)
+ .hasMessage("Confirmed-commit operation returned error.");
+ }
- final InputStream is = mock(InputStream.class);
- when(mockChannel.getInputStream())
- .thenReturn(is);
- mockResponse(is, "");
- final NetconfSession netconfSession = createNetconfSession(100);
+ /* ----- cancel-commit ----- */
- final RpcReply rpcReply = RpcReply.builder()
- .ok(false)
- .build();
+ @Test
+ public void cancelCommitReturnsTrueOnOkReply() throws Exception {
+ when(mockNetconfSession.cancelCommit("abc")).thenCallRealMethod();
+ when(mockNetconfSession.getRpcReply(anyString())).thenReturn(OK_RPC_REPLY);
+ when(mockNetconfSession.hasError()).thenReturn(false);
+ when(mockNetconfSession.isOK()).thenReturn(true);
- mockResponse(is, rpcReply.getXml());
+ assertThat(mockNetconfSession.cancelCommit("abc")).isTrue();
+ }
- assertThrows(LoadException.class,
- () -> netconfSession.loadXMLConfiguration("some config", "merge"));
+ @Test
+ public void cancelCommitReturnsFalseOnErrorReply() throws Exception {
+ when(mockNetconfSession.cancelCommit(null)).thenCallRealMethod();
+ when(mockNetconfSession.getRpcReply(anyString())).thenReturn(ERROR_RPC_REPLY);
+ when(mockNetconfSession.hasError()).thenReturn(true);
+ when(mockNetconfSession.isOK()).thenReturn(false);
- verify(is, times(2)).read(any(), anyInt(), anyInt());
+ assertThat(mockNetconfSession.cancelCommit(null)).isFalse();
}
- @Test
- public void loadXmlConfigurationWillFailIfResponseIsOkWithErrors() throws Exception {
+ // Helper methods to reduce code duplication and improve readability
- final InputStream is = mock(InputStream.class);
- when(mockChannel.getInputStream())
- .thenReturn(is);
- mockResponse(is, "");
- final NetconfSession netconfSession = createNetconfSession(100);
+ private NetconfSession createNetconfSession(int commandTimeout) throws IOException {
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+ try {
+ builder = factory.newDocumentBuilder();
+ } catch (ParserConfigurationException e) {
+ throw new NetconfException(String.format("Error creating XML Parser: %s", e.getMessage()));
+ }
- final RpcReply rpcReply = RpcReply.builder()
- .ok(true)
- .error(RpcError.builder().errorSeverity(RpcError.ErrorSeverity.ERROR).build())
- .build();
- mockResponse(is, rpcReply.getXml());
+ return new NetconfSession(mockChannel, CONNECTION_TIMEOUT, commandTimeout, FAKE_HELLO, builder);
+ }
- assertThrows(LoadException.class,
- () -> netconfSession.loadXMLConfiguration("some config", "merge"));
+ private void writeDataWithDelay() throws IOException, InterruptedException {
+ outPipe.write(FAKE_RPC_REPLY.getBytes(StandardCharsets.UTF_8));
+ for (int i = 0; i < 7; i++) {
+ outPipe.write(FAKE_RPC_REPLY.getBytes(StandardCharsets.UTF_8));
+ Thread.sleep(200);
+ outPipe.flush();
+ }
+ Thread.sleep(200);
+ outPipe.close();
+ }
- verify(is, times(2)).read(any(), anyInt(), anyInt());
+ private void writeDataAndClose() throws IOException, InterruptedException {
+ outPipe.write(FAKE_RPC_REPLY.getBytes(StandardCharsets.UTF_8));
+ Thread.sleep(200);
+ outPipe.flush();
+ Thread.sleep(200);
+ outPipe.close();
}
-}
+ private void writeValidResponse() throws IOException, InterruptedException {
+ outPipe.write(FAKE_RPC_REPLY.getBytes(StandardCharsets.UTF_8));
+ outPipe.write(DEVICE_PROMPT_BYTE);
+ Thread.sleep(200);
+ outPipe.flush();
+ Thread.sleep(200);
+ outPipe.close();
+ }
+
+ private void writeLldpResponse(byte[] lldpResponse) throws IOException, InterruptedException {
+ outPipe.write(FAKE_RPC_REPLY.getBytes(StandardCharsets.UTF_8));
+ outPipe.write(DEVICE_PROMPT_BYTE);
+ outPipe.flush();
+ Thread.sleep(800);
+ outPipe.write(lldpResponse);
+ outPipe.flush();
+ Thread.sleep(700);
+ outPipe.write(DEVICE_PROMPT_BYTE);
+ outPipe.flush();
+ Thread.sleep(1900);
+ outPipe.close();
+ }
+
+ private String createHelloMessage() {
+ return "\n"
+ + " \n"
+ + " urn:ietf:params:netconf:base:1.0\n"
+ + " urn:ietf:params:netconf:base:1.0#candidate\n"
+ + " urn:ietf:params:netconf:base:1.0#confirmed-commit\n"
+ + " urn:ietf:params:netconf:base:1.0#validate\n"
+ + " urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file\n"
+ + " \n"
+ + " 27700\n"
+ + "";
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/net/juniper/netconf/TestConstants.java b/src/test/java/net/juniper/netconf/TestConstants.java
index e4357f1..32a95d4 100644
--- a/src/test/java/net/juniper/netconf/TestConstants.java
+++ b/src/test/java/net/juniper/netconf/TestConstants.java
@@ -1,5 +1,15 @@
package net.juniper.netconf;
+/**
+ * Central location for NETCONF protocol constants used across the library.
+ *
+ * The values defined here correspond to RFC 6241 (base 1.0) and related drafts
+ * so that all modules reference a single, canonical source of truth rather than
+ * scattering string literals throughout the codebase.
+ *
+ * This class is a simple constant holder and is therefore marked {@code final}
+ * and given a private constructor to prevent instantiation.
+ */
public class TestConstants {
public static final String CORRECT_HELLO = "\n" +
diff --git a/src/test/java/net/juniper/netconf/XMLBuilderTest.java b/src/test/java/net/juniper/netconf/XMLBuilderTest.java
new file mode 100644
index 0000000..8b64350
--- /dev/null
+++ b/src/test/java/net/juniper/netconf/XMLBuilderTest.java
@@ -0,0 +1,100 @@
+package net.juniper.netconf;
+
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+public class XMLBuilderTest {
+
+ @Test
+ public void createNewConfig_twoElements_createsExpectedXML() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewConfig("system", "services");
+ assertThat(xml.toString())
+ .containsIgnoringWhitespaces(
+ "");
+ }
+
+ @Test
+ public void createNewConfig_oneElement_createsExpectedXML() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewConfig("system");
+ assertThat(xml.toString()).containsIgnoringWhitespaces("");
+ }
+
+ @Test
+ public void createNewConfig_threeElements_createsExpectedXML() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewConfig("system", "services", "ftp");
+ assertThat(xml.toString()).containsIgnoringWhitespaces("");
+ }
+
+ @Test
+ public void createNewConfig_list_createsExpectedXML() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewConfig(Arrays.asList("system", "services", "ftp"));
+ assertThat(xml.toString()).containsIgnoringWhitespaces("");
+ }
+
+ @Test
+ public void createNewConfig_emptyList_returnsNull() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewConfig(Collections.emptyList());
+ assertThat(xml).isNull();
+ }
+
+ @Test
+ public void createNewRPC_twoElements_createsExpectedXML() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewRPC("get-interface-information", "terse");
+ String xmlStr = xml.toString();
+
+ // Verify opening tag has message‑id and correct namespace (order irrelevant)
+ assertThat(xmlStr)
+ .matches("(?s).*]*message-id=\"\\d+\"[^>]*xmlns=\"urn:ietf:params:xml:ns:netconf:base:1.0\"[^>]*>.*");
+
+ // Verify payload hierarchy, ignoring whitespace/line breaks
+ assertThat(xmlStr)
+ .containsIgnoringWhitespaces("");
+ }
+
+ @Test
+ public void createNewXML_fourElements_createsExpectedXML() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewXML("top", "middle", "sub", "leaf");
+ assertThat(xml.toString()).containsIgnoringWhitespaces("");
+ }
+
+ @Test
+ public void createNewXML_list_createsExpectedXML() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewXML(Arrays.asList("a", "b", "c"));
+ assertThat(xml.toString()).containsIgnoringWhitespaces("");
+ }
+
+ @Test
+ public void createNewXML_emptyList_returnsNull() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewXML(Collections.emptyList());
+ assertThat(xml).isNull();
+ }
+
+ @Test
+ public void createNewRPC_autoAddsMessageIdAndNamespace() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewRPC("get", "running");
+ String xmlStr = xml.toString();
+
+ // Assert the rpc element has a message-id attribute with a numeric value
+ assertThat(xmlStr)
+ .contains("message-id=\"")
+ .matches("(?s).*]*message-id=\"\\d+\"[^>]*xmlns=\"urn:ietf:params:xml:ns:netconf:base:1.0\"[^>]*>.*");
+
+ // Ensure hierarchy is intact
+ assertThat(xmlStr)
+ .containsIgnoringWhitespaces("");
+ }
+}
diff --git a/src/test/java/net/juniper/netconf/XMLTest.java b/src/test/java/net/juniper/netconf/XMLTest.java
index 08acef1..b3cf7db 100644
--- a/src/test/java/net/juniper/netconf/XMLTest.java
+++ b/src/test/java/net/juniper/netconf/XMLTest.java
@@ -1,6 +1,6 @@
package net.juniper.netconf;
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
@@ -8,9 +8,13 @@
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
+import org.w3c.dom.Node;
+import java.util.stream.Collectors;
+import java.util.Map;
import static net.juniper.netconf.TestHelper.getSampleFile;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
public class XMLTest {
@@ -45,4 +49,269 @@ public void GIVEN_sampleCliOutputRpc_WHEN_findValueOfSample_THEN_returnValue() t
String expectedValue = "operational-response";
testFindValue(sampleFileName,findValueList, expectedValue);
}
-}
+
+ @Test
+ public void GIVEN_missingElement_WHEN_findValue_THEN_returnNull() throws Exception {
+ String sampleFileName = "sampleMissingElement.xml";
+ List findValueList = Arrays.asList(
+ "environment-component-information",
+ "environment-component-item",
+ "name~Nonexistent Component",
+ "temperature"
+ );
+ String expectedValue = null;
+ testFindValue(sampleFileName, findValueList, expectedValue);
+ }
+
+ @Test
+ public void GIVEN_missingFile_WHEN_findValue_THEN_throwException() throws Exception {
+ assertThrows(java.io.FileNotFoundException.class, () -> {
+ String sampleFileName = "nonexistent.xml";
+ List findValueList = Collections.singletonList("any");
+ testFindValue(sampleFileName, findValueList, "irrelevant");
+ });
+ }
+
+ @Test
+ public void GIVEN_emptyFindValueList_WHEN_findValue_THEN_returnNull() throws Exception {
+ String sampleFileName = "sampleEmptyFPCTempRpcReply.xml";
+ List findValueList = Collections.emptyList();
+ String expectedValue = null;
+ testFindValue(sampleFileName, findValueList, expectedValue);
+ }
+
+ @Test
+ public void GIVEN_simpleXml_WHEN_toString_THEN_returnXmlString() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewXML("foo", "bar");
+
+ String xmlString = xml.toString();
+ assertThat(xmlString).contains("");
+ assertThat(xmlString).contains("");
+ }
+
+ @Test
+ public void GIVEN_twoIdenticalXmls_WHEN_equals_THEN_returnTrue() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml1 = builder.createNewXML("top", "child");
+ XML xml2 = builder.createNewXML("top", "child");
+
+ org.xmlunit.assertj.XmlAssert.assertThat(xml1.toString())
+ .and(xml2.toString())
+ .areIdentical(); }
+
+ @Test
+ public void GIVEN_xml_WHEN_hashCodeInvoked_THEN_noException() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewXML("alpha", "beta");
+
+ int hash = xml.hashCode();
+ assertThat(hash).isNotZero();
+ }
+
+ @Test
+ public void GIVEN_noTextInRoot_WHEN_findValue_THEN_returnNull() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewXML("root");
+
+ String value = xml.findValue(Collections.singletonList("root"));
+ assertThat(value).isNull();
+ }
+
+ @Test
+ public void GIVEN_childNodeWithText_WHEN_findValue_THEN_returnText() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewXML("parent");
+ xml.addPath("child").setTextContent("hello");
+
+ String value = xml.findValue(Arrays.asList("child"));
+ assertThat(value).isEqualTo("hello");
+ }
+
+ @Test
+ public void GIVEN_parentWithTwoItems_WHEN_findNodes_item_THEN_returnBoth() throws Exception {
+ DocumentBuilder builder = factory.newDocumentBuilder();
+ org.w3c.dom.Document doc = builder.newDocument();
+ org.w3c.dom.Element parent = doc.createElement("parent");
+ doc.appendChild(parent);
+
+ org.w3c.dom.Element item1 = doc.createElement("item");
+ item1.setTextContent("one");
+ parent.appendChild(item1);
+
+ org.w3c.dom.Element item2 = doc.createElement("item");
+ item2.setTextContent("two");
+ parent.appendChild(item2);
+
+ XML xml = new XML(parent);
+
+ List result = xml.findNodes(Collections.singletonList("item"));
+ assertThat(result).hasSize(2);
+ assertThat(result.stream().map(Node::getTextContent).collect(Collectors.toSet()))
+ .containsExactlyInAnyOrder("one", "two");
+ }
+ /* ------------------------------------------------------------------
+ * Junos‑specific XML attribute helpers
+ * ------------------------------------------------------------------ */
+
+ @Test
+ public void GIVEN_activeElement_WHEN_junosDeactivate_THEN_inactiveAttrSet() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewXML("system");
+ xml.junosDeactivate();
+
+ String inactive = xml.getOwnerDocument().getDocumentElement().getAttribute("inactive");
+ assertThat(inactive).isEqualTo("inactive");
+ }
+
+ @Test
+ public void GIVEN_activeElement_WHEN_junosRename_THEN_renameAndNameAttrsSet() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewXML("interface");
+ xml.junosRename("ge-0/0/0", "ge-0/0/1");
+
+ org.w3c.dom.Element element = xml.getOwnerDocument().getDocumentElement();
+ assertThat(element.getAttribute("rename")).isEqualTo("ge-0/0/0");
+ assertThat(element.getAttribute("name")).isEqualTo("ge-0/0/1");
+ }
+
+ @Test
+ public void GIVEN_activeElement_WHEN_junosInsert_THEN_insertAndNameAttrsSet() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xml = builder.createNewXML("policy");
+ xml.junosInsert("before-me", "new-policy");
+
+ org.w3c.dom.Element element = xml.getOwnerDocument().getDocumentElement();
+ assertThat(element.getAttribute("insert")).isEqualTo("before-me");
+ assertThat(element.getAttribute("name")).isEqualTo("new-policy");
+ }
+
+ @Test
+ public void GIVEN_activeElement_WHEN_append_THEN_childAddedAndReturned() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xmlParent = builder.createNewXML("configuration");
+
+ XML xmlChild = xmlParent.append("system");
+ // Verify method returns a new XML pointing at the child
+ assertThat(xmlChild.getOwnerDocument().getDocumentElement().getNodeName())
+ .isEqualTo("configuration");
+ assertThat(xmlChild.toString()).contains("");
+
+ // Ensure the child element is actually appended under the parent
+ String full = xmlParent.getOwnerDocument().getDocumentElement().getTextContent();
+ assertThat(full).isEmpty(); // configuration has one child but no text
+ }
+
+ /* ------------------------------------------------------------------
+ * Append helpers (element / text / map / array)
+ * ------------------------------------------------------------------ */
+
+ @Test
+ public void GIVEN_parent_WHEN_appendElementWithText_THEN_childContainsText() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xmlParent = builder.createNewXML("config");
+ XML xmlChild = xmlParent.append("hostname", "router1");
+
+ // verify returned XML is for
+ assertThat(xmlChild.getOwnerDocument().getDocumentElement().getNodeName())
+ .isEqualTo("config");
+ assertThat(xmlChild.toString()).contains("router1");
+ }
+
+ @Test
+ public void GIVEN_parent_WHEN_appendMultipleSameName_THEN_allChildrenAdded() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xmlParent = builder.createNewXML("interfaces");
+ xmlParent.append("unit", new String[] { "0", "1", "2" });
+
+ org.w3c.dom.NodeList units =
+ xmlParent.getOwnerDocument().getDocumentElement().getElementsByTagName("unit");
+ assertThat(units.getLength()).isEqualTo(3);
+ }
+
+ @Test
+ public void GIVEN_parent_WHEN_appendMap_THEN_childrenMatchMap() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xmlParent = builder.createNewXML("system");
+ Map map = Map.of("services", "on",
+ "location", "lab");
+ xmlParent.append(map);
+
+ assertThat(xmlParent.toString())
+ .contains("on")
+ .contains("lab");
+ }
+
+ @Test
+ public void GIVEN_parent_WHEN_appendElementThenMap_THEN_newXMLContainsMapChildren() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xmlParent = builder.createNewXML("configuration");
+
+ Map childMap = Map.of("rpc", "true", "ssh", "true");
+ XML xmlServices = xmlParent.append("services", childMap);
+
+ // ensure returned XML is level
+ assertThat(xmlServices.toString())
+ .contains("true")
+ .contains("true");
+ }
+
+ /* ------------------------------------------------------------------
+ * Sibling helper tests
+ * ------------------------------------------------------------------ */
+
+ @Test
+ public void GIVEN_child_WHEN_addSiblingElement_THEN_siblingAppended() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xmlParent = builder.createNewXML("parent");
+ XML xmlChild = xmlParent.addPath("child1");
+
+ xmlChild.addSibling("child2");
+
+ org.w3c.dom.NodeList children =
+ xmlParent.getOwnerDocument().getDocumentElement().getElementsByTagName("*");
+ assertThat(children.getLength()).isEqualTo(2);
+ }
+
+ @Test
+ public void GIVEN_child_WHEN_addSiblingWithText_THEN_textSet() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xmlParent = builder.createNewXML("parent");
+ XML xmlChild = xmlParent.addPath("item1");
+
+ xmlChild.addSibling("item2", "value");
+
+ org.w3c.dom.Element sibling =
+ (org.w3c.dom.Element) xmlParent.getOwnerDocument()
+ .getDocumentElement()
+ .getElementsByTagName("item2").item(0);
+ assertThat(sibling.getTextContent()).isEqualTo("value");
+ }
+
+ @Test
+ public void GIVEN_child_WHEN_addSiblingsArray_THEN_allAdded() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xmlParent = builder.createNewXML("list");
+ XML xmlChild = xmlParent.addPath("entry");
+
+ xmlChild.addSiblings("entry", new String[] { "a", "b" });
+
+ org.w3c.dom.NodeList entries =
+ xmlParent.getOwnerDocument().getDocumentElement().getElementsByTagName("entry");
+ assertThat(entries.getLength()).isEqualTo(3); // original + 2 new
+ }
+
+ @Test
+ public void GIVEN_child_WHEN_addSiblingsMap_THEN_allAdded() throws Exception {
+ XMLBuilder builder = new XMLBuilder();
+ XML xmlParent = builder.createNewXML("data");
+ XML xmlChild = xmlParent.addPath("level");
+
+ Map map = Map.of("alpha","1","beta","2");
+ xmlChild.addSiblings(map);
+
+ assertThat(xmlParent.toString())
+ .contains("1")
+ .contains("2");
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/net/juniper/netconf/element/DatastoreTest.java b/src/test/java/net/juniper/netconf/element/DatastoreTest.java
new file mode 100644
index 0000000..4d6f7c6
--- /dev/null
+++ b/src/test/java/net/juniper/netconf/element/DatastoreTest.java
@@ -0,0 +1,40 @@
+package net.juniper.netconf.element;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class DatastoreTest {
+
+ @Test
+ void testToStringReturnsLowercaseName() {
+ assertEquals("running", Datastore.RUNNING.toString());
+ assertEquals("candidate", Datastore.CANDIDATE.toString());
+ assertEquals("startup", Datastore.STARTUP.toString());
+ assertEquals("intended", Datastore.INTENDED.toString());
+ assertEquals("operational", Datastore.OPERATIONAL.toString());
+ }
+
+ @Test
+ void testFromXmlNameIsCaseInsensitive() {
+ assertEquals(Datastore.RUNNING, Datastore.fromXmlName("RUNNING"));
+ assertEquals(Datastore.STARTUP, Datastore.fromXmlName("Startup"));
+ assertEquals(Datastore.OPERATIONAL, Datastore.fromXmlName("operational"));
+ }
+
+ @Test
+ void testFromXmlNameThrowsOnUnknown() {
+ Exception ex = assertThrows(IllegalArgumentException.class, () -> {
+ Datastore.fromXmlName("bogus");
+ });
+ assertTrue(ex.getMessage().contains("Unknown Datastore XML name"));
+ }
+
+ @Test
+ void testFromXmlNameThrowsOnNull() {
+ Exception ex = assertThrows(IllegalArgumentException.class, () -> {
+ Datastore.fromXmlName(null);
+ });
+ assertTrue(ex.getMessage().contains("cannot be null"));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/net/juniper/netconf/element/HelloTest.java b/src/test/java/net/juniper/netconf/element/HelloTest.java
index 3b9364a..bc36767 100644
--- a/src/test/java/net/juniper/netconf/element/HelloTest.java
+++ b/src/test/java/net/juniper/netconf/element/HelloTest.java
@@ -1,36 +1,44 @@
package net.juniper.netconf.element;
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
import org.xmlunit.assertj.XmlAssert;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.net.URI;
+import java.net.URISyntaxException;
public class HelloTest {
// Samples taken from https://www.juniper.net/documentation/us/en/software/junos/netconf/topics/concept/netconf-session-rfc-compliant.html
- public static final String HELLO_WITHOUT_NAMESPACE = ""
- + "\n"
- + " \n"
- + " urn:ietf:params:netconf:base:1.0\n"
- + " urn:ietf:params:netconf:base:1.0#candidate\n"
- + " urn:ietf:params:netconf:base:1.0#confirmed-commit\n"
- + " urn:ietf:params:netconf:base:1.0#validate\n"
- + " urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file\n"
- + " \n"
- + " 27700\n"
- + "";
-
- public static final String HELLO_WITH_NAMESPACE = "" +
- "\n"
- + " \n"
- + " urn:ietf:params:netconf:base:1.0\n"
- + " urn:ietf:params:netconf:base:1.0#candidate\n"
- + " urn:ietf:params:netconf:base:1.0#confirmed-commit\n"
- + " urn:ietf:params:netconf:base:1.0#validate\n"
- + " urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file\n"
- + " \n"
- + " 27703\n"
- + "";
+ public static final String HELLO_WITHOUT_NAMESPACE = """
+ \
+
+
+ urn:ietf:params:netconf:base:1.0
+ urn:ietf:params:netconf:base:1.0#candidate
+ urn:ietf:params:netconf:base:1.0#confirmed-commit
+ urn:ietf:params:netconf:base:1.0#validate
+ urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file
+ urn:ietf:params:netconf:base:1.1
+
+ 27700
+ """;
+
+ public static final String HELLO_WITH_NAMESPACE = """
+ \
+
+
+ urn:ietf:params:netconf:base:1.0
+ urn:ietf:params:netconf:base:1.0#candidate
+ urn:ietf:params:netconf:base:1.0#confirmed-commit
+ urn:ietf:params:netconf:base:1.0#validate
+ urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file
+ urn:ietf:params:netconf:base:1.1
+
+ 27703
+ """;
@Test
public void willCreateAnObjectFromPacketWithoutNamespace() throws Exception {
@@ -38,16 +46,17 @@ public void willCreateAnObjectFromPacketWithoutNamespace() throws Exception {
final Hello hello = Hello.from(HELLO_WITHOUT_NAMESPACE);
assertThat(hello.getSessionId())
- .isEqualTo("27700");
+ .isEqualTo("27700");
assertThat(hello.getCapabilities())
- .containsExactly(
- "urn:ietf:params:netconf:base:1.0",
- "urn:ietf:params:netconf:base:1.0#candidate",
- "urn:ietf:params:netconf:base:1.0#confirmed-commit",
- "urn:ietf:params:netconf:base:1.0#validate",
- "urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file");
+ .containsExactly(
+ "urn:ietf:params:netconf:base:1.0",
+ "urn:ietf:params:netconf:base:1.0#candidate",
+ "urn:ietf:params:netconf:base:1.0#confirmed-commit",
+ "urn:ietf:params:netconf:base:1.0#validate",
+ "urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file",
+ "urn:ietf:params:netconf:base:1.1");
assertThat(hello.hasCapability("urn:ietf:params:netconf:base:1.0#candidate"))
- .isTrue();
+ .isTrue();
}
@Test
@@ -56,52 +65,158 @@ public void willCreateAnObjectFromPacketWithNamespace() throws Exception {
final Hello hello = Hello.from(HELLO_WITH_NAMESPACE);
assertThat(hello.getSessionId())
- .isEqualTo("27703");
+ .isEqualTo("27703");
assertThat(hello.getCapabilities())
- .containsExactly(
- "urn:ietf:params:netconf:base:1.0",
- "urn:ietf:params:netconf:base:1.0#candidate",
- "urn:ietf:params:netconf:base:1.0#confirmed-commit",
- "urn:ietf:params:netconf:base:1.0#validate",
- "urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file");
+ .containsExactly(
+ "urn:ietf:params:netconf:base:1.0",
+ "urn:ietf:params:netconf:base:1.0#candidate",
+ "urn:ietf:params:netconf:base:1.0#confirmed-commit",
+ "urn:ietf:params:netconf:base:1.0#validate",
+ "urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file",
+ "urn:ietf:params:netconf:base:1.1");
assertThat(hello.hasCapability("urn:ietf:params:netconf:base:1.0#candidate"))
- .isTrue();
+ .isTrue();
}
@Test
public void willCreateXmlFromAnObject() {
final Hello hello = Hello.builder()
- .capability("urn:ietf:params:netconf:base:1.0")
- .capability("urn:ietf:params:netconf:base:1.0#candidate")
- .capability("urn:ietf:params:netconf:base:1.0#confirmed-commit")
- .capability("urn:ietf:params:netconf:base:1.0#validate")
- .capability("urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file")
- .sessionId("27700")
- .build();
+ .capability("urn:ietf:params:netconf:base:1.0")
+ .capability("urn:ietf:params:netconf:base:1.0#candidate")
+ .capability("urn:ietf:params:netconf:base:1.0#confirmed-commit")
+ .capability("urn:ietf:params:netconf:base:1.0#validate")
+ .capability("urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file")
+ .capability("urn:ietf:params:netconf:base:1.1")
+
+ .sessionId("27700")
+ .build();
XmlAssert.assertThat(hello.getXml())
- .and(HELLO_WITHOUT_NAMESPACE)
- .ignoreWhitespace()
- .areIdentical();
+ .and(HELLO_WITHOUT_NAMESPACE)
+ .ignoreWhitespace()
+ .areIdentical();
}
@Test
public void willCreateXmlWithNamespaceFromAnObject() {
final Hello hello = Hello.builder()
- .namespacePrefix("nc")
- .capability("urn:ietf:params:netconf:base:1.0")
- .capability("urn:ietf:params:netconf:base:1.0#candidate")
- .capability("urn:ietf:params:netconf:base:1.0#confirmed-commit")
- .capability("urn:ietf:params:netconf:base:1.0#validate")
- .capability("urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file")
- .sessionId("27703")
- .build();
+ .namespacePrefix("nc")
+ .capability("urn:ietf:params:netconf:base:1.0")
+ .capability("urn:ietf:params:netconf:base:1.0#candidate")
+ .capability("urn:ietf:params:netconf:base:1.0#confirmed-commit")
+ .capability("urn:ietf:params:netconf:base:1.0#validate")
+ .capability("urn:ietf:params:netconf:base:1.0#url?protocol=http,ftp,file")
+ .sessionId("27703")
+ .build();
XmlAssert.assertThat(hello.getXml())
- .and(HELLO_WITH_NAMESPACE)
- .ignoreWhitespace()
- .areIdentical();
+ .and(HELLO_WITH_NAMESPACE)
+ .ignoreWhitespace()
+ .areIdentical();
+ }
+
+ @Test
+ public void willHandleEmptyCapabilities() {
+ Hello hello = Hello.builder()
+ .sessionId("99999")
+ .build();
+
+ // Base capability 1.1 is auto‑injected by the builder
+ assertThat(hello.getCapabilities())
+ .containsExactly("urn:ietf:params:netconf:base:1.1");
+ assertThat(hello.getSessionId()).isEqualTo("99999");
+ }
+
+ @Test
+ public void willHandleNullSessionId() {
+ Hello hello = Hello.builder()
+ .capability("urn:ietf:params:netconf:base:1.0")
+ .build();
+
+ assertThat(hello.getSessionId()).isNull();
+ }
+
+ @Test
+ public void willThrowOnMalformedXml() {
+ String badXml = "urn";
+ assertThatThrownBy(() -> Hello.from(badXml))
+ .isInstanceOf(Exception.class);
+ }
+
+ @Test
+ public void willRoundTripNamespaceXml() throws Exception {
+ Hello original = Hello.from(HELLO_WITH_NAMESPACE);
+ Hello roundTripped = Hello.from(original.getXml());
+ assertThat(roundTripped).isEqualTo(original);
+ }
+
+ @Test
+ public void differentObjectsNotEqual() {
+ Hello h1 = Hello.builder().sessionId("1").build();
+ Hello h2 = Hello.builder().sessionId("2").build();
+ assertThat(h1).isNotEqualTo(h2);
+ }
+
+ @Test
+ public void willHandleNullCapabilityCheck() {
+ Hello hello = Hello.builder().build();
+ assertThat(hello.hasCapability(null)).isFalse();
+ }
+
+ /**
+ * RFC 6241 §3.1 – capability names MUST be valid URIs.
+ * Supplying an invalid capability string to the builder should
+ * throw an IllegalArgumentException.
+ */
+ @Test
+ public void willRejectNonUriCapability() {
+ String bogus = "not a uri";
+ assertThatThrownBy(() -> Hello.builder()
+ .sessionId("42")
+ .capability(bogus)
+ .build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Capability MUST be a valid URI per RFC 3986:");
+ }
+
+ @Test
+ void builderAddsBase11CapabilityByDefault() {
+ Hello hello = Hello.builder().sessionId("123").build();
+ assertThat(hello.getCapabilities())
+ .contains("urn:ietf:params:netconf:base:1.1");
+ }
+
+ @Test
+ public void willRejectDtdInXml() {
+ String withDtd = """
+
+ ]>
+
+ 1
+
+ """;
+
+ assertThatThrownBy(() -> Hello.from(withDtd))
+ .isInstanceOf(Exception.class)
+ .hasMessageContaining("DOCTYPE");
+ }
+
+ /**
+ * Ensures every capability returned by Hello#getCapabilities() parses as a URI.
+ */
+ @Test
+ public void capabilitiesAreUris() throws URISyntaxException {
+ Hello hello = Hello.builder()
+ .sessionId("99")
+ .capability("urn:ietf:params:netconf:base:1.0")
+ .capability("urn:ietf:params:netconf:capability:writable-running:1.0")
+ .build();
+
+ for (String cap : hello.getCapabilities()) {
+ new URI(cap); // throws URISyntaxException if invalid
+ }
}
}
\ No newline at end of file
diff --git a/src/test/java/net/juniper/netconf/element/RpcErrorTest.java b/src/test/java/net/juniper/netconf/element/RpcErrorTest.java
new file mode 100644
index 0000000..8d6d0c9
--- /dev/null
+++ b/src/test/java/net/juniper/netconf/element/RpcErrorTest.java
@@ -0,0 +1,69 @@
+package net.juniper.netconf.element;
+
+import net.juniper.netconf.element.RpcError.ErrorSeverity;
+import net.juniper.netconf.element.RpcError.ErrorTag;
+import net.juniper.netconf.element.RpcError.ErrorType;
+import net.juniper.netconf.element.RpcError.RpcErrorInfo;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.*;
+
+/**
+ * Unit tests for the {@link RpcError} record and its helper enums / builder.
+ */
+class RpcErrorTest {
+
+ @Test
+ void builderCreatesEquivalentRecord() {
+ RpcErrorInfo info = RpcErrorInfo.builder()
+ .badAttribute("attr")
+ .sessionId("101")
+ .build();
+
+ RpcError fromBuilder = RpcError.builder()
+ .errorType(ErrorType.RPC)
+ .errorTag(ErrorTag.INVALID_VALUE)
+ .errorSeverity(ErrorSeverity.ERROR)
+ .errorPath("/interfaces/interface[name='xe-0/0/0']")
+ .errorMessage("invalid value")
+ .errorMessageLanguage("en")
+ .errorInfo(info)
+ .build();
+
+ RpcError direct = new RpcError(ErrorType.RPC,
+ ErrorTag.INVALID_VALUE,
+ ErrorSeverity.ERROR,
+ "/interfaces/interface[name='xe-0/0/0']",
+ "invalid value",
+ "en",
+ info);
+
+ assertThat(fromBuilder).isEqualTo(direct);
+ assertThat(fromBuilder.hashCode()).isEqualTo(direct.hashCode());
+ }
+
+ @Test
+ void enumsRoundTripFromString() {
+ assertThat(ErrorType.from("protocol")).isEqualTo(ErrorType.PROTOCOL);
+ assertThat(ErrorTag.from("unknown-element")).isEqualTo(ErrorTag.UNKNOWN_ELEMENT);
+ assertThat(ErrorSeverity.from("warning")).isEqualTo(ErrorSeverity.WARNING);
+
+ // unknown returns null
+ assertThat(ErrorTag.from("does-not-exist")).isNull();
+ }
+
+ @Test
+ void toStringContainsKeyFields() {
+ RpcError error = RpcError.builder()
+ .errorType(ErrorType.TRANSPORT)
+ .errorTag(ErrorTag.LOCK_DENIED)
+ .errorSeverity(ErrorSeverity.ERROR)
+ .errorMessage("lock denied")
+ .build();
+
+ String txt = error.toString();
+ assertThat(txt).contains("TRANSPORT")
+ .contains("LOCK_DENIED")
+ .contains("lock denied");
+ }
+}
diff --git a/src/test/java/net/juniper/netconf/element/RpcReplyLoadConfigResultsTest.java b/src/test/java/net/juniper/netconf/element/RpcReplyLoadConfigResultsTest.java
index cf64c36..2a49778 100644
--- a/src/test/java/net/juniper/netconf/element/RpcReplyLoadConfigResultsTest.java
+++ b/src/test/java/net/juniper/netconf/element/RpcReplyLoadConfigResultsTest.java
@@ -1,29 +1,31 @@
package net.juniper.netconf.element;
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
import org.xmlunit.assertj.XmlAssert;
import static org.assertj.core.api.Assertions.assertThat;
public class RpcReplyLoadConfigResultsTest {
- private static final String LOAD_CONFIG_RESULTS_OK_NO_NAMESPACE = ""
- + "\n" +
- " \n" +
- " \n" +
- " \n" +
- "";
-
- private static final String LOAD_CONFIG_RESULTS_OK_WITH_NAMESPACE = ""
- + "\n" +
- " \n" +
- " \n" +
- " \n" +
- "";
+ private static final String LOAD_CONFIG_RESULTS_OK_NO_NAMESPACE = """
+ \
+
+
+
+
+ """;
+
+ private static final String LOAD_CONFIG_RESULTS_OK_WITH_NAMESPACE = """
+ \
+
+
+
+
+ """;
private static final String LOAD_CONFIG_RESULTS_ERROR_NO_NAMESPACE = ""
+ "\n" +
"\n";
- private static final String LOAD_CONFIG_RESULTS_ERROR_WITH_NAMESPACE = ""
- + "\n" +
- " \n" +
- " \n" +
- " protocol\n" +
- " operation-failed\n" +
- " error\n" +
- " syntax error\n" +
- " \n" +
- " foobar\n" +
- " \n" +
- " \n" +
- " \n" +
- " \n" +
- "\n";
+ private static final String LOAD_CONFIG_RESULTS_ERROR_WITH_NAMESPACE = """
+ \
+
+
+
+ protocol
+ operation-failed
+ error
+ syntax error
+
+ foobar
+
+
+
+
+
+ """;
@Test
public void willParseAnOkResponseWithNoNamespacePrefix() throws Exception {
@@ -70,7 +74,7 @@ public void willParseAnOkResponseWithNoNamespacePrefix() throws Exception {
.isEqualTo("3");
assertThat(rpcReply.getAction())
.isEqualTo("set");
- assertThat(rpcReply.isOk())
+ assertThat(rpcReply.isOK())
.isTrue();
assertThat(rpcReply.hasErrorsOrWarnings())
.isFalse();
@@ -92,7 +96,7 @@ public void willParseAnOkResponseWithNamespacePrefix() throws Exception {
.isEqualTo("4");
assertThat(rpcReply.getAction())
.isEqualTo("set");
- assertThat(rpcReply.isOk())
+ assertThat(rpcReply.isOK())
.isTrue();
assertThat(rpcReply.hasErrorsOrWarnings())
.isFalse();
@@ -114,7 +118,7 @@ public void willParseAnErrorResponseWithoutNamespacePrefix() throws Exception {
.isEqualTo("5");
assertThat(rpcReply.getAction())
.isEqualTo("set");
- assertThat(rpcReply.isOk())
+ assertThat(rpcReply.isOK())
.isTrue();
assertThat(rpcReply.hasErrorsOrWarnings())
.isTrue();
@@ -143,7 +147,7 @@ public void willParseAnErrorResponseWithNamespacePrefix() throws Exception {
.isEqualTo("6");
assertThat(rpcReply.getAction())
.isEqualTo("set");
- assertThat(rpcReply.isOk())
+ assertThat(rpcReply.isOK())
.isTrue();
assertThat(rpcReply.hasErrorsOrWarnings())
.isTrue();
@@ -201,7 +205,7 @@ public void willCreateXmlErrorWithoutNamespace() {
.messageId("5")
.action("set")
.ok(true)
- .error(RpcError.builder()
+ .addError(RpcError.builder()
.errorType(RpcError.ErrorType.PROTOCOL)
.errorTag(RpcError.ErrorTag.OPERATION_FAILED)
.errorSeverity(RpcError.ErrorSeverity.ERROR)
@@ -226,7 +230,7 @@ public void willCreateXmlErrorWithNamespace() {
.messageId("6")
.action("set")
.ok(true)
- .error(RpcError.builder()
+ .addError(RpcError.builder()
.errorType(RpcError.ErrorType.PROTOCOL)
.errorTag(RpcError.ErrorTag.OPERATION_FAILED)
.errorSeverity(RpcError.ErrorSeverity.ERROR)
diff --git a/src/test/java/net/juniper/netconf/element/RpcReplyTest.java b/src/test/java/net/juniper/netconf/element/RpcReplyTest.java
index 65eeb96..24ef97c 100644
--- a/src/test/java/net/juniper/netconf/element/RpcReplyTest.java
+++ b/src/test/java/net/juniper/netconf/element/RpcReplyTest.java
@@ -1,11 +1,12 @@
package net.juniper.netconf.element;
-import org.junit.Test;
+import org.junit.jupiter.api.Test;
import org.xmlunit.assertj.XmlAssert;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class RpcReplyTest {
@@ -42,13 +43,19 @@ public class RpcReplyTest {
" \n" +
"
";
+ private static final String MALFORMED_RPC_REPLY = "";
+
+ // Same OK reply but framed with RFC 6242 §4.3 delimiter
+ private static final String RPC_REPLY_WITH_OK_FRAMED =
+ RPC_REPLY_WITH_OK + "]]>]]>";
+
@Test
public void willParseRpcReplyWithoutNamespace() throws Exception {
final RpcReply rpcReply = RpcReply.from(RPC_REPLY_WITHOUT_NAMESPACE);
assertThat(rpcReply.getMessageId())
.isEqualTo("3");
- assertThat(rpcReply.isOk())
+ assertThat(rpcReply.isOK())
.isFalse();
assertThat(rpcReply.hasErrorsOrWarnings())
.isFalse();
@@ -64,7 +71,7 @@ public void willParseRpcReplyWithNamespace() throws Exception {
assertThat(rpcReply.getMessageId())
.isEqualTo("4");
- assertThat(rpcReply.isOk())
+ assertThat(rpcReply.isOK())
.isFalse();
assertThat(rpcReply.hasErrorsOrWarnings())
.isFalse();
@@ -80,7 +87,7 @@ public void willCreateOkRpcReply() throws Exception {
assertThat(rpcReply.getMessageId())
.isEqualTo("5");
- assertThat(rpcReply.isOk())
+ assertThat(rpcReply.isOK())
.isTrue();
assertThat(rpcReply.hasErrorsOrWarnings())
.isFalse();
@@ -96,7 +103,7 @@ public void willParseRpcReplyWithErrors() throws Exception {
assertThat(rpcReply.getMessageId())
.isEqualTo("101");
- assertThat(rpcReply.isOk())
+ assertThat(rpcReply.isOK())
.isFalse();
assertThat(rpcReply.hasErrorsOrWarnings())
.isTrue();
@@ -168,7 +175,7 @@ public void willCreateXmlWithOkFromAnObject() {
public void willCreateXmlWithErrors() {
final RpcReply rpcReply = RpcReply.builder()
.messageId("101")
- .error(RpcError.builder()
+ .addError(RpcError.builder()
.errorType(RpcError.ErrorType.APPLICATION)
.errorTag(RpcError.ErrorTag.INVALID_VALUE)
.errorSeverity(RpcError.ErrorSeverity.ERROR)
@@ -176,7 +183,7 @@ public void willCreateXmlWithErrors() {
.errorMessage("MTU value 25000 is not within range 256..9192")
.errorMessageLanguage("en")
.build())
- .error(RpcError.builder()
+ .addError(RpcError.builder()
.errorType(RpcError.ErrorType.APPLICATION)
.errorTag(RpcError.ErrorTag.INVALID_VALUE)
.errorSeverity(RpcError.ErrorSeverity.ERROR)
@@ -190,4 +197,40 @@ public void willCreateXmlWithErrors() {
.ignoreWhitespace()
.areIdentical();
}
+
+ /**
+ * RFC 6241 §4.3: a peer SHOULD respond with
+ * if the incoming message is not well‑formed XML. In our client-side parser we
+ * expect a SAXException (wrapped) rather than a valid RpcReply object.
+ */
+ @Test
+ public void willThrowOnMalformedXml() {
+ assertThatThrownBy(() -> RpcReply.from(MALFORMED_RPC_REPLY))
+ .isInstanceOf(Exception.class); // Xml parsing failed (SAXException or wrapped)
+ }
+
+ /**
+ * RFC 6241 requires UTF‑8 encoding. Passing bytes in ISO‑8859‑1 that contain
+ * invalid UTF‑8 sequences should also raise a parse failure.
+ */
+ @Test
+ public void willThrowOnNonUtf8Encoding() {
+ byte[] isoBytes = MALFORMED_RPC_REPLY.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1);
+ String wrongEncoded = new String(isoBytes, java.nio.charset.StandardCharsets.ISO_8859_1); // contains invalid UTF‑8 if any high‑bytes
+ assertThatThrownBy(() -> RpcReply.from(wrongEncoded))
+ .isInstanceOf(Exception.class);
+ }
+ /**
+ * RFC 6242 §4.3: parser must ignore the "]]>]]>" end‑of‑message delimiter
+ * that legacy :base:1.0 peers append.
+ */
+ @Test
+ public void willParseRpcReplyWithDelimiter() throws Exception {
+ final RpcReply rpcReply = RpcReply.from(RPC_REPLY_WITH_OK_FRAMED);
+
+ assertThat(rpcReply.getMessageId())
+ .isEqualTo("5");
+ assertThat(rpcReply.isOK()).isTrue();
+ assertThat(rpcReply.hasErrorsOrWarnings()).isFalse();
+ }
}
\ No newline at end of file
diff --git a/src/test/java/net/juniper/netconf/integration/INTEGRATION_TESTS.md b/src/test/java/net/juniper/netconf/integration/INTEGRATION_TESTS.md
new file mode 100644
index 0000000..90f7f24
--- /dev/null
+++ b/src/test/java/net/juniper/netconf/integration/INTEGRATION_TESTS.md
@@ -0,0 +1,53 @@
+# NetConf Java Integration Tests
+
+This directory contains integration tests for the netconf-java library that test against real network devices.
+
+## Overview
+
+The integration tests verify:
+- Basic device connection and authentication
+- Server capabilities retrieval
+- Configuration retrieval via get-config
+- Multiple sequential connections
+- Error handling and timeouts
+- Device-specific RPC operations
+
+## Running the Tests
+
+### Method 1: Using JUnit (Recommended)
+
+```bash
+# Run with interactive prompts
+mvn test -Dtest=NetconfIntegrationTest -Dnetconf.integration.enabled=true
+
+# Run with predefined credentials
+mvn test -Dtest=NetconfIntegrationTest -Dnetconf.integration.enabled=true \
+ -Dnetconf.host=192.168.1.1 \
+ -Dnetconf.username=admin \
+ -Dnetconf.password=secret \
+ -Dnetconf.port=830
+```
+
+### Method 2: Using the Shell Script
+
+```bash
+# Make the script executable
+chmod +x run-integration-tests.sh
+
+# Run with interactive prompts
+./run-integration-tests.sh
+
+# Run with command line arguments
+./run-integration-tests.sh --host 192.168.1.1 --username admin --password secret
+```
+
+### Method 3: Manual Test Runner
+
+For environments where JUnit is not available:
+
+```bash
+# Compile the manual runner
+javac -cp "target/classes:target/dependency/*" src/test/java/net/juniper/netconf/integration/ManualTestRunner.java
+
+# Run the manual tests
+java -cp "target/classes:target/dependency/*:src/test/java" net.juniper.netconf.integration.
\ No newline at end of file
diff --git a/src/test/java/net/juniper/netconf/integration/NetconfIntegrationTest.java b/src/test/java/net/juniper/netconf/integration/NetconfIntegrationTest.java
new file mode 100644
index 0000000..252937f
--- /dev/null
+++ b/src/test/java/net/juniper/netconf/integration/NetconfIntegrationTest.java
@@ -0,0 +1,351 @@
+package net.juniper.netconf.integration;
+
+import net.juniper.netconf.Device;
+import net.juniper.netconf.NetconfException;
+import net.juniper.netconf.XML;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+
+import java.io.Console;
+import java.io.IOException;
+import java.util.Scanner;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Integration tests for netconf-java library against real network devices.
+ *
+ * To run these tests:
+ * mvn test -Dtest=NetconfIntegrationTest -Dnetconf.integration.enabled=true
+ *
+ * Or run with specific device details:
+ * mvn test -Dtest=NetconfIntegrationTest -Dnetconf.integration.enabled=true \
+ * -Dnetconf.host=192.168.1.1 -Dnetconf.username=admin -Dnetconf.password=secret
+ */
+@EnabledIfSystemProperty(named = "netconf.integration.enabled", matches = "true")
+public class NetconfIntegrationTest {
+
+ private static String hostname;
+ private static String username;
+ private static String password;
+ private static int port = 830; // Default NETCONF port
+ private static int timeout = 30000; // 30 seconds
+
+ @BeforeAll
+ static void setupCredentials() {
+ // Try to get credentials from system properties first
+ hostname = System.getProperty("netconf.host");
+ username = System.getProperty("netconf.username");
+ password = System.getProperty("netconf.password");
+
+ String portStr = System.getProperty("netconf.port");
+ if (portStr != null) {
+ port = Integer.parseInt(portStr);
+ }
+
+ String timeoutStr = System.getProperty("netconf.timeout");
+ if (timeoutStr != null) {
+ timeout = Integer.parseInt(timeoutStr);
+ }
+
+ // If not provided via system properties, prompt user
+ if (hostname == null || username == null || password == null) {
+ Console console = System.console();
+ Scanner scanner = new Scanner(System.in, StandardCharsets.UTF_8);
+
+ System.out.println("=== NETCONF Integration Test Setup ===");
+
+ if (hostname == null) {
+ System.out.print("Enter device hostname/IP: ");
+ hostname = console != null ? console.readLine() : scanner.nextLine();
+ }
+
+ if (username == null) {
+ System.out.print("Enter username: ");
+ username = console != null ? console.readLine() : scanner.nextLine();
+ }
+
+ if (password == null) {
+ if (console != null) {
+ char[] passwordChars = console.readPassword("Enter password: ");
+ password = new String(passwordChars);
+ } else {
+ System.out.print("Enter password: ");
+ password = scanner.nextLine();
+ }
+ }
+
+ System.out.print("Enter port (default 830): ");
+ String portInput = console != null ? console.readLine() : scanner.nextLine();
+ if (!portInput.trim().isEmpty()) {
+ port = Integer.parseInt(portInput.trim());
+ }
+
+ System.out.println("Using connection details:");
+ System.out.println(" Host: " + hostname);
+ System.out.println(" Username: " + username);
+ System.out.println(" Port: " + port);
+ System.out.println(" Timeout: " + timeout + "ms");
+ System.out.println();
+ }
+
+ assertNotNull(hostname, "Hostname must be provided");
+ assertNotNull(username, "Username must be provided");
+ assertNotNull(password, "Password must be provided");
+ }
+
+ @Test
+ @DisplayName("Test device connection and basic capabilities")
+ void testDeviceConnection() throws NetconfException {
+ System.out.println("Testing device connection...");
+
+ Device device = Device.builder()
+ .hostName(hostname)
+ .userName(username)
+ .password(password)
+ .port(port)
+ .connectionTimeout(timeout)
+ .strictHostKeyChecking(false)
+ .build();
+
+ try {
+ device.connect();
+ assertTrue(device.isConnected(), "Device should be connected");
+
+ // Test getting server capabilities
+ String[] capabilities = device.getNetconfCapabilities().toArray(new String[0]);
+ assertNotNull(capabilities, "Server capabilities should not be null");
+ assertTrue(capabilities.length > 0, "Server should have at least one capability");
+
+ System.out.println("✓ Successfully connected to device");
+ System.out.println("✓ Server capabilities count: " + capabilities.length);
+
+ // Print some capabilities for debugging
+ System.out.println("Server capabilities:");
+ for (int i = 0; i < Math.min(5, capabilities.length); i++) {
+ System.out.println(" " + capabilities[i]);
+ }
+ if (capabilities.length > 5) {
+ System.out.println(" ... and " + (capabilities.length - 5) + " more");
+ }
+
+ } finally {
+ if (device.isConnected()) {
+ device.close();
+ assertFalse(device.isConnected(), "Device should be disconnected after close");
+ }
+ }
+ }
+
+ @Test
+ @DisplayName("Test basic get-config operation")
+ void testGetConfig() throws
+ org.xml.sax.SAXException, IOException {
+ System.out.println("Testing get-config operation...");
+
+ Device device = Device.builder()
+ .hostName(hostname)
+ .userName(username)
+ .password(password)
+ .port(port)
+ .connectionTimeout(timeout)
+ .strictHostKeyChecking(false)
+ .build();
+
+ try {
+ device.connect();
+
+ // Test get-config with running datastore
+ XML rpcReply = device.executeRPC("");
+
+ assertNotNull(rpcReply, "RPC reply should not be null");
+
+ String response = rpcReply.toString();
+ assertNotNull(response, "RPC reply string should not be null");
+ assertFalse(response.isEmpty(), "RPC reply should not be empty");
+ assertTrue(response.contains("rpc-reply"), "Response should contain rpc-reply");
+
+ System.out.println("✓ Successfully executed get-config");
+ System.out.println("✓ Response length: " + response.length() + " characters");
+
+ } finally {
+ if (device.isConnected()) {
+ device.close();
+ }
+ }
+ }
+
+ @Test
+ @DisplayName("Test get operation with interface information")
+ void testGetInterfaceInformation() throws org.xml.sax.SAXException, IOException {
+ System.out.println("Testing get interface information...");
+
+ Device device = Device.builder()
+ .hostName(hostname)
+ .userName(username)
+ .password(password)
+ .port(port)
+ .connectionTimeout(timeout)
+ .strictHostKeyChecking(false)
+ .build();
+
+ try {
+ device.connect();
+
+ // Try different RPC formats to test flexibility
+ String[] rpcFormats = {
+ "get-interface-information",
+ "",
+ ""
+ };
+
+ boolean atLeastOneSucceeded = false;
+
+ for (String rpc : rpcFormats) {
+ try {
+ System.out.println("Trying RPC format: " + rpc);
+ XML rpcReply = device.executeRPC(rpc);
+
+ assertNotNull(rpcReply, "RPC reply should not be null for format: " + rpc);
+ String response = rpcReply.toString();
+ assertFalse(response.isEmpty(), "RPC reply should not be empty for format: " + rpc);
+
+ System.out.println("✓ RPC format '" + rpc + "' succeeded");
+ atLeastOneSucceeded = true;
+
+ } catch (NetconfException e) {
+ System.out.println("✗ RPC format '" + rpc + "' failed: " + e.getMessage());
+ // Continue trying other formats
+ }
+ }
+
+ // At least one format should work on a properly configured device
+ // Note: This might fail on non-Juniper devices, which is expected
+ if (!atLeastOneSucceeded) {
+ System.out.println("ℹ None of the interface information RPC formats succeeded. " +
+ "This might be normal for non-Juniper devices.");
+ }
+
+ } finally {
+ if (device.isConnected()) {
+ device.close();
+ }
+ }
+ }
+
+ @Test
+ @DisplayName("Test connection timeout handling")
+ void testConnectionTimeout() throws NetconfException {
+ System.out.println("Testing connection timeout handling...");
+
+ // Use an unreachable IP to test timeout
+ long startTime;
+ try (Device device = Device.builder()
+ .hostName("192.0.2.1") // RFC 5737 test IP - should be unreachable
+ .userName("test")
+ .password("test")
+ .port(830)
+ .strictHostKeyChecking(false)
+ .connectionTimeout(5000) // 5 second timeout
+ .build()) {
+
+ startTime = System.currentTimeMillis();
+
+ assertThrows(NetconfException.class, device::connect,
+ "Connection to unreachable host should throw NetconfException");
+ }
+
+ long elapsedTime = System.currentTimeMillis() - startTime;
+
+ // Should timeout within reasonable time (allow some variance)
+ assertTrue(elapsedTime < 10000,
+ "Timeout should occur within 10 seconds, but took " + elapsedTime + "ms");
+
+ System.out.println("✓ Connection timeout handled correctly in " + elapsedTime + "ms");
+ }
+
+ @Test
+ @DisplayName("Test multiple sequential connections")
+ void testMultipleConnections() throws NetconfException {
+ System.out.println("Testing multiple sequential connections...");
+
+ for (int i = 1; i <= 3; i++) {
+ System.out.println("Connection attempt " + i + "/3");
+
+ Device device = Device.builder()
+ .hostName(hostname)
+ .userName(username)
+ .password(password)
+ .port(port)
+ .strictHostKeyChecking(false)
+ .connectionTimeout(timeout)
+ .build();
+
+ try {
+ device.connect();
+ assertTrue(device.isConnected(), "Device should be connected on attempt " + i);
+
+ // Brief operation to ensure connection is working
+ int capabilityCount = device.getNetconfCapabilities().size();
+ assertTrue(capabilityCount > 0, "Should have capabilities on attempt " + i);
+
+ } finally {
+ if (device.isConnected()) {
+ device.close();
+ assertFalse(device.isConnected(), "Device should be disconnected after close on attempt " + i);
+ }
+ }
+
+ // Small delay between connections
+ try {
+ Thread.sleep(1000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+
+ System.out.println("✓ Multiple sequential connections completed successfully");
+ }
+
+ @Test
+ @DisplayName("Test RPC error handling")
+ void testRPCErrorHandling() throws NetconfException {
+ System.out.println("Testing RPC error handling...");
+
+ Device device = Device.builder()
+ .hostName(hostname)
+ .userName(username)
+ .password(password)
+ .port(port)
+ .strictHostKeyChecking(false)
+ .connectionTimeout(timeout)
+ .build();
+
+ try {
+ device.connect();
+
+ // Send an intentionally malformed RPC
+ assertThrows(Exception.class, () -> device.executeRPC(
+ ""),
+ "Invalid RPC should throw an exception");
+
+ System.out.println("✓ RPC error handling works correctly");
+
+ // Verify connection is still usable after error
+ int capabilityCount = device.getNetconfCapabilities().size();
+ assertTrue(capabilityCount > 0,
+ "Connection should still be usable after RPC error");
+
+ System.out.println("✓ Connection remains usable after RPC error");
+
+ } finally {
+ if (device.isConnected()) {
+ device.close();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/net/juniper/netconf/integration/RUN-CRPD-CONTAINER.md b/src/test/java/net/juniper/netconf/integration/RUN-CRPD-CONTAINER.md
new file mode 100644
index 0000000..68dc858
--- /dev/null
+++ b/src/test/java/net/juniper/netconf/integration/RUN-CRPD-CONTAINER.md
@@ -0,0 +1,86 @@
+# Running Juniper cRPD for integration tests
+
+This project’s integration tests need a live NETCONF endpoint.
+You can spin up Juniper’s containerised Routing Protocol Daemon (**cRPD**) locally in < 1 minute.
+
+---
+
+## 1 . Prerequisites
+
+* **Docker + Colima** (or Docker Desktop) on macOS
+ ```bash
+ brew install colima docker docker-compose
+ colima start # arm64 by default; add --arch x86_64 if you need an amd64 VM
+ ```
+
+* **cRPD image** (free evaluation tarball from Juniper)
+ Place it under `src/test/resources/` as shown below.
+
+---
+
+## 2 . Load the image into Docker
+
+```bash
+docker load < src/test/resources/junos-routing-crpd-docker-23.2R1.13-arm64.tgz
+# or …-amd64… if you’re running a colima --arch x86_64 VM
+```
+
+Verify:
+
+```bash
+docker images | grep crpd
+# crpd 23.2R1.13 0cf5ad… 498MB
+```
+
+---
+
+## 3 . Start cRPD with NETCONF and SSH exposed
+
+```bash
+docker run -d --name crpd1 --privileged \
+ -p 2222:22 \ # SSH
+ -p 1830:830 \ # NETCONF
+ crpd:23.2R1.13
+```
+
+Wait ~40 s, then configure a test user and enable NETCONF:
+
+```bash
+docker exec -ti crpd1 cli
+
+# inside Junos CLI
+configure
+set system root-authentication plain-text-password
+# (enter a password, e.g. Junos123)
+set system login user test uid 2000 class super-user
+set system login user test authentication plain-text-password
+# (password: test1234)
+
+set system services ssh
+set system services netconf ssh
+commit and-quit
+```
+
+---
+
+## 4 . Run the integration test wrapper
+
+```bash
+./src/test/java/net/juniper/netconf/integration/run-integration-tests.sh \
+ --username test \
+ --password test1234 \
+ --host localhost \
+ --port 1830
+```
+
+The script builds the library, spins up JUnit tests, and targets the NETCONF service you just started.
+
+---
+
+### Cleaning up
+
+```bash
+docker rm -f crpd1 # stop & remove the container
+```
+
+That’s it! You now have a repeatable way to launch a Junos device for automated NETCONF testing.
\ No newline at end of file
diff --git a/src/test/java/net/juniper/netconf/integration/run-integration-tests.sh b/src/test/java/net/juniper/netconf/integration/run-integration-tests.sh
new file mode 100755
index 0000000..9067daf
--- /dev/null
+++ b/src/test/java/net/juniper/netconf/integration/run-integration-tests.sh
@@ -0,0 +1,123 @@
+#!/bin/bash
+
+# run-integration-tests.sh
+# Script to run netconf-java integration tests with interactive credential prompts
+
+set -e
+
+echo "=== NetConf Java Integration Test Runner ==="
+echo "This script will run integration tests against a real network device."
+echo "You will be prompted for connection details if not provided via environment."
+echo ""
+
+# Check if Maven is available
+if ! command -v mvn &> /dev/null; then
+ echo "Error: Maven is not installed or not in PATH"
+ exit 1
+fi
+
+# Parse command line arguments
+INTERACTIVE=true
+SKIP_COMPILE=false
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --host)
+ NETCONF_HOST="$2"
+ shift 2
+ ;;
+ --username)
+ NETCONF_USERNAME="$2"
+ shift 2
+ ;;
+ --password)
+ NETCONF_PASSWORD="$2"
+ shift 2
+ ;;
+ --port)
+ NETCONF_PORT="$2"
+ shift 2
+ ;;
+ --timeout)
+ NETCONF_TIMEOUT="$2"
+ shift 2
+ ;;
+ --skip-compile)
+ SKIP_COMPILE=true
+ shift
+ ;;
+ --help|-h)
+ echo "Usage: $0 [options]"
+ echo "Options:"
+ echo " --host Device hostname or IP address"
+ echo " --username SSH username"
+ echo " --password SSH password"
+ echo " --port NETCONF port (default: 830)"
+ echo " --timeout Connection timeout in milliseconds (default: 30000)"
+ echo " --skip-compile Skip Maven compile phase"
+ echo " --help, -h Show this help message"
+ echo ""
+ echo "Environment variables can also be used:"
+ echo " NETCONF_HOST, NETCONF_USERNAME, NETCONF_PASSWORD, NETCONF_PORT, NETCONF_TIMEOUT"
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1"
+ echo "Use --help for usage information"
+ exit 1
+ ;;
+ esac
+done
+
+# Set defaults from environment if not provided via command line
+NETCONF_HOST=${NETCONF_HOST:-$NETCONF_HOST}
+NETCONF_USERNAME=${NETCONF_USERNAME:-$NETCONF_USERNAME}
+NETCONF_PASSWORD=${NETCONF_PASSWORD:-$NETCONF_PASSWORD}
+NETCONF_PORT=${NETCONF_PORT:-830}
+NETCONF_TIMEOUT=${NETCONF_TIMEOUT:-30000}
+
+# Compile the project first (unless skipped)
+if [ "$SKIP_COMPILE" = false ]; then
+ echo "Compiling project..."
+ mvn compile test-compile -q
+ if [ $? -ne 0 ]; then
+ echo "Error: Failed to compile project"
+ exit 1
+ fi
+ echo "✓ Project compiled successfully"
+ echo ""
+fi
+
+# Build Maven command
+MVN_CMD="mvn test -Dtest=NetconfIntegrationTest -Dnetconf.integration.enabled=true"
+
+# Add system properties if provided
+if [ -n "$NETCONF_HOST" ]; then
+ MVN_CMD="$MVN_CMD -Dnetconf.host=$NETCONF_HOST"
+fi
+
+if [ -n "$NETCONF_USERNAME" ]; then
+ MVN_CMD="$MVN_CMD -Dnetconf.username=$NETCONF_USERNAME"
+fi
+
+if [ -n "$NETCONF_PASSWORD" ]; then
+ MVN_CMD="$MVN_CMD -Dnetconf.password=$NETCONF_PASSWORD"
+fi
+
+if [ -n "$NETCONF_PORT" ]; then
+ MVN_CMD="$MVN_CMD -Dnetconf.port=$NETCONF_PORT"
+fi
+
+if [ -n "$NETCONF_TIMEOUT" ]; then
+ MVN_CMD="$MVN_CMD -Dnetconf.timeout=$NETCONF_TIMEOUT"
+fi
+
+echo "Running integration tests..."
+echo "Command: $MVN_CMD"
+echo ""
+
+# Execute the tests
+eval $MVN_CMD
+
+echo ""
+echo "=== Integration Tests Complete ==="
\ No newline at end of file
diff --git a/src/test/resources/sampleEmptyFPCTempRpcReply.xml b/src/test/resources/sampleEmptyFPCTempRpcReply.xml
new file mode 100644
index 0000000..acaac66
--- /dev/null
+++ b/src/test/resources/sampleEmptyFPCTempRpcReply.xml
@@ -0,0 +1,7 @@
+
+
+
+ Routing Engine 0
+ 41 degrees C / 105 degrees F
+
+
\ No newline at end of file
diff --git a/src/test/resources/sampleMissingElement.xml b/src/test/resources/sampleMissingElement.xml
new file mode 100644
index 0000000..acaac66
--- /dev/null
+++ b/src/test/resources/sampleMissingElement.xml
@@ -0,0 +1,7 @@
+
+
+
+ Routing Engine 0
+ 41 degrees C / 105 degrees F
+
+
\ No newline at end of file