OPeNDAP/CMR Integration

0.1.0-SNAPSHOT


OPeNDAP Integration in the CMR

dependencies

cheshire
5.8.0
clojusc/trifl
0.2.0
clojusc/twig
0.3.2
com.stuartsierra/component
0.3.2
environ
1.1.0
gov.nasa.earthdata/cmr-site-templates
0.1.0-SNAPSHOT
http-kit
2.3.0
markdown-clj
1.0.2
metosin/reitit-core
0.1.1-SNAPSHOT
metosin/reitit-ring
0.1.1-SNAPSHOT
metosin/ring-http-response
0.9.0
org.clojure/clojure
1.9.0
org.clojure/core.cache
0.7.1
org.clojure/data.xml
0.2.0-alpha5
ring/ring-core
1.6.3
ring/ring-codec
1.1.0
ring/ring-defaults
0.3.1
selmer
1.11.7



(this space intentionally left almost blank)
 
(ns cmr.opendap.ous.collection.core
  (:require
   [clojure.string :as string]
   [cmr.opendap.components.config :as config]
   [cmr.opendap.http.request :as request]
   [cmr.opendap.http.response :as response]
   [taoensso.timbre :as log]))
(defn build-query
  [params]
  (str "concept_id=" (:collection-id params)))

Given a data structure with :collection-id, get the metadata for the associated collection.

(defn get-metadata
  [search-endpoint user-token params]
  (let [url (str search-endpoint
                 "/collections?"
                 (build-query params))
        results (request/async-get url
                 (-> {}
                     (request/add-token-header user-token)
                     (request/add-accept "application/json"))
                 response/json-handler)]
    (log/debug "Got results from CMR collection search:" results)
    (first (get-in @results [:feed :entry]))))
(defn extract-variable-ids
  [entry]
  (get-in entry [:associations :variables]))
(defn extract-service-ids
  [entry]
  (get-in entry [:associations :services]))
 
(ns cmr.opendap.ous.collection.params.v1
  (:require
   [clojure.set :as set]
   [cmr.opendap.ous.collection.params.const :as const]
   [cmr.opendap.ous.util :as util]))
(defrecord OusPrototypeParams
  [;; `format` is any of the formats supported by the target OPeNDAP server,
   ;; such as `json`, `ascii`, `nc`, `nc4`, `dods`, etc.
   format
   ;;
   ;; `coverage` can be:
   ;;  * a list of granule concept ids
   ;;  * a list of granule ccontept ids + a collection concept id
   ;;  * a single collection concept id
   coverage
   ;;
   ;; `rangesubset` is a list of UMM-Var concept ids
   rangesubset
   ;;
   ;; `subset` is used to indicate desired spatial subsetting and is a list of
   ;; lon/lat values, as used in WCS. It is parsed from URL queries like so:
   ;;  `?subset=lat(22,34)&subset=lon(169,200)`
   ;; giving values like so:
   ;;  `["lat(22,34)" "lon(169,200)"]`
   subset])
(def params-keys
  (set/difference
   (set (keys (map->OusPrototypeParams {})))
   const/shared-keys))
(defn params?
  [params]
  (seq (set/intersection
        (set (keys params))
        params-keys)))
(defn create-params
  [params]
  (map->OusPrototypeParams
    (assoc params :format (or (:format params)
                              const/default-format)
                  :coverage (util/->seq (:coverage params))
                  :rangesubset (util/->seq (:rangesubset params)))))
 
(ns cmr.opendap.ous.collection.params.v2
  (:require
   [clojure.set :as set]
   [cmr.opendap.ous.collection.params.const :as const]
   [cmr.opendap.ous.util :as ous-util]
   [cmr.opendap.util :as util]
   [taoensso.timbre :as log]))
(defrecord CollectionParams
  [;; `collection-id` is the concept id for the collection in question. Note
   ;; that the collection concept id is not provided in query params,
   ;; but in the path as part of the REST URL. Regardless, we offer it here as
   ;; a record field.
   collection-id
   ;;
   ;; `format` is any of the formats supported by the target OPeNDAP server,
   ;; such as `json`, `ascii`, `nc`, `nc4`, `dods`, etc.
   format
   ;;
   ;; `granules` is list of granule concept ids; default behaviour is a
   ;; whitelist.
   granules
   ;;
   ;; `exclude-granules` is a boolean when set to true causes granules list
   ;; to be a blacklist.
   exclude-granules
   ;;
   ;; XXX Is this where we want to accept the paging size for granule
   ;;     concepts?
   ;; granule-count
   ;;
   ;; `variables` is a list of variables to be speficied when creating the
   ;; OPeNDAP URL. This is used for subsetting.
   variables
   ;;
   ;; `subset` is used the same way as `subset` for WCS: to indicate desired
   ;; spatial subsetting in URL queries like so:
   ;;  `?subset=lat(56.109375,67.640625)&subset=lon(-9.984375,19.828125)`
   subset
   ;;
   ;; `bounding-box` is provided for CMR/EDSC-compatibility as an alternative
   ;; to using `subset` for spatial-subsetting.
   bounding-box])
(def params-keys
  (set/difference
   (set (keys (map->CollectionParams {})))
   const/shared-keys))
(defn params?
  [params]
  (seq (set/intersection
        (set (keys params))
        params-keys)))
(defn not-array?
  [array]
  (or (nil? array)
      (empty? array)))
(defn create-params
  [params]
  (let [bounding-box (ous-util/->seq (:bounding-box params))
        subset (:subset params)
        granules-array (ous-util/->seq (get params (keyword "granules[]")))
        variables-array (ous-util/->seq (get params (keyword "variables[]")))]
    (log/trace "bounding-box:" bounding-box)
    (log/trace "subset:" subset)
    (log/trace "granules-array:" granules-array)
    (log/trace "variables-array:" variables-array)
    (map->CollectionParams
      (assoc params
        :format (or (:format params) const/default-format)
        :granules (if (not-array? granules-array)
                       (ous-util/->seq (:granules params))
                       granules-array)
        :variables (if (not-array? variables-array)
                       (ous-util/->seq (:variables params))
                       variables-array)
        :exclude-granules (util/bool (:exclude-granules params))
        :subset (if (seq bounding-box)
                 (ous-util/bounding-box->subset bounding-box)
                 (:subset params))
        :bounding-box (if (seq bounding-box)
                       (mapv #(Float/parseFloat %) bounding-box)
                       (when (seq subset)
                        (ous-util/subset->bounding-box subset)))))))
(defrecord CollectionsParams
  [;; This isn't defined for the OUS Prototype, since it didn't support
   ;; submitting multiple collections at a time. As such, there is no
   ;; prototype-oriented record for this.
   ;;
   ;; `collections` is a list of `CollectionParams` records.
   collections])
 

This namespace defines records for the accepted URL query parameters or, if using HTTP POST, keys in a JSON payload. Additionall, functions for working with these parameters are defined here.

(ns cmr.opendap.ous.collection.params.core
  (:require
   [clojure.string :as string]
   [cmr.opendap.ous.collection.params.v1 :as v1]
   [cmr.opendap.ous.collection.params.v2 :as v2]
   [cmr.opendap.ous.util :as util]
   [taoensso.timbre :as log])
  (:refer-clojure :exclude [parse]))
(defn params?
  [type params]
  (case type
    :v1 (v1/params? params)
    :v2 (v2/params? params)))
(defn create-params
  [type params]
  (case type
    :v1 (v1/create-params params)
    :v2 (v2/create-params params)))
(defn v1->v2
  [params]
  (let [collection-id (or (:collection-id params)
                          (util/coverage->collection (:coverage params)))
        subset (:subset params)]
    (-> params
        (assoc :collection-id collection-id
               :granules (util/coverage->granules (:coverage params))
               :variables (:rangesubset params)
               ;; There was never an analog in v1 for exclude-granules, so set
               ;; to false.
               :exclude-granules false
               :bounding-box (when (seq subset)
                              (util/subset->bounding-box subset)))
        (dissoc :coverage :rangesubset)
        (v2/map->CollectionParams))))
(defn parse
  [raw-params]
  (log/trace "Got params:" raw-params)
  (let [params (util/normalize-params raw-params)]
    (cond (params? :v2 params)
          (do
            (log/trace "Parameters are of type `collection` ...")
            (create-params :v2 params))
          (params? :v1 params)
          (do
            (log/trace "Parameters are of type `ous-prototype` ...")
            (v1->v2
             (create-params :v1 params)))
          (:collection-id params)
          (do
            (log/trace "Found collection id; assuming `collection` ...")
            (create-params :v2 params))
          :else {:error :unsupported-parameters})))
 
(ns cmr.opendap.ous.collection.params.const)
(def default-format "nc")
(def shared-keys
  #{:collection-id :format :subset})
 
(ns cmr.opendap.ous.collection.results)
(defrecord CollectionResults
  [;; The number of results returned
   hits
   ;; Number of milleseconds elapsed from start to end of call
   took
   ;; The actual items in the result set
   items])
(defn create
  [results & {:keys [elapsed]}]
  (map->CollectionResults
    {;; Our 'hits' is simplistic for now; will change when we support
     ;; paging, etc.
     :hits (count results)
     :took elapsed
     :items results}))
 
(ns cmr.opendap.ous.granule
  (:require
   [clojure.string :as string]
   [cmr.opendap.const :as const]
   [cmr.opendap.http.request :as request]
   [cmr.opendap.http.response :as response]
   [cmr.opendap.util :as util]
   [ring.util.codec :as codec]
   [taoensso.timbre :as log]))
(defn build-include
  [gran-ids]
  (string/join
   "&"
   (conj
    (map #(str (codec/url-encode "concept_id[]")
               "="
               %)
         gran-ids)
    (str "page_size=" (count gran-ids)))))
(defn build-exclude
  [gran-ids]
  (string/join
   "&"
   (conj
    (map #(str (codec/url-encode "exclude[echo_granule_id][]")
               "="
               %)
         gran-ids)
    ;; We don't know how many granule ids will be involved in an exclude,
    ;; so we use the max page size of 2000.
    "page_size=2000")))

Build the query string for querying granles, bassed upon the options passed in the parameters.

