diff --git a/webapp/src/clj/lipas/backend/api/v2.clj b/webapp/src/clj/lipas/backend/api/v2.clj index f356697b3..3b4c6ed96 100644 --- a/webapp/src/clj/lipas/backend/api/v2.clj +++ b/webapp/src/clj/lipas/backend/api/v2.clj @@ -6,6 +6,7 @@ [lipas.schema.sports-sites :as sports-sites-schema] [lipas.schema.sports-sites.activities :as activities-schema] [lipas.schema.sports-sites.types :as types-schema] + [lipas.schema.sports-sites.location :as location-schema] [reitit.coercion.malli] [reitit.openapi :as openapi] [reitit.swagger-ui :as swagger-ui] @@ -107,10 +108,10 @@ :description "The LIPAS system provides comprehensive data about sports and recreational facilities in Finland. The API is organized into three main sections: **Sports Sites** -The core entities of LIPAS. Each sports facility is classified using a hierarchical type system, where specific facility types belong to subcategories within seven main categories. Each facility type has its own specific set of properties. +The core entities of LIPAS. Each sports facility is classified using a hierarchical type system, where specific facility types belong to subcategories within seven main categories. Each facility type has its own specific set of properties and a defined geometry type (Point, LineString, or Polygon) that describes its spatial representation. **Sports Site Categories** -Access to the hierarchical type classification system used for categorizing sports facilities. This helps in understanding the structure and relationships between different facility types. +Access to the hierarchical type classification system used for categorizing sports facilities. **Locations of Interest** Additional non-facility entities in LIPAS, that complement the sports facility data." @@ -154,7 +155,7 @@ Additional non-facility entities in LIPAS, that complement the sports facility d ["/{type-code}" {:get - {:summary "Get single sports site category by type-code" + {:summary "Get sports site category by type code" :handler (fn [req] (let [type-code (-> req :parameters :path :type-code)] @@ -206,10 +207,10 @@ Additional non-facility entities in LIPAS, that complement the sports facility d [:city-codes {:optional true :decode/string decode-heisenparam - :description (-> sports-sites-schema/city-codes + :description (-> location-schema/city-codes second :description)} - #'sports-sites-schema/city-codes] + #'location-schema/city-codes] [:type-codes {:optional true @@ -249,7 +250,7 @@ Additional non-facility entities in LIPAS, that complement the sports facility d ["/{lipas-id}" {:get - {:summary "Get single sports facility by lipas-id" + {:summary "Get sports facility by lipas-id" :handler (fn [req] (tap> (:parameters req)) @@ -269,7 +270,7 @@ Additional non-facility entities in LIPAS, that complement the sports facility d } ["" - {:get {:summary "Get a paginated list of locations of interest" + {:get {:summary "Get a paginated list of Locations of Interest" :handler (fn [req] (tap> (:parameters req)) (let [params (:query (:parameters req)) @@ -317,7 +318,7 @@ Additional non-facility entities in LIPAS, that complement the sports facility d ["/{loi-id}" {:get - {:summary "Get single Location of Interest by id" + {:summary "Get Location of Interest by id" :handler (fn [req] (tap> (:parameters req)) diff --git a/webapp/src/cljc/lipas/data/activities.cljc b/webapp/src/cljc/lipas/data/activities.cljc index e50684d78..2a0565025 100644 --- a/webapp/src/cljc/lipas/data/activities.cljc +++ b/webapp/src/cljc/lipas/data/activities.cljc @@ -2,8 +2,9 @@ (:require #?(:clj [cheshire.core :as json]) #?(:clj [clojure.data.csv :as csv]) - #?(:clj [clojure.string :as str]) + [clojure.string :as str] [lipas.data.materials :as materials] + [lipas.data.types :as types] [lipas.schema.common :as common-schema] [lipas.utils :as utils] [malli.core :as m] diff --git a/webapp/src/cljc/lipas/schema/common.cljc b/webapp/src/cljc/lipas/schema/common.cljc index 222d5e803..4c38c402d 100644 --- a/webapp/src/cljc/lipas/schema/common.cljc +++ b/webapp/src/cljc/lipas/schema/common.cljc @@ -1,6 +1,11 @@ (ns lipas.schema.common (:require [lipas.data.status :as status])) +(def -iso8601-pattern #"^(?:[1-9]\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?Z$") + +(def iso8601-timestamp [:re {:description "ISO 8601 timestamp in UTC timezone"} + -iso8601-pattern]) + (def status (into [:enum] (keys status/statuses))) (def statuses [:set status]) ;; https://github.com/metosin/malli/issues/670 diff --git a/webapp/src/cljc/lipas/schema/lois.cljc b/webapp/src/cljc/lipas/schema/lois.cljc index a3305e56d..ffb6ba863 100644 --- a/webapp/src/cljc/lipas/schema/lois.cljc +++ b/webapp/src/cljc/lipas/schema/lois.cljc @@ -24,16 +24,19 @@ (into [:map {:description (str cat-k " > " (:value type-v)) :title (-> type-v :label :en)} - [:id [:string ]] - [:event-date [:string]] + [:id loi-id] + [:event-date {:description "Timestamp when this information became valid (ISO 8601, UTC time zone)"} + #'common/iso8601-timestamp] #_[:created-at [:string]] [:geometries (case (:geom-type type-v) ("Polygon") #'common/polygon-feature-collection ("LineString") #'common/line-string-feature-collection #'common/point-feature-collection)] [:status common/status] - [:loi-category [:enum cat-k]] - [:loi-type [:enum (:value type-v)]]] + [:loi-category {:description "The category of the type of the Location of Interest"} + [:enum cat-k]] + [:loi-type {:description "The type of the Location of Interest"} + [:enum (:value type-v)]]] (for [[prop-k prop-v] (:props type-v)] [prop-k {:optional true} (:schema prop-v)]))]))) diff --git a/webapp/src/cljc/lipas/schema/sports_sites.cljc b/webapp/src/cljc/lipas/schema/sports_sites.cljc index c901f1516..3606a39f5 100644 --- a/webapp/src/cljc/lipas/schema/sports_sites.cljc +++ b/webapp/src/cljc/lipas/schema/sports_sites.cljc @@ -1,6 +1,5 @@ (ns lipas.schema.sports-sites (:require [lipas.data.admins :as admins] - [lipas.data.cities :as cities] [lipas.data.owners :as owners] [lipas.data.activities :as activities] [lipas.data.prop-types :as prop-types] @@ -17,46 +16,6 @@ (def lipas-id [:int {:min 0 :label "Lipas-id" :description "Unique identifier of sports facility in LIPAS system."}]) -(def city-code - (into [:enum {:title "CityCode" - :description "Official municipality identifier https://stat.fi/fi/luokitukset/kunta/kunta_1_20240101"}] - (sort (keys cities/by-city-code)))) - -(def city-codes - [:set {:title "CityCodes" - :description (-> city-code second :description)} - city-code]) - -(defn make-location-schema [feature-schema] - [:map {:description "Location of the sports facility."} - [:city - [:map - [:city-code #'city-code] - [:neighborhood {:optional true} #'location-schema/neighborhood]]] - [:address #'location-schema/address] - [:postal-code #'location-schema/postal-code] - [:postal-office {:optional true} #'location-schema/postal-code] - [:geometries - [:map - [:type [:enum "FeatureCollection"]] - [:features - [:vector feature-schema]]]]]) - -(def line-string-feature-props - [:map - [:name {:optional true} :string] - [:lipas-id {:optional true} #'lipas-id] - [:type-code {:optional true} :int] - [:route-part-difficulty {:optional true} :string] - [:travel-direction {:optional true} :string]]) - -(def line-string-feature - (mu/assoc common/line-string-feature :properties line-string-feature-props)) - -(def point-location (make-location-schema common/point-feature)) -(def line-string-location (make-location-schema line-string-feature)) -(def polygon-location (make-location-schema common/polygon-feature)) - (def owner (into [:enum {:title "Onwer" :description "Owner entity of the sports facility."}] @@ -130,6 +89,8 @@ :description description :closed false} [:lipas-id #'lipas-id] + [:event-date {:description "Timestamp when this information became valid (ISO 8601, UTC time zone)"} + #'common/iso8601-timestamp] [:status #'common/status] [:name #'name] [:marketing-name {:optional true} #'marketing-name] @@ -162,9 +123,9 @@ :description (get-in x [:description :en]) :type-codes #{type-code} :location-schema (case geometry-type - "Point" #'point-location - "LineString" #'line-string-location - "Polygon" #'polygon-location) + "Point" #'location-schema/point-location + "LineString" #'location-schema/line-string-location + "Polygon" #'location-schema/polygon-location) :extras-schema (cond-> [:map] (seq props) (conj [:properties diff --git a/webapp/src/cljc/lipas/schema/sports_sites/activities.cljc b/webapp/src/cljc/lipas/schema/sports_sites/activities.cljc index 4d10ff09e..8810ca015 100644 --- a/webapp/src/cljc/lipas/schema/sports_sites/activities.cljc +++ b/webapp/src/cljc/lipas/schema/sports_sites/activities.cljc @@ -1,5 +1,8 @@ (ns lipas.schema.sports-sites.activities - (:require [lipas.data.activities :as activities-data])) + (:require [clojure.string :as str] + [lipas.data.activities :as activities-data] + [lipas.data.types :as types] + [malli.util :as mu])) (def activity (into [:enum] (keys activities-data/activities))) (def activities @@ -7,10 +10,39 @@ :description "Enriched activity related content for Luontoon.fi service. Certain sports facility types may contain data about activities that can be practiced at the facility."} activity]) -(def fishing activities-data/fishing-schema) -(def outdoor-recreation-areas activities-data/outdoor-recreation-areas-schema) -(def outdoor-recreation-routes activities-data/outdoor-recreation-routes-schema) -(def outdoor-recreation-facilities activities-data/outdoor-recreation-facilities-schema) -(def cycling activities-data/cycling-schema) -(def paddling activities-data/paddling-schema) -(def birdwatching activities-data/birdwatching-schema) +(defn -append-description + [schema {:keys [type-codes label]}] + (let [s (str (:en label) + " is an activity associated with facility types " + (str/join ", " (for [[k m] (select-keys types/all type-codes)] + (str k " " (get-in m [:name :en])))) + ". Enriched activity information is collected for Luontoon.fi service.")] + (mu/update-properties schema assoc :description s))) + +(def fishing (-append-description + activities-data/fishing-schema + activities-data/fishing)) + +(def outdoor-recreation-areas (-append-description + activities-data/outdoor-recreation-areas-schema + activities-data/outdoor-recreation-areas)) + +(def outdoor-recreation-routes (-append-description + activities-data/outdoor-recreation-routes-schema + activities-data/outdoor-recreation-routes)) + +(def outdoor-recreation-facilities (-append-description + activities-data/outdoor-recreation-facilities-schema + activities-data/outdoor-recreation-facilities)) + +(def cycling (-append-description + activities-data/cycling-schema + activities-data/cycling)) + +(def paddling (-append-description + activities-data/paddling-schema + activities-data/paddling)) + +(def birdwatching (-append-description + activities-data/birdwatching-schema + activities-data/birdwatching)) diff --git a/webapp/src/cljc/lipas/schema/sports_sites/location.cljc b/webapp/src/cljc/lipas/schema/sports_sites/location.cljc index bf25fce44..502092e6c 100644 --- a/webapp/src/cljc/lipas/schema/sports_sites/location.cljc +++ b/webapp/src/cljc/lipas/schema/sports_sites/location.cljc @@ -1,5 +1,8 @@ (ns lipas.schema.sports-sites.location - (:require [lipas.schema.core :as specs])) + (:require [lipas.schema.core :as specs] + [lipas.data.cities :as cities] + [lipas.schema.common :as common] + [malli.util :as mu])) (def address [:string {:description "Street address of the sports facility." @@ -19,3 +22,46 @@ [:string {:description "Neighborhood or common name for the area of the location." :min 1 :max 100}]) + + +(def city-code + (into [:enum {:title "CityCode" + :description "Official municipality identifier https://stat.fi/fi/luokitukset/kunta/kunta_1_20240101"}] + (sort (keys cities/by-city-code)))) + +(def city-codes + [:set {:title "CityCodes" + :description (-> city-code second :description)} + city-code]) + +(defn make-location-schema [feature-schema geom-type] + [:map {:description (str "Location of the sports facility with required " + geom-type + " feature.")} + [:city + [:map + [:city-code #'city-code] + [:neighborhood {:optional true} #'neighborhood]]] + [:address #'address] + [:postal-code #'postal-code] + [:postal-office {:optional true} #'postal-code] + [:geometries + [:map + [:type [:enum "FeatureCollection"]] + [:features + [:vector feature-schema]]]]]) + +(def line-string-feature-props + [:map + [:name {:optional true} :string] + #_[:lipas-id {:optional true} #'lipas-id] + [:type-code {:optional true} :int] + [:route-part-difficulty {:optional true} :string] + [:travel-direction {:optional true} :string]]) + +(def line-string-feature + (mu/assoc common/line-string-feature :properties line-string-feature-props)) + +(def point-location (make-location-schema common/point-feature "Point")) +(def line-string-location (make-location-schema line-string-feature "LineString")) +(def polygon-location (make-location-schema common/polygon-feature "Polygon")) diff --git a/webapp/test/clj/lipas/timestamps_test.clj b/webapp/test/clj/lipas/timestamps_test.clj new file mode 100644 index 000000000..60c36948f --- /dev/null +++ b/webapp/test/clj/lipas/timestamps_test.clj @@ -0,0 +1,57 @@ +(ns lipas.timestamps-test + (:require [clojure.test :refer [deftest testing is]] + [lipas.schema.common :as common-schema])) + +(def iso8601-pattern common-schema/-iso8601-pattern) + +(deftest iso8601-timestamp-test + (testing "Valid UTC timestamps" + (is (re-matches iso8601-pattern "2024-12-27T13:25:00Z")) + (is (re-matches iso8601-pattern "2024-12-27T13:25:00.123Z")) + (is (re-matches iso8601-pattern "2024-01-01T00:00:00Z")) + (is (re-matches iso8601-pattern "2024-12-31T23:59:59Z")) + (is (re-matches iso8601-pattern "2024-02-29T00:00:00Z")) ; Valid leap year + (is (re-matches iso8601-pattern "2000-02-29T00:00:00Z"))) ; Valid century leap year + + (testing "Invalid timestamps" + ; Non-UTC timestamps (should all fail) + (is (nil? (re-matches iso8601-pattern "2024-12-27T13:25:00+02:00"))) + (is (nil? (re-matches iso8601-pattern "2024-12-27T13:25:00-02:00"))) + (is (nil? (re-matches iso8601-pattern "2024-12-27T13:25:00.123+02:00"))) + + ; Invalid dates + (is (nil? (re-matches iso8601-pattern "2024-13-01T00:00:00Z"))) ; Invalid month + (is (nil? (re-matches iso8601-pattern "2024-00-01T00:00:00Z"))) ; Invalid month + (is (nil? (re-matches iso8601-pattern "2024-12-32T00:00:00Z"))) ; Invalid day + (is (nil? (re-matches iso8601-pattern "2024-12-00T00:00:00Z"))) ; Invalid day + (is (nil? (re-matches iso8601-pattern "2023-02-29T00:00:00Z"))) ; Invalid leap year date + + ; Invalid times + (is (nil? (re-matches iso8601-pattern "2024-12-27T24:00:00Z"))) ; Invalid hour + (is (nil? (re-matches iso8601-pattern "2024-12-27T12:60:00Z"))) ; Invalid minute + (is (nil? (re-matches iso8601-pattern "2024-12-27T12:00:60Z"))) ; Invalid second + + ; Invalid formats + (is (nil? (re-matches iso8601-pattern "2024-12-27 12:00:00Z"))) ; Space instead of T + (is (nil? (re-matches iso8601-pattern "2024-12-27T12:00:00"))) ; Missing Z + (is (nil? (re-matches iso8601-pattern "24-12-27T12:00:00Z"))) ; Two-digit year + (is (nil? (re-matches iso8601-pattern "2024/12/27T12:00:00Z")))) ; Wrong separators + + (testing "Edge cases for months with different lengths" + (is (re-matches iso8601-pattern "2024-01-31T00:00:00Z")) ; January 31 valid + (is (nil? (re-matches iso8601-pattern "2024-04-31T00:00:00Z"))) ; April 31 invalid + (is (re-matches iso8601-pattern "2024-04-30T00:00:00Z")) ; April 30 valid + (is (nil? (re-matches iso8601-pattern "2024-02-30T00:00:00Z"))) ; February 30 invalid + (is (re-matches iso8601-pattern "2024-06-30T00:00:00Z")) ; June 30 valid + (is (nil? (re-matches iso8601-pattern "2024-06-31T00:00:00Z")))) ; June 31 invalid + + (testing "Fractional seconds variations" + (is (re-matches iso8601-pattern "2024-12-27T13:25:00.1Z")) + (is (re-matches iso8601-pattern "2024-12-27T13:25:00.12Z")) + (is (re-matches iso8601-pattern "2024-12-27T13:25:00.123Z")) + (is (re-matches iso8601-pattern "2024-12-27T13:25:00.1234567890Z")))) + + +(comment + (clojure.test/run-tests *ns*) + )