diff --git a/domain/src/main/java/org/jivesoftware/smackx/pubsub/PubSubUri.java b/domain/src/main/java/org/jivesoftware/smackx/pubsub/PubSubUri.java
new file mode 100644
index 0000000..95ecbed
--- /dev/null
+++ b/domain/src/main/java/org/jivesoftware/smackx/pubsub/PubSubUri.java
@@ -0,0 +1,215 @@
+package org.jivesoftware.smackx.pubsub;
+
+import org.jxmpp.jid.BareJid;
+import org.jxmpp.jid.EntityBareJid;
+import org.jxmpp.jid.impl.JidCreate;
+import org.jxmpp.stringprep.XmppStringprepException;
+import org.jxmpp.util.XmppStringUtils;
+
+import java.net.URI;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+public class PubSubUri {
+
+ private static final Logger LOGGER = Logger.getLogger(PubSubUri.class.getName());
+
+ public static final String SCHEME = "xmpp";
+ public static final String NODE = ";node=";
+ public static final String ITEM = ";item=";
+ public static final String ACTION = ";action=";
+ public static final String PUBSUB = "pubsub";
+
+ private final BareJid service;
+ private final String node;
+ private final String item;
+ private final Action action;
+
+ public PubSubUri(BareJid service, String node, String item, Action action) {
+ this.service = service;
+ this.node = node;
+ this.item = item;
+ this.action = action;
+ }
+
+ /**
+ * Parse a {@link PubSubUri}.
+ * This method ignores unknown parameters.
+ *
+ * @param s string representation of the URI.
+ * @return parsed {@link PubSubUri}
+ * @throws XmppStringprepException if the string does not represent a valid XMPP PubSub URI.
+ */
+ public static PubSubUri from(String s)
+ throws XmppStringprepException {
+ URI uri = URI.create(s);
+ throwIfNoXmppUri(uri);
+ String ssp = uri.getSchemeSpecificPart();
+ BareJid service = getService(ssp);
+ String node = getNodeOrNull(ssp);
+ String item = getItemOrNull(ssp);
+ Action action = getActionOrNull(ssp);
+
+ return new PubSubUri(service, node, item, action);
+ }
+
+ /**
+ * Return the {@link BareJid} of the referenced pubsub service.
+ * That may be a {@link org.jxmpp.jid.DomainBareJid} like 'pubsub.shakespeare.lit' or a
+ * {@link EntityBareJid} like 'hamlet@denmark.lit'.
+ *
+ * @return pubsub service jid
+ */
+ public BareJid getService() {
+ return service;
+ }
+
+ /**
+ * Return the pubsub node name or null if not present.
+ *
+ * @return node name or null
+ */
+ public String getNode() {
+ return node;
+ }
+
+ /**
+ * Return the pubsub item name or null if not present.
+ *
+ * @return item or null
+ */
+ public String getItem() {
+ return item;
+ }
+
+ /**
+ * Return the {@link Action} of the {@link PubSubUri} or null if not present.
+ *
+ * @return action or null
+ */
+ public Action getAction() {
+ return action;
+ }
+
+ /**
+ * Throw an {@link IllegalArgumentException} if the {@link URI URIs} scheme doesn't equal 'xmpp'.
+ *
+ * @param uri uri
+ */
+ private static void throwIfNoXmppUri(URI uri) throws XmppStringprepException {
+ if (!uri.getScheme().equals(SCHEME)) {
+ throw new XmppStringprepException(uri.toString(), "URI is not of scheme 'xmpp'.");
+ }
+ }
+
+ /**
+ * Extract the {@link BareJid} of the pubsub service the URI is pointing to.
+ *
+ * @param ssp scheme specific part of the URI
+ * @return pubsub service jid
+ *
+ * @throws XmppStringprepException if the service jid is malformed.
+ */
+ private static BareJid getService(String ssp) throws XmppStringprepException {
+ String serviceString = ssp.substring(0, ssp.indexOf("?"));
+ // TODO: Use JidCreate.bareFrom(serviceString) once jxmpp is bumped to > 1.0.0
+ // see https://github.com/igniterealtime/jxmpp/pull/22
+ throwIfFullJid(serviceString);
+ BareJid jid;
+ try {
+ jid = JidCreate.entityBareFromUrlEncoded(serviceString);
+ } catch (XmppStringprepException e) {
+ jid = JidCreate.domainBareFromUrlEncoded(serviceString);
+ }
+ return jid;
+ }
+
+ /**
+ * Throw an {@link XmppStringprepException} if the provided {@link String} represents a {@link org.jxmpp.jid.FullJid}.
+ *
+ * @param jid jid as string
+ * @throws XmppStringprepException if jid has resource part
+ */
+ private static void throwIfFullJid(String jid) throws XmppStringprepException {
+ if (XmppStringUtils.isFullJID(jid)) {
+ throw new XmppStringprepException(jid, "FullJid not allowed here.");
+ }
+ }
+
+ /**
+ * Return the value of the 'node' key or null if not present.
+ * @param ssp scheme specific part
+ * @return node value or null
+ */
+ private static String getNodeOrNull(String ssp) {
+ return getValueOrNull(ssp, NODE);
+ }
+
+
+ /**
+ * Return the value of the 'item' key or null if not present.
+ * @param ssp scheme specific part
+ * @return item value or null
+ */
+ private static String getItemOrNull(String ssp) {
+ return getValueOrNull(ssp, ITEM);
+ }
+
+
+ /**
+ * Return the value of the 'action' key or null if not present.
+ * @param ssp scheme specific part
+ * @return action value or null
+ */
+ private static Action getActionOrNull(String ssp) {
+ String actionString = getValueOrNull(ssp, ACTION);
+ if (actionString == null) {
+ return null;
+ }
+ if (!ssp.substring(ssp.indexOf("?") + 1).startsWith(PUBSUB)) {
+ LOGGER.log(Level.WARNING, "URI contains 'action', but does not have iquerytype set to '" + PUBSUB + "'.\n" +
+ "See https://tools.ietf.org/html/rfc5122#section-2.2 and https://xmpp.org/extensions/xep-0060.html#example-229 f.");
+ }
+ return Action.valueOf(actionString);
+ }
+
+ /**
+ * Returns the ivalue value of the ipair with ikey key.
+ *
+ * @see ABNF in section 2.2 of rfc5122
+ * @param ssp scheme specific part of the XMPP URI/IRI.
+ * @param key ikey
+ * @return ivalue
+ */
+ private static String getValueOrNull(String ssp, String key) {
+ String args = ssp.substring(ssp.indexOf("?") + 1);
+ boolean hasKey = args.contains(key);
+ if (!hasKey) return null;
+
+ int keyPos = args.indexOf(key) + key.length();
+ int nextPos = args.indexOf(";", keyPos);
+ if (nextPos == -1) {
+ nextPos = args.length();
+ }
+ return args.substring(keyPos, nextPos);
+ }
+
+ @Override
+ public String toString() {
+ return SCHEME + ":" + getService() + "?" +
+ (getAction() != null ? PUBSUB + ACTION + getAction() : "") +
+ (getNode() != null ? NODE + getNode() : "") +
+ (getItem() != null ? ITEM + getItem() : "");
+ }
+
+ /**
+ * PubSub specific actions as defined in XEP-0060: Publish-Subscribe: ยง16.6 URI Query Types.
+ *
+ * @see Registered URI Query Types
+ */
+ public enum Action {
+ subscribe,
+ unsubscribe,
+ retrieve
+ }
+}
diff --git a/domain/src/test/java/org/jivesoftware/smackx/pubsub/PubSubUriTest.java b/domain/src/test/java/org/jivesoftware/smackx/pubsub/PubSubUriTest.java
new file mode 100644
index 0000000..50ae54c
--- /dev/null
+++ b/domain/src/test/java/org/jivesoftware/smackx/pubsub/PubSubUriTest.java
@@ -0,0 +1,155 @@
+package org.jivesoftware.smackx.pubsub;
+
+import org.junit.Test;
+import org.jxmpp.jid.DomainBareJid;
+import org.jxmpp.jid.EntityBareJid;
+import org.jxmpp.jid.impl.JidCreate;
+import org.jxmpp.stringprep.XmppStringprepException;
+
+import static junit.framework.TestCase.assertEquals;
+import static junit.framework.TestCase.assertNull;
+import static junit.framework.TestCase.assertTrue;
+import static org.junit.Assert.assertThrows;
+
+public class PubSubUriTest {
+
+ /**
+ * Parse a URI that points to a pubsub node.
+ *
+ * @see
+ * XEP-0060: Publish-Subscribe: Example 227. XMPP URI for a node
+ * @throws XmppStringprepException not expected
+ */
+ @Test
+ public void pubSubNodeUriTest() throws XmppStringprepException {
+ final String uriString = "xmpp:pubsub.shakespeare.lit?;node=princely_musings";
+ PubSubUri uri = PubSubUri.from(uriString);
+
+ assertEquals(JidCreate.domainBareFromOrThrowUnchecked("pubsub.shakespeare.lit"), uri.getService());
+ assertEquals("princely_musings", uri.getNode());
+ assertNull(uri.getItem());
+ assertNull(uri.getAction());
+ assertEquals(uriString, uri.toString());
+ }
+
+ /**
+ * Parse a URI that points to an item inside a pubsub node.
+ *
+ * @see
+ * XEP-0060: Publish-Subscribe: Example 228. XMPP URI for a pubsub item
+ * @throws XmppStringprepException not expected
+ */
+ @Test
+ public void pubSubItemInNodeUriTest() throws XmppStringprepException {
+ final String uriString = "xmpp:pubsub.shakespeare.lit?;node=princely_musings;item=ae890ac52d0df67ed7cfdf51b644e901";
+ PubSubUri uri = PubSubUri.from(uriString);
+
+ assertEquals(JidCreate.domainBareFromOrThrowUnchecked("pubsub.shakespeare.lit"), uri.getService());
+ assertEquals("princely_musings", uri.getNode());
+ assertEquals("ae890ac52d0df67ed7cfdf51b644e901", uri.getItem());
+ assertNull(uri.getAction());
+ assertEquals(uriString, uri.toString());
+ }
+
+ /**
+ * Parse a URI for subscribing to a pubsub node.
+ *
+ * @see
+ * XEP-0060: Publish-Subscribe: Example 229. URI for subscribing to a pubsub node
+ * @throws XmppStringprepException not expected
+ */
+ @Test
+ public void pubSubSubscribeToNodeUriTest() throws XmppStringprepException {
+ final String uriString = "xmpp:pubsub.shakespeare.lit?pubsub;action=subscribe;node=princely_musings";
+ PubSubUri uri = PubSubUri.from(uriString);
+
+ assertEquals(JidCreate.domainBareFromOrThrowUnchecked("pubsub.shakespeare.lit"), uri.getService());
+ assertEquals("princely_musings", uri.getNode());
+ assertNull(uri.getItem());
+ assertEquals(PubSubUri.Action.subscribe, uri.getAction());
+ assertEquals(uriString, uri.toString());
+ }
+
+ /**
+ * Parse a URI for retrieving a single item from a specific node.
+ *
+ * @see
+ * XEP-0060: Publish-Subscribe: Example 230. URI for retrieving a pubsub item
+ * @throws XmppStringprepException not expected
+ */
+ @Test
+ public void pubSubRetrieveItemFromNodeUriTest() throws XmppStringprepException {
+ final String uriString = "xmpp:pubsub.shakespeare.lit?pubsub;action=retrieve;node=princely_musings;item=ae890ac52d0df67ed7cfdf51b644e901";
+ PubSubUri uri = PubSubUri.from(uriString);
+
+ assertEquals(JidCreate.domainBareFromOrThrowUnchecked("pubsub.shakespeare.lit"), uri.getService());
+ assertEquals(PubSubUri.Action.retrieve, uri.getAction());
+ assertEquals("princely_musings", uri.getNode());
+ assertEquals("ae890ac52d0df67ed7cfdf51b644e901", uri.getItem());
+ assertEquals(uriString, uri.toString());
+ }
+
+ /**
+ * Parse a URI for unsubscribing from a specific pubsub node.
+ *
+ * @see
+ * XEP-0060: Publish-Subscribe: Example 233. Pubsub Unsubscribe Action: IRI/URI
+ * @throws XmppStringprepException not expected
+ */
+ @Test
+ public void pubSubUnsubscribeFromNodeUriTest() throws XmppStringprepException {
+ final String uriString = "xmpp:pubsub.shakespeare.lit?pubsub;action=unsubscribe;node=princely_musings";
+ PubSubUri uri = PubSubUri.from(uriString);
+
+ assertEquals(JidCreate.domainBareFromOrThrowUnchecked("pubsub.shakespeare.lit"), uri.getService());
+ assertEquals(PubSubUri.Action.unsubscribe, uri.getAction());
+ assertEquals("princely_musings", uri.getNode());
+ assertNull(uri.getItem());
+ assertEquals(uriString, uri.toString());
+ }
+
+ /**
+ * Parse a URI for retrieving multiple items from a particular pubsub node.
+ * @see
+ * XEP-0060: Publish-Subscribe: Example 235. Pubsub Retrieve Action: IRI/URI
+ */
+ @Test
+ public void pubSubRetrieveItemsFromNodeUriTest() throws XmppStringprepException {
+ final String uriString = "xmpp:pubsub.shakespeare.lit?pubsub;action=retrieve;node=princely_musings";
+ PubSubUri uri = PubSubUri.from(uriString);
+
+ assertEquals(JidCreate.domainBareFromOrThrowUnchecked("pubsub.shakespeare.lit"), uri.getService());
+ assertEquals(PubSubUri.Action.retrieve, uri.getAction());
+ assertEquals("princely_musings", uri.getNode());
+ assertNull(uri.getItem());
+ assertEquals(uriString, uri.toString());
+ }
+
+ @Test
+ public void serviceJidsAreParsedToCorrectJidTypes() throws XmppStringprepException {
+ final String uriWithDomainJid = "xmpp:pubsub.shakespeare.lit?;node=princely_musings";
+ final String uriWithEntityJid = "xmpp:hamlet@denmark.lit?;node=blog";
+
+ PubSubUri withDomainJid = PubSubUri.from(uriWithDomainJid);
+ PubSubUri withEntityJid = PubSubUri.from(uriWithEntityJid);
+
+ assertTrue(withDomainJid.getService() instanceof DomainBareJid);
+ assertTrue(withEntityJid.getService() instanceof EntityBareJid);
+ }
+
+ /**
+ * Ensure that parsing a uri with an invalid scheme ('xmpps' in this case) will cause a
+ * {@link XmppStringprepException} to be thrown.
+ */
+ @Test
+ public void invalidSchemeCausesThrowingTest() {
+ assertThrows(XmppStringprepException.class,
+ () -> PubSubUri.from("xmpps:pubsub.shakespeare.lit?;node=princely_musings"));
+ }
+
+ @Test
+ public void fullJidCausesThrowingTest() {
+ assertThrows(XmppStringprepException.class,
+ () -> PubSubUri.from("xmpp:alice@wonderland.lit/illegalResourcePart?:node=rabbit_hole"));
+ }
+}