(defn build-query
  [params]
  (let [coll-id (:collection-id params)
        gran-ids (util/remove-empty (:granules params))
        exclude? (:exclude-granules params)]
    (str "collection_concept_id=" coll-id
         (when (seq gran-ids)
          (str "&"
               (if exclude?
                 (build-exclude gran-ids)
                 (build-include gran-ids)))))))

Given a data structure with :collection-id, :granules, and :exclude-granules keys, get the metadata for the desired granules.

Which granule metadata is returned depends upon the values of :granules and :exclude-granules

(defn get-metadata
  [search-endpoint user-token params]
  (let [url (str search-endpoint
                 "/granules?"
                 (build-query params))
        results (request/async-get url
                 (-> {}
                     (request/add-token-header user-token)
                     (request/add-accept "application/json"))
                 response/json-handler)]
    (log/debug "Got results from CMR granule search:" results)
    (get-in @results [:feed :entry])))

The criteria defined in the prototype was to iterate through the links, only examining those links that were not 'inherited', and find the one whose :rel value matched a particular string.

It is currently unclear what the best criteria for this decision is.

XXX This logic was copied from the prototype; it is generally viewed by the CMR Team & the Metadata Tools Team that this approach is flawed, and that adding support for this approach to UMM-S was a short-term hack.

(defn match-datafile-link
  [link-data]
  (let [rel (:rel link-data)]
    (and (not (:inherited link-data))
              (= const/datafile-link-rel rel))))
(defn extract-datafile-link
  [granule-entry]
  (let [link (->> (:links granule-entry)
                  (filter match-datafile-link)
                  first)]
    {:granule-id (:id granule-entry)
     :link-rel (:rel link)
     :link-href (:href link)}))
 
(ns cmr.opendap.ous.util
  (:require
   [clojure.string :as string]))
(defn normalize-param
  [param]
  (-> param
      name
      (string/replace "_" "-")
      (string/lower-case)
      keyword))
(defn normalize-params
  [params]
  (->> params
       (map (fn [[k v]] [(normalize-param k) v]))
       (into {})))
