Skip to content

Commit

Permalink
[Clojure] Add model generation and conforming (#122)
Browse files Browse the repository at this point in the history
* [Clojure] Add model generation and verification

- Generate clojure.specs from models
- Optionally validate them at runtime (validation is active if
  orchestra.spec.test/instrument is called after specs are imported)
- Coerce the results of the API calls to get objects that conform
  to the spec (e.g. get Date objects for dates and time instead of strings)

* [Clojure] Make model conforming configurable and opt-out

* [Clojure] Move specs from a single file to a ns per model

So that the order of the forms will be resolved by the compiler,
otherwise we'd have to implement a topological ordering.

* [Clojure] Update petstore sample and set automatic decoding off

* [Clojure] Stop testing Clojure generator on Java7

* [Clojure] Fix tests and handling of multiple arity

* [Clojure] Fix tests and add testing for the new decoding feature

* [Clojure] Capitalize names of generated models

* [Clojure] Rename petstore specs to be capitalized

* Revert to lowercase spec names, and postfix the data specs
  • Loading branch information
f-f authored and wing328 committed Aug 13, 2018
1 parent aed8e38 commit 74d7012
Show file tree
Hide file tree
Showing 18 changed files with 529 additions and 154 deletions.
1 change: 0 additions & 1 deletion CI/pom.xml.circleci.java7
Expand Up @@ -837,7 +837,6 @@
<module>samples/client/petstore/scala-akka</module>
<module>samples/client/petstore/scala-httpclient</module>
<module>samples/client/petstore/scalaz</module>
<module>samples/client/petstore/clojure</module>
<module>samples/client/petstore/java/feign</module>
<module>samples/client/petstore/java/jersey1</module>
<module>samples/client/petstore/java/jersey2</module>
Expand Down
Expand Up @@ -17,23 +17,16 @@

package org.openapitools.codegen.languages;

import org.openapitools.codegen.CliOption;
import org.openapitools.codegen.CodegenConfig;
import org.openapitools.codegen.CodegenConstants;
import org.openapitools.codegen.CodegenOperation;
import org.openapitools.codegen.CodegenType;
import org.openapitools.codegen.DefaultCodegen;
import org.openapitools.codegen.SupportingFile;
import org.openapitools.codegen.*;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.media.*;
import io.swagger.v3.oas.models.info.*;

import org.apache.commons.lang3.StringUtils;
import org.openapitools.codegen.utils.ModelUtils;

import java.io.File;
import java.util.Map;
import java.util.List;
import java.util.*;

public class ClojureClientCodegen extends DefaultCodegen implements CodegenConfig {
private static final String PROJECT_NAME = "projectName";
Expand All @@ -44,23 +37,29 @@ public class ClojureClientCodegen extends DefaultCodegen implements CodegenConfi
private static final String PROJECT_LICENSE_URL = "projectLicenseUrl";
private static final String BASE_NAMESPACE = "baseNamespace";

static final String X_BASE_SPEC = "x-baseSpec";
static final String X_MODELS = "x-models";

protected String projectName;
protected String projectDescription;
protected String projectVersion;
protected String baseNamespace;
protected Set<String> baseSpecs;
protected Set<String> models = new HashSet<>();

protected String sourceFolder = "src";

public ClojureClientCodegen() {
super();
outputFolder = "generated-code" + File.separator + "clojure";
modelTemplateFiles.put("spec.mustache", ".clj");
apiTemplateFiles.put("api.mustache", ".clj");
embeddedTemplateDir = templateDir = "clojure";

cliOptions.add(new CliOption(PROJECT_NAME,
"name of the project (Default: generated from info.title or \"openapi-clj-client\")"));
cliOptions.add(new CliOption(PROJECT_DESCRIPTION,
"description of the project (Default: using info.description or \"Client library of <projectNname>\")"));
"description of the project (Default: using info.description or \"Client library of <projectName>\")"));
cliOptions.add(new CliOption(PROJECT_VERSION,
"version of the project (Default: using info.version or \"1.0.0\")"));
cliOptions.add(new CliOption(PROJECT_URL,
Expand All @@ -71,6 +70,49 @@ public ClojureClientCodegen() {
"URL of the license the project uses (Default: using info.license.url or not included in project.clj)"));
cliOptions.add(new CliOption(BASE_NAMESPACE,
"the base/top namespace (Default: generated from projectName)"));

typeMapping.clear();

// We have specs for most of the types:
typeMapping.put("integer", "int?");
typeMapping.put("long", "int?");
typeMapping.put("short", "int?");
typeMapping.put("number", "float?");
typeMapping.put("float", "float?");
typeMapping.put("double", "float?");
typeMapping.put("array", "list?");
typeMapping.put("map", "map?");
typeMapping.put("boolean", "boolean?");
typeMapping.put("string", "string?");
typeMapping.put("char", "char?");
typeMapping.put("date", "inst?");
typeMapping.put("DateTime", "inst?");
typeMapping.put("UUID", "uuid?");

// But some type mappings are not really worth/meaningful to check for:
typeMapping.put("object", "any?"); // Like, everything is an object.
typeMapping.put("file", "any?"); // We don't really have specs for files,
typeMapping.put("binary", "any?"); // nor binary.
// And while there is a way to easily check if something is a bytearray,
// (https://stackoverflow.com/questions/14796964/), it's not possible
// to conform it yet, so we leave it as is.
typeMapping.put("ByteArray", "any?");

// Set of base specs that don't need to be imported
baseSpecs = new HashSet<>(
Arrays.asList(
"int?",
"float?",
"list?",
"map?",
"boolean?",
"string?",
"char?",
"inst?",
"uuid?",
"any?"
)
);
}

@Override
Expand All @@ -88,6 +130,75 @@ public String getHelp() {
return "Generates a Clojure client library.";
}

@Override
public String getTypeDeclaration(Schema p) {
if (p instanceof ArraySchema) {
ArraySchema ap = (ArraySchema) p;
Schema inner = ap.getItems();

return "(s/coll-of " + getTypeDeclaration(inner) + ")";
} else if (ModelUtils.isMapSchema(p)) {
Schema inner = (Schema) p.getAdditionalProperties();

return "(s/map-of string? " + getTypeDeclaration(inner) + ")";
}

// If it's a type we defined, we want to append the spec suffix
if (!typeMapping.containsKey(super.getSchemaType(p))) {
return super.getTypeDeclaration(p) + "-spec";
} else {
return super.getTypeDeclaration(p);
}
}

@Override
public String getSchemaType(Schema p) {
String openAPIType = super.getSchemaType(p);

if (typeMapping.containsKey(openAPIType)) {
return typeMapping.get(openAPIType);
} else {
return toModelName(openAPIType);
}
}

@Override
public String toModelName(String name) {
return dashize(name);
}

@Override
public String toVarName(String name) {
name = name.replaceAll("[^a-zA-Z0-9_-]+", ""); // FIXME: a parameter should not be assigned. Also declare the methods parameters as 'final'.
return name;
}

@Override
public CodegenModel fromModel(String name, Schema mod, Map<String, Schema> allDefinitions) {
CodegenModel model = super.fromModel(name, mod, allDefinitions);

// If a var is a base spec we won't need to import it
for (CodegenProperty var : model.vars) {
if (baseSpecs.contains(var.complexType)) {
var.vendorExtensions.put(X_BASE_SPEC, true);
} else {
var.vendorExtensions.put(X_BASE_SPEC, false);
}
if (var.items != null) {
if (baseSpecs.contains(var.items.complexType)) {
var.items.vendorExtensions.put(X_BASE_SPEC, true);
} else {
var.items.vendorExtensions.put(X_BASE_SPEC, false);
}
}
}

// We also add all models to our model list so we can import them e.g. in operations
models.add(model.classname);

return model;
}

@Override
public void preprocessOpenAPI(OpenAPI openAPI) {
super.preprocessOpenAPI(openAPI);
Expand Down Expand Up @@ -151,12 +262,14 @@ public void preprocessOpenAPI(OpenAPI openAPI) {
baseNamespace = dashize(projectName);
}
apiPackage = baseNamespace + ".api";
modelPackage = baseNamespace + ".specs";

additionalProperties.put(PROJECT_NAME, projectName);
additionalProperties.put(PROJECT_DESCRIPTION, escapeText(projectDescription));
additionalProperties.put(PROJECT_VERSION, projectVersion);
additionalProperties.put(BASE_NAMESPACE, baseNamespace);
additionalProperties.put(CodegenConstants.API_PACKAGE, apiPackage);
additionalProperties.put(CodegenConstants.MODEL_PACKAGE, modelPackage);

final String baseNamespaceFolder = sourceFolder + File.separator + namespaceToFolder(baseNamespace);
supportingFiles.add(new SupportingFile("project.mustache", "", "project.clj"));
Expand All @@ -175,6 +288,11 @@ public String apiFileFolder() {
return outputFolder + File.separator + sourceFolder + File.separator + namespaceToFolder(apiPackage);
}

@Override
public String modelFileFolder() {
return outputFolder + File.separator + sourceFolder + File.separator + namespaceToFolder(modelPackage);
}

@Override
public String toOperationId(String operationId) {
// throw exception if method name is empty
Expand All @@ -190,6 +308,11 @@ public String toApiFilename(String name) {
return underscore(toApiName(name));
}

@Override
public String toModelFilename(String name) {
return underscore(toModelName(name));
}

@Override
public String toApiName(String name) {
return dashize(name);
Expand All @@ -200,13 +323,6 @@ public String toParamName(String name) {
return toVarName(name);
}

@Override
public String toVarName(String name) {
name = name.replaceAll("[^a-zA-Z0-9_-]+", ""); // FIXME: a parameter should not be assigned. Also declare the methods parameters as 'final'.
name = dashize(name);
return name;
}

@Override
public String escapeText(String input) {
if (input == null) {
Expand All @@ -222,6 +338,8 @@ public Map<String, Object> postProcessOperationsWithModels(Map<String, Object> o
for (CodegenOperation op : ops) {
// Convert httpMethod to lower case, e.g. "get", "post"
op.httpMethod = op.httpMethod.toLowerCase();

op.vendorExtensions.put(X_MODELS, models);
}
return operations;
}
Expand Down
26 changes: 18 additions & 8 deletions modules/openapi-generator/src/main/resources/clojure/api.mustache
@@ -1,12 +1,18 @@
{{=< >=}}(ns <package>.<classname>
(:require [<baseNamespace>.core :refer [call-api check-required-params with-collection-format]])
(:require [<baseNamespace>.core :refer [call-api check-required-params with-collection-format *api-context*]]
[clojure.spec.alpha :as s]
[spec-tools.core :as st]
[orchestra.core :refer [defn-spec]]
<#operations><#operation><#-first><#vendorExtensions.x-models>[<modelPackage>.<.> :refer :all]
</vendorExtensions.x-models></-first></operation></operations>)
(:import (java.io File)))

<#operations><#operation>
(defn <operationId>-with-http-info
(defn-spec <operationId>-with-http-info any?
"<&summary><#notes>
<&notes></notes>"<#hasOptionalParams>
([<#allParams><#required><#isFile>^File </isFile><paramName> </required></allParams>] (<operationId>-with-http-info<#allParams><#required> <paramName></required></allParams> nil))</hasOptionalParams>
<#hasOptionalParams>(</hasOptionalParams>[<#allParams><#required><#isFile>^File </isFile><paramName> </required></allParams><#hasOptionalParams>{:keys [<#allParams><^required><#isFile>^File </isFile><paramName> </required></allParams>]}</hasOptionalParams>]<#hasRequiredParams>
([<#allParams><#required><#isFile>^File </isFile><paramName> <dataType><#hasMore>, </hasMore></required></allParams>] (<operationId>-with-http-info<#allParams><#required> <paramName></required></allParams> nil))</hasOptionalParams>
<#hasOptionalParams>(</hasOptionalParams>[<#allParams><#required><#isFile>^File </isFile><paramName> <dataType><#hasMore>, </hasMore></required></allParams><#hasOptionalParams>{:keys [<#allParams><^required><#isFile>^File </isFile><paramName><#hasMore> </hasMore></required></allParams>]} (s/map-of keyword? any?)</hasOptionalParams>]<#hasRequiredParams>
<#hasOptionalParams> </hasOptionalParams>(check-required-params<#allParams><#required> <paramName></required></allParams>)</hasRequiredParams>
<#hasOptionalParams> </hasOptionalParams>(call-api "<path>" :<httpMethod>
<#hasOptionalParams> </hasOptionalParams> {:path-params {<#pathParams>"<baseName>" <#collectionFormat>(with-collection-format <paramName> :<collectionFormat>)</collectionFormat><^collectionFormat><paramName></collectionFormat> </pathParams>}
Expand All @@ -18,10 +24,14 @@
<#hasOptionalParams> </hasOptionalParams> :accepts [<#produces>"<& mediaType>"<#hasMore> </hasMore></produces>]
<#hasOptionalParams> </hasOptionalParams> :auth-names [<#authMethods>"<&name>"<#hasMore> </hasMore></authMethods>]})<#hasOptionalParams>)</hasOptionalParams>)

(defn <operationId>
(defn-spec <operationId> <#returnType><returnType></returnType><^returnType>any?</returnType>
"<&summary><#notes>
<&notes></notes>"<#hasOptionalParams>
([<#allParams><#required><#isFile>^File </isFile><paramName> </required></allParams>] (<operationId><#allParams><#required> <paramName></required></allParams> nil))</hasOptionalParams>
<#hasOptionalParams>(</hasOptionalParams>[<#allParams><#required><#isFile>^File </isFile><paramName> </required></allParams><#hasOptionalParams>optional-params</hasOptionalParams>]
<#hasOptionalParams> </hasOptionalParams>(:data (<operationId>-with-http-info<#allParams><#required> <paramName></required></allParams><#hasOptionalParams> optional-params</hasOptionalParams>))<#hasOptionalParams>)</hasOptionalParams>)
([<#allParams><#required><#isFile>^File </isFile><paramName> <dataType><#hasMore>, </hasMore></required></allParams>] (<operationId><#allParams><#required> <paramName></required></allParams> nil))</hasOptionalParams>
<#hasOptionalParams>(</hasOptionalParams>[<#allParams><#required><#isFile>^File </isFile><paramName> <dataType><#hasMore>, </hasMore></required></allParams><#hasOptionalParams>optional-params any?</hasOptionalParams>]
<#hasOptionalParams> </hasOptionalParams>(let [res (:data (<operationId>-with-http-info<#allParams><#required> <paramName></required></allParams><#hasOptionalParams> optional-params</hasOptionalParams>))]
<#hasOptionalParams> </hasOptionalParams> (if (:decode-models *api-context*)
<#hasOptionalParams> </hasOptionalParams> (st/decode <#returnType><returnType></returnType><^returnType>any?</returnType> res st/string-transformer)
<#hasOptionalParams> </hasOptionalParams> res))<#hasOptionalParams>)</hasOptionalParams>)

</operation></operations>
Expand Up @@ -16,6 +16,7 @@
{:base-url "<&basePath>"
:date-format "yyyy-MM-dd"
:datetime-format "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"
:decode-models false
:debug false
:auths {<#authMethods>"<&name>" nil<#hasMore>
</hasMore></authMethods>}})
Expand Down
Expand Up @@ -3,6 +3,8 @@
:url "<&projectUrl>"</projectUrl><#projectLicenseName>
:license {:name "<&projectLicenseName>"<#projectLicenseUrl>
:url "<&projectLicenseUrl>"</projectLicenseUrl>}</projectLicenseName>
:dependencies [[org.clojure/clojure "1.7.0"]
[clj-http "3.6.0"]
[cheshire "5.5.0"]])
:dependencies [[org.clojure/clojure "1.9.0"]
[metosin/spec-tools "0.7.0"]
[clj-http "3.8.0"]
[orchestra "2017.11.12-1"]
[cheshire "5.8.0"]])
19 changes: 19 additions & 0 deletions modules/openapi-generator/src/main/resources/clojure/spec.mustache
@@ -0,0 +1,19 @@
{{=< >=}}(ns <package>.<classname>
(:require [clojure.spec.alpha :as s]
[spec-tools.data-spec :as ds]
<#models><#model><#vars><^isContainer><^vendorExtensions.x-baseSpec>[<package>.<complexType> :refer :all]
</vendorExtensions.x-baseSpec></isContainer><#isContainer><^vendorExtensions.x-baseSpec>[<package>.<complexType> :refer :all]
</vendorExtensions.x-baseSpec></isContainer></vars></model></models>)
(:import (java.io File)))

<#models><#model>
(def <classname>-data
{<#vars>
(ds/<#required>req</required><^required>opt</required> :<name>) <datatype></vars>
})

(def <classname>-spec
(ds/spec
{:name ::<classname>
:spec <classname>-data}))
</model></models>
8 changes: 5 additions & 3 deletions samples/client/petstore/clojure/project.clj
Expand Up @@ -2,6 +2,8 @@
:description "This is a sample server Petstore server. For this sample, you can use the api key \"special-key\" to test the authorization filters"
:license {:name "Apache-2.0"
:url "http://www.apache.org/licenses/LICENSE-2.0.html"}
:dependencies [[org.clojure/clojure "1.7.0"]
[clj-http "3.6.0"]
[cheshire "5.5.0"]])
:dependencies [[org.clojure/clojure "1.9.0"]
[metosin/spec-tools "0.7.0"]
[clj-http "3.8.0"]
[orchestra "2017.11.12-1"]
[cheshire "5.8.0"]])

0 comments on commit 74d7012

Please sign in to comment.