From fec4c96635f1b4c52749b7554eb3f1fdc8c3a4de Mon Sep 17 00:00:00 2001 From: Anton Mashkov Date: Sun, 12 Jan 2025 23:31:47 +0300 Subject: [PATCH] Resolved Homework-15 --- otus-14/homework/project.clj | 11 +- otus-14/homework/spec.json | 23 ++++ otus-14/homework/src/spec_faker/core.clj | 95 ++++++++++++-- .../src/spec_faker/data_generator.clj | 118 ++++++++++++++++++ .../src/spec_faker/schema_validator.clj | 26 ++++ otus-14/homework/test/spec_faker/test.clj | 68 ++++++++++ otus-14/project.clj | 4 +- 7 files changed, 333 insertions(+), 12 deletions(-) create mode 100644 otus-14/homework/spec.json create mode 100644 otus-14/homework/src/spec_faker/data_generator.clj create mode 100644 otus-14/homework/src/spec_faker/schema_validator.clj create mode 100644 otus-14/homework/test/spec_faker/test.clj diff --git a/otus-14/homework/project.clj b/otus-14/homework/project.clj index 7742175..49dc7a9 100644 --- a/otus-14/homework/project.clj +++ b/otus-14/homework/project.clj @@ -2,14 +2,19 @@ :description "A simple Web-app that can fake for you some data using your Swagger spec" :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0" :url "https://www.eclipse.org/legal/epl-2.0/"} - :dependencies [[org.clojure/clojure "1.10.1"] + :dependencies [[org.clojure/clojure "1.12.0"] [ring/ring-core "1.10.0"] [ring/ring-jetty-adapter "1.10.0"] [ring/ring-defaults "0.3.4"] + [ring/ring-devel "1.10.0"] + [ring/ring-json "0.5.1"] [compojure "1.7.0"] [hiccup "1.0.5"] - [cheshire "5.11.0"]] + [org.clojure/test.check "1.1.1"] + [cheshire "5.11.0"] + [metosin/compojure-api "1.1.14"]] :main ^:skip-aot spec-faker.core :target-path "target/%s" :profiles {:uberjar {:aot :all - :jvm-opts ["-Dclojure.compiler.direct-linking=true"]}}) + :jvm-opts ["-Dclojure.compiler.direct-linking=true"]} + :test {:dependencies [[ring/ring-mock "0.4.0"]]}}) diff --git a/otus-14/homework/spec.json b/otus-14/homework/spec.json new file mode 100644 index 0000000..f270e8a --- /dev/null +++ b/otus-14/homework/spec.json @@ -0,0 +1,23 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 3, + "maxLength": 50 + }, + "age": { + "type": "integer", + "minimum": 18, + "maximum": 99 + }, + "email": { + "type": "string", + "format": "email" + }, + "is_active": { + "type": "boolean" + } + }, + "required": ["name", "age", "email"] +} diff --git a/otus-14/homework/src/spec_faker/core.clj b/otus-14/homework/src/spec_faker/core.clj index c412065..d73fe7b 100644 --- a/otus-14/homework/src/spec_faker/core.clj +++ b/otus-14/homework/src/spec_faker/core.clj @@ -1,13 +1,92 @@ (ns spec-faker.core (:require [ring.adapter.jetty :refer [run-jetty]] - [compojure.core :refer :all] - [hiccup.core :refer [html]]) - (:gen-class)) + [cheshire.core :as json] + [compojure.api.sweet :refer :all] + [compojure.route :as route] + [spec-faker.schema-validator :refer [validate-schema explain-validation-error]] + [spec-faker.data-generator :refer [gen-example]] + [ring.util.http-response :refer :all] + [hiccup.core :refer [html]] + [schema.core :as s]) + (:gen-class) + (:import (com.fasterxml.jackson.core JsonParseException) + (java.net URLEncoder))) +;; Convert JSON string to EDN +(defn json-to-edn [json-str] + (json/parse-string json-str true)) + +(def bad-json-response + (bad-request {:error "Invalid JSON\n"})) + +(defn invalid-spec-response [decoded-spec] + (bad-request {:error (str "Invalid spec\n" (explain-validation-error decoded-spec))})) + +(def input-form-response + (html + [:html + [:head [:title "Spec Faker"]] + [:body + [:h1 "Insert OpenAPI specification"] + [:form {:method "post" :action "/"} + [:textarea {:name "spec" :rows 10 :cols 50}] + [:br] + [:input {:type "submit" :value "Generate"}]]]])) + +(defn gen-data-response [spec] + (let [decoded-spec (json-to-edn spec)] + (if (validate-schema decoded-spec) + (ok (gen-example decoded-spec)) + (invalid-spec-response decoded-spec)))) + +(defn redirect-response [spec-json] + (see-other (str "/?spec=" (URLEncoder/encode ^String spec-json "UTF-8")))) + +(defn invalid-spec-form-response [spec-json] + (let [decoded-spec (json-to-edn spec-json)] + (bad-request {:error (str "Invalid Swagger spec: " (explain-validation-error decoded-spec))}))) + + +;; Define the API +(defapi app + {:swagger {:ui "/swagger-ui" + :spec "/swagger.json" + :data {:info {:title "Spec Faker API" + :description "Generate example data from OpenAPI specifications"} + :tags [{:name "faker" :description "APIs for generating example data"}]}}} + + (context "/" [] + :tags ["faker"] + + ; GET endpoint to fetch form or generate data + (GET "/" [] + :query-params [{spec :- (s/maybe s/Str) nil}] + (if spec + (try + (gen-data-response spec) + (catch JsonParseException _ bad-json-response)) + (content-type (ok input-form-response) "text/html"))) + + + ;; POST endpoint to validate and process spec + (POST "/" [] + :form-params [spec :- s/Str] + :return {:redirect s/Str} + (try + (if (validate-schema (json-to-edn spec)) + (redirect-response spec) + (invalid-spec-form-response spec)) + (catch JsonParseException _ bad-json-response)))) + + (undocumented + (ANY "*" [] + (not-found "Not Found")))) + +(comment + (run-jetty #'app {:join? false + :port 8000})) + +;; Start the server (defn -main [& args] - (run-jetty - (routes - (GET "/" [] - (html [:h1 "Hello World!"]))) - {:port 8000})) + (run-jetty app {:port 8000 :join? false})) \ No newline at end of file diff --git a/otus-14/homework/src/spec_faker/data_generator.clj b/otus-14/homework/src/spec_faker/data_generator.clj new file mode 100644 index 0000000..972e168 --- /dev/null +++ b/otus-14/homework/src/spec_faker/data_generator.clj @@ -0,0 +1,118 @@ +(ns spec-faker.data-generator + (:require [clojure.test.check.generators :as gen])) + +(defn gen-integer + "Генерирует целое число в интервале [min, max], если указаны. + Если min/max не указаны, применяем некий разумный диапазон по умолчанию." + [schema] + (let [minimum (:minimum schema 0) + maximum (:maximum schema Integer/MAX_VALUE)] + (gen/choose minimum maximum))) + +(defn gen-boolean + "Генерирует случайное boolean-значение." + [] + gen/boolean) + +(defn gen-random-string + "Вспомогательная функция: генерирует строку из букв/цифр ограниченной длины. + При необходимости усечёт строку до max-length." + [min-len max-len] + (gen/fmap + (fn [s] + (let [cut (subs s 0 (min max-len (count s)))] + cut)) + (gen/such-that #(>= (count %) min-len) gen/string-alphanumeric))) + +(defn gen-email + "Простейший генератор email-адреса. Игнорируем minLength/maxLength, + но по желанию можно усложнить формат." + [min-len max-len] + (gen/fmap + (fn [[user domain tld]] + (let [email (str user "@" domain "." tld)] + (subs email 0 (min (count email) max-len)))) + (gen/tuple + (gen/such-that #(>= (count %) (max 1 (dec min-len))) gen/string-alphanumeric) + (gen/such-that #(pos? (count %)) gen/string-alphanumeric) + (gen/elements ["com" "org" "net" "ru" "test"])))) + +(defn gen-string + "Генерирует строку с учётом minLength, maxLength и формата (например, email)." + [schema] + (let [min-length (or (get schema :minLength) 0) + max-length (or (get schema :maxLength) (max 10 min-length)) ; чтобы max >= min + format (get schema :format)] + (case format + "email" (gen-email min-length max-length) + (gen-random-string min-length max-length)))) + +(defn gen-object + "Принимает карту (properties) вида {\"propertyName\" propertySchema, ...} + Возвращает генератор, создающий Clojure map: + {:propertyName <случайное значение>}." + [properties] + (let [ + prop-gens + (map + (fn [[prop-name prop-spec]] + (let [t (:type prop-spec)] + [prop-name + (case t + "integer" (gen-integer prop-spec) + "boolean" (gen-boolean) + "string" (gen-string prop-spec) + (gen/return nil))])) + properties)] + (apply gen/hash-map (mapcat identity prop-gens)))) + +(defn gen-schema + "Рекурсивно обходит schema и возвращает генератор данных для неё." + [schema] + (let [t (:type schema)] + (case t + "object" + (let [props (:properties schema {})] + (gen-object props)) + + "integer" + (gen-integer schema) + + "boolean" + (gen-boolean) + + "string" + (gen-string schema) + + (gen/return nil)))) + +(defn gen-example + ([schema count] + (gen/sample (gen-schema schema) count)) + ([schema] + (first (gen-example schema 1)))) + + + +(def example-spec + {:type "object" + :properties + {:age {:type "integer" :minimum 18 :maximum 80} + :email {:type "string" :format "email" :minLength 6 :maxLength 30} + :isActive {:type "boolean"} + :nickname {:type "string" :minLength 3 :maxLength 10}}}) + +(comment + (let [gen-example (gen-schema example-spec)] + (doseq [value (gen/sample gen-example 1)] + (prn value)))) + +(comment + (gen/sample (gen-schema example-spec) 1) + (gen/sample (gen-integer {:minimum 10 :maximum 300}) 1) + (gen/sample (gen-random-string 2 10)) + (gen/sample gen/string-alphanumeric 5) + (gen/sample (gen/such-that #(>= (count %) 2) gen/string-alphanumeric) 5) + (gen/sample (gen-string {:minLength 3 :maxLength 10})) + (gen/vector gen/char-alphanumeric 2 4) + ) diff --git a/otus-14/homework/src/spec_faker/schema_validator.clj b/otus-14/homework/src/spec_faker/schema_validator.clj new file mode 100644 index 0000000..44cd156 --- /dev/null +++ b/otus-14/homework/src/spec_faker/schema_validator.clj @@ -0,0 +1,26 @@ +(ns spec-faker.schema-validator + (:require [clojure.spec.alpha :as s])) + +(s/def ::type #{"string" "integer" "boolean" "object"}) +(s/def ::format (s/nilable #{"email" "uuid"})) +(s/def ::minLength pos-int?) +(s/def ::maxLength pos-int?) +(s/def ::minimum number?) +(s/def ::maximum number?) + +(s/def ::property + (s/keys :req-un [::type] + :opt-un [::format ::minLength ::maxLength ::minimum ::maximum])) + +(s/def ::properties (s/map-of keyword? ::property)) +(s/def ::required (s/coll-of string? :kind vector?)) + +(s/def ::openapi-schema + (s/keys :req-un [::type ::properties] + :opt-un [::required])) + +(defn validate-schema [schema] + (s/valid? ::openapi-schema schema)) + +(defn explain-validation-error [schema] + (s/explain-str ::openapi-schema schema)) \ No newline at end of file diff --git a/otus-14/homework/test/spec_faker/test.clj b/otus-14/homework/test/spec_faker/test.clj new file mode 100644 index 0000000..90acddf --- /dev/null +++ b/otus-14/homework/test/spec_faker/test.clj @@ -0,0 +1,68 @@ +(ns spec-faker.test + (:require [clojure.test :refer :all] + [ring.mock.request :as mock] + [spec-faker.core :refer [app]]) + (:import (java.net URLEncoder))) + +(deftest test-get-root + (testing "GET / without spec" + (let [response (app (mock/request :get "/"))] + (is (= 200 (:status response))) + (is (.contains (:body response) "Insert OpenAPI specification"))))) + +(deftest test-get-with-spec + (testing "GET / with valid spec" + (let [spec "{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}}}" + response (app (mock/request :get "/" {:spec spec})) + body (slurp (:body response))] + (is (= 200 (:status response))) + (is (.contains body "\"name\"")))) + + (testing "GET / with invalid spec" + (let [spec "{\"type\":\"invalid\"}" + response (app (mock/request :get "/" {:spec spec})) + body (slurp (:body response))] + (is (= 400 (:status response))) + (is (.contains body "Invalid spec")))) + + (testing "GET / with invalid JSON" + (let [spec "{\"type\":\"object\",\"properties\":{\"name\"}" + response (app (mock/request :get "/" {:spec spec})) + body (slurp (:body response))] + (is (= 400 (:status response))) + (is (.contains body "Invalid JSON"))))) + +(deftest test-post-root + (testing "POST / with valid spec" + (let [spec "{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}}}" + response (app (-> (mock/request :post "/") + (mock/content-type "application/x-www-form-urlencoded") + (mock/body (str "spec=" (URLEncoder/encode spec "UTF-8")))))] + (is (= 303 (:status response))) + (is (= (str "/?spec=" (URLEncoder/encode spec "UTF-8")) + (get-in response [:headers "Location"]))))) + + (testing "POST / with invalid spec" + (let [spec "{\"type\":\"invalid\"}" + response (app (-> (mock/request :post "/") + (mock/content-type "application/x-www-form-urlencoded") + (mock/body (str "spec=" (URLEncoder/encode spec "UTF-8"))))) + body (slurp (:body response))] + (is (= 400 (:status response))) + (is (.contains body "Invalid Swagger spec")))) + + (testing "POST / with invalid JSON" + (let [spec "{\"type\":\"object\",\"properties\":{\"name\"}" + response (app (-> (mock/request :post "/") + (mock/content-type "application/x-www-form-urlencoded") + (mock/body (str "spec=" (URLEncoder/encode spec "UTF-8"))))) + body (slurp (:body response))] + (is (= 400 (:status response))) + (is (.contains body "Invalid JSON"))))) + + +(deftest test-not-found + (testing "GET /unknown" + (let [response (app (mock/request :get "/unknown"))] + (is (= 404 (:status response))) + (is (= "Not Found" (:body response)))))) \ No newline at end of file diff --git a/otus-14/project.clj b/otus-14/project.clj index c29b888..7831cd5 100644 --- a/otus-14/project.clj +++ b/otus-14/project.clj @@ -3,14 +3,16 @@ :url "http://example.com/FIXME" :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0" :url "https://www.eclipse.org/legal/epl-2.0/"} - :dependencies [[org.clojure/clojure "1.10.1"] + :dependencies [[org.clojure/clojure "1.12.0"] [ring/ring-core "1.10.0"] [ring/ring-devel "1.10.0"] [ring/ring-jetty-adapter "1.10.0"] [ring/ring-defaults "0.3.4"] [compojure "1.7.0"] [hiccup "1.0.5"] + [org.clojure/test.check "1.1.1"] [cheshire "5.11.0"]] + :modules {:dirs ["homework"]} :main ^:skip-aot otus-14.core :target-path "target/%s" :profiles {:uberjar {:aot :all