(defn ->seq
  [data]
  (cond (nil? data) []
        (empty? data) []
        (coll? data) data
        (string? data) (string/split data #",")))
(defn bounding-box->subset
  [[lon-lo lat-lo lon-hi lat-hi]]
  [(format "lat(%s,%s)" lat-lo lat-hi)
   (format "lon(%s,%s)" lon-lo lon-hi)])
(defn get-matches
  [regex elems]
  (->> elems
       (map (comp rest (partial re-find regex)))
       (remove empty?)
       first))
(defn subset->bounding-lat
  [elems]
  (get-matches
   (re-pattern (str ".*lat\\("
                    "\\s*(-?[0-9]+\\.?[0-9]*)\\s*,"
                    "\\s*(-?[0-9]+\\.?[0-9]*)\\s*"))
   elems))
(defn subset->bounding-lon
  [elems]
  (get-matches
   (re-pattern (str ".*lon\\("
                    "\\s*(-?[0-9]+\\.?[0-9]*)\\s*,"
                    "\\s*(-?[0-9]+\\.?[0-9]*)\\s*"))
   elems))

In the CMR and EDSC, a bounding box is defined by the lower-left corner to the upper-right, furthermore, they defined this as a flattened list, ordering with longitude first. As such, a bounding box is of the form: [lower-longitude, lower-latitude, upper-longitude, upper-latitude].

This is the form that this function returns.

(defn subset->bounding-box
  [elems]
  (let [[lon-lo lon-hi] (subset->bounding-lon elems)
        [lat-lo lat-hi] (subset->bounding-lat elems)]
    (map #(Float/parseFloat %) [lon-lo lat-lo lon-hi lat-hi])))
(defn coverage->granules
  [coverage]
  (let [ids (filter #(string/starts-with? % "G") coverage)]
    (if (empty? ids)
      nil
      ids)))
(defn coverage->collection
  [coverage]
  (let [id (filter #(string/starts-with? % "C") coverage)]
    (if (empty? id)
      nil
      (first id))))
 
(ns cmr.opendap.ous.core
  (:require
   [clojure.set :as set]
   [clojure.string :as string]
   [cmr.opendap.ous.collection.core :as collection]
   [cmr.opendap.ous.collection.params.core :as params]
   [cmr.opendap.ous.collection.results :as results]
   [cmr.opendap.ous.granule :as granule]
   [cmr.opendap.ous.service :as service]
   [cmr.opendap.ous.variable :as variable]
   [cmr.opendap.util :as util]
   [taoensso.timbre :as log]))

Notes ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

General caveat: the logic for this code was translated -- without domain knowledge -- from Node.js code that was established as faulty and buggy. ALL OF THIS needs to be REASSESSED with an intelligent, knowledgable eye.

Notes on representing spatial extents.

EDSC uses URL-encoded long/lat numbers representing a bounding box Note that the ordering is the same as that used by CMR (see below). -9.984375%2C56.109375%2C19.828125%2C67.640625 which URL-decodes to: -9.984375,56.109375,19.828125,67.640625

OPeNDAP download URLs have something I haven't figured out yet; given that one of the numbers if over 180, it can't be degrees ... it might be what WCS uses for x and y? Latitude[22:34],Longitude[169:200]

The OUS Prototype uses the WCS standard for lat/long: SUBSET=axis[,crs](low,high) For lat/long this takes the following form: subset=lat(56.109375,67.640625)&subset=lon(-9.984375,19.828125)

CMR supports bounding spatial extents by describing a rectangle using four comma-separated values: 1. lower left longitude 2. lower left latitude 3. upper right longitude 4. upper right latitude For example: bounding_box==-9.984375,56.109375,19.828125,67.640625

Google's APIs use lower left, upper right, but the specify lat first, then long: southWest = LatLng(56.109375,-9.984375); northEast = LatLng(67.640625,19.828125);

We're going to codify parameters with records to keep things well documented. Additionally, this will make converting between parameter schemes an explicit operation on explicit data.

Utility/Support Functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn bounding-info->opendap-lat-lon
  [{var-name :name opendap-bounds :opendap}]
  (variable/format-opendap-bounds var-name opendap-bounds))
(defn bounding-info->opendap-query
  ([bounding-info]
    (bounding-info->opendap-query bounding-info nil))
  ([bounding-info bounding-box]
   (when (seq bounding-info)
     (str
      (->> bounding-info
           (map bounding-info->opendap-lat-lon)
           (string/join ",")
           (str "?"))
      ","
      (variable/format-opendap-lat-lon
       (variable/create-opendap-bounds bounding-box))))))

XXX WARNING!!! The pattern matching code has been taken from the Node.js prototype ... and IT IS AWFUL. This is only temporary ...

(def fallback-pattern #"(.*)(/datapool/DEV01)(.*)")
(def fallback-replacement "/opendap/DEV01/user")
(defn data-file->opendap-url
  [pattern-info data-file]
  (let [pattern (re-pattern (:pattern-match pattern-info))
        data-url (:link-href data-file)]
    (if (re-matches pattern data-url)
      (do
        (log/debug "Granule URL matched provided pattern ...")
        (string/replace data-url
                        pattern
                        (str (:pattern-subs pattern-info) "$2")))
      (do
        (log/debug
         "Granule URL didn't match provided pattern; trying default ...")
        (if (re-matches fallback-pattern data-url)
          (string/replace data-url
                          fallback-pattern
                          (str "$1" fallback-replacement "$3")))))))
(defn data-files->opendap-urls
  [params pattern-info data-files query-string]
  (->> data-files
       (map (partial data-file->opendap-url pattern-info))
       (map #(str % "." (:format params) query-string))))

There are several variable and bounding scenarios we need to consider:

  • no spatial subsetting and no variables - return no query string in OPeNDAP URL; this will give users all variables for the entire extent defined in the variables' metadata.
  • variables but no spatial subsetting - return a query string with just the variables requested; a Latitude,Longitude will also be appended to the OPeNDAP URL; this will give users just these variables, but for the entire extent defined in each variable's metadata.
  • variables and spatial subsetting - return a query string with the variables requested as well as the subsetting requested; this will give users just these variables, with data limited to the specified spatial range.
  • spatial subsetting but no variables - this is a special case that needs to do a little more work: special subsetting without variables will link to an essentially empty OPeNDAP file; as such, we need to iterate through all the variables in the metadata and create an OPeNDAP URL query string that provides the sensible default of all variables.

    For each of those conditions, a different value of vars will be returned, allowing for the desired result. Respective to the bullet points above:

  • vars - empty vector

  • vars - metadata for all the specified variable ids
  • vars - metadata for all the specified variable ids
  • vars - metadata for all the variables associated in the collection
(defn apply-bounding-conditions
  [search-endpoint user-token coll {:keys [bounding-box variables] :as params}]
  (log/debugf (str "Applying bounding conditions with bounding box %s and "
                   "variable ids %s ...")
              bounding-box
              variables)
  (cond
    ;; Condition 1 - no spatial subsetting and no variables
    (and (nil? bounding-box) (empty? variables))
    []
    ;; Condition 2 - variables but no spatial subsetting
    (and (nil? bounding-box) (not (empty? variables)))
    (variable/get-metadata search-endpoint user-token params)
    ;; Condition 3 - variables and spatial subsetting
    (and bounding-box (not (empty? variables)))
    (variable/get-metadata search-endpoint user-token params)
    ;; Condition 4 - spatial subsetting but no variables
    (and bounding-box (empty? variables))
    (variable/get-metadata search-endpoint
     user-token
     (assoc params :variables (collection/extract-variable-ids coll)))))
(defn get-opendap-urls
  [search-endpoint user-token raw-params]
  (log/trace "Got params:" raw-params)
  (let [start (util/now)
        params (params/parse raw-params)
        bounding-box (:bounding-box params)
        granules (granule/get-metadata search-endpoint user-token params)
        data-files (map granule/extract-datafile-link granules)
        coll (collection/get-metadata search-endpoint user-token params)
        service-ids (collection/extract-service-ids coll)
        services (service/get-metadata search-endpoint user-token service-ids)
        pattern-info (service/extract-pattern-info (first services))
        vars (apply-bounding-conditions search-endpoint user-token coll params)
        bounding-info (map #(variable/extract-bounding-info % bounding-box)
                           vars)
        query (bounding-info->opendap-query bounding-info bounding-box)]
    (log/trace "data-files:" (into [] data-files))
    (log/trace "pattern-info:" pattern-info)
    (log/debug "variable bounding-info:" (into [] bounding-info))
    (log/debug "query:" query)
    (results/create
     (data-files->opendap-urls params pattern-info data-files query)
     :elapsed (util/timed start))))
 
(ns cmr.opendap.ous.variable
  (:require
   [clojure.string :as string]
   [cmr.opendap.components.config :as config]
   [cmr.opendap.http.request :as request]
   [cmr.opendap.http.response :as response]
   [cmr.opendap.util :as util]
   [ring.util.codec :as codec]
   [taoensso.timbre :as log]))

Constants/Default Values ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(def default-lon-lo -180.0)
(def default-lon-hi 180.0)
(def default-lat-lo -90.0)
(def default-lat-hi 90.0)
(def default-x-lo 0.0)
(def default-x-hi 360.0)
(def default-y-lo 0.0)
(def default-y-hi 180.0)
(def default-stride 1)

Records ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defrecord Dimensions [x y])
(defrecord ArrayLookup [low high])

XXX There is a note in Abdul's code along these lines: "hack for descending orbits (array index 0 is at 90 degrees north)" If I understand correctly, this would cause the indices for high and low values for latitude to be reversed ... so we reverse them here, where all OPeNDAP coords get created. This enables proper lookup in OPeNDAP arrays.

This REALLY needs to be investigated, though.
(defn create-opendap-lookup
  [lon-lo lat-lo lon-hi lat-hi]
  (map->ArrayLookup
   {:low {:x lon-lo
          :y lat-hi} ;; <-- swap hi for lo
    :high {:x lon-hi
           :y lat-lo}})) ;; <-- swap lo for hi

<-- swap lo for hi

Support/Utility Functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

This function is intended to be used if spatial subsetting is not proivded in the query: in that case, all the bounds of all the variables will be counted, and the one most-used is what will be returned.

(defn dominant-bounds
  [bounding-info]
  (->> bounding-info
       (map :bounds)
       util/most-frequent))

Get the most common dimensions from the bounding-info.

(defn dominant-dimensions
  [bounding-info]
  (->> bounding-info
       (map :dimensions)
       util/most-frequent))

XXX Can we use these instead? Why was the phase shifting written so obtrusely? There's got to be a reason, I just don't know it ...

(defn new-lon-phase-shift
  [x-dim in]
    (int (Math/floor (+ (/ x-dim 2) in))))
(defn new-lat-phase-shift
  [y-dim in]
  (- y-dim
     (int (Math/floor (+ (/ y-dim 2) in)))))

The following longitudinal phase shift functions are translated from the OUS Node.js prototype. It would be nice to use the more general functions above, if those work out.

(defn lon-lo-phase-shift
  [x-dim lon-lo]
  (-> (/ (* (- x-dim 1)
            (- lon-lo default-lon-lo))
         (- default-lon-hi default-lon-lo))
      Math/floor
      int))
(defn lon-hi-phase-shift
  [x-dim lon-hi]
  (-> (/ (* (- x-dim 1)
            (- lon-hi default-lon-lo))
         (- default-lon-hi default-lon-lo))
      Math/ceil
      int))

XXX Note that the following two functions were copied from this JS:

var lats = value.replace("lat(","").replace(")","").split(","); //hack for descending orbits (array index 0 is at 90 degrees north) yarrayend = YDim - 1 - Math.floor((YDim-1)*(lats[0]-latbegin)/(latend-lat_begin)); yarraybegin = YDim -1 - Math.ceil((YDim-1)*(lats[1]-latbegin)/(latend-lat_begin));

Note the "hack" JS comment ...

This is complicated by the fact that, immediately before those lines of code are a conflicting set of lines overrwitten by the ones pasted above:

yarraybegin = Math.floor((YDim-1)*(lats[0]-latbegin)/(latend-lat_begin)); yarrayend = Math.ceil((YDim-1)*(lats[1]-latbegin)/(latend-lat_begin));

These original JS functions are re-created in Clojure here:

(defn orig-lat-lo-phase-shift
  [y-dim lat-lo]
  (-> (/ (* (- y-dim 1)
            (- lat-lo default-lat-lo))
          (- default-lat-hi default-lat-lo))
       Math/floor
       int))
(defn orig-lat-hi-phase-shift
  [y-dim lat-hi]
  (-> (/ (* (- y-dim 1)
            (- lat-hi default-lat-lo))
          (- default-lat-hi default-lat-lo))
       Math/ceil
       int))

The following latitudinal phase shift functions are what is currently being used.

(defn lat-lo-phase-shift
  [y-dim lat-lo]
  (int
    (- y-dim
       1
       (Math/floor (/ (* (- y-dim 1)
                         (- lat-lo default-lat-lo))
                      (- default-lat-hi default-lat-lo))))))
(defn lat-hi-phase-shift
  [y-dim lat-hi]
  (int
    (- y-dim
       1
       (Math/ceil (/ (* (- y-dim 1)
                        (- lat-hi default-lat-lo))
                     (- default-lat-hi default-lat-lo))))))

Core Functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn build-query
  [variable-ids]
  (string/join
   "&"
   (conj
    (map #(str (codec/url-encode "concept_id[]")
               "=" %)
         variable-ids)
    (str "page_size=" (count variable-ids)))))

Given a 'params' data structure with a ':variables' key (which may or may not have values) and a list of all collection variable-ids, return the metadata for the passed variables, if defined, and for all associated variables, if params does not contain any.

(defn get-metadata
  [search-endpoint user-token {variable-ids :variables}]
  (if (seq variable-ids)
    (let [url (str search-endpoint
                   "/variables?"
                   (build-query variable-ids))
          results (request/async-get url
                   (-> {}
                       (request/add-token-header user-token)
                       (request/add-accept "application/vnd.nasa.cmr.umm+json"))
                   response/json-handler)]
      (log/debug "Got results from CMR variable search:" results)
      (log/debug "Variable ids used:" variable-ids)
      (:items @results))
    []))
(defn parse-dimensions
  [dim]
  ;; XXX It seems that the X and Y have been swapped for at least
  ;;     on collection's variables; Simon and I are looking into this
  ;;     for now, we're just gonna pretend ... by changing the order
  ;;     below :-(
  [(:Size (first (filter #(= "YDim" (:Name %)) dim)))
   (:Size (first (filter #(= "XDim" (:Name %)) dim)))])
(defn extract-dimensions
  [entry]
  (->> (get-in entry [:umm :Dimensions])
       (parse-dimensions)
       (apply ->Dimensions)))

Parse bounds that are annotated with Lat and Lon, returning values in the same order that CMR uses for spatial bounding boxes.

(defn parse-annotated-bounds
  [bounds]
  (let [lon-regex "Lon:\\s*(-?[0-9]+),\\s*(-?[0-9]+).*;\\s*"
        lat-regex "Lat:\\s*(-[0-9]+),\\s*(-?[0-9]+).*"
        [lon-lo lon-hi lat-lo lat-hi]
         (rest (re-find (re-pattern (str lon-regex lat-regex)) bounds))]
    [lon-lo lat-lo lon-hi lat-hi]))
(defn parse-cmr-bounds
  [bounds]
  "Parse a list of lat/lon values ordered according to the CMR convention
  of lower-left lon, lower-left lat, upper-right long, upper-right lat."
  (map string/trim (string/split bounds #",\s*")))
(defn parse-bounds
  [bounds]
  (if (string/starts-with? bounds "Lon")
    (parse-annotated-bounds bounds)
    (parse-cmr-bounds bounds)))
(defn extract-bounds
  [entry]
  (if entry
    (->> entry
         (#(get-in % [:umm :Characteristics :Bounds]))
         parse-bounds
         (map #(Float/parseFloat %)))
    nil))
(defn create-opendap-bounds
  ([bounding-box]
   (create-opendap-bounds {:x default-x-hi :y default-y-hi} bounding-box))
  ([{x-dim :x y-dim :y} [lon-lo lat-lo lon-hi lat-hi :as bounding-box]]
   (if bounding-box
     (let [x-lo (lon-lo-phase-shift x-dim lon-lo)
           x-hi (lon-hi-phase-shift x-dim lon-hi)
           y-lo (lat-lo-phase-shift y-dim lat-lo)
           y-hi (lat-hi-phase-shift y-dim lat-hi)]
       (create-opendap-lookup x-lo y-lo x-hi y-hi))
     nil)))
(defn format-opendap-lat
  ([opendap-bounds]
   (format-opendap-lat opendap-bounds default-stride))
  ([opendap-bounds stride]
   (if opendap-bounds
     (format "[%s:%s:%s]"
              (get-in opendap-bounds [:low :y])
              stride
              (get-in opendap-bounds [:high :y])))))
(defn format-opendap-lon
  ([opendap-bounds]
   (format-opendap-lon opendap-bounds default-stride))
  ([opendap-bounds stride]
   (if opendap-bounds
     (format "[%s:%s:%s]"
              (get-in opendap-bounds [:low :x])
              stride
              (get-in opendap-bounds [:high :x])))))
(defn format-opendap-var-lat-lon
  ([opendap-bounds]
   (format-opendap-var-lat-lon opendap-bounds default-stride))
  ([opendap-bounds stride]
   (if opendap-bounds
     (format "[*]%s%s"
       (format-opendap-lat opendap-bounds stride)
       (format-opendap-lon opendap-bounds stride)))))
(defn format-opendap-lat-lon
  ([opendap-bounds]
   (format-opendap-lat-lon opendap-bounds default-stride))
  ([opendap-bounds stride]
   (format "Latitude%s,Longitude%s"
           (format-opendap-lat opendap-bounds stride)
           (format-opendap-lon opendap-bounds stride))))
(defn format-opendap-bounds
  ([bound-name opendap-bounds]
   (format-opendap-bounds bound-name opendap-bounds default-stride))
  ([bound-name opendap-bounds stride]
   (format "%s%s"
            bound-name
            (format-opendap-var-lat-lon opendap-bounds stride))))

This function is executed at the variable level, however it has general, non-variable-specific bounding info passed to it in order to support spatial subsetting

(defn extract-bounding-info
  [entry bounding-box]
  (let [dims (extract-dimensions entry)
        ; bounds (or bounding-box (extract-bounds entry))
        ]
    {:concept-id (get-in entry [:meta :concept-id])
     :name (get-in entry [:umm :Name])
     :dimensions dims
     :bounds bounding-box
     :opendap (create-opendap-bounds dims bounding-box)
     :size (get-in entry [:umm :Characteristics :Size])}))
 
(ns cmr.opendap.ous.service
  (:require
   [clojure.string :as string]
   [cmr.opendap.components.config :as config]
   [cmr.opendap.http.request :as request]
   [cmr.opendap.http.response :as response]
   [ring.util.codec :as codec]
   [taoensso.timbre :as log]))
(defn build-query
  [service-ids]
  (string/join
   "&"
   (conj
    (map #(str (codec/url-encode "concept_id[]")
               "=" %)
         service-ids)
    (str "page_size=" (count service-ids)))))

Given a service-id, get the metadata for the associate service.

(defn get-metadata
  [search-endpoint user-token service-ids]
  (log/debug "Getting service metadata for:" service-ids)
  (let [url (str search-endpoint
                 "/services?"
                 (build-query service-ids))
        results (request/async-get url
                 (-> {}
                     (request/add-token-header user-token)
                     (request/add-accept "application/vnd.nasa.cmr.umm+json"))
                 response/json-handler)]
    (log/debug "Got results from CMR service search:" results)
    (:items @results)))
(defn match-opendap
  [service-data]
  (= "opendap" (string/lower-case (:Type service-data))))
(defn extract-pattern-info
  [service-entry]
  (let [umm (:umm service-entry)]
    (when (match-opendap umm)
      {:service-id (get-in service-entry [:meta :concept-id])
       ;; XXX WARNING!!! The regex's saved in the UMM data are broken!
       ;;                We're manually hacking the regex to fix this ...
       ;;                this makes things EXTREMELY FRAGILE!
       :pattern-match (str "(" (:OnlineAccessURLPatternMatch umm) ")(.*)")
       :pattern-subs (:OnlineAccessURLPatternSubstitution umm)})))
 

This namespace represents the authorization API for CMR OPeNDAP. This is where the rest of the application goes when it needs to perform checks on roles or permissions for a given user and/or concept.

Currently, this namespace is only used by the REST middleware that checks resources for authorization.

(ns cmr.opendap.auth.core
  (:require
   [cmr.opendap.auth.permissions :as permissions]
   [cmr.opendap.auth.roles :as roles]
   [cmr.opendap.auth.token :as token]
   [cmr.opendap.components.caching :as caching]
   [cmr.opendap.components.config :as config]
   [cmr.opendap.errors :as errors]
   [cmr.opendap.http.response :as response]
   [taoensso.timbre :as log]))

A supporting function for check-roles-permissions that handles the roles side of things.

(defn check-roles
  [system handler request route-roles user-token user-id]
  (log/debug "Roles annotated in routes ...")
  (if (roles/admin? system
                    route-roles
                    user-token
                    user-id)
    (handler request)
    (response/not-allowed errors/no-permissions)))

A supporting function for check-roles-permissions that handles the permissions side of things.

(defn check-permissions
  [system handler request route-permissions user-token user-id]
  (let [concept-id (permissions/route-concept-id request)]
    (log/debug "Permissions annotated in routes ...")
    (if (permissions/concept? system
                              route-permissions
                              user-token
                              user-id
                              concept-id)
      (handler request)
      (response/not-allowed errors/no-permissions))))

A supporting function for check-route-access that handles the actual checking.

(defn check-roles-permissions
  [system handler request route-roles route-permissions]
  (if-let [user-token (token/extract request)]
    (do
      (log/debug "ECHO token provided; proceeding ...")
      (let [user-id (token/->cached-user system user-token)]
        (log/trace "user-token: [REDACTED]")
        (log/trace "user-id:" user-id)
        (cond ;; XXX For now, there is only the admin role in the CMR, so
              ;;     we'll just keep this specific to that for now. Later, if
              ;;     more roles are used, we'll want to make this more
              ;;     generic ...
              route-roles
              (check-roles
               system handler request route-roles user-token user-id)
              route-permissions
              (check-permissions
               system handler request route-permissions user-token user-id))))
    (do
      (log/warn "ECHO token not provided for protected resource")
      (response/not-allowed errors/token-required))))

This is the primary function for this namespace, utilized directly by CMR OPeNDAP's authorization middleware. Given a request which contains route-specific authorization requirements and potentially a user token, it checks against these as well as the level of access require for any requested concepts.

(defn check-route-access
  [system handler request]
  ;; Before performing any GETs/POSTs against CMR Access Control or ECHO,
  ;; let's make sure that's actually necessary, only doing it in the cases
  ;; where the route is annotated for roles/permissions.
  (let [route-roles (roles/route-annotation request)
        route-permissions (permissions/route-annotation request)]
    (if (or route-roles route-permissions)
      (do
        (log/debug (str "Either roles or permissions were annotated in "
                        "routes; checking ACLs ..."))
        (log/trace "route-roles:" route-roles)
        (log/trace "route-permissions:" route-permissions)
        (check-roles-permissions
         system handler request route-roles route-permissions))
      (do
        (log/debug (str "Neither roles nor permissions were annotated in "
                        "the routes; skipping ACL check ..."))
        (handler request)))))
 

Roles for CMR OPeNDAP are utilized in the application routes when it is necessary to limit access to resources based on the role of a user.

Roles are included in the route definition along with the route's handler. For example: ``` [... ["my/route" { :get {:handler my-handlers/my-route :roles #{:admin}} :post ...}] ...]

(ns cmr.opendap.auth.roles
  (:require
   [clojure.set :as set]
   [cmr.opendap.auth.acls :as acls]
   [cmr.opendap.components.caching :as caching]
   [cmr.opendap.components.config :as config]
   [reitit.ring :as ring]
   [taoensso.timbre :as log]))

The canonical ingest management ACL definition.

(def management-acl
  :INGEST_MANAGEMENT_ACL)

The query formatter used when making a roles query to the CMR Access Control API. Note that only the management ACL is currently supported, and that this maps below to admin.

(def echo-management-query
  {:system_object (name management-acl)})

Generate a key to be used for caching role data.

(defn admin-key
  [token]
  (str "admin:" token))
(defn cmr-acl->reitit-acl
  [cmr-acl]
  (if (seq (management-acl cmr-acl))
    #{:admin}
    #{}))

Extract any roles annotated in the route associated with the given request.

(defn route-annotation
  [request]
  (get-in (ring/get-match request) [:data :get :roles]))

Query the CMR Access Control API to get the roles for the given token+user.

(defn admin
  [base-url token user-id]
  (let [perms (acls/check-access base-url
                                 token
                                 user-id
                                 echo-management-query)]
    (cmr-acl->reitit-acl @perms)))

Look up the roles for token+user in the cache; if there is a miss, make the actual call for the lookup.

(defn cached-admin
  [system token user-id]
  (caching/lookup system
                  (admin-key token)
                  #(admin (config/get-access-control-url system)
                          token
                          user-id)))

Check to see if the roles of a given token+user match the required roles for the route.

(defn admin?
  [system route-roles token user-id]
  (seq (set/intersection (cached-admin system token user-id)
                         route-roles)))
 

The functions in this API are responsible for such things as making queries to CMR Access Control to get token-to-user mappings, extracting tokens from request headers, and defining caching keys and related tasks.

(ns cmr.opendap.auth.token
  (:require
   [clojure.data.xml :as xml]
   [cmr.opendap.components.caching :as caching]
   [cmr.opendap.components.config :as config]
   [cmr.opendap.const :as const]
   [cmr.opendap.http.request :as request]
   [cmr.opendap.http.response :as response]
   [taoensso.timbre :as log]))

The path segment to the CMR Access Control API resource that is queried in order to get user/token mappings.

(def token-info-resource
  "/tokens/get_token_info")

Generate a key to be used for caching token data.

(defn token-data-key
  [token]
  (str "token-data:" token))

Generate a key to be used for caching user-id data.

(defn user-id-key
  [token]
  (str "user-id:" token))

Extract the value of Echo-Token header that was passed in the request.

(defn extract
  [request]
  (request/get-header request "echo-token"))

Given a parsed XML element from a get-token-info payload, extract the user name.

(defn extract-user-name
  [xml-element]
  (when (= :user_name (:tag xml-element))
   (:content xml-element)))

Parse the XML that is returned when querying the CMR Access Control API for token info.

(defn parse-token-data
  [xml-str]
  (log/trace "Got token XML data:" xml-str)
  (first
    (remove nil?
      (mapcat #(when (map? %) (extract-user-name %))
              (:content (xml/parse-str xml-str))))))

Query the CMR Access Control API for information assocated with the given token.

(defn get-token-info
  [base-url token]
  (let [url (str base-url token-info-resource)
        data (str "id=" token)]
    (request/async-post
      url
      (-> {:body data}
          (request/add-token-header token)
          (request/add-form-ct))
      #(response/client-handler % parse-token-data))))

Given a token, return the associated user name.

(defn ->user
  [base-url token]
  @(get-token-info base-url token))

Look up the user for a token in the cache; if there is a miss, make the actual call for the lookup.

(defn ->cached-user
  [system token]
  (caching/lookup
   system
   (user-id-key token)
   #(->user (config/get-echo-rest-url system) token)))
 

Permissions for CMR OPeNDAP are utilized in the application routes when it is necessary to limit access to resources based on the specific capabilities granted to a user.

Permissions are included in the route definition along with the route's handler. For example: ``` [... ["my/route" { :get {:handler my-handlers/my-route :permissions #{:read}} :post ...}] ...]

(ns cmr.opendap.auth.permissions
  (:require
   [clojure.set :as set]
   [cmr.opendap.auth.acls :as acls]
   [cmr.opendap.components.caching :as caching]
   [cmr.opendap.components.config :as config]
   [reitit.ring :as ring]
   [taoensso.timbre :as log]))

The query formatter used when making a concept permissions query to the CMR Access Control API.

(def echo-concept-query
  #(hash-map :concept_id %))

Generate a key to be used for caching permissions data.

(defn concept-key
  [token]
  (str "concept:" token))

Construct permissions

(defn reitit-acl-data
  [concept-id annotation]
  (when (and concept-id annotation)
    {concept-id annotation}))

Convert a CMR ACL to an ACL that can be matched against permissions in the reitit routing library's data structure. There following conditions are handled:

  • return an empty set when a CMR ACL is nil-valued
  • return a reitit-ready ACL when a map (representing a CMR ACL) is given
  • return the CMR ACL as-is in all other cases.
(defn cmr-acl->reitit-acl
  [cmr-acl]
  (log/debug "Got CMR ACL:" cmr-acl)
  (cond (nil? cmr-acl)
        #{}
        (map? cmr-acl)
        (->> cmr-acl
             (map (fn [[k v]] [(keyword k) (set (map keyword v))]))
             (into {}))
        :else cmr-acl))

Given a request, return the concept id for which we are checking permissions.

(defn route-concept-id
  [request]
  (get-in request [:path-params :concept-id]))

Extract any permissions annotated in the route associated with the given request.

(defn route-annotation
  [request]
  (reitit-acl-data
   (route-concept-id request)
   (cmr-acl->reitit-acl
    (get-in (ring/get-match request) [:data :get :permissions]))))

Query the CMR Access Control API to get the permissions the given token+user have for the given concept.

(defn concept
  [base-url token user-id concept-id]
  (let [perms (acls/check-access base-url
                                 token
                                 user-id
                                 (echo-concept-query concept-id))]
    (log/debug "Got perms:" @perms)
    (cmr-acl->reitit-acl @perms)))

Look up the permissions for a concept in the cache; if there is a miss, make the actual call for the lookup.

(defn cached-concept
  [system token user-id concept-id]
  (caching/lookup system
                  (concept-key token)
                  #(concept (config/get-access-control-url system)
                            token
                            user-id
                            concept-id)))

Check to see if the concept permissions of a given token+user match the required permissions for the route.

(defn concept?
  [system route-perms token user-id concept-id]
  (let [id (keyword concept-id)
        required (cmr-acl->reitit-acl route-perms)
        concept-perms (cached-concept system
                                      token
                                      user-id
                                      concept-id)]
    (seq (set/intersection (id required) (id concept-perms)))))
 

This namespace is provided for common code needed by the roles and permissions namespaces.

(ns cmr.opendap.auth.acls
  (:require
   [cheshire.core :as json]
   [clojure.set :as set]
   [cmr.opendap.components.caching :as caching]
   [cmr.opendap.http.request :as request]
   [cmr.opendap.http.response :as response]
   [org.httpkit.client :as httpc]
   [taoensso.timbre :as log]))

The path segment to the CMR Access Control API resource that is queried in order to get user permissions.

(def permissions-resource
  "/permissions")

This function is responsible for making a call to the CMR Access Control permissions resource to check what has been granted for the given user.

(defn check-access
  [base-url token user-id acl-query]
  (let [url (str base-url permissions-resource)
        req {:query-params (merge {:user_id user-id}
                                  acl-query)}]
    (request/async-get
     url
     (request/add-token-header req token)
     response/json-handler)))
 
(ns cmr.opendap.util)
(defn bool
  [arg]
  (if (contains? #{true :true "true" "TRUE" "t" "T" 1} arg)
    true
    false))
(defn remove-empty
  [coll]
  (remove #(or (nil? %) (empty? %)) coll))

Merge maps recursively.

(defn deep-merge
  [& maps]
  (if (every? #(or (map? %) (nil? %)) maps)
    (apply merge-with deep-merge maps)
    (last maps)))
(defn now
  []
  (/ (System/currentTimeMillis) 1000))
(defn timed
  [start]
  (- (now) start))

This identifies the most frequently occuring data in a collection and returns it.

(defn most-frequent
  [data]
  (->> data
       frequencies
       ;; the 'frequencies' function puts data first; let's swap the order
       (map (fn [[k v]] [v k]))
       ;; sort in reverse order to get the highest counts first
       (sort (comp - compare))
       ;; just get the highest
       first
       ;; the first element is the count, the second is the bounding data
       second))
 
(ns cmr.opendap.core
  (:require
   [clojusc.twig :as logger]
   [cmr.opendap.components.core :as components]
   [com.stuartsierra.component :as component]
   [trifl.java :as trifl])
  (:gen-class))
(logger/set-level! '[cmr.opendap] :info logger/no-color-log-formatter)
(defn -main
  [& args]
  (let [system (components/init)]
    (component/start system)
    (trifl/add-shutdown-handler #(component/stop system))))
 
(ns cmr.opendap.testing.util
  (:require
   [cheshire.core :as json]
   [clojure.java.io :as io]
   [clojure.string :as string])
  (:import
   (clojure.lang Keyword)))
(defn parse-response
  [response]
  (try
    (let [data (json/parse-string (:body response) true)]
      (cond
        (not (nil? (:items data)))
        (:items data)
        :else data))
    (catch Exception e
      {:error {:msg "Couldn't parse body."
               :body (:body response)}})))
(defn create-json-payload
  [data]
  {:body (json/generate-string data)})
(defn create-json-stream-payload
  [data]
  {:body (io/input-stream
          (byte-array
           (map (comp byte int)
            (json/generate-string data))))})
(defn get-env-token
  [^Keyword deployment]
  (System/getenv (format "CMR_%s_TOKEN"
                         (string/upper-case (name deployment)))))
(def get-sit-token #(get-env-token :sit))
(def get-uat-token #(get-env-token :uat))
(def get-prod-token #(get-env-token :prod))
 
(ns cmr.opendap.testing.system
  (:require
    [clojusc.dev.system.core :as system-api]
    [clojusc.twig :as logger]
    [cmr.opendap.components.config :as config]
    [cmr.opendap.components.testing]))

Hide logging as much as possible before the system starts up, which should disable logging entirely for tests.

(logger/set-level! '[] :fatal)
(def ^:dynamic *mgr* (atom nil))
(defn startup
  []
  (alter-var-root #'*mgr* (constantly (atom (system-api/create-state-manager))))
  (system-api/set-system-ns (:state @*mgr*) "cmr.opendap.components.testing")
  (system-api/startup @*mgr*))
(defn shutdown
  []
  (when *mgr*
    (let [result (system-api/shutdown @*mgr*)]
      (alter-var-root #'*mgr* (constantly (atom nil)))
      result)))
(defn system
  []
  (system-api/get-system (:state @*mgr*)))
(defn http-port
  []
  (config/http-port (system)))

Testing fixture for system and integration tests.

(defn with-system
  [test-fn]
  (startup)
  (test-fn)
  (shutdown))
 
(ns cmr.opendap.components.caching
  (:require
    [clojure.core.cache :as cache]
    [clojure.java.io :as io]
    [com.stuartsierra.component :as component]
    [cmr.opendap.components.config :as config]
    [taoensso.timbre :as log]))

Support/utility Data & Functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn load-cache
  [system]
  (if-let [sys system]
    (if-let [filename (config/cache-dumpfile system)]
      (try
        (read-string
          (slurp filename))
        (catch Exception _ nil)))))
(defn dump-cache
  [system cache-data]
  (let [dumpfile (config/cache-dumpfile system)]
    (io/make-parents dumpfile)
    (spit
      dumpfile
      (prn-str cache-data))))

Caching Component API ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn create-cache
  [system]
  (let [init (merge (config/cache-init system)
                    (load-cache system))
        ttl (config/cache-ttl-ms system)
        threshold (config/cache-lru-threshold system)
        cache (-> init
                  (cache/ttl-cache-factory :ttl ttl)
                  (cache/lru-cache-factory :threshold threshold))]
    (log/debug "Creating TTL Cache with time-to-live of" ttl)
    (log/debug "Composing with LRU cache with threshold (item count)" threshold)
    (log/trace "Starting value:" init)
    (atom cache)))
(defn get-cache
  [system]
  (get-in system [:caching :cache]))
(defn evict
  [system item-key]
  (swap! (get-cache system) cache/evict item-key))
(defn lookup
  ([system item-key]
    (cache/lookup @(get-cache system) item-key))
  ([system item-key value-fn]
    (let [ch @(get-cache system)]
      (if (cache/has? ch item-key)
        (do
          (log/debug "Cache has key; skipping value function ...")
          (log/trace "Key:" item-key)
          (cache/hit ch item-key))
        (when-let [value (value-fn)]
          (log/debug "Cache miss; calling value function ...")
          (when-not (or (nil? value) (empty? value))
            (swap! (get-cache system) #(cache/miss % item-key value))))))
    (lookup system item-key)))

Component Lifecycle Implementation ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defrecord Caching [cache])
(defn start
  [this]
  (log/info "Starting caching component ...")
  (let [cache (create-cache this)]
    (log/debug "Started caching component.")
    (assoc this :cache cache)))
(defn stop
  [this]
  (log/info "Stopping caching component ...")
  (if-let [cache-ref (:cache this)]
    (if-let [cache @cache-ref]
      (dump-cache this cache)))
  (log/debug "Stopped caching component.")
  (assoc this :cache nil))
(def lifecycle-behaviour
  {:start start
   :stop stop})
(extend Caching
  component/Lifecycle
  lifecycle-behaviour)

Component Constructor ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn create-component
  []
  (map->Caching {}))
 
(ns cmr.opendap.components.core
  (:require
    [cmr.opendap.components.caching :as caching]
    [cmr.opendap.components.config :as config]
    [cmr.opendap.components.httpd :as httpd]
    [cmr.opendap.components.logging :as logging]
    [com.stuartsierra.component :as component]))

Common Configuration Components ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(def cfg
  {:config (config/create-component)})
(def log
  {:logging (component/using
             (logging/create-component)
             [:config])})
(def cache
  {:caching (component/using
             (caching/create-component)
             [:config :logging])})
(def httpd
  {:httpd (component/using
           (httpd/create-component)
           [:config :logging :caching])})
(def cache-without-logging
  {:caching (component/using
             (caching/create-component)
             [:config])})
(def httpd-without-logging
  {:httpd (component/using
           (httpd/create-component)
           [:config :caching])})

Component Initializations ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn initialize-bare-bones
  []
  (component/map->SystemMap
    (merge cfg
           log)))
(defn initialize-with-web
  []
  (component/map->SystemMap
    (merge cfg
           log
           cache
           httpd)))
(defn initialize-without-logging
  []
  (component/map->SystemMap
    (merge cfg
           cache-without-logging
           httpd-without-logging)))
(def init-lookup
  {:basic #'initialize-bare-bones
   :testing #'initialize-without-logging
   :web #'initialize-with-web})
(defn init
  ([]
    (init :web))
  ([mode]
    ((mode init-lookup))))
 
(ns cmr.opendap.components.logging
  (:require
    [clojusc.twig :as logger]
    [com.stuartsierra.component :as component]
    [cmr.opendap.components.config :as config]
    [taoensso.timbre :as log]))

Logging Component API ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

TBD

Component Lifecycle Implementation ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defrecord Logging [])
(defn start
  [this]
  (log/info "Starting logging component ...")
  (let [log-level (config/log-level this)
        log-nss (config/log-nss this)]
    (log/debug "Setting up logging with level" log-level)
    (log/debug "Logging namespaces:" log-nss)
    (if (config/log-color? this)
      (do
        (log/debug "Enabling color logging ...")
        (logger/set-level! log-nss log-level))
      (logger/set-level! log-nss log-level logger/no-color-log-formatter))
    (log/debug "Started logging component.")
    this))
(defn stop
  [this]
  (log/info "Stopping logging component ...")
  (log/debug "Stopped logging component.")
  this)
(def lifecycle-behaviour
  {:start start
   :stop stop})
(extend Logging
  component/Lifecycle
  lifecycle-behaviour)

Component Constructor ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn create-component
  []
  (map->Logging {}))
 

A component system setup namespace for use in testing.

(ns cmr.opendap.components.testing
  (:require
    [cmr.opendap.components.core :as core]))

Component Initialization ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn init
  ([]
    (init :testing))
  ([mode]
    ((mode core/init-lookup))))
 
(ns cmr.opendap.components.config
  (:require
   [cmr.opendap.config :as config]
   [com.stuartsierra.component :as component]
   [taoensso.timbre :as log])
  (:import
   (clojure.lang Keyword)))

Utility Functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn- get-cfg
  [system]
  (->> [:config :data]
       (get-in system)
       (into {})))

Config Component API ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn cache-dumpfile
  [system]
  (get-in (get-cfg system) [:caching :dumpfile]))
(defn cache-init
  [system]
  (get-in (get-cfg system) [:caching :init]))
(defn cache-lru-threshold
  [system]
  (get-in (get-cfg system) [:caching :lru :threshold]))
(defn cache-ttl-ms
  [system]
  (* (get-in (get-cfg system) [:caching :ttl :minutes]) ; minutes
     60 ; seconds
     1000 ; milliseconds))
(defn cache-type
  [system]
  (get-in (get-cfg system) [:caching :type]))
(defn get-service
  [system service]
  (let [svc-cfg (get-in (get-cfg system)
                        (concat [:cmr] (config/service-keys service)))]
    svc-cfg))
(defn cmr-base-url
  [system]
  (config/service->base-url (get-service system :search)))

This function returns the cmr-opendap URL with a trailing slash, but without the 'opendap' appended.

(defn opendap-base-url
  [system]
  (str (config/service->base-public-url (get-service system :opendap)) "/"))

This function returns the cmr-opendap URL with a trailing slash.

(defn opendap-url
  [system]
  (str (config/service->public-url (get-service system :opendap)) "/"))
(defn get-service-url
  [system service]
  (config/service->url (get-service system service)))

The URLs returned by these functions have no trailing slash:

(def get-access-control-url #(get-service-url % :access-control))
(def get-echo-rest-url #(get-service-url % :echo-rest))
(def get-ingest-url #(get-service-url % :ingest))
(def get-opendap-url #(get-service-url % :opendap))
(def get-search-url #(get-service-url % :search))
(defn http-assets
  [system]
  (get-in (get-cfg system) [:httpd :assets]))
(defn http-docs
  [system]
  (get-in (get-cfg system) [:httpd :docs]))
(defn http-port
  [system]
  (or (get-in (get-cfg system) [:cmr :opendap :port])
      (get-in (get-cfg system) [:httpd :port])))
(defn http-index-dirs
  [system]
  (get-in (get-cfg system) [:httpd :index-dirs]))
(defn http-replace-base-url
  [system]
  (get-in (get-cfg system) [:httpd :replace-base-url]))
(defn http-rest-docs-base-url-template
  [system]
  (get-in (get-cfg system) [:httpd :rest-docs :base-url-template]))
(defn http-rest-docs-outdir
  [system]
  (get-in (get-cfg system) [:httpd :rest-docs :outdir]))
(defn http-rest-docs-source
  [system]
  (get-in (get-cfg system) [:httpd :rest-docs :source]))
(defn http-skip-static
  [system]
  (get-in (get-cfg system) [:httpd :skip-static]))
(defn log-color?
  [system]
  (or (get-in (get-cfg system) [:cmr :opendap :logging :color])
      (get-in (get-cfg system) [:logging :color])))
(defn log-level
  [system]
  (get-in (get-cfg system) [:logging :level]))
(defn log-nss
  [system]
  (get-in (get-cfg system) [:logging :nss]))

Component Lifecycle Implementation ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defrecord Config [data])
(defn start
  [this]
  (log/info "Starting config component ...")
  (log/debug "Started config component.")
  (let [cfg (config/data)]
    (log/debug "Built configuration:" cfg)
    (assoc this :data cfg)))
(defn stop
  [this]
  (log/info "Stopping config component ...")
  (log/debug "Stopped config component.")
  this)
(def lifecycle-behaviour
  {:start start
   :stop stop})
(extend Config
  component/Lifecycle
  lifecycle-behaviour)

Component Constructor ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn create-component
  []
  (map->Config {}))
 
(ns cmr.opendap.components.httpd
  (:require
    [com.stuartsierra.component :as component]
    [cmr.opendap.components.config :as config]
    [cmr.opendap.rest.app :as rest-api]
    [org.httpkit.server :as server]
    [taoensso.timbre :as log]))

HTTP Server Component API ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

TBD

Component Lifecycle Implementation ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defrecord HTTPD [])
(defn start
  [this]
  (log/info "Starting httpd component ...")
  (let [port (config/http-port this)
        server (server/run-server (rest-api/app this) {:port port})]
    (log/debugf "HTTPD is listening on port %s" port)
    (log/debug "Started httpd component.")
    (assoc this :server server)))
(defn stop
  [this]
  (log/info "Stopping httpd component ...")
  (if-let [server (:server this)]
    (server))
  (assoc this :server nil))
(def lifecycle-behaviour
  {:start start
   :stop stop})
(extend HTTPD
  component/Lifecycle
  lifecycle-behaviour)

Component Constructor ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn create-component
  []
  (map->HTTPD {}))
 
(ns cmr.opendap.errors)
(def no-permissions "You do not have permissions to access that resource.")
(def token-required "An ECHO token is required to access this resource.")
 
(ns cmr.opendap.http.request
  (:require
   [cmr.opendap.const :as const]
   [org.httpkit.client :as httpc]
   [taoensso.timbre :as log])
  (:refer-clojure :exclude [get]))
(def default-options
  {:user-agent const/user-agent
   :insecure? true})
(defn options
  [req & opts]
  (apply assoc (concat [req] opts)))
(defn get-header
  [req field]
  (get-in req [:headers field]))
(defn add-header
  ([field value]
    (add-header {} field value))
  ([req field value]
    (assoc-in req [:headers field] value)))
(defn add-accept
  ([value]
    (add-accept {} value))
  ([req value]
    (add-header req "Accept" value)))
(defn add-token-header
  ([token]
    (add-token-header {} token))
  ([req token]
    (add-header req "Echo-Token" token)))
(defn add-user-agent
  ([]
    (add-user-agent {}))
  ([req]
    (add-header req "User-Agent" const/user-agent)))
(defn add-form-ct
  ([]
    (add-form-ct {}))
  ([req]
    (add-header req "Content-Type" "application/x-www-form-urlencoded")))
(defn add-client-id
  ([]
    (add-client-id {}))
  ([req]
    (add-header req "Client-Id" const/client-id)))
(defn request
  [method url req & [callback]]
  (httpc/request (-> default-options
                     (add-client-id)
                     (add-user-agent)
                     (merge req)
                     (assoc :url url :method method))
                  callback))
(defn async-get
  ([url]
    (async-get url {}))
  ([url req]
    (async-get url req nil))
  ([url req callback]
    (request :get url req callback)))
(defn async-post
  ([url]
    (async-post url {:body nil}))
  ([url req]
    (async-post url req nil))
  ([url req callback]
    (request :post url req callback)))
(defn get
  [& args]
  @(apply async-get args))
(defn post
  [& args]
  @(apply async-post args))
 

This namespace defines a default set of transform functions suitable for use in presenting results to HTTP clients.

Note that ring-based middleeware may take advantage of these functions either by single use or composition.

(ns cmr.opendap.http.response
  (:require
   [cheshire.core :as json]
   [ring.util.http-response :as response]
   [taoensso.timbre :as log]))
(defn client-handler
  ([response]
    (client-handler response identity))
  ([{:keys [status headers body error]} parse-fn]
    (log/debug "Handling client response ...")
    (cond error
          (log/error error)
          (>= status 400)
          (do
            (log/error status)
            (log/debug "Headers:" headers)
            (log/debug "Body:" body))
          :else
          (parse-fn body))))
(defn parse-json-body
  [body]
  (let [str-data (if (string? body) body (slurp body))
        json-data (json/parse-string str-data true)]
    (log/trace "str-data:" str-data)
    (log/trace "json-data:" json-data)
    json-data))
(def json-handler #(client-handler % parse-json-body))
(defn ok
  [_request & args]
  (response/ok args))
(defn json
  [_request data]
  (-> data
      json/generate-string
      response/ok
      (response/content-type "application/json")))
(defn text
  [_request data]
  (-> data
      response/ok
      (response/content-type "text/plain")))
(defn html
  [_request data]
  (-> data
      response/ok
      (response/content-type "text/html")))
(defn not-found
  [_request]
  (response/content-type
   (response/not-found "Not Found")
   "text/plain"))
(defn not-allowed
  [data]
  (-> data
      response/forbidden
      (response/content-type "text/plain")))
(defn cors
  [request response]
  (case (:request-method request)
    :options (-> response
                 (response/content-type "text/plain; charset=utf-8")
                 (response/header "Access-Control-Allow-Origin" "*")
                 (response/header "Access-Control-Allow-Methods" "POST, PUT, GET, DELETE, OPTIONS")
                 (response/header "Access-Control-Allow-Headers" "Content-Type")
                 (response/header "Access-Control-Max-Age" "2592000"))
    (response/header response "Access-Control-Allow-Origin" "*")))
 
(ns cmr.opendap.const)
(def client-id "cmr-opendap-service")
(def user-agent
  "CMR OPeNDAP Service/1.0 (+https://github.com/cmr-exchange/cmr-opendap)")
(def datafile-link-rel "http://esipfed.org/ns/fedsearch/1.1/data#")
 

The functions of this namespace are specifically responsible for generating data structures to be consumed by site page templates.

Of special note: this namespace and its sibling page namespace are only ever meant to be used in the cmr.search.site namespace, particularly in support of creating site routes for access in a browser.

Under no circumstances should cmr.search.site.data be accessed from outside this context; the data functions defined herein are specifically for use in page templates, structured explicitly for their needs.

(ns cmr.opendap.site.data)

Data Utility Functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(def default-title "CMR OPeNDAP")

Data for templates that display a link to Partner Guides. Clients should overrirde these keys in their own base static and base page maps if they need to use different values.

(def default-partner-guide
  {:partner-url "https://wiki.earthdata.nasa.gov/display/CMR/CMR+Client+Partner+User+Guide"
   :partner-text "Client Partner's Guide"})

Data that all static pages have in common.

Note that static pages don't have any context.

(defn base-static
  []
  (merge default-partner-guide
         {:base-url ""
          :app-title default-title}))

Data that all pages have in common.

Note that dynamic pages need to provide the base-url.

(defn base-dynamic
  ([]
   (base-dynamic {}))
  ([data]
   (merge default-partner-guide
          {:app-title default-title}
          data)))

Page Data Functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

Data that all app pages have in common.

The :cli variant uses a special constructed context (see static.StaticContext).

The default variant is the original, designed to work with the regular request context which contains the state of a running CMR.

(defmulti base-page
  :execution-context)
(defmethod base-page :cli
  [data]
  (base-static data))
(defmethod base-page :default
  [data]
  (base-dynamic data))
 

The functions of this namespace are specifically responsible for returning ready-to-serve pages.

(ns cmr.opendap.site.pages
  (:require
   [cmr.opendap.site.data :as data]
   [selmer.parser :as selmer]
   [ring.util.response :as response]
   [taoensso.timbre :as log]))

Page Utility Functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

A utility function for preparing templates.

(defn render-template
  [template page-data]
  (response/response
   (selmer/render-file template page-data)))

A utility function for preparing HTML templates.

(defn render-html
  [template page-data]
  (response/content-type
   (render-template template page-data)
   "text/html"))

HTML page-genereating functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

Prepare the home page template.

(defn home
  [request data]
  (render-html
   "templates/opendap-home.html"
   (data/base-dynamic data)))

Prepare the top-level search docs page.

(defn opendap-docs
  [request data]
  (log/debug "Calling opendap-docs page ...")
  (render-html
   "templates/opendap-docs.html"
   (data/base-dynamic data)))

Prepare the home page template.

(defn not-found
  ([request]
    (not-found request {:base-url "/opendap"}))
  ([request data]
    (render-html
     "templates/opendap-not-found.html"
     (data/base-dynamic data))))
 

The functions of this namespace are specifically responsible for generating the static resources of the top-level and site pages and sitemaps.

(ns cmr.opendap.site.static
  (:require
   [clojure.java.io :as io]
   [clojusc.twig :as logger]
   [cmr.opendap.components.config :as config]
   [cmr.opendap.components.core :as components]
   [cmr.opendap.site.data :as data]
   [com.stuartsierra.component :as component]
   [markdown.core :as markdown]
   [selmer.parser :as selmer]
   [taoensso.timbre :as log]
   [trifl.java :as trifl])
  (:gen-class))
(logger/set-level! '[cmr.opendap] :info)

Utility Functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

This is the function used by default to render templates, given data that the template needs to render.

(defn generate
  [target template-file data]
  (log/debug "Rendering data from template to:" target)
  (log/debug "Template:" template-file)
  (log/debug "Data:" data)
  (io/make-parents target)
  (->> data
       (selmer/render-file template-file)
       (spit target)))

Content Generators ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

Generate the HTML for the CMR OPeNDAP REST API docs page.

(defn generate-rest-api-docs
  [docs-source docs-dir base-url]
  (generate
   (format "%s/index.html" docs-dir)
   "templates/opendap-docs-static.html"
   {:base-url base-url
    :page-content (markdown/md-to-html-string (slurp docs-source))}))

A convenience function that pulls together all the static content generators in this namespace. This is the function that should be called in the parent static generator namespace.

(defn generate-all
  [docs-source docs-dir base-url]
  (log/debug "Generating static site files ...")
  (generate-rest-api-docs docs-source docs-dir base-url))
(defn -main
  [& args]
  (let [system-init (components/init :basic)
        system (component/start system-init)]
    (trifl/add-shutdown-handler #(component/stop system))
    (generate-all
      (config/http-rest-docs-source system)
      (config/http-rest-docs-outdir system)
      (config/http-rest-docs-base-url-template system))))
 
(ns cmr.opendap.health)
(defn has-data?
  [x]
  (if (nil? x)
    false
    true))
(defn config-ok?
  [component]
  (has-data? (:config component)))
(defn logging-ok?
  [component]
  (has-data? (:logging component)))
(defn components-ok?
  [component]
  {:config {:ok? (config-ok? component)}
   :httpd {:ok? true}
   :logging {:ok? (logging-ok? component)}})
 

This namespace defines the REST API handlers for collection resources.

(ns cmr.opendap.rest.handler.collection
  (:require
   [cheshire.core :as json]
   [clojure.java.io :as io]
   [cmr.opendap.auth.token :as token]
   [cmr.opendap.components.config :as config]
   [cmr.opendap.ous.core :as ous]
   [cmr.opendap.http.response :as response]
   [taoensso.timbre :as log]))

OUS Handlers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

Private function for creating OPeNDAP URLs when supplied with an HTTP GET.

(defn- generate-via-get
  [request search-endpoint user-token concept-id]
  (log/debug "Generating URLs based on HTTP GET ...")
  (->> request
       :params
       (merge {:collection-id concept-id})
       (ous/get-opendap-urls search-endpoint user-token)
       (response/json request)))

Private function for creating OPeNDAP URLs when supplied with an HTTP POST.

(defn- generate-via-post
  [request search-endpoint user-token concept-id]
  (->> request
       :body
       (slurp)
       (#(json/parse-string % true))
       (merge {:collection-id concept-id})
       (ous/get-opendap-urls search-endpoint user-token)
       (response/json request)))

XXX

(defn unsupported-method
  [request]
  ;; XXX add error message; utilize error reporting infra
  {:error :not-implemented})

XXX

(defn generate-urls
  [component]
  (fn [request]
    (log/debug "Method-dispatching for URLs generation ...")
    (log/trace "request:" request)
    (let [user-token (token/extract request)
          concept-id (get-in request [:path-params :concept-id])
          search-endpoint (config/get-search-url component)]
      (case (:request-method request)
        :get (generate-via-get request search-endpoint user-token concept-id)
        :post (generate-via-post request search-endpoint user-token concept-id)
        (unsupported-method request)))))

XXX

(defn batch-generate
  [component]
  ;; XXX how much can we minimize round-tripping here?
  ;;     this may require creating divergent logic/impls ...
  (fn [request]
    {:error :not-implemented}))
 

This namespace defines the handlers for REST API resources.

Simple handlers will only need to make a call to a library and then have that data prepared for the client by standard response function. More complex handlers will need to perform additional tasks. For example, in order of increasing complexity: * utilize non-default, non-trivial response functions * operate on the obtained data with various transformations, including extracting form data, query strings, etc. * take advantage of middleware functions that encapsulate complicated business logic

(ns cmr.opendap.rest.handler.core
  (:require
   [clojure.java.io :as io]
   [clojusc.twig :as twig]
   [cmr.opendap.health :as health]
   [cmr.opendap.http.response :as response]
   [ring.middleware.file :as file-middleware]
   [ring.util.codec :as codec]
   [ring.util.http-response]
   [ring.util.response :as ring-response]
   [taoensso.timbre :as log]))

Admin Handlers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn health
  [component]
  (fn [request]
    (->> component
         health/components-ok?
         (response/json request))))
(def ping
  (fn [request]
    (response/json request {:result :pong})))

Utility Handlers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn status
  [status-keyword]
  (fn [request]
    ((ns-resolve 'ring.util.http-response (symbol (name status-keyword))) {})))
(def ok
  (fn [request]
    (response/ok request)))
(defn text-file
  [filepath]
  (fn [request]
    (if-let [file-resource (io/resource filepath)]
      (response/text request (slurp file-resource)))))
(defn html-file
  [filepath]
  (fn [request]
    (if-let [file-resource (io/resource filepath)]
      (response/html request (slurp file-resource)))))
(defn dynamic-page
  [page-fn data]
  #(page-fn % data))
(defn permanent-redirect
  [location]
  (fn [request]
    (ring-response/redirect location :moved-permanently)))
 

This namespace defines the REST routes provided by this service.

Upon idnetifying a particular request as matching a given route, work is then handed off to the relevant request handler function.

(ns cmr.opendap.rest.route
  (:require
   [cmr.opendap.components.config :as config]
   [cmr.opendap.health :as health]
   [cmr.opendap.rest.handler.collection :as collection-handler]
   [cmr.opendap.rest.handler.core :as core-handler]
   [cmr.opendap.site.pages :as pages]
   [reitit.ring :as ring]
   [taoensso.timbre :as log]))

CMR OPeNDAP Routes ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn main
  [httpd-component]
  [["/opendap" {
    :get (core-handler/dynamic-page
          pages/home
          {:base-url (config/opendap-url httpd-component)})
    :head core-handler/ok}]])

Note that these routes only cover part of the docs; the rest are supplied via static content from specific directories (done in middleware).

(defn docs
  [httpd-component]
  [["/opendap/docs" {
    :get (core-handler/dynamic-page
          pages/opendap-docs
          {:base-url (config/opendap-url httpd-component)})}]])

REST API Routes ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn ous-api
  [httpd-component]
  [["/opendap/ous/collections" {
    :post {:handler collection-handler/batch-generate
          ;; XXX protecting collections will be a little different than
          ;;     protecting a single collection, since the concept-id isn't in
          ;;     the path-params. Instead, we'll have to parse the body,
          ;;     extract the concepts ids from that, create an ACL query
          ;;     containing multiple concept ids, and then check those results.
          ;; :permission #{...?}
          }
    :options core-handler/ok}]
   ["/opendap/ous/collection/:concept-id" {
    :get {:handler (collection-handler/generate-urls httpd-component)
          :permissions #{:read}}
    :post {:handler (collection-handler/generate-urls httpd-component)
           :permissions #{:read}}
    :options core-handler/ok}]])
(defn admin-api
  [httpd-component]
  [["/opendap/health" {
    :get (core-handler/health httpd-component)
    :options core-handler/ok}]
   ["/opendap/ping" {
    :get {:handler core-handler/ping
          :roles #{:admin}}
    :post {:handler core-handler/ping
           :roles #{:admin}}
    :options core-handler/ok}]])

Static & Redirect Routes ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn redirects
  [httpd-component]
  [["/opendap/robots.txt" {
    :get (core-handler/permanent-redirect
          (str (config/get-search-url httpd-component)
               "/robots.txt"))}]])
(defn static
  [httpd-component]
  [;; Google verification files
   ["/opendap/googled099d52314962514.html" {
    :get (core-handler/text-file
          "public/verifications/googled099d52314962514.html")}]])

Testing Routes ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(def testing
  [["/testing/401" {:get (core-handler/status :unauthorized)}]
   ["/testing/403" {:get (core-handler/status :forbidden)}]
   ["/testing/404" {:get (core-handler/status :not-found)}]
   ["/testing/405" {:get (core-handler/status :method-not-allowed)}]
   ["/testing/500" {:get (core-handler/status :internal-server-error)}]
   ["/testing/503" {:get (core-handler/status :service-unavailable)}]])
 

Custom ring middleware for CMR OPeNDAP.

(ns cmr.opendap.rest.middleware
  (:require
   [clojure.string :as string]
   [cmr.opendap.auth.core :as auth]
   [cmr.opendap.components.config :as config]
   [cmr.opendap.http.response :as response]
   [cmr.opendap.site.pages :as pages]
   [ring.middleware.content-type :as ring-ct]
   [ring.middleware.defaults :as ring-defaults]
   [ring.middleware.file :as ring-file]
   [ring.middleware.not-modified :as ring-nm]
   [ring.util.request :as request]
   [ring.util.response :as rign-response]
   [taoensso.timbre :as log]))

Ring-based middleware for supporting CORS requests.

(defn wrap-cors
  [handler]
  (fn [request]
    (response/cors request (handler request))))

Ring-based middleware forremoving a single trailing slash from the end of the URI, if present.

(defn wrap-trailing-slash
  [handler]
  (fn [request]
    (let [uri (:uri request)]
      (handler (assoc request :uri (if (and (not (= "/" uri))
                                            (.endsWith uri "/"))
                                     (subs uri 0 (dec (count uri)))
                                     uri))))))
(defn wrap-fallback-content-type
  [handler default-content-type]
  (fn [request]
    (condp = (:content-type request)
      nil (assoc-in (handler request)
                    [:headers "Content-Type"]
                    default-content-type)
      "application/octet-stream" (assoc-in (handler request)
                                           [:headers "Content-Type"]
                                           default-content-type)
      :else (handler request))))
(defn wrap-directory-resource
  ([handler system]
    (wrap-directory-resource handler system "text/html"))
  ([handler system content-type]
    (fn [request]
      (let [response (handler request)]
        (cond
          (contains? (config/http-index-dirs system)
                     (:uri request))
          (rign-response/content-type response content-type)
          :else
          response)))))
(defn wrap-base-url-subs
  [handler system]
  (fn [request]
    (let [response (handler request)]
      (if (contains? (config/http-replace-base-url system)
                     (:uri request))
        (assoc response
               :body
               (string/replace
                (slurp (:body response))
                (re-pattern (config/http-rest-docs-base-url-template system))
                (config/opendap-url system)))
        response))))
(defn wrap-resource
  [handler system]
  (let [docs-resource (config/http-docs system)
        assets-resource (config/http-assets system)
        compound-handler (-> handler
                             (ring-file/wrap-file
                              docs-resource {:allow-symlinks? true})
                             (ring-file/wrap-file
                              assets-resource {:allow-symlinks? true})
                             (wrap-directory-resource system)
                             (wrap-base-url-subs system)
                             (ring-ct/wrap-content-type)
                             (ring-nm/wrap-not-modified))]
    (fn [request]
      (if (contains? (config/http-skip-static system)
                     (:uri request))
        (handler request)
        (compound-handler request)))))
(defn wrap-not-found
  [handler system]
  (fn [request]
    (let [response (handler request)
          status (:status response)]
      (when (nil? status)
        (log/debug "Got nil status in not-found middleware ..."))
      (if (or (= 404 status) (nil? status))
        (assoc (pages/not-found
                request
                {:base-url (config/opendap-url system)})
               :status 404)
        response))))

Ring-based middleware for supporting the protection of routes using the CMR Access Control service and CMR Legacy ECHO support.

In particular, this wrapper allows for the protection of routes by both roles as well as concept-specific permissions. This is done by annotating the routes per the means described in the reitit library's documentation.

(defn wrap-auth
  [handler system]
  (fn [request]
    (log/debug "Running perms middleware ...")
    (auth/check-route-access system handler request)))
(defn reitit-auth
  [system]
  "This auth middleware is specific to reitit, providing the data structure
  necessary that will allow for the extraction of roles and permissions
  settings from the request.
  For more details, see the docstring above for `wrap-auth`."
  {:data
    {:middleware [#(wrap-auth % system)]}})
 
(ns cmr.opendap.rest.app
  (:require
   [clojure.java.io :as io]
   [cmr.opendap.components.config :as config]
   [cmr.opendap.rest.handler.core :as handler]
   [cmr.opendap.rest.middleware :as middleware]
   [cmr.opendap.rest.route :as route]
   [ring.middleware.defaults :as ring-defaults]
   [reitit.ring :as ring]
   [taoensso.timbre :as log]))
(defn rest-api-routes
  [httpd-component]
  (concat
   (route/ous-api httpd-component)
   (route/admin-api httpd-component)))
(defn site-routes
  [httpd-component]
  (concat
   (route/main httpd-component)
   (route/docs httpd-component)
   (route/redirects httpd-component)
   (route/static httpd-component)))
(defn all-routes
  [httpd-component]
  (concat
    (rest-api-routes httpd-component)
    (site-routes httpd-component)
    route/testing))
(defn app
  [httpd-component]
  (let [docs-resource (config/http-docs httpd-component)
        assets-resource (config/http-assets httpd-component)]
    (-> httpd-component
        all-routes
        (ring/router (middleware/reitit-auth httpd-component))
        ring/ring-handler
        (ring-defaults/wrap-defaults ring-defaults/api-defaults)
        (middleware/wrap-resource httpd-component)
        middleware/wrap-trailing-slash
        middleware/wrap-cors
        (middleware/wrap-not-found httpd-component))))
 
(ns cmr.opendap.config
  (:require
   [clojure.edn :as edn]
   [clojure.java.io :as io]
   [clojure.string :as string]
   [cmr.opendap.util :as util]
   [environ.core :as environ])
  (:import
    (clojure.lang Keyword)))
(def config-file "config/cmr-opendap/config.edn")
(defn cfg-data
  ([]
    (cfg-data config-file))
  ([filename]
    (with-open [rdr (io/reader (io/resource filename))]
      (edn/read (new java.io.PushbackReader rdr)))))
(defn cmr-only
  [[k v]]
  (let [key-name (name k)]
    (when (string/starts-with? key-name "cmr-")
      [(mapv keyword (string/split key-name #"-"))
       (try
        (Integer/parseInt v)
        (catch Exception _e
          v))])))
(defn nest-vars
  [acc [ks v]]
  (assoc-in acc ks v))
(defn env-props-data
  []
  (->> (#'environ/read-system-props)
       (util/deep-merge (#'environ/read-system-env))
       (map cmr-only)
       (remove nil?)
       (reduce nest-vars {})))
(defn data
  []
  (util/deep-merge (cfg-data)
                   (env-props-data)))

We need to special-case two-word services, as split by the environment and system property parser above.

(defn service-keys
  [^Keyword service]
  (cond (or (= service :access)
            (= service :access-control))
        [:access :control]
        (or (= service :echo)
            (= service :echo-rest))
        [:echo :rest]
        :else [service]))
(defn service->base-url
  [^Keyword service]
  (format "%s://%s:%s"
          (or (:protocol service) "https")
          (:host service)
          (or (:port service) "443")))
(defn service->url
  [^Keyword service]
  (format "%s%s"
          (service->base-url service)
          (or (get-in service [:relative :root :url])
              (:context service)
              "/")))
(defn service->base-public-url
  [^Keyword service]
  (let [protocol (or (get-in service [:public :protocol]) "https")
        host (get-in service [:public :host])]
    (if (= "https" protocol)
      (format "%s://%s" protocol host)
      (format "%s://%s:%s" protocol host (get-in service [:public :port])))))
(defn service->public-url
  [^Keyword service]
  (format "%s%s"
          (service->base-public-url service)
          (or (get-in service [:relative :root :url])
              (:context service)
              "/")))