Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions otus-14/homework/project.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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"]]}})
23 changes: 23 additions & 0 deletions otus-14/homework/spec.json
Original file line number Diff line number Diff line change
@@ -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"]
}
95 changes: 87 additions & 8 deletions otus-14/homework/src/spec_faker/core.clj
Original file line number Diff line number Diff line change
@@ -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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gen-class обычно в самом конце пишут. Или хотя бы require и import рядом располагают, как связанные по смыслу директивы

(: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)]
Copy link

@astynax astynax Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Строго говоря, тут может и ошибка возникнуть, если JSON не получится декодировать.

UPD: вижу, что ниже эта ошибка обрабатывается. Но я бы ожидал отлов ошибки парсинга здесь, раз уж в этой функции вы уже responses возвращаете, в том числе и bad request.

(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"))))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Можно воспользоваться hiccup.util/url, раз уж hiccup всё равно используется в проекте


(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}))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

В main как раз нужно делать join, иначе если вы запустите программу не из REPL, то сервер тут же будет остановлен, поскольку главный поток завершит работу сразу же.

118 changes: 118 additions & 0 deletions otus-14/homework/src/spec_faker/data_generator.clj
Original file line number Diff line number Diff line change
@@ -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)]
Comment on lines +8 to +9
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Можно попробовать вынести эти опции в деструктуризацию и указать значения по умолчанию там же через :or {minimum 0 ..}.

(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))))
Copy link

@astynax astynax Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Я бы не очень хотел, чтобы функция возвращала здесь не последовательность, а один элемент, даже если count подразумевается равным единице - не очень приятно получать результаты вызова функции, имеющие разные типы без особой на то нужды. Если хочется возвращать именно один элемент, то имеет смысл завести две функции: gen-example и gen-examples.




(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)
)
26 changes: 26 additions & 0 deletions otus-14/homework/src/spec_faker/schema_validator.clj
Original file line number Diff line number Diff line change
@@ -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))
68 changes: 68 additions & 0 deletions otus-14/homework/test/spec_faker/test.clj
Original file line number Diff line number Diff line change
@@ -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))))))
4 changes: 3 additions & 1 deletion otus-14/project.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down