sdp0.1.1-SNAPSHOTA parser and emitter for the Session Description Protocol (SDP) as described by RFC 4566. dependencies
| (this space intentionally left almost blank) | ||||||||||||
The | (ns multimedia.streaming.sdp (:require [multimedia.streaming.sdp.parser :as parser] [clojure.string :as string])) | ||||||||||||
What is SDP? | |||||||||||||
SDP is intended for describing multimedia sessions for the purposes of session announcement, session invitation and other forms of multimedia session initiation. An SDP session description is entirely textual using the ISO 10646 character set in UTF-8 encoding. SDP field names and attribute names use only the US-ASCII subset of UTF-8, but textual fields and attribute values may use the full ISO 10646 character set. Field and attribute values that use the full UTF-8 character set are never directly compared, hence there is no requirement for UTF-8 normalisation. | |||||||||||||
An Example | |||||||||||||
An SDP session description consists of a number of lines of text of the form
where Session descriptions are divided into sections and consist of a session-level section followed by zero or more media-level sections. The session-level part starts with a 'v=' line and continues to the first media-level section. Each media-level section starts with an 'm=' line and continues to the next media-level section or to the end of the whole session description. In general, session-level values are the default for all media unless overridden by an equivalent media-level value. | (def example-sdp-string "v=0 o=jdoe 2890844526 2890842807 IN IP4 10.47.16.5 s=SDP Seminar i=A Seminar on the session description protocol u=http://www.example.com/seminars/sdp.pdf e=j.doe@example.com (Jane Doe) p=+44 (0)1445 637948 c=IN IP4 224.2.17.12/127 b=CT:128 t=2873397496 2873404696 r=7d 1h 0 25h z=2882844526 -1h 2898848070 0 k=clear:gf638ebi3rh3i3o3e35767 a=recvonly m=audio 49170 RTP/AVP 0 i=Media title c=IN IP4 224.2.17.14/127 c=IN IP4 224.2.17.18/225 b=AT:14 a=recvonly a=ctlmethod:serverpush m=video 51372 RTP/AVP 992882844526 a=rtpmap:99 h263-1998/90000") | ||||||||||||
Mime-Type | |||||||||||||
The mime-type string to be used for SDP data is 'application/sdp'. | (def mime-type "application/sdp") | ||||||||||||
Parsing | |||||||||||||
The
The following
See Custom Representations for information on how to change the data representation of the parsed fields. | (defn parse [sdp-string & flags] (let [{:keys [relaxed]} (set flags) prep-lines (comp (remove string/blank?) (map parser/mapify-lines) parser/add-line-numbers parser/add-sections (parser/check-line-order relaxed))] (->> sdp-string string/split-lines (transduce prep-lines (completing (parser/parse-lines relaxed)) {})))) | ||||||||||||
SDP Description Structure | |||||||||||||
The parsed SDP description is a map containing at least the Note that keys are shown here in parsing order for clarity, however the parsed map is unordered. Top level keys represent session level fields. The individual media level
fields are found under the Vectors indicate that any number of entries is possible for that field. | (def example-sdp-description {:version 0 :origin {:username "jdoe" :session-id "2890844526" :session-version "2890842807" :network-type "IN" :address-type "IP4" :address "10.47.16.5"} :name "SDP Seminar" :information "A Seminar on the session description protocol" :uri "http://www.example.com/seminars/sdp.pdf" :email ["j.doe@example.com (Jane Doe)"] :phone ["+44 (0)1445 637948"] :connection {:network-type "IN" :address-type "IP4" :address "224.2.17.12/127"} :bandwidth [{:bandwidth-type "CT" :bandwidth 128}] :timing [{:start-time 2873397496N :end-time 2873404696N :repeat [{:repeat-interval "7d" :active-duration "1h" :offsets-from-start "0"}]}] :timezone [{:adjustment-time 2882844526N :offset "-1h"} {:adjustment-time 2898848070N :offset "0"}] :encryption-key {:method "clear" :payload "gf638ebi3rh3i3o3e35767"} :attributes [{:attribute "recvonly"}] :media-descriptions [{:media-type "audio" :port 49170 :protocol "RTP/AVP" :format "0" :information "Media title" :connection [{:network-type "IN" :address-type "IP4" :address "224.2.17.14" :ttl 127}] :bandwidth [{:bandwidth-type "AT" :bandwidth 14}] :attributes [{:attribute "recvonly"} {:attribute "ctlmethod" :value "serverpush"}]} {:media-type "video" :port 51372 :protocol "RTP/AVP" :format "992882844526" :attributes [{:attribute "rtpmap" :value "99 h263-1998/90000"}]}]}) | ||||||||||||
Emitting | |||||||||||||
The
This function is not currently implemented. | (defn emit [sdp-description] :not-implemented) | ||||||||||||
Custom Representations | |||||||||||||
Each field in the SDP structure is parsed as a specific type. For each type
there exists a parsing function that is called to produce the field value
from the textual representation. It is sometimes useful to provide a custom
parsing function that will return types suitable to the application at hand.
For example, an application built upon the Java 8 platform may wish to parse
instants as | |||||||||||||
The
The supplied parsing functions must accept the raw value as a string and return the parsed representation. Parsers can be specified for any of the fields listed in the Calling this function will affect all future invocations of | (defn use-custom-parsers! [parser-map] (alter-var-root #'parse (fn [f] (fn [sdp-string & flags] (binding [parser/parse-fns (merge parser/parse-fns parser-map)] (apply f example-sdp-string flags)))))) | ||||||||||||
The | (ns multimedia.streaming.sdp.parser (:require [clojure.string :as string] [clojure.core.match :refer [match]] [clojure.tools.logging :as log] [dire.core :refer [with-handler!]])) | ||||||||||||
Parser Implementation | |||||||||||||
The parser implementation is intended to be as declarative as possible. It consists of three main parts:
When data is fed to the parser it is first processed by the input preparation pipeline. This performs some simple transformations on the input to make the parsers job easier. The prepared input is then fed to the parser which parses the various fields, using a set of overridable parsing functions and inserts them into the parsed structure according to the rules in the parser configuration. | |||||||||||||
Input Preparation | |||||||||||||
The input preperation pipeline is implemented as the following set of composed transducers. | |||||||||||||
| (defn mapify-lines [line] (let [[k v] (string/split line #"=" 2)] {:type (keyword (string/trim k)) :value (string/triml v)})) | ||||||||||||
| (defn add-line-numbers [xf] (let [line-number (volatile! 0)] (fn ([] (xf)) ([result] (xf result)) ([result input] (xf result (assoc input :line-number (vswap! line-number inc))))))) | ||||||||||||
| (defn add-sections [xf] (let [section (volatile! :session)] (fn ([] (xf)) ([result] (xf result)) ([result {line-type :type :as input}] (when (= :m line-type) (vreset! section :media)) (xf result (assoc input :section @section)))))) | ||||||||||||
SDP has a strict line order. The | (def line-order {:session {:v #{:o} :o #{:s} :s #{:i :u :e :p :c :b :t :z :k :a :m} :i #{:u :e :p :c :b :t :z :k :a :m} :u #{:e :p :c :b :t :z :k :a :m} :e #{:e :p :c :b :t :z :k :a :m} :p #{:p :c :b :t :z :k :a :m} :c #{:b :t :z :k :a :m} :b #{:t :z :k :a :m} :t #{:t :r :z :k :a :m} :r #{:t :z :k :a :m} :z #{:k :a :m} :k #{:a :m} :a #{:a :m}} :media {:m #{:m :i :c :b :k :a} :i #{:m :c :b :k :a} :c #{:c :m :b :k :a} :b #{:m :k :a} :k #{:m :a} :a #{:m :a}}}) | ||||||||||||
| (defn check-line-order [& flags] (fn [xf] (let [{:keys [relaxed]} (set flags) allowed-lines (volatile! #{:v})] (fn ([] (xf)) ([result] (xf result)) ([result {:keys [type line-number section] :as line}] (let [allowed @allowed-lines possibilities (get-in line-order [section type])] (when possibilities (vreset! allowed-lines possibilities)) (cond (allowed type) (xf result line) relaxed (do (log/warn "Skipping illegal line type '" type "' on line " line-number ", expected one of " allowed) result) :else (throw (ex-info (str "Illegal line type '" type "' on line " line-number ", expected one of " allowed) {:error-type :illegal-line-type :expected allowed :received type :line-number line-number}))))))))) | ||||||||||||
The input data leaves the input preparation stage as a sequence of maps that contain all the information needed for the parsing stage. Additionally, if the parser is in its default strict mode, the lines are guaranteed to be in the correct order. | |||||||||||||
Parser Configuration | |||||||||||||
The behaviour of the parser is dictated by the parser configuration. This configuration describes the structure of the SDP protocol, how each field should be parsed and how it should be inserted into the parsed SDP description. | |||||||||||||
Insert Functions | |||||||||||||
The following functions provide the options for inserting a parsed field into the SDP description. | |||||||||||||
| (defn vectorize [sdp key field] (if (vector? (key field)) (update-in sdp [key] into (key field)) (update-in sdp [key] (fnil conj []) (key field)))) | ||||||||||||
would add | (defn in-last [parent] (fn [sdp key field] (let [index (dec (count (parent sdp)))] (assoc-in sdp [parent index key] (key field))))) | ||||||||||||
would add | (defn vectorize-in-last [parent] (fn [sdp key field] (let [index (dec (count (parent sdp)))] (if (vector? (key field)) (update-in sdp [parent index key] into (key field)) (update-in sdp [parent index key] (fnil conj []) (key field)))))) | ||||||||||||
Parse Rules | |||||||||||||
The At the top level, the
If the key is not present, errors for that field are always treated as fatal.
If the key is not present, the field is inserted at the top level of the SDP structure. In the event of multiple values for the same field, only the last value will be present in the SDP structure. Parse Spec Field Spec | (def parse-rules {:v {:name :version :parse-as :integer :on-fail [:default-to 0]} :o {:name :origin :parse-as {:separator #"\s+" :fields [{:name :username :parse-as :string} {:name :session-id :parse-as :numeric-string} {:name :session-version :parse-as :string} {:name :network-type :parse-as :string :expect #{"IN"}} {:name :address-type :parse-as :string :expect #{"IP4" "IP6"}} {:name :address :parse-as :host}]}} :s {:name :name :parse-as :string :on-fail [:default-to " "]} :i {:name :information :parse-as :string :insert {:media (in-last :media-descriptions)}} :u {:name :uri :parse-as :string} :e {:name :email :parse-as :email :insert vectorize} :p {:name :phone :parse-as :phone :insert vectorize} :c {:name :connection :parse-as {:separator #"\s+" :fields [{:name :network-type :parse-as :string :expect #{"IN"}} {:name :address-type :parse-as :string :expect #{"IP4" "IP6"}} {:name :address :parse-as :address}]} :insert {:media (vectorize-in-last :media-descriptions)}} :b {:name :bandwidth :parse-as {:separator #":" :fields [{:name :bandwidth-type :parse-as :string :expect #{"CT" "AS"}} {:name :bandwidth :parse-as :integer}]} :insert {:session vectorize :media (vectorize-in-last :media-descriptions)}} :t {:name :timing :parse-as {:separator #"\s+" :fields [{:name :start-time :parse-as :instant} {:name :end-time :parse-as :instant}]} :insert vectorize} :r {:name :repeat :parse-as {:separator #"\s+" :fields [{:name :repeat-interval :parse-as :duration} {:name :active-duration :parse-as :duration} {:name :offsets-from-start :parse-as :duration}]} :insert (vectorize-in-last :timing)} :z {:name :timezone :parse-as {:separator #"\s+" :fields [{:name :adjustment-time :parse-as :instant} {:name :offset :parse-as :duration}] :repeats? true}} :k {:name :encryption-key :parse-as {:separator #":" :fields [{:name :method :parse-as :string :expect #{"clear" "base64" "uri" "prompt"}} {:name :payload :parse-as :string}]} :insert {:media (in-last :media-descriptions)}} :a {:name :attributes :parse-as {:separator #":" :fields [{:name :attribute :parse-as :string} {:name :value :parse-as :string}]} :insert {:session vectorize :media (vectorize-in-last :media-descriptions)}} :m {:name :media-descriptions :parse-as {:separator #"\s+" :fields [{:name :media-type :parse-as :string :expect #{"audio" "video" "text" "application" "message"}} {:name :port :parse-as :port} {:name :protocol :parse-as :string :expect #{"udp" "RTP/AVP" "RTP/SAVP"}} {:name :format :parse-as :string}]} :insert vectorize}}) | ||||||||||||
Parse Functions | |||||||||||||
The following functions comprise the default parse functions used to parse atomic fields. | |||||||||||||
| (defn numeric-string [field] (try (bigint field) field (catch NumberFormatException e (throw (Exception. "String is not numeric"))))) | ||||||||||||
| (defn integer-in-range [min max] (fn [field] (let [value (Integer. field)] (if (<= min value max) value (throw (Exception. (str "Value '" field "' is outside the legal range " "(min: " min ", max: " max ")"))))))) | ||||||||||||
| (def ip4-address #"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$") | ||||||||||||
| (defn multicast? [address] (-> address java.net.InetAddress/getByName .isMulticastAddress)) | ||||||||||||
and is parsed into the structure
with the presence of the | (defn address [field] (let [[address ttl address-count] (string/split field #"/" 3) address-type (cond (re-matches ip4-address address) :IPv4 (re-matches #".*:.*" address) :IPv6 :else :FQDN) result {:address address}] (match [address-type ttl address-count] [:IPv4 nil nil] (if (multicast? address) (throw (Exception. (str "Multicast IPv4 address " "requires a TTL value"))) result) [:IPv4 ttl nil] (assoc result :ttl ((integer-in-range 0 255) ttl)) [:IPv4 ttl count] (assoc result :ttl ((integer-in-range 0 255) ttl) :address-count (Integer. count)) [:IPv6 nil nil] result [:IPv6 count nil] (assoc result :address-count (Integer. count)) [:IPv6 _ _] (throw (Exception. "TTL value not allowed for IPv6")) [:FQDN nil nil] result [:FQDN _ _] (throw (Exception. "TTL value not allowed for FQDN"))))) | ||||||||||||
The | (def ^:dynamic parse-fns {:string identity :integer #(Integer. %) :numeric-string numeric-string :instant bigint :duration identity :host identity :address address :email identity :phone identity :port (integer-in-range 0 65535)}) | ||||||||||||
Error Functions | |||||||||||||
The | (def error-fns {:default-to (fn [[rule value default line-num e]] (log/info "Bad value '" value "' for " (name (:name rule)) " on line " line-num ", substituting default value '" default "'") {(:name rule) default}) :error (fn [[rule value line-num e]] (throw (ex-info (str "Bad value '" value "' for " (name (:name rule)) " on line " line-num) {:error-type :parse-failed :field (:name rule) :value value :parser (:parse-as rule)} e)))}) | ||||||||||||
Execution Core | |||||||||||||
The execution core has no intrinsic understanding of the SDP format. It is
simply a generic parsing engine that is given a set of parse rules as
described in the
The lines are parsed in order and the resulting values inserted into the SDP structure. The following functions comprise the execution core. | |||||||||||||
| (defn flatten-fields [map] (reduce (fn [r [k v]] (if (map? v) (merge r v) (assoc r k v))) {} map)) | ||||||||||||
| (defn parse-simple-field [{:keys [parse-as name expect] :as rule} value line-num relaxed] (let [parsed ((parse-as parse-fns) value)] (when-not (or (nil? expect) (expect parsed)) (log/debug "Non-standard value '" parsed "' for '" name "' on line " line-num ", expected one of " expect)) {name parsed})) | ||||||||||||
This | (with-handler! #'parse-simple-field Exception (fn [e & [{error-rule :on-fail :as rule} value line-num relaxed]] (cond (nil? relaxed) ((:error error-fns) [rule value line-num e]) (vector? error-rule) (let [[func param] error-rule] ((func error-fns) [rule value param line-num e])) :else ((:error error-fns) [rule value line-num e])))) | ||||||||||||
| (defn parse-compound-field [{{:keys [repeats? separator] field-rules :fields} :parse-as name :name} value line-num relaxed] (let [fields (string/split value separator) [field-rules parse-fn] (if repeats? [(cycle field-rules) (comp (map parse-simple-field) (partition-all (count field-rules)) (map (partial apply merge)))] [field-rules (map parse-simple-field)]) parsed (sequence parse-fn field-rules fields (repeat line-num) (repeat relaxed))] (if repeats? {name (into [] parsed)} {name (transduce (map flatten-fields) merge parsed)}))) | ||||||||||||
| (defn parse-lines [& flags] (let [{:keys [relaxed]} (set flags)] (fn [result {:keys [type value line-number section] :as line}] (let [{:keys [insert parse-as name] :as rule} (type parse-rules) parsed (if (map? parse-as) (parse-compound-field rule value line-number relaxed) (parse-simple-field rule value line-number relaxed)) insert (if (map? insert) (section insert) insert) insert (if (fn? insert) insert (fn [final key parsed] (conj final parsed)))] (insert result name parsed))))) | ||||||||||||