Merge d22b920c7a1d6bd183eabd517c16aa6e99173549 into d6c46342693205f0dae441b45742d9c85d41cf33

This commit is contained in:
William Cheng 2025-05-09 13:57:50 +02:00 committed by GitHub
commit 4e138f23e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
91 changed files with 8739 additions and 0 deletions

View File

@ -0,0 +1,8 @@
generatorName: typescript-koa-server
outputDir: samples/server/petstore/typescript-koa-server
#outputDir: /Users/williamcheng/Code/typescript/PetStore
inputSpec: modules/openapi-generator/src/test/resources/3_0/typescript-koa-server/petstore.yaml
templateDir: modules/openapi-generator/src/main/resources/typescript-koa-server
additionalProperties:
ngVersion: 15.0.3
supportsES6: true

View File

@ -0,0 +1,633 @@
/*
* Copyright 2018 OpenAPI-Generator Contributors (https://openapi-generator.tech)
* Copyright 2018 SmartBear Software
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openapitools.codegen.languages;
import io.swagger.v3.oas.models.media.Schema;
import org.openapitools.codegen.*;
import org.openapitools.codegen.meta.features.DocumentationFeature;
import org.openapitools.codegen.meta.features.GlobalFeature;
import org.openapitools.codegen.model.ModelMap;
import org.openapitools.codegen.model.ModelsMap;
import org.openapitools.codegen.model.OperationMap;
import org.openapitools.codegen.model.OperationsMap;
import org.openapitools.codegen.utils.ModelUtils;
import org.openapitools.codegen.utils.SemVer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.apache.commons.lang3.StringUtils.capitalize;
import static org.openapitools.codegen.utils.CamelizeOption.LOWERCASE_FIRST_LETTER;
import static org.openapitools.codegen.utils.StringUtils.*;
public class TypeScriptKoaServerCodegen extends AbstractTypeScriptClientCodegen {
private final Logger LOGGER = LoggerFactory.getLogger(TypeScriptKoaServerCodegen.class);
private static String CLASS_NAME_PREFIX_PATTERN = "^[a-zA-Z0-9]*$";
private static String CLASS_NAME_SUFFIX_PATTERN = "^[a-zA-Z0-9]*$";
private static String FILE_NAME_SUFFIX_PATTERN = "^[a-zA-Z0-9.-]*$";
public static enum QUERY_PARAM_OBJECT_FORMAT_TYPE {dot, json, key}
public static enum PROVIDED_IN_LEVEL {none, root, any, platform}
private static final String DEFAULT_IMPORT_PREFIX = "./";
private static final String DEFAULT_MODEL_IMPORT_DIRECTORY_PREFIX = "../";
public static final String NPM_REPOSITORY = "npmRepository";
public static final String WITH_INTERFACES = "withInterfaces";
public static final String USE_SINGLE_REQUEST_PARAMETER = "useSingleRequestParameter";
public static final String TAGGED_UNIONS = "taggedUnions";
public static final String NG_VERSION = "ngVersion";
public static final String PROVIDED_IN = "providedIn";
public static final String ENFORCE_GENERIC_MODULE_WITH_PROVIDERS = "enforceGenericModuleWithProviders";
public static final String HTTP_CONTEXT_IN_OPTIONS = "httpContextInOptions";
public static final String API_MODULE_PREFIX = "apiModulePrefix";
public static final String CONFIGURATION_PREFIX = "configurationPrefix";
public static final String SERVICE_SUFFIX = "serviceSuffix";
public static final String SERVICE_FILE_SUFFIX = "serviceFileSuffix";
public static final String MODEL_SUFFIX = "modelSuffix";
public static final String MODEL_FILE_SUFFIX = "modelFileSuffix";
public static final String FILE_NAMING = "fileNaming";
public static final String STRING_ENUMS = "stringEnums";
public static final String STRING_ENUMS_DESC = "Generate string enums instead of objects for enum values.";
public static final String QUERY_PARAM_OBJECT_FORMAT = "queryParamObjectFormat";
protected String ngVersion = "15.0.3";
protected String npmRepository = null;
private boolean useSingleRequestParameter = false;
protected String serviceSuffix = "Service";
protected String serviceFileSuffix = ".service";
protected String modelSuffix = "";
protected String modelFileSuffix = "";
protected String fileNaming = "camelCase";
protected Boolean stringEnums = false;
protected QUERY_PARAM_OBJECT_FORMAT_TYPE queryParamObjectFormat = QUERY_PARAM_OBJECT_FORMAT_TYPE.dot;
protected PROVIDED_IN_LEVEL providedIn = PROVIDED_IN_LEVEL.root;
private boolean taggedUnions = false;
public TypeScriptKoaServerCodegen() {
super();
modifyFeatureSet(features -> features
.includeDocumentationFeatures(DocumentationFeature.Readme)
.includeGlobalFeatures(GlobalFeature.ParameterStyling)
);
this.outputFolder = "generated-code/typescript-koa";
supportsMultipleInheritance = true;
embeddedTemplateDir = templateDir = "typescript-koa";
modelTemplateFiles.put("model.mustache", ".ts");
apiTemplateFiles.put("api.service.mustache", ".ts");
languageSpecificPrimitives.add("Blob");
typeMapping.put("file", "Blob");
apiPackage = "api";
modelPackage = "model";
this.cliOptions.add(new CliOption(NPM_REPOSITORY,
"Use this property to set an url your private npmRepo in the package.json"));
this.cliOptions.add(CliOption.newBoolean(WITH_INTERFACES,
"Setting this property to true will generate interfaces next to the default class implementations.",
false));
this.cliOptions.add(CliOption.newBoolean(USE_SINGLE_REQUEST_PARAMETER,
"Setting this property to true will generate functions with a single argument containing all API endpoint parameters instead of one argument per parameter.",
false));
this.cliOptions.add(CliOption.newBoolean(TAGGED_UNIONS,
"Use discriminators to create tagged unions instead of extending interfaces.",
this.taggedUnions));
CliOption providedInCliOpt = new CliOption(PROVIDED_IN,
"Use this property to provide Injectables in wanted level.").defaultValue("root");
Map<String, String> providedInOptions = new HashMap<>();
providedInOptions.put(PROVIDED_IN_LEVEL.none.toString(), "No providedIn)");
providedInOptions.put(PROVIDED_IN_LEVEL.root.toString(), "The application-level injector in most apps.");
providedInOptions.put(PROVIDED_IN_LEVEL.platform.toString(), "A special singleton platform injector shared by all applications on the page.");
providedInOptions.put(PROVIDED_IN_LEVEL.any.toString(), "Provides a unique instance in each lazy loaded module while all eagerly loaded modules share one instance.");
providedInCliOpt.setEnum(providedInOptions);
this.cliOptions.add(providedInCliOpt);
this.cliOptions.add(new CliOption(API_MODULE_PREFIX, "The prefix of the generated ApiModule."));
this.cliOptions.add(new CliOption(CONFIGURATION_PREFIX, "The prefix of the generated Configuration."));
this.cliOptions.add(new CliOption(SERVICE_SUFFIX, "The suffix of the generated service.").defaultValue(this.serviceSuffix));
this.cliOptions.add(new CliOption(SERVICE_FILE_SUFFIX, "The suffix of the file of the generated service (service<suffix>.ts).").defaultValue(this.serviceFileSuffix));
this.cliOptions.add(new CliOption(MODEL_SUFFIX, "The suffix of the generated model."));
this.cliOptions.add(new CliOption(MODEL_FILE_SUFFIX, "The suffix of the file of the generated model (model<suffix>.ts)."));
this.cliOptions.add(new CliOption(FILE_NAMING, "Naming convention for the output files: 'camelCase', 'kebab-case'.").defaultValue(this.fileNaming));
this.cliOptions.add(new CliOption(STRING_ENUMS, STRING_ENUMS_DESC).defaultValue(String.valueOf(this.stringEnums)));
this.cliOptions.add(new CliOption(QUERY_PARAM_OBJECT_FORMAT, "The format for query param objects: 'dot', 'json', 'key'.").defaultValue(this.queryParamObjectFormat.name()));
}
@Override
protected void addAdditionPropertiesToCodeGenModel(CodegenModel codegenModel, Schema schema) {
codegenModel.additionalPropertiesType = getTypeDeclaration(getAdditionalProperties(schema));
addImport(codegenModel, codegenModel.additionalPropertiesType);
}
@Override
public String getName() {
return "typescript-koa-server";
}
@Override
public String getHelp() {
return "Generates a TypeScript Koa (2.x) server.";
}
@Override
public void processOpts() {
super.processOpts();
supportingFiles.add(new SupportingFile("env.example.mustache", "", ".env.example"));
supportingFiles.add(new SupportingFile("jest.config.js.mustache", "", ".jest.config.js"));
supportingFiles.add(new SupportingFile("app.ts.mustache", "", "app.ts"));
supportingFiles.add(new SupportingFile("build.js.mustache", "", "build.js"));
supportingFiles.add(new SupportingFile("docker-compose.mustache", "", "docker-compose.yml"));
supportingFiles.add(new SupportingFile("nodemon.mustache", "", "nodemon.json"));
supportingFiles.add(new SupportingFile("package.mustache", "", "package.json"));
supportingFiles.add(new SupportingFile("tsconfig.mustache", "", "tsconfig.json"));
// app/session folder
supportingFiles.add(new SupportingFile("sessions.service.ts.mustache", "app" + File.separator + "services", "sessions.service.ts"));
supportingFiles.add(new SupportingFile("sessions.index.ts.mustache", "app" + File.separator + "ervices", "index.ts"));
// app/helpers folder
supportingFiles.add(new SupportingFile("client.ts.mustache", "app" + File.separator + "helpers", "client.ts"));
// app/jobs folder
supportingFiles.add(new SupportingFile("jobs.README.mustache", "app" + File.separator + "jobs", "README.md"));
// app/controllers
supportingFiles.add(new SupportingFile("index.ts.mustache", "app" + File.separator + "controllers", "index.ts"));
supportingFiles.add(new SupportingFile("sessions.controller.ts.mustache", "app" + File.separator + "controllers", "sessions.controller.ts"));
// app/controllers/__tests__
supportingFiles.add(new SupportingFile("session.test.js.mustache", "app" + File.separator + "controllers" + File.separator + "__tests__", "session.test.js"));
// configs
supportingFiles.add(new SupportingFile("application.ts.mustache", "configs", "application.ts"));
supportingFiles.add(new SupportingFile("bootstrap.ts.mustache", "configs", "bootstrap.ts"));
supportingFiles.add(new SupportingFile("interceptors.ts.mustache", "configs", "interceptors.ts"));
supportingFiles.add(new SupportingFile("koa.middlewares.ts.mustache", "configs", "koa.middlewares.ts"));
supportingFiles.add(new SupportingFile("routing.middlewares.ts.mustache", "configs", "routing.middlewares.ts"));
supportingFiles.add(new SupportingFile("routing.options.ts.mustache", "configs", "routing.options.ts"));
supportingFiles.add(new SupportingFile("utils.ts.mustache", "configs", "utils.ts"));
// configs/constants
supportingFiles.add(new SupportingFile("development.ts.mustache", "configs" + File.separator + "constants", "development.ts"));
supportingFiles.add(new SupportingFile("envs.ts.mustache", "configs" + File.separator + "constants", "envs.ts"));
supportingFiles.add(new SupportingFile("configs.constants.index.ts.mustache", "configs" + File.separator + "constants", "index.ts"));
supportingFiles.add(new SupportingFile("production.ts.mustache", "configs" + File.separator + "constants", "production.ts"));
supportingFiles.add(new SupportingFile("staging.ts.mustache", "configs" + File.separator + "constants", "staging.ts"));
// prisma
supportingFiles.add(new SupportingFile("schema.prisma.mustache", "prisma", "schema.prisma"));
supportingFiles.add(new SupportingFile("gitignore", "", ".gitignore"));
supportingFiles.add(new SupportingFile("git_push.sh.mustache", "", "git_push.sh"));
supportingFiles.add(new SupportingFile("README.mustache", "", "README.md"));
if (additionalProperties.containsKey(CONFIGURATION_PREFIX)) {
String configurationPrefix = additionalProperties.get(CONFIGURATION_PREFIX).toString();
validateClassPrefixArgument("Configuration", configurationPrefix);
additionalProperties.put("configurationClassName", configurationPrefix + "Configuration");
additionalProperties.put("configurationParametersInterfaceName", configurationPrefix + "ConfigurationParameters");
} else {
additionalProperties.put("configurationClassName", "Configuration");
additionalProperties.put("configurationParametersInterfaceName", "ConfigurationParameters");
}
}
private String getIndexDirectory() {
String indexPackage = modelPackage.substring(0, Math.max(0, modelPackage.lastIndexOf('.')));
return indexPackage.replace('.', File.separatorChar);
}
public void setStringEnums(boolean value) {
stringEnums = value;
}
public Boolean getStringEnums() {
return stringEnums;
}
public boolean getQueryParamObjectFormatDot() {
return QUERY_PARAM_OBJECT_FORMAT_TYPE.dot.equals(queryParamObjectFormat);
}
public boolean getQueryParamObjectFormatJson() {
return QUERY_PARAM_OBJECT_FORMAT_TYPE.json.equals(queryParamObjectFormat);
}
public boolean getQueryParamObjectFormatKey() {
return QUERY_PARAM_OBJECT_FORMAT_TYPE.key.equals(queryParamObjectFormat);
}
@Override
public boolean isDataTypeFile(final String dataType) {
return "Blob".equals(dataType);
}
@Override
public String getTypeDeclaration(Schema p) {
if (ModelUtils.isFileSchema(p)) {
return "Blob";
} else {
return super.getTypeDeclaration(p);
}
}
private String applyLocalTypeMapping(String type) {
if (typeMapping.containsKey(type)) {
type = typeMapping.get(type);
}
return type;
}
@Override
public void postProcessParameter(CodegenParameter parameter) {
super.postProcessParameter(parameter);
parameter.dataType = applyLocalTypeMapping(parameter.dataType);
}
@Override
public OperationsMap postProcessOperationsWithModels(OperationsMap operations, List<ModelMap> allModels) {
OperationMap objs = operations.getOperations();
// Add filename information for api imports
objs.put("apiFilename", getApiFilenameFromClassname(objs.getClassname()));
List<CodegenOperation> ops = objs.getOperation();
boolean hasSomeFormParams = false;
boolean hasSomeEncodableParams = false;
for (CodegenOperation op : ops) {
if (op.getHasFormParams()) {
hasSomeFormParams = true;
}
op.httpMethod = op.httpMethod.toLowerCase(Locale.ENGLISH);
// Prep a string buffer where we're going to set up our new version of the string.
StringBuilder pathBuffer = new StringBuilder();
ParameterExpander paramExpander = new ParameterExpander(op, this::toParamName);
int insideCurly = 0;
// Iterate through existing string, one character at a time.
for (int i = 0; i < op.path.length(); i++) {
switch (op.path.charAt(i)) {
case '{':
// We entered curly braces, so track that.
insideCurly++;
break;
case '}':
// We exited curly braces, so track that.
insideCurly--;
pathBuffer.append(paramExpander.buildPathEntry());
hasSomeEncodableParams = true;
break;
default:
char nextChar = op.path.charAt(i);
if (insideCurly > 0) {
paramExpander.appendToParameterName(nextChar);
} else {
pathBuffer.append(nextChar);
}
break;
}
}
// Overwrite path to TypeScript template string, after applying everything we just did.
op.path = pathBuffer.toString();
}
operations.put("hasSomeFormParams", hasSomeFormParams);
operations.put("hasSomeEncodableParams", hasSomeEncodableParams);
// Add additional filename information for model imports in the services
List<Map<String, String>> imports = operations.getImports();
for (Map<String, String> im : imports) {
// This property is not used in the templates any more, subject for removal
im.put("filename", im.get("import"));
im.put("classname", im.get("classname"));
}
return operations;
}
@Override
public ModelsMap postProcessModels(ModelsMap objs) {
ModelsMap result = super.postProcessModels(objs);
return postProcessModelsEnum(result);
}
@Override
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
Map<String, ModelsMap> result = super.postProcessAllModels(objs);
for (ModelsMap entry : result.values()) {
for (ModelMap mo : entry.getModels()) {
CodegenModel cm = mo.getModel();
if (taggedUnions) {
mo.put(TAGGED_UNIONS, true);
if (cm.discriminator != null && cm.children != null) {
for (CodegenModel child : cm.children) {
cm.imports.add(child.classname);
setChildDiscriminatorValue(cm, child);
}
}
// with tagged union, a child model doesn't extend the parent (all properties are just copied over)
// it means we don't need to import that parent any more
if (cm.parent != null) {
cm.imports.remove(cm.parent);
// however, it's possible that the child model contains a recursive reference to the parent
// in order to support this case, we update the list of imports from properties once again
for (CodegenProperty cp : cm.allVars) {
addImportsForPropertyType(cm, cp);
}
removeSelfReferenceImports(cm);
}
}
// Add additional filename information for imports
Set<String> parsedImports = parseImports(cm);
mo.put("tsImports", toTsImports(cm, parsedImports));
}
}
return result;
}
private void setChildDiscriminatorValue(CodegenModel parent, CodegenModel child) {
if (child.vendorExtensions.isEmpty() ||
!child.vendorExtensions.containsKey("x-discriminator-value")
) {
for (CodegenProperty prop : child.allVars) {
if (prop.baseName.equals(parent.discriminator.getPropertyName())) {
for (CodegenDiscriminator.MappedModel mappedModel : parent.discriminator.getMappedModels()) {
if (mappedModel.getModelName().equals(child.classname)) {
prop.discriminatorValue = mappedModel.getMappingName();
}
}
}
}
}
}
/**
* Parse imports
*/
private Set<String> parseImports(CodegenModel cm) {
Set<String> newImports = new HashSet<>();
if (cm.imports.size() > 0) {
for (String name : cm.imports) {
if (name.indexOf(" | ") >= 0) {
String[] parts = name.split(" \\| ");
Collections.addAll(newImports, parts);
} else {
newImports.add(name);
}
}
}
return newImports;
}
private List<Map<String, String>> toTsImports(CodegenModel cm, Set<String> imports) {
List<Map<String, String>> tsImports = new ArrayList<>();
for (String im : imports) {
if (!im.equals(cm.classname)) {
HashMap<String, String> tsImport = new HashMap<>();
// TVG: This is used as class name in the import statements of the model file
tsImport.put("classname", im);
tsImport.put("filename", toModelFilename(removeModelPrefixSuffix(im)));
tsImports.add(tsImport);
}
}
return tsImports;
}
@Override
public String toApiName(String name) {
if (name.length() == 0) {
return "DefaultService";
}
return camelize(name) + serviceSuffix;
}
@Override
public String toApiFilename(String name) {
if (name.length() == 0) {
return "default.service";
}
return this.convertUsingFileNamingConvention(name) + serviceFileSuffix;
}
@Override
public String toApiImport(String name) {
if (importMapping.containsKey(name)) {
return importMapping.get(name);
}
return apiPackage() + "/" + toApiFilename(name);
}
@Override
public String toModelFilename(String name) {
if (importMapping.containsKey(name)) {
return importMapping.get(name);
}
return DEFAULT_IMPORT_PREFIX + this.convertUsingFileNamingConvention(super.toModelFilename(name)) + modelFileSuffix;
}
@Override
public String toModelImport(String name) {
if (importMapping.containsKey(name)) {
return importMapping.get(name);
}
return DEFAULT_MODEL_IMPORT_DIRECTORY_PREFIX + modelPackage() + "/" + toModelFilename(name).substring(DEFAULT_IMPORT_PREFIX.length());
}
public String getNpmRepository() {
return npmRepository;
}
public void setNpmRepository(String npmRepository) {
this.npmRepository = npmRepository;
}
private boolean getUseSingleRequestParameter() {
return useSingleRequestParameter;
}
private void setUseSingleRequestParameter(boolean useSingleRequestParameter) {
this.useSingleRequestParameter = useSingleRequestParameter;
}
private String getApiFilenameFromClassname(String classname) {
String name = classname.substring(0, classname.length() - serviceSuffix.length());
return toApiFilename(name);
}
@Override
public String toModelName(String name) {
name = addSuffix(name, modelSuffix);
return super.toModelName(name);
}
public String removeModelPrefixSuffix(String name) {
String result = name;
if (modelSuffix.length() > 0 && result.endsWith(modelSuffix)) {
result = result.substring(0, result.length() - modelSuffix.length());
}
String prefix = capitalize(this.modelNamePrefix);
String suffix = capitalize(this.modelNameSuffix);
if (prefix.length() > 0 && result.startsWith(prefix)) {
result = result.substring(prefix.length());
}
if (suffix.length() > 0 && result.endsWith(suffix)) {
result = result.substring(0, result.length() - suffix.length());
}
return result;
}
/**
* Validates that the given string value only contains '-', '.' and alpha numeric characters.
* Throws an IllegalArgumentException, if the string contains any other characters.
*
* @param argument The name of the argument being validated. This is only used for displaying an error message.
* @param value The value that is being validated.
*/
private void validateFileSuffixArgument(String argument, String value) {
if (!value.matches(FILE_NAME_SUFFIX_PATTERN)) {
throw new IllegalArgumentException(
String.format(Locale.ROOT, "%s file suffix only allows '.', '-' and alphanumeric characters.", argument)
);
}
}
/**
* Validates that the given string value only contains alpha numeric characters.
* Throws an IllegalArgumentException, if the string contains any other characters.
*
* @param argument The name of the argument being validated. This is only used for displaying an error message.
* @param value The value that is being validated.
*/
private void validateClassPrefixArgument(String argument, String value) {
if (!value.matches(CLASS_NAME_PREFIX_PATTERN)) {
throw new IllegalArgumentException(
String.format(Locale.ROOT, "%s class prefix only allows alphanumeric characters.", argument)
);
}
}
/**
* Validates that the given string value only contains alpha numeric characters.
* Throws an IllegalArgumentException, if the string contains any other characters.
*
* @param argument The name of the argument being validated. This is only used for displaying an error message.
* @param value The value that is being validated.
*/
private void validateClassSuffixArgument(String argument, String value) {
if (!value.matches(CLASS_NAME_SUFFIX_PATTERN)) {
throw new IllegalArgumentException(
String.format(Locale.ROOT, "%s class suffix only allows alphanumeric characters.", argument)
);
}
}
/**
* Set the query param object format.
*
* @param format the query param object format to use
*/
public void setQueryParamObjectFormat(String format) {
try {
queryParamObjectFormat = QUERY_PARAM_OBJECT_FORMAT_TYPE.valueOf(format);
} catch (IllegalArgumentException e) {
String values = Stream.of(QUERY_PARAM_OBJECT_FORMAT_TYPE.values())
.map(value -> "'" + value.name() + "'")
.collect(Collectors.joining(", "));
String msg = String.format(Locale.ROOT, "Invalid query param object format '%s'. Must be one of %s.", format, values);
throw new IllegalArgumentException(msg);
}
}
/**
* Set the file naming type.
*
* @param fileNaming the file naming to use
*/
private void setFileNaming(String fileNaming) {
if ("camelCase".equals(fileNaming) || "kebab-case".equals(fileNaming)) {
this.fileNaming = fileNaming;
} else {
throw new IllegalArgumentException("Invalid file naming '" +
fileNaming + "'. Must be 'camelCase' or 'kebab-case'");
}
}
/**
* Converts the original name according to the current <code>fileNaming</code> strategy.
*
* @param originalName the original name to transform
* @return the transformed name
*/
private String convertUsingFileNamingConvention(String originalName) {
String name = this.removeModelPrefixSuffix(originalName);
if ("kebab-case".equals(fileNaming)) {
name = dashize(underscore(name));
} else {
name = camelize(name, LOWERCASE_FIRST_LETTER);
}
return name;
}
/**
* Set the Injectable level
*
* @param level the wanted level
*/
public void setProvidedIn(String level) {
try {
providedIn = PROVIDED_IN_LEVEL.valueOf(level);
} catch (IllegalArgumentException e) {
String values = Stream.of(PROVIDED_IN_LEVEL.values())
.map(value -> "'" + value.name() + "'")
.collect(Collectors.joining(", "));
String msg = String.format(Locale.ROOT, "Invalid providedIn level '%s'. Must be one of %s.", level, values);
throw new IllegalArgumentException(msg);
}
}
/**
*
*/
private boolean getIsProvidedInNone() {
return PROVIDED_IN_LEVEL.none.equals(providedIn);
}
}

View File

@ -150,6 +150,7 @@ org.openapitools.codegen.languages.TypeScriptAxiosClientCodegen
org.openapitools.codegen.languages.TypeScriptFetchClientCodegen
org.openapitools.codegen.languages.TypeScriptInversifyClientCodegen
org.openapitools.codegen.languages.TypeScriptJqueryClientCodegen
org.openapitools.codegen.languages.TypeScriptKoaServerCodegen
org.openapitools.codegen.languages.TypeScriptNestjsClientCodegen
org.openapitools.codegen.languages.TypeScriptNodeClientCodegen
org.openapitools.codegen.languages.TypeScriptReduxQueryClientCodegen

View File

@ -0,0 +1,107 @@
### koa-ts
The best practice of building Koa2 with TypeScript. [中文](/README_CN.md)
---
#### Usage
1. Run `npm init koa-ts`
2. Install dependencies: `yarn`
3. Rename `.env.example` to `.env`, and run `prisma db push` to synchronize the data model
4. Start the server: `yarn dev`. visit: http://127.0.0.1:3000/apis/sessions
> **(Optional)** the project has built-in a docker compose, run `yarn dev:db` to run database automatic.
---
#### Project Layout
```
├── app
│   ├── controllers --- server controllers
│   ├── helpers --- helper func (interceptor / error handler / validator...)
│   ├── jobs --- task (periodic task / trigger task / email server...)
│   ├── entities --- database entities/models
│   └── services --- adhesive controller and model
├── config
│   ├── constants --- environment variable
│  ├── koa.middlewares --- middlewares for Koa
│  ├── routing.middlewares --- middlewares for Routing Controller
│  ├── routing.options --- configs for Routing Controller
│   ├── bootstrap --- lifecycle
│   └── interceptors --- global interceptor
│   └── utils --- pure functions for help
└── test --- utils for testcase
├── .env --- environment file
```
---
#### Feature
- Separation configuration and business logic.
- Export scheme model and interface, follow style of TypeScript.
- Test cases and lint configuration.
- The best practice for Dependency Injection in Koa project.
- Get constraints on your data model with Prisma.
- TypeScript hotload.
---
#### Lifecycle
1. `app.ts` -> collect env vars `constants` -> collect env files `variables.env`
2. envs ready, call `bootstrap.before()`
3. lift `routing-controllers` -> lift Koa middlewares -> register `Container` for DI
4. start Koa &amp; invoke `bootstrap.after()` after startup
---
#### Databases
The project uses Prisma as the intelligent ORM tool by default. Supports `PostgreSQL`, `MySQL` and `SQLite`.
- You can change the data type and connection method in the `.env` file
- After each modification to file `/prisma/schema.prisma`, you need to run `prisma migrate dev` to migrate the database.
- After each modification to file `/prisma/schema.prisma`, you need to run `prisma generate` to sync types.
---
#### About Environments
When nodejs is running, `ENV` does not mean `NODE_ENV`:
- After NodeJS project is built, we always run it as `NODE_ENV=PRODUCTION`, which may affect some framework optimizations.
- `NODE_ENV` only identifies the NodeJS runtime, independent of the business.
- You should use `ENV` to identify the environment.
For the data settings of each environment, you can refer to the following:
- **Development Mode** (`ENV=development`): read configurations from `configs/constants/development.ts` file, but it will still be overwritten by `.env` file.
- **Production Mode** (`ENV=production`): read configurations from `configs/constants/production.ts` file, but it will still be overwritten by `.env` file.
---
#### Reference
- [routing-controllers](https://github.com/typestack/routing-controllers)
- [Prisma](https://www.prisma.io/docs/concepts)
---
#### LICENSE
This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for more info.

View File

@ -0,0 +1,37 @@
import { NgModule, ModuleWithProviders, SkipSelf, Optional } from '@angular/core';
import { {{configurationClassName}} } from './configuration';
import { HttpClient } from '@angular/common/http';
{{#apiInfo}}
{{#apis}}
import { {{classname}} } from './{{importPath}}';
{{/apis}}
{{/apiInfo}}
@NgModule({
imports: [],
declarations: [],
exports: [],
providers: [{{#isProvidedInNone}}
{{#apiInfo}}{{#apis}}{{classname}}{{^-last}},
{{/-last}}{{/apis}}{{/apiInfo}} {{/isProvidedInNone}}]
})
export class {{apiModuleClassName}} {
public static forRoot(configurationFactory: () => {{configurationClassName}}): ModuleWithProviders{{#enforceGenericModuleWithProviders}}<{{apiModuleClassName}}>{{/enforceGenericModuleWithProviders}} {
return {
ngModule: {{apiModuleClassName}},
providers: [ { provide: {{configurationClassName}}, useFactory: configurationFactory } ]
};
}
constructor( @Optional() @SkipSelf() parentModule: {{apiModuleClassName}},
@Optional() http: HttpClient) {
if (parentModule) {
throw new Error('{{apiModuleClassName}} is already loaded. Import in your base AppModule only.');
}
if (!http) {
throw new Error('You need to import the HttpClientModule in your AppModule! \n' +
'See also https://github.com/angular/angular/issues/20575');
}
}
}

View File

@ -0,0 +1,404 @@
{{>licenseInfo}}
/* tslint:disable:no-unused-variable member-ordering */
import { Inject, Injectable, Optional } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams,
HttpResponse, HttpEvent, HttpParameterCodec{{#httpContextInOptions}}, HttpContext {{/httpContextInOptions}}
} from '@angular/common/http';
import { CustomHttpParameterCodec } from '../encoder';
import { Observable } from 'rxjs';
{{#imports}}
// @ts-ignore
import { {{ classname }} } from '{{ filename }}';
{{/imports}}
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS } from '../variables';
import { {{configurationClassName}} } from '../configuration';
{{#withInterfaces}}
import {
{{classname}}Interface{{#useSingleRequestParameter}}{{#operations}}{{#operation}}{{#allParams.0}},
{{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}RequestParams{{/allParams.0}}{{/operation}}{{/operations}}{{/useSingleRequestParameter}}
} from './{{classFilename}}Interface';
{{/withInterfaces}}
{{#operations}}
{{^withInterfaces}}
{{#useSingleRequestParameter}}
{{#operation}}
{{#allParams.0}}
export interface {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}RequestParams {
{{#allParams}}
{{#description}}/** {{.}} */
{{/description}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}{{#isNullable}} | null{{/isNullable}};
{{/allParams}}
}
{{/allParams.0}}
{{/operation}}
{{/useSingleRequestParameter}}
{{/withInterfaces}}
{{#description}}
/**
* {{&description}}
*/
{{/description}}
{{#isProvidedInNone}}
@Injectable()
{{/isProvidedInNone}}
{{^isProvidedInNone}}
@Injectable({
providedIn: '{{providedIn}}'
})
{{/isProvidedInNone}}
{{#withInterfaces}}
export class {{classname}} implements {{classname}}Interface {
{{/withInterfaces}}
{{^withInterfaces}}
export class {{classname}} {
{{/withInterfaces}}
protected basePath = '{{{basePath}}}';
public defaultHeaders = new HttpHeaders();
public configuration = new {{configurationClassName}}();
public encoder: HttpParameterCodec;
constructor(protected httpClient: HttpClient, @Optional()@Inject(BASE_PATH) basePath: string|string[], @Optional() configuration: {{configurationClassName}}) {
if (configuration) {
this.configuration = configuration;
}
if (typeof this.configuration.basePath !== 'string') {
if (Array.isArray(basePath) && basePath.length > 0) {
basePath = basePath[0];
}
if (typeof basePath !== 'string') {
basePath = this.basePath;
}
this.configuration.basePath = basePath;
}
this.encoder = this.configuration.encoder || new CustomHttpParameterCodec();
}
{{#hasSomeFormParams}}
/**
* @param consumes string[] mime-types
* @return true: consumes contains 'multipart/form-data', false: otherwise
*/
private canConsumeForm(consumes: string[]): boolean {
const form = 'multipart/form-data';
for (const consume of consumes) {
if (form === consume) {
return true;
}
}
return false;
}
{{/hasSomeFormParams}}
// @ts-ignore
private addToHttpParams(httpParams: HttpParams, value: any, key?: string): HttpParams {
{{#isQueryParamObjectFormatJson}}
httpParams = this.addToHttpParamsRecursive(httpParams, value, key);
{{/isQueryParamObjectFormatJson}}
{{^isQueryParamObjectFormatJson}}
if (typeof value === "object" && value instanceof Date === false) {
httpParams = this.addToHttpParamsRecursive(httpParams, value);
} else {
httpParams = this.addToHttpParamsRecursive(httpParams, value, key);
}
{{/isQueryParamObjectFormatJson}}
return httpParams;
}
private addToHttpParamsRecursive(httpParams: HttpParams, value?: any, key?: string): HttpParams {
if (value == null) {
return httpParams;
}
if (typeof value === "object") {
{{#isQueryParamObjectFormatJson}}
if (key != null) {
httpParams = httpParams.append(key, JSON.stringify(value));
} else {
throw Error("key may not be null if value is a QueryParamObject");
}
{{/isQueryParamObjectFormatJson}}
{{^isQueryParamObjectFormatJson}}
if (Array.isArray(value)) {
(value as any[]).forEach( elem => httpParams = this.addToHttpParamsRecursive(httpParams, elem, key));
} else if (value instanceof Date) {
if (key != null) {
httpParams = httpParams.append(key, (value as Date).toISOString(){{^isDateTime}}.substr(0, 10){{/isDateTime}});
} else {
throw Error("key may not be null if value is Date");
}
} else {
Object.keys(value).forEach( k => httpParams = this.addToHttpParamsRecursive(
httpParams, value[k], key != null ? `${key}{{#isQueryParamObjectFormatDot}}.{{/isQueryParamObjectFormatDot}}{{#isQueryParamObjectFormatKey}}[{{/isQueryParamObjectFormatKey}}${k}{{#isQueryParamObjectFormatKey}}]{{/isQueryParamObjectFormatKey}}` : k));
}
{{/isQueryParamObjectFormatJson}}
} else if (key != null) {
httpParams = httpParams.append(key, value);
} else {
throw Error("key may not be null if value is not object or array");
}
return httpParams;
}
{{#operation}}
/**
{{#summary}}
* {{.}}
{{/summary}}
{{#notes}}
* {{.}}
{{/notes}}
{{^useSingleRequestParameter}}
{{#allParams}}
* @param {{paramName}} {{description}}
{{/allParams}}
{{/useSingleRequestParameter}}
{{#useSingleRequestParameter}}
{{#allParams.0}}
* @param requestParameters
{{/allParams.0}}
{{/useSingleRequestParameter}}
* @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
* @param reportProgress flag to report request and response progress.
{{#isDeprecated}}
* @deprecated
{{/isDeprecated}}
*/
public {{nickname}}({{^useSingleRequestParameter}}{{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}{{/useSingleRequestParameter}}{{#useSingleRequestParameter}}{{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}RequestParams, {{/allParams.0}}{{/useSingleRequestParameter}}observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: {{#produces}}'{{mediaType}}'{{^-last}} | {{/-last}}{{/produces}}{{^produces}}undefined{{/produces}},{{#httpContextInOptions}} context?: HttpContext{{/httpContextInOptions}}}): Observable<{{#returnType}}{{{returnType}}}{{#isResponseTypeFile}}|undefined{{/isResponseTypeFile}}{{/returnType}}{{^returnType}}any{{/returnType}}>;
public {{nickname}}({{^useSingleRequestParameter}}{{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}{{/useSingleRequestParameter}}{{#useSingleRequestParameter}}{{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}RequestParams, {{/allParams.0}}{{/useSingleRequestParameter}}observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: {{#produces}}'{{mediaType}}'{{^-last}} | {{/-last}}{{/produces}}{{^produces}}undefined{{/produces}},{{#httpContextInOptions}} context?: HttpContext{{/httpContextInOptions}}}): Observable<HttpResponse<{{#returnType}}{{{returnType}}}{{#isResponseTypeFile}}|undefined{{/isResponseTypeFile}}{{/returnType}}{{^returnType}}any{{/returnType}}>>;
public {{nickname}}({{^useSingleRequestParameter}}{{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}{{/useSingleRequestParameter}}{{#useSingleRequestParameter}}{{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}RequestParams, {{/allParams.0}}{{/useSingleRequestParameter}}observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: {{#produces}}'{{mediaType}}'{{^-last}} | {{/-last}}{{/produces}}{{^produces}}undefined{{/produces}},{{#httpContextInOptions}} context?: HttpContext{{/httpContextInOptions}}}): Observable<HttpEvent<{{#returnType}}{{{returnType}}}{{#isResponseTypeFile}}|undefined{{/isResponseTypeFile}}{{/returnType}}{{^returnType}}any{{/returnType}}>>;
public {{nickname}}({{^useSingleRequestParameter}}{{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}{{/useSingleRequestParameter}}{{#useSingleRequestParameter}}{{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}RequestParams, {{/allParams.0}}{{/useSingleRequestParameter}}observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: {{#produces}}'{{mediaType}}'{{^-last}} | {{/-last}}{{/produces}}{{^produces}}undefined{{/produces}},{{#httpContextInOptions}} context?: HttpContext{{/httpContextInOptions}}}): Observable<any> {
{{#allParams}}
{{#useSingleRequestParameter}}
const {{paramName}} = requestParameters.{{paramName}};
{{/useSingleRequestParameter}}
{{#required}}
if ({{paramName}} === null || {{paramName}} === undefined) {
throw new Error('Required parameter {{paramName}} was null or undefined when calling {{nickname}}.');
}
{{/required}}
{{/allParams}}
{{#hasQueryParamsOrAuth}}
let localVarQueryParameters = new HttpParams({encoder: this.encoder});
{{#queryParams}}
{{#isArray}}
if ({{paramName}}) {
{{#isQueryParamObjectFormatJson}}
localVarQueryParameters = this.addToHttpParams(localVarQueryParameters,
<any>{{paramName}}, '{{baseName}}');
{{/isQueryParamObjectFormatJson}}
{{^isQueryParamObjectFormatJson}}
{{#isCollectionFormatMulti}}
{{paramName}}.forEach((element) => {
localVarQueryParameters = this.addToHttpParams(localVarQueryParameters,
<any>element, '{{baseName}}');
})
{{/isCollectionFormatMulti}}
{{^isCollectionFormatMulti}}
localVarQueryParameters = this.addToHttpParams(localVarQueryParameters,
[...{{paramName}}].join(COLLECTION_FORMATS['{{collectionFormat}}']), '{{baseName}}');
{{/isCollectionFormatMulti}}
{{/isQueryParamObjectFormatJson}}
}
{{/isArray}}
{{^isArray}}
if ({{paramName}} !== undefined && {{paramName}} !== null) {
localVarQueryParameters = this.addToHttpParams(localVarQueryParameters,
<any>{{paramName}}, '{{baseName}}');
}
{{/isArray}}
{{/queryParams}}
{{/hasQueryParamsOrAuth}}
let localVarHeaders = this.defaultHeaders;
{{#headerParams}}
{{#isArray}}
if ({{paramName}}) {
localVarHeaders = localVarHeaders.set('{{baseName}}', [...{{paramName}}].join(COLLECTION_FORMATS['{{collectionFormat}}']));
}
{{/isArray}}
{{^isArray}}
if ({{paramName}} !== undefined && {{paramName}} !== null) {
localVarHeaders = localVarHeaders.set('{{baseName}}', String({{paramName}}));
}
{{/isArray}}
{{/headerParams}}
{{#authMethods}}
{{#-first}}
let localVarCredential: string | undefined;
{{/-first}}
// authentication ({{name}}) required
localVarCredential = this.configuration.lookupCredential('{{name}}');
if (localVarCredential) {
{{#isApiKey}}
{{#isKeyInHeader}}
localVarHeaders = localVarHeaders.set('{{keyParamName}}', localVarCredential);
{{/isKeyInHeader}}
{{#isKeyInQuery}}
localVarQueryParameters = localVarQueryParameters.set('{{keyParamName}}', localVarCredential);
{{/isKeyInQuery}}
{{/isApiKey}}
{{#isBasic}}
{{#isBasicBasic}}
localVarHeaders = localVarHeaders.set('Authorization', 'Basic ' + localVarCredential);
{{/isBasicBasic}}
{{#isBasicBearer}}
localVarHeaders = localVarHeaders.set('Authorization', 'Bearer ' + localVarCredential);
{{/isBasicBearer}}
{{/isBasic}}
{{#isOAuth}}
localVarHeaders = localVarHeaders.set('Authorization', 'Bearer ' + localVarCredential);
{{/isOAuth}}
}
{{/authMethods}}
let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept;
if (localVarHttpHeaderAcceptSelected === undefined) {
// to determine the Accept header
const httpHeaderAccepts: string[] = [
{{#produces}}
'{{{mediaType}}}'{{^-last}},{{/-last}}
{{/produces}}
];
localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts);
}
if (localVarHttpHeaderAcceptSelected !== undefined) {
localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected);
}
{{#httpContextInOptions}}
let localVarHttpContext: HttpContext | undefined = options && options.context;
if (localVarHttpContext === undefined) {
localVarHttpContext = new HttpContext();
}
{{/httpContextInOptions}}
{{#bodyParam}}
{{- duplicated below, don't forget to change}}
// to determine the Content-Type header
const consumes: string[] = [
{{#consumes}}
'{{{mediaType}}}'{{^-last}},{{/-last}}
{{/consumes}}
];
{{/bodyParam}}
{{#hasFormParams}}
{{^bodyParam}}
// to determine the Content-Type header
const consumes: string[] = [
{{#consumes}}
'{{{mediaType}}}'{{^-last}},{{/-last}}
{{/consumes}}
];
{{/bodyParam}}
{{/hasFormParams}}
{{#bodyParam}}
const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes);
if (httpContentTypeSelected !== undefined) {
localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected);
}
{{/bodyParam}}
{{#hasFormParams}}
const canConsumeForm = this.canConsumeForm(consumes);
let localVarFormParams: { append(param: string, value: any): any; };
let localVarUseForm = false;
let localVarConvertFormParamsToString = false;
{{#formParams}}
{{#isFile}}
// use FormData to transmit files using content-type "multipart/form-data"
// see https://stackoverflow.com/questions/4007969/application-x-www-form-urlencoded-or-multipart-form-data
localVarUseForm = canConsumeForm;
{{/isFile}}
{{/formParams}}
if (localVarUseForm) {
localVarFormParams = new FormData();
} else {
localVarFormParams = new HttpParams({encoder: this.encoder});
}
{{#formParams}}
{{#isArray}}
if ({{paramName}}) {
{{#isCollectionFormatMulti}}
{{paramName}}.forEach((element) => {
localVarFormParams = localVarFormParams.append('{{baseName}}', <any>element) as any || localVarFormParams;
})
{{/isCollectionFormatMulti}}
{{^isCollectionFormatMulti}}
if (localVarUseForm) {
{{paramName}}.forEach((element) => {
localVarFormParams = localVarFormParams.append('{{baseName}}', <any>element) as any || localVarFormParams;
})
} else {
localVarFormParams = localVarFormParams.append('{{baseName}}', [...{{paramName}}].join(COLLECTION_FORMATS['{{collectionFormat}}'])) as any || localVarFormParams;
}
{{/isCollectionFormatMulti}}
}
{{/isArray}}
{{^isArray}}
if ({{paramName}} !== undefined) {
localVarFormParams = localVarFormParams.append('{{baseName}}', {{^isModel}}<any>{{paramName}}{{/isModel}}{{#isModel}}localVarUseForm ? new Blob([JSON.stringify({{paramName}})], {type: 'application/json'}) : <any>{{paramName}}{{/isModel}}) as any || localVarFormParams;
}
{{/isArray}}
{{/formParams}}
{{/hasFormParams}}
{{^isResponseFile}}
let responseType_: 'text' | 'json' | 'blob' = 'json';
if (localVarHttpHeaderAcceptSelected) {
if (localVarHttpHeaderAcceptSelected.startsWith('text')) {
responseType_ = 'text';
} else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) {
responseType_ = 'json';
} else {
responseType_ = 'blob';
}
}
{{/isResponseFile}}
let localVarPath = `{{{path}}}`;
return this.httpClient.request{{^isResponseFile}}<{{#returnType}}{{{returnType}}}{{#isResponseTypeFile}}|undefined{{/isResponseTypeFile}}{{/returnType}}{{^returnType}}any{{/returnType}}>{{/isResponseFile}}('{{httpMethod}}', `${this.configuration.basePath}${localVarPath}`,
{
{{#httpContextInOptions}}
context: localVarHttpContext,
{{/httpContextInOptions}}
{{#bodyParam}}
body: {{paramName}},
{{/bodyParam}}
{{^bodyParam}}
{{#hasFormParams}}
body: localVarConvertFormParamsToString ? localVarFormParams.toString() : localVarFormParams,
{{/hasFormParams}}
{{/bodyParam}}
{{#hasQueryParamsOrAuth}}
params: localVarQueryParameters,
{{/hasQueryParamsOrAuth}}
{{#isResponseFile}}
responseType: "blob",
{{/isResponseFile}}
{{^isResponseFile}}
responseType: <any>responseType_,
{{/isResponseFile}}
withCredentials: this.configuration.withCredentials,
headers: localVarHeaders,
observe: observe,
reportProgress: reportProgress
}
);
}
{{/operation}}}
{{/operations}}

View File

@ -0,0 +1,51 @@
{{>licenseInfo}}
import { HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
{{#imports}}
import { {{classname}} } from '../model/models';
{{/imports}}
import { {{configurationClassName}} } from '../configuration';
{{#operations}}
{{#useSingleRequestParameter}}
{{#operation}}
{{#allParams.0}}
export interface {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}RequestParams {
{{#allParams}}
{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}{{#isNullable}} | null{{/isNullable}};
{{/allParams}}
}
{{/allParams.0}}
{{/operation}}
{{/useSingleRequestParameter}}
{{#description}}
/**
* {{&description}}
*/
{{/description}}
export interface {{classname}}Interface {
defaultHeaders: HttpHeaders;
configuration: {{configurationClassName}};
{{#operation}}
/**
* {{summary}}
* {{notes}}
{{^useSingleRequestParameter}}
{{#allParams}}* @param {{paramName}} {{description}}
{{/allParams}}{{/useSingleRequestParameter}}{{#useSingleRequestParameter}}{{#allParams.0}}* @param requestParameters
{{/allParams.0}}{{/useSingleRequestParameter}}{{#isDeprecated}}
* @deprecated
{{/isDeprecated}}*/
{{nickname}}({{^useSingleRequestParameter}}{{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}{{/useSingleRequestParameter}}{{#useSingleRequestParameter}}{{#allParams.0}}requestParameters: {{#prefixParameterInterfaces}}{{classname}}{{/prefixParameterInterfaces}}{{operationIdCamelCase}}RequestParams, {{/allParams.0}}{{/useSingleRequestParameter}}extraHttpRequestParams?: any): Observable<{{{returnType}}}{{^returnType}}{}{{/returnType}}>;
{{/operation}}
}
{{/operations}}

View File

@ -0,0 +1,12 @@
{{#apiInfo}}
{{#apis}}
{{#operations}}
export * from './{{ classFilename }}';
import { {{ classname }} } from './{{ classFilename }}';
{{/operations}}
{{#withInterfaces}}
export * from './{{ classFilename }}Interface';
{{/withInterfaces}}
{{/apis}}
export const APIS = [{{#apis}}{{#operations}}{{ classname }}{{/operations}}{{^-last}}, {{/-last}}{{/apis}}];
{{/apiInfo}}

View File

@ -0,0 +1,17 @@
import { Server } from 'http'
import { print } from 'configs/utils'
import CONSTANTS from 'configs/constants'
import createServer from 'configs/application'
import { bootstrapAfter } from 'configs/bootstrap'
module.exports = (async (): Promise<Server> => {
try {
const app = await createServer()
return app.listen(CONSTANTS.PORT, () => {
print.log(`server listening on ${CONSTANTS.PORT}, in ${CONSTANTS.ENV_LABEL} mode.`)
bootstrapAfter()
})
} catch (e) {
console.log(e)
}
})()

View File

@ -0,0 +1,20 @@
import 'reflect-metadata'
import Koa from 'koa'
import { Container } from 'typedi'
import { routingConfigs } from './routing.options'
import { useMiddlewares } from './koa.middlewares'
import { useKoaServer, useContainer } from 'routing-controllers'
const createServer = async (): Promise<Koa> => {
const koa: Koa = new Koa()
useMiddlewares(koa)
useContainer(Container)
const app: Koa = useKoaServer<Koa>(koa, routingConfigs)
return app
}
export default createServer

View File

@ -0,0 +1,18 @@
import { join } from 'path'
import { print } from './utils'
import dotenv from 'dotenv'
// "before" will trigger before the app lift.
export const bootstrapBefore = (): object => {
// solve ncc path link.
const result = dotenv.config({ path: join(__dirname, '../.env') })
if (result.error) {
print.danger('Environment variable not loaded: not found ".env" file.')
return {}
}
print.log('.env loaded.')
return result.parsed
}
// "after" will trigger after the "container" mounted..
export const bootstrapAfter = (): any => {}

View File

@ -0,0 +1,19 @@
const { nodeExternalsPlugin } = require('esbuild-node-externals')
require('esbuild')
.build({
entryPoints: ['app.ts'],
bundle: true,
outfile: 'dist/index.js',
platform: 'node',
plugins: [
nodeExternalsPlugin({
dependencies: false,
}),
],
external: ['cors', 'kcors'],
})
.catch(err => {
console.log(err)
process.exit(1)
})

View File

@ -0,0 +1,23 @@
import { PrismaClient } from '@prisma/client'
declare global {
namespace NodeJS {
interface Global {
prisma: PrismaClient
}
}
}
let prisma: PrismaClient
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient()
} else {
if (!global.prisma) {
global.prisma = new PrismaClient()
}
prisma = global.prisma
}
export default prisma

View File

@ -0,0 +1,54 @@
import { bootstrapBefore } from '../bootstrap'
import development, { EevRecord } from './development'
import staging from './staging'
import production from './production'
import { ENVS } from './envs'
const parsedEnvs = bootstrapBefore()
const getCurrentEnv = (): ENVS => {
const env = process.env?.ENV
if (typeof env === 'undefined') {
console.warn(`/n> ENV is not set, fallback to ${ENVS.DEVELOPMENT}.`)
}
const upperCaseEnv = `${env}`.toUpperCase()
if (upperCaseEnv === ENVS.PRODUCTION) return ENVS.PRODUCTION
if (upperCaseEnv === ENVS.STAGING) return ENVS.STAGING
return ENVS.DEVELOPMENT
}
const getCurrentConstants = (ident: ENVS): EevRecord => {
let constants = development
const source =
ident === ENVS.PRODUCTION
? production
: ident === ENVS.STAGING
? staging
: development
Object.keys(development).forEach(key => {
const sourceValue = source[key]
const processValue = process.env[key]
const parsedValue = parsedEnvs[key]
if (typeof sourceValue !== 'undefined') {
constants[key] = sourceValue
}
if (typeof processValue !== 'undefined') {
constants[key] = processValue
}
if (typeof parsedValue !== 'undefined') {
constants[key] = parsedValue
}
})
constants.ENV_LABEL = source.ENV_LABEL
return constants
}
export const CURRENT_ENV = getCurrentEnv()
export const isProd = () => CURRENT_ENV === ENVS.PRODUCTION
const CONSTANTS = getCurrentConstants(CURRENT_ENV)
export default CONSTANTS

View File

@ -0,0 +1,205 @@
import { HttpParameterCodec } from '@angular/common/http';
import { Param } from './param';
export interface {{configurationParametersInterfaceName}} {
/**
* @deprecated Since 5.0. Use credentials instead
*/
apiKeys?: {[ key: string ]: string};
username?: string;
password?: string;
/**
* @deprecated Since 5.0. Use credentials instead
*/
accessToken?: string | (() => string);
basePath?: string;
withCredentials?: boolean;
/**
* Takes care of encoding query- and form-parameters.
*/
encoder?: HttpParameterCodec;
/**
* Override the default method for encoding path parameters in various
* <a href="https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#style-values">styles</a>.
* <p>
* See {@link README.md} for more details
* </p>
*/
encodeParam?: (param: Param) => string;
/**
* The keys are the names in the securitySchemes section of the OpenAPI
* document. They should map to the value used for authentication
* minus any standard prefixes such as 'Basic' or 'Bearer'.
*/
credentials?: {[ key: string ]: string | (() => string | undefined)};
}
export class {{configurationClassName}} {
/**
* @deprecated Since 5.0. Use credentials instead
*/
apiKeys?: {[ key: string ]: string};
username?: string;
password?: string;
/**
* @deprecated Since 5.0. Use credentials instead
*/
accessToken?: string | (() => string);
basePath?: string;
withCredentials?: boolean;
/**
* Takes care of encoding query- and form-parameters.
*/
encoder?: HttpParameterCodec;
/**
* Encoding of various path parameter
* <a href="https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#style-values">styles</a>.
* <p>
* See {@link README.md} for more details
* </p>
*/
encodeParam: (param: Param) => string;
/**
* The keys are the names in the securitySchemes section of the OpenAPI
* document. They should map to the value used for authentication
* minus any standard prefixes such as 'Basic' or 'Bearer'.
*/
credentials: {[ key: string ]: string | (() => string | undefined)};
constructor(configurationParameters: {{configurationParametersInterfaceName}} = {}) {
this.apiKeys = configurationParameters.apiKeys;
this.username = configurationParameters.username;
this.password = configurationParameters.password;
this.accessToken = configurationParameters.accessToken;
this.basePath = configurationParameters.basePath;
this.withCredentials = configurationParameters.withCredentials;
this.encoder = configurationParameters.encoder;
if (configurationParameters.encodeParam) {
this.encodeParam = configurationParameters.encodeParam;
}
else {
this.encodeParam = param => this.defaultEncodeParam(param);
}
if (configurationParameters.credentials) {
this.credentials = configurationParameters.credentials;
}
else {
this.credentials = {};
}
{{#authMethods}}
// init default {{name}} credential
if (!this.credentials['{{name}}']) {
{{#isApiKey}}
this.credentials['{{name}}'] = () => {
{{! Fallback behaviour may be removed for 5.0 release. See #5062 }}
if (this.apiKeys === null || this.apiKeys === undefined) {
return undefined;
} else {
return this.apiKeys['{{name}}'] || this.apiKeys['{{keyParamName}}'];
}
};
{{/isApiKey}}
{{#isBasic}}
{{#isBasicBasic}}
this.credentials['{{name}}'] = () => {
return (this.username || this.password)
? btoa(this.username + ':' + this.password)
: undefined;
};
{{/isBasicBasic}}
{{#isBasicBearer}}
this.credentials['{{name}}'] = () => {
return typeof this.accessToken === 'function'
? this.accessToken()
: this.accessToken;
};
{{/isBasicBearer}}
{{/isBasic}}
{{#isOAuth}}
this.credentials['{{name}}'] = () => {
return typeof this.accessToken === 'function'
? this.accessToken()
: this.accessToken;
};
{{/isOAuth}}
}
{{/authMethods}}
}
/**
* Select the correct content-type to use for a request.
* Uses {@link {{configurationClassName}}#isJsonMime} to determine the correct content-type.
* If no content type is found return the first found type if the contentTypes is not empty
* @param contentTypes - the array of content types that are available for selection
* @returns the selected content-type or <code>undefined</code> if no selection could be made.
*/
public selectHeaderContentType (contentTypes: string[]): string | undefined {
if (contentTypes.length === 0) {
return undefined;
}
const type = contentTypes.find((x: string) => this.isJsonMime(x));
if (type === undefined) {
return contentTypes[0];
}
return type;
}
/**
* Select the correct accept content-type to use for a request.
* Uses {@link {{configurationClassName}}#isJsonMime} to determine the correct accept content-type.
* If no content type is found return the first found type if the contentTypes is not empty
* @param accepts - the array of content types that are available for selection.
* @returns the selected content-type or <code>undefined</code> if no selection could be made.
*/
public selectHeaderAccept(accepts: string[]): string | undefined {
if (accepts.length === 0) {
return undefined;
}
const type = accepts.find((x: string) => this.isJsonMime(x));
if (type === undefined) {
return accepts[0];
}
return type;
}
/**
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
public isJsonMime(mime: string): boolean {
const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i');
return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json');
}
public lookupCredential(key: string): string | undefined {
const value = this.credentials[key];
return typeof value === 'function'
? value()
: value;
}
private defaultEncodeParam(param: Param): string {
// This implementation exists as fallback for missing configuration
// and for backwards compatibility to older typescript-angular generator versions.
// It only works for the 'simple' parameter style.
// Date-handling only works for the 'date-time' format.
// All other styles and Date-formats are probably handled incorrectly.
//
// But: if that's all you need (i.e.: the most common use-case): no need for customization!
const value = param.dataFormat === 'date-time' && param.value instanceof Date
? (param.value as Date).toISOString()
: param.value;
return encodeURIComponent(String(value));
}
}

View File

@ -0,0 +1,9 @@
const development = {
ENV_LABEL: 'DEVELOPMENT',
PORT: 3001,
}
export default development
export type EevRecord = typeof development

View File

@ -0,0 +1,18 @@
version: '3.3'
services:
restapi.postgres:
container_name: koa-ts
image: postgres:13
volumes:
- postgres:/var/lib/postgres
restart: always
ports:
- ${POSTGRES_PORT}:${POSTGRES_PORT}
env_file:
- ./.env
labels:
com.startupteam.description: "postgres container for koa-ts"
volumes:
postgres: {}

View File

@ -0,0 +1,20 @@
import { HttpParameterCodec } from '@angular/common/http';
/**
* Custom HttpParameterCodec
* Workaround for https://github.com/angular/angular/issues/18261
*/
export class CustomHttpParameterCodec implements HttpParameterCodec {
encodeKey(k: string): string {
return encodeURIComponent(k);
}
encodeValue(v: string): string {
return encodeURIComponent(v);
}
decodeKey(k: string): string {
return decodeURIComponent(k);
}
decodeValue(v: string): string {
return decodeURIComponent(v);
}
}

View File

@ -0,0 +1,6 @@
ENV=development
POSTGRES_USER=root
POSTGRES_PASSWORD=rootpassword
POSTGRES_DB=dev
POSTGRES_PORT=5432
DATABASE_URL='postgresql://root:rootpassword@localhost:5432/dev?schema=public'

View File

@ -0,0 +1,5 @@
export enum ENVS {
DEVELOPMENT = 'DEVELOPMENT',
STAGING = 'STAGING',
PRODUCTION = 'PRODUCTION',
}

View File

@ -0,0 +1,57 @@
#!/bin/sh
# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/
#
# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com"
git_user_id=$1
git_repo_id=$2
release_note=$3
git_host=$4
if [ "$git_host" = "" ]; then
git_host="{{{gitHost}}}"
echo "[INFO] No command line input provided. Set \$git_host to $git_host"
fi
if [ "$git_user_id" = "" ]; then
git_user_id="{{{gitUserId}}}"
echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id"
fi
if [ "$git_repo_id" = "" ]; then
git_repo_id="{{{gitRepoId}}}"
echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id"
fi
if [ "$release_note" = "" ]; then
release_note="{{{releaseNote}}}"
echo "[INFO] No command line input provided. Set \$release_note to $release_note"
fi
# Initialize the local directory as a Git repository
git init
# Adds the files in the local repository and stages them for commit.
git add .
# Commits the tracked changes and prepares them to be pushed to a remote repository.
git commit -m "$release_note"
# Sets the new remote
git_remote=$(git remote)
if [ "$git_remote" = "" ]; then # git remote not defined
if [ "$GIT_TOKEN" = "" ]; then
echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment."
git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git
else
git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git
fi
fi
git pull origin master
# Pushes (Forces) the changes in the local repository up to the remote repository
echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git"
git push origin master 2>&1 | grep -v 'To https'

View File

@ -0,0 +1,4 @@
wwwroot/*.js
node_modules
typings
dist

View File

@ -0,0 +1,6 @@
export * from './api/api';
export * from './model/models';
export * from './variables';
export * from './configuration';
export * from './api.module';
export * from './param';

View File

@ -0,0 +1 @@
export * from './sessions.controller'

View File

@ -0,0 +1,12 @@
import { InterceptorInterface, Action, Interceptor } from 'routing-controllers'
import { Service } from 'typedi'
@Interceptor()
@Service()
export class AutoAssignJSONInterceptor implements InterceptorInterface {
intercept(action: Action, content: any): any {
if (typeof content === 'object')
return JSON.stringify(Object.assign({ message: 'ok' }, content))
return JSON.stringify({ message: content })
}
}

View File

@ -0,0 +1,18 @@
module.exports = {
verbose: true,
testEnvironment: 'node',
moduleFileExtensions: ['ts', 'js'],
testPathIgnorePatterns: ['/dist/'],
transform: {
'^.+\\.[t|j]s?$': ['ts-jest'],
},
transformIgnorePatterns: ['<rootDir>/node_modules/'],
testRegex: '.*\\.test\\.(j|t)s?$',
moduleNameMapper: {
'tests/(.*)$': '<rootDir>/tests/$1',
'configs/(.*)$': '<rootDir>/configs/$1',
'app/(.*)$': '<rootDir>/app/$1',
server: '<rootDir>/app.ts',
// app: '<rootDir>/app.ts',
},
}

View File

@ -0,0 +1,3 @@
## Jobs
You can add scheduled tasks, email tasks, or any third-party work here.

View File

@ -0,0 +1,14 @@
import Koa from 'koa'
import logger from 'koa-logger'
import bodyParser from 'koa-bodyparser'
import { isProd } from './constants'
export const useMiddlewares = <T extends Koa>(app: T): T => {
if (isProd()) {
app.use(logger())
}
app.use(bodyParser())
return app
}

View File

@ -0,0 +1,11 @@
/**
* {{{appName}}}
* {{{appDescription}}}
*
* {{#version}}The version of the OpenAPI document: {{{.}}}{{/version}}
* {{#infoEmail}}Contact: {{{.}}}{{/infoEmail}}
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/

View File

@ -0,0 +1,16 @@
{{>licenseInfo}}
{{#models}}
{{#model}}
{{#tsImports}}
import { {{classname}} } from '{{filename}}';
{{/tsImports}}
{{#description}}
/**
* {{{.}}}
*/
{{/description}}
{{#isEnum}}{{>modelEnum}}{{/isEnum}}{{^isEnum}}{{#isAlias}}{{>modelAlias}}{{/isAlias}}{{^isAlias}}{{#taggedUnions}}{{>modelTaggedUnion}}{{/taggedUnions}}{{^taggedUnions}}{{#oneOf}}{{#-first}}{{>modelOneOf}}{{/-first}}{{/oneOf}}{{^oneOf}}{{>modelGeneric}}{{/oneOf}}{{/taggedUnions}}{{/isAlias}}{{/isEnum}}
{{/model}}
{{/models}}

View File

@ -0,0 +1 @@
export type {{classname}} = {{dataType}};

View File

@ -0,0 +1,20 @@
{{#stringEnums}}
export enum {{classname}} {
{{#allowableValues}}
{{#enumVars}}
{{name}} = {{{value}}}{{^-last}},{{/-last}}
{{/enumVars}}
{{/allowableValues}}
}
{{/stringEnums}}
{{^stringEnums}}
export type {{classname}} = {{#allowableValues}}{{#enumVars}}{{{value}}}{{^-last}} | {{/-last}}{{/enumVars}}{{/allowableValues}};
export const {{classname}} = {
{{#allowableValues}}
{{#enumVars}}
{{name}}: {{{value}}} as {{classname}}{{^-last}},{{/-last}}
{{/enumVars}}
{{/allowableValues}}
};
{{/stringEnums}}

View File

@ -0,0 +1,10 @@
export interface {{classname}}{{#allParents}}{{#-first}} extends {{/-first}}{{{.}}}{{^-last}}, {{/-last}}{{/allParents}} { {{>modelGenericAdditionalProperties}}
{{#vars}}
{{#description}}
/**
* {{{.}}}
*/
{{/description}}
{{#isReadOnly}}readonly {{/isReadOnly}}{{{name}}}{{^required}}?{{/required}}: {{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{#isNullable}} | null{{/isNullable}};
{{/vars}}
}{{>modelGenericEnums}}

View File

@ -0,0 +1,5 @@
{{#additionalPropertiesType}}
[key: string]: {{{additionalPropertiesType}}}{{#hasVars}} | any{{/hasVars}};
{{/additionalPropertiesType}}

View File

@ -0,0 +1,30 @@
{{#hasEnums}}
{{^stringEnums}}
export namespace {{classname}} {
{{/stringEnums}}
{{#vars}}
{{#isEnum}}
{{#stringEnums}}
export enum {{classname}}{{enumName}} {
{{#allowableValues}}
{{#enumVars}}
{{name}} = {{{value}}}{{^-last}},{{/-last}}
{{/enumVars}}
{{/allowableValues}}
};
{{/stringEnums}}
{{^stringEnums}}
export type {{enumName}} = {{#allowableValues}}{{#enumVars}}{{{value}}}{{^-last}} | {{/-last}}{{/enumVars}}{{/allowableValues}};
export const {{enumName}} = {
{{#allowableValues}}
{{#enumVars}}
{{name}}: {{{value}}} as {{enumName}}{{^-last}},{{/-last}}
{{/enumVars}}
{{/allowableValues}}
};
{{/stringEnums}}
{{/isEnum}}
{{/vars}}
{{^stringEnums}}}{{/stringEnums}}
{{/hasEnums}}

View File

@ -0,0 +1,14 @@
{{#hasImports}}
import {
{{#imports}}
{{{.}}},
{{/imports}}
} from './';
{{/hasImports}}
/**
* @type {{classname}}{{#description}}
* {{{.}}}{{/description}}
* @export
*/
export type {{classname}} = {{#oneOf}}{{{.}}}{{^-last}} | {{/-last}}{{/oneOf}};

View File

@ -0,0 +1,21 @@
{{#discriminator}}
export type {{classname}} = {{#children}}{{^-first}} | {{/-first}}{{classname}}{{/children}};
{{/discriminator}}
{{^discriminator}}
{{#parent}}
export interface {{classname}} { {{>modelGenericAdditionalProperties}}
{{#allVars}}
{{#description}}
/**
* {{{.}}}
*/
{{/description}}
{{name}}{{^required}}?{{/required}}: {{#discriminatorValue}}'{{.}}'{{/discriminatorValue}}{{^discriminatorValue}}{{#isEnum}}{{{datatypeWithEnum}}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{/discriminatorValue}}{{#isNullable}} | null{{/isNullable}};
{{/allVars}}
}
{{>modelGenericEnums}}
{{/parent}}
{{^parent}}
{{>modelGeneric}}
{{/parent}}
{{/discriminator}}

View File

@ -0,0 +1,5 @@
{{#models}}
{{#model}}
export * from '{{{ classFilename }}}';
{{/model}}
{{/models}}

View File

@ -0,0 +1,6 @@
{
"$schema": "./node_modules/ng-packagr/ng-package.schema.json",
"lib": {
"entryFile": "index.ts"
}
}

View File

@ -0,0 +1,22 @@
{
"verbose": false,
"debug": false,
"exec": "ts-node -r tsconfig-paths/register ./app.ts",
"ignore": [
"mochawesome-report",
"node_modules",
"./test",
"**/*.d.ts",
"*.test.ts",
"*.spec.ts",
"fixtures/*",
"test/**/*",
"docs/*"
],
"events": {
"restart": ""
},
"watch": ["./app", "./configs", "./app.ts"],
"ext": "ts",
"inspect": true
}

View File

@ -0,0 +1,58 @@
{
"name": "{{{npmName}}}",
"version": "{{{npmVersion}}}",
"license": "Unlicense",
"main": "app.ts",
"scripts": {
"dev": "export NODE_ENV=development; ts-node-dev -r tsconfig-paths/register app.ts",
"dev:db": "docker compose -f docker-compose.yml up -d",
"prettier": "prettier --write '**/*.{js,ts}'",
"test": "jest --config .jest.config.js --no-cache --detectOpenHandles",
"prod:build": "node ./build.js",
"prod:start": "prisma generate && prisma migrate deploy && export NODE_ENV=production; node ./dist/index.js"
},
"repository": {
"type": "git",
"url": "https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}.git"
},
"keywords": [
"koa",
"openapi-server",
"openapi-generator"
],
"engines": {
"node": ">= 14.x"
},
"prettier": "@geist-ui/prettier-config",
"devDependencies": {
"@geist-ui/prettier-config": "^1.0.1",
"@types/jest": "^25.2.2",
"@types/koa": "^2.13.4",
"@types/koa-bodyparser": "^4.3.5",
"@types/node": "^17.0.8",
"esbuild": "^0.14.11",
"esbuild-node-externals": "^1.4.1",
"jest": "^26.6.3",
"prettier": "^2.5.1",
"prisma": "^4.6.1",
"supertest": "^4.0.2",
"ts-jest": "^26.5.3",
"ts-node-dev": "^2.0.0",
"tsconfig-paths": "^3.12.0",
"typescript": "^4.5.4"
},
"dependencies": {
"@prisma/client": "^4.6.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"dotenv": "^12.0.3",
"koa": "^2.13.4",
"koa-bodyparser": "^4.3.0",
"koa-logger": "^3.2.1",
"koa-multer": "^1.0.2",
"koa-router": "^10.1.1",
"reflect-metadata": "^0.1.13",
"routing-controllers": "^0.9.0",
"typedi": "^0.10.0"
}
}

View File

@ -0,0 +1,69 @@
/**
* Standard parameter styles defined by OpenAPI spec
*/
export type StandardParamStyle =
| 'matrix'
| 'label'
| 'form'
| 'simple'
| 'spaceDelimited'
| 'pipeDelimited'
| 'deepObject'
;
/**
* The OpenAPI standard {@link StandardParamStyle}s may be extended by custom styles by the user.
*/
export type ParamStyle = StandardParamStyle | string;
/**
* Standard parameter locations defined by OpenAPI spec
*/
export type ParamLocation = 'query' | 'header' | 'path' | 'cookie';
/**
* Standard types as defined in <a href="https://swagger.io/specification/#data-types">OpenAPI Specification: Data Types</a>
*/
export type StandardDataType =
| "integer"
| "number"
| "boolean"
| "string"
| "object"
| "array"
;
/**
* Standard {@link DataType}s plus your own types/classes.
*/
export type DataType = StandardDataType | string;
/**
* Standard formats as defined in <a href="https://swagger.io/specification/#data-types">OpenAPI Specification: Data Types</a>
*/
export type StandardDataFormat =
| "int32"
| "int64"
| "float"
| "double"
| "byte"
| "binary"
| "date"
| "date-time"
| "password"
;
export type DataFormat = StandardDataFormat | string;
/**
* The parameter to encode.
*/
export interface Param {
name: string;
value: unknown;
in: ParamLocation;
style: ParamStyle,
explode: boolean;
dataType: DataType;
dataFormat: DataFormat | undefined;
}

View File

@ -0,0 +1,7 @@
import { EevRecord } from './development'
const production: Partial<EevRecord> = {
ENV_LABEL: 'PRODUCTION',
}
export default production

View File

@ -0,0 +1,18 @@
import { KoaMiddlewareInterface, Middleware } from 'routing-controllers'
import { Service } from 'typedi'
@Middleware({ type: 'before' })
@Service()
export class HeaderMiddleware implements KoaMiddlewareInterface {
async use(context: any, next: (err?: any) => any): Promise<any> {
context.set('Access-Control-Allow-Methods', 'GET,HEAD,PUT,POST,DELETE,PATCH')
context.set(
'Access-Control-Allow-Origin',
context.request.header.origin || context.request.origin,
)
context.set('Access-Control-Allow-Headers', ['content-type'])
context.set('Access-Control-Allow-Credentials', 'true')
context.set('Content-Type', 'application/json; charset=utf-8')
return next()
}
}

View File

@ -0,0 +1,21 @@
import { RoutingControllersOptions } from 'routing-controllers'
import * as controllers from 'app/controllers'
import * as middlewares from './routing.middlewares'
import * as interceptors from './interceptors'
import { dictToArray } from './utils'
export const routingConfigs: RoutingControllersOptions = {
controllers: dictToArray(controllers),
middlewares: dictToArray(middlewares),
interceptors: dictToArray(interceptors),
// router prefix
// e.g. api => http://hostname:port/{routePrefix}/{controller.method}
routePrefix: '/apis',
// auto validate entity item
// learn more: https://github.com/typestack/class-validator
validation: true,
}

View File

@ -0,0 +1,14 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Session {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
}

View File

@ -0,0 +1,19 @@
import server from 'server'
import request from 'supertest'
describe('routers: session', () => {
let app
beforeAll(async () => {
app = await server
})
it('should be return 200 status code', async () => {
const res = await request(app).get('/apis/sessions')
expect(res.status).toEqual(200)
})
afterAll(async done => {
app.close()
done()
})
})

View File

@ -0,0 +1,31 @@
import {
BadRequestError,
Post,
JsonController,
BodyParam,
Get,
} from 'routing-controllers'
import { SessionsService } from '../services'
import { Prisma } from '@prisma/client'
import { Service } from 'typedi'
@JsonController()
@Service()
export class SessionsController {
constructor(private sessionsService: SessionsService) {}
@Get('/sessions')
async query() {
return []
}
@Post('/sessions')
async create(
@BodyParam('username') name: string,
): Promise<Prisma.SessionGetPayload<any>> {
if (!name) {
throw new BadRequestError('username is required')
}
return await this.sessionsService.create({ name })
}
}

View File

@ -0,0 +1 @@
export * from './sessions.service'

View File

@ -0,0 +1,21 @@
import { Service } from 'typedi'
import prisma from 'app/helpers/client'
import { Prisma } from '@prisma/client'
@Service()
export class SessionsService {
/**
* Type 'Prisma.SessionCreateInput' is automatically generated.
* Whenever you modify file 'prisma/schema.prisma' and then run command:
* prisma generate
* prisma migrate dev
* The types is automatically updated.
*
* About CRUD: https://www.prisma.io/docs/concepts/components/prisma-client/crud
*/
async create(session: Prisma.SessionCreateInput) {
return prisma.session.create({
data: session,
})
}
}

View File

@ -0,0 +1,7 @@
import { EevRecord } from './development'
const staging: Partial<EevRecord> = {
ENV_LABEL: 'STAGING',
}
export default staging

View File

@ -0,0 +1,39 @@
{
"compilerOptions": {
"allowJs": true,
"moduleResolution": "Node",
"module": "commonjs",
"outDir": "./dist",
"lib": ["ESNext"],
"removeComments": true,
"skipLibCheck": true,
"noImplicitAny": false,
"esModuleInterop": true,
"preserveConstEnums": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"typeRoots": [
"node_modules/@types",
"typings"
],
"baseUrl": ".",
"paths": {
"configs/*": ["./configs/*"],
"app": ["app"]
},
},
"compileOnSave": false,
"include": [
"app/**/*",
"configs/**/*",
"**/*.ts",
"**/*.tsx"
],
"files": [
"app.ts",
],
"exclude": [
"node_modules",
"dist"
]
}

View File

@ -0,0 +1,8 @@
export const dictToArray = (dict: object): Array<any> =>
Object.keys(dict).map(name => dict[name])
export const print = {
log: (text: string) => console.log('\x1b[37m%s \x1b[2m%s\x1b[0m', '>', text),
danger: (text: string) => console.log('\x1b[31m%s \x1b[31m%s\x1b[0m', '>', text),
tip: (text: string) => console.log('\x1b[36m%s \x1b[36m%s\x1b[0m', '>', text),
}

View File

@ -0,0 +1,9 @@
import { InjectionToken } from '@angular/core';
export const BASE_PATH = new InjectionToken<string>('basePath');
export const COLLECTION_FORMATS = {
'csv': ',',
'tsv': ' ',
'ssv': ' ',
'pipes': '|'
}

View File

@ -0,0 +1,738 @@
openapi: 3.0.0
servers:
- url: 'http://petstore.swagger.io/v2'
info:
description: >-
This is a sample server Petstore server. For this sample, you can use the api key
`special-key` to test the authorization filters.
version: 1.0.0
title: OpenAPI Petstore
license:
name: Apache-2.0
url: 'https://www.apache.org/licenses/LICENSE-2.0.html'
tags:
- name: pet
description: Everything about your Pets
- name: store
description: Access to Petstore orders
- name: user
description: Operations about user
paths:
/pet:
post:
tags:
- pet
summary: Add a new pet to the store
description: ''
operationId: addPet
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/json:
schema:
$ref: '#/components/schemas/Pet'
'405':
description: Invalid input
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
requestBody:
$ref: '#/components/requestBodies/Pet'
put:
tags:
- pet
summary: Update an existing pet
description: ''
operationId: updatePet
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/json:
schema:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid ID supplied
'404':
description: Pet not found
'405':
description: Validation exception
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
requestBody:
$ref: '#/components/requestBodies/Pet'
/pet/findByStatus:
get:
tags:
- pet
summary: Finds Pets by status
description: Multiple status values can be provided with comma separated strings
operationId: findPetsByStatus
parameters:
- name: status
in: query
description: Status values that need to be considered for filter
required: true
style: form
explode: false
deprecated: true
schema:
type: array
items:
type: string
enum:
- available
- pending
- sold
default: available
responses:
'200':
description: successful operation
content:
application/xml:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid status value
security:
- petstore_auth:
- 'read:pets'
/pet/findByTags:
get:
tags:
- pet
summary: Finds Pets by tags
description: >-
Multiple tags can be provided with comma separated strings. Use tag1,
tag2, tag3 for testing.
operationId: findPetsByTags
parameters:
- name: tags
in: query
description: Tags to filter by
required: true
style: form
explode: false
schema:
type: array
items:
type: string
responses:
'200':
description: successful operation
content:
application/xml:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid tag value
security:
- petstore_auth:
- 'read:pets'
deprecated: true
'/pet/{petId}':
get:
tags:
- pet
summary: Find pet by ID
description: Returns a single pet
operationId: getPetById
parameters:
- name: petId
in: path
description: ID of pet to return
required: true
schema:
type: integer
format: int64
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/json:
schema:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid ID supplied
'404':
description: Pet not found
security:
- api_key: []
post:
tags:
- pet
summary: Updates a pet in the store with form data
description: ''
operationId: updatePetWithForm
parameters:
- name: petId
in: path
description: ID of pet that needs to be updated
required: true
schema:
type: integer
format: int64
responses:
'405':
description: Invalid input
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
requestBody:
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
name:
description: Updated name of the pet
type: string
status:
description: Updated status of the pet
type: string
delete:
tags:
- pet
summary: Deletes a pet
description: ''
operationId: deletePet
parameters:
- name: api_key
in: header
required: false
schema:
type: string
- name: petId
in: path
description: Pet id to delete
required: true
schema:
type: integer
format: int64
responses:
'400':
description: Invalid pet value
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
'/pet/{petId}/uploadImage':
post:
tags:
- pet
summary: uploads an image
description: ''
operationId: uploadFile
parameters:
- name: petId
in: path
description: ID of pet to update
required: true
schema:
type: integer
format: int64
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
additionalMetadata:
description: Additional data to pass to server
type: string
file:
description: file to upload
type: string
format: binary
/store/inventory:
get:
tags:
- store
summary: Returns pet inventories by status
description: Returns a map of status codes to quantities
operationId: getInventory
responses:
'200':
description: successful operation
content:
application/json:
schema:
type: object
additionalProperties:
type: integer
format: int32
security:
- api_key: []
/store/order:
post:
tags:
- store
summary: Place an order for a pet
description: ''
operationId: placeOrder
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Order'
application/json:
schema:
$ref: '#/components/schemas/Order'
'400':
description: Invalid Order
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
description: order placed for purchasing the pet
required: true
'/store/order/{orderId}':
get:
tags:
- store
summary: Find purchase order by ID
description: >-
For valid response try integer IDs with value <= 5 or > 10. Other values
will generate exceptions
operationId: getOrderById
parameters:
- name: orderId
in: path
description: ID of pet that needs to be fetched
required: true
schema:
type: integer
format: int64
minimum: 1
maximum: 5
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Order'
application/json:
schema:
$ref: '#/components/schemas/Order'
'400':
description: Invalid ID supplied
'404':
description: Order not found
delete:
tags:
- store
summary: Delete purchase order by ID
description: >-
For valid response try integer IDs with value < 1000. Anything above
1000 or nonintegers will generate API errors
operationId: deleteOrder
parameters:
- name: orderId
in: path
description: ID of the order that needs to be deleted
required: true
schema:
type: string
responses:
'400':
description: Invalid ID supplied
'404':
description: Order not found
/user:
post:
tags:
- user
summary: Create user
description: This can only be done by the logged in user.
operationId: createUser
responses:
default:
description: successful operation
security:
- api_key: []
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/User'
description: Created user object
required: true
/user/createWithArray:
post:
tags:
- user
summary: Creates list of users with given input array
description: ''
operationId: createUsersWithArrayInput
responses:
default:
description: successful operation
security:
- api_key: []
requestBody:
$ref: '#/components/requestBodies/UserArray'
/user/createWithList:
post:
tags:
- user
summary: Creates list of users with given input array
description: ''
operationId: createUsersWithListInput
responses:
default:
description: successful operation
security:
- api_key: []
requestBody:
$ref: '#/components/requestBodies/UserArray'
/user/login:
get:
tags:
- user
summary: Logs user into the system
description: ''
operationId: loginUser
parameters:
- name: username
in: query
description: The user name for login
required: true
schema:
type: string
pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$'
- name: password
in: query
description: The password for login in clear text
required: true
schema:
type: string
responses:
'200':
description: successful operation
headers:
Set-Cookie:
description: >-
Cookie authentication key for use with the `api_key`
apiKey authentication.
schema:
type: string
example: AUTH_KEY=abcde12345; Path=/; HttpOnly
X-Rate-Limit:
description: calls per hour allowed by the user
schema:
type: integer
format: int32
X-Expires-After:
description: date in UTC when token expires
schema:
type: string
format: date-time
content:
application/xml:
schema:
type: string
application/json:
schema:
type: string
'400':
description: Invalid username/password supplied
/user/logout:
get:
tags:
- user
summary: Logs out current logged in user session
description: ''
operationId: logoutUser
responses:
default:
description: successful operation
security:
- api_key: []
'/user/{username}':
get:
tags:
- user
summary: Get user by user name
description: ''
operationId: getUserByName
parameters:
- name: username
in: path
description: The name that needs to be fetched. Use user1 for testing.
required: true
schema:
type: string
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/User'
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
description: Invalid username supplied
'404':
description: User not found
put:
tags:
- user
summary: Updated user
description: This can only be done by the logged in user.
operationId: updateUser
parameters:
- name: username
in: path
description: name that need to be deleted
required: true
schema:
type: string
responses:
'400':
description: Invalid user supplied
'404':
description: User not found
security:
- api_key: []
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/User'
description: Updated user object
required: true
delete:
tags:
- user
summary: Delete user
description: This can only be done by the logged in user.
operationId: deleteUser
parameters:
- name: username
in: path
description: The name that needs to be deleted
required: true
schema:
type: string
responses:
'400':
description: Invalid username supplied
'404':
description: User not found
security:
- api_key: []
externalDocs:
description: Find out more about Swagger
url: 'http://swagger.io'
components:
requestBodies:
UserArray:
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
description: List of user object
required: true
Pet:
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
description: Pet object that needs to be added to the store
required: true
securitySchemes:
petstore_auth:
type: oauth2
flows:
implicit:
authorizationUrl: 'http://petstore.swagger.io/api/oauth/dialog'
scopes:
'write:pets': modify pets in your account
'read:pets': read your pets
api_key:
type: apiKey
name: api_key
in: header
schemas:
Order:
title: Pet Order
description: An order for a pets from the pet store
type: object
properties:
id:
type: integer
format: int64
petId:
type: integer
format: int64
quantity:
type: integer
format: int32
shipDate:
type: string
format: date-time
status:
type: string
description: Order Status
enum:
- placed
- approved
- delivered
complete:
type: boolean
default: false
xml:
name: Order
Category:
title: Pet category
description: A category for a pet
type: object
properties:
id:
type: integer
format: int64
name:
type: string
pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$'
xml:
name: Category
User:
title: a User
description: A User who is purchasing from the pet store
type: object
properties:
id:
type: integer
format: int64
username:
type: string
firstName:
type: string
lastName:
type: string
email:
type: string
password:
type: string
phone:
type: string
userStatus:
type: integer
format: int32
description: User Status
xml:
name: User
Tag:
title: Pet Tag
description: A tag for a pet
type: object
properties:
id:
type: integer
format: int64
name:
type: string
xml:
name: Tag
Pet:
title: a Pet
description: A pet for sale in the pet store
type: object
required:
- name
- photoUrls
properties:
id:
type: integer
format: int64
category:
$ref: '#/components/schemas/Category'
name:
type: string
example: doggie
photoUrls:
type: array
xml:
name: photoUrl
wrapped: true
items:
type: string
tags:
type: array
xml:
name: tag
wrapped: true
items:
$ref: '#/components/schemas/Tag'
status:
type: string
description: pet status in the store
deprecated: true
enum:
- available
- pending
- sold
xml:
name: Pet
ApiResponse:
title: An uploaded response
description: Describes the result of uploading an image resource
type: object
properties:
code:
type: integer
format: int32
type:
type: string
message:
type: string

View File

@ -0,0 +1,6 @@
ENV=development
POSTGRES_USER=root
POSTGRES_PASSWORD=rootpassword
POSTGRES_DB=dev
POSTGRES_PORT=5432
DATABASE_URL='postgresql://root:rootpassword@localhost:5432/dev?schema=public'

View File

@ -0,0 +1,4 @@
wwwroot/*.js
node_modules
typings
dist

View File

@ -0,0 +1,18 @@
module.exports = {
verbose: true,
testEnvironment: 'node',
moduleFileExtensions: ['ts', 'js'],
testPathIgnorePatterns: ['/dist/'],
transform: {
'^.+\\.[t|j]s?$': ['ts-jest'],
},
transformIgnorePatterns: ['<rootDir>/node_modules/'],
testRegex: '.*\\.test\\.(j|t)s?$',
moduleNameMapper: {
'tests/(.*)$': '<rootDir>/tests/$1',
'configs/(.*)$': '<rootDir>/configs/$1',
'app/(.*)$': '<rootDir>/app/$1',
server: '<rootDir>/app.ts',
// app: '<rootDir>/app.ts',
},
}

View File

@ -0,0 +1,23 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.
# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md

View File

@ -0,0 +1,40 @@
.env.example
.gitignore
.jest.config.js
README.md
api/pet.service.ts
api/store.service.ts
api/user.service.ts
app.ts
app/controllers/__tests__/session.test.js
app/controllers/index.ts
app/controllers/sessions.controller.ts
app/helpers/client.ts
app/jobs/README.md
app/services/sessions.service.ts
apps/ervices/index.ts
build.js
configs/application.ts
configs/bootstrap.ts
configs/constants/development.ts
configs/constants/envs.ts
configs/constants/index.ts
configs/constants/production.ts
configs/constants/staging.ts
configs/interceptors.ts
configs/koa.middlewares.ts
configs/routing.middlewares.ts
configs/routing.options.ts
configs/utils.ts
docker-compose.yml
git_push.sh
model/apiResponse.ts
model/category.ts
model/order.ts
model/pet.ts
model/tag.ts
model/user.ts
nodemon.json
package.json
prisma/schema.prisma
tsconfig.json

View File

@ -0,0 +1 @@
6.3.0-SNAPSHOT

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 - 2021 Witt(@unix)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,107 @@
### koa-ts
The best practice of building Koa2 with TypeScript. [中文](/README_CN.md)
---
#### Usage
1. Run `npm init koa-ts`
2. Install dependencies: `yarn`
3. Rename `.env.example` to `.env`, and run `prisma db push` to synchronize the data model
4. Start the server: `yarn dev`. visit: http://127.0.0.1:3000/apis/sessions
> **(Optional)** the project has built-in a docker compose, run `yarn dev:db` to run database automatic.
---
#### Project Layout
```
├── app
│   ├── controllers --- server controllers
│   ├── helpers --- helper func (interceptor / error handler / validator...)
│   ├── jobs --- task (periodic task / trigger task / email server...)
│   ├── entities --- database entities/models
│   └── services --- adhesive controller and model
├── config
│   ├── constants --- environment variable
│  ├── koa.middlewares --- middlewares for Koa
│  ├── routing.middlewares --- middlewares for Routing Controller
│  ├── routing.options --- configs for Routing Controller
│   ├── bootstrap --- lifecycle
│   └── interceptors --- global interceptor
│   └── utils --- pure functions for help
└── test --- utils for testcase
├── .env --- environment file
```
---
#### Feature
- Separation configuration and business logic.
- Export scheme model and interface, follow style of TypeScript.
- Test cases and lint configuration.
- The best practice for Dependency Injection in Koa project.
- Get constraints on your data model with Prisma.
- TypeScript hotload.
---
#### Lifecycle
1. `app.ts` -> collect env vars `constants` -> collect env files `variables.env`
2. envs ready, call `bootstrap.before()`
3. lift `routing-controllers` -> lift Koa middlewares -> register `Container` for DI
4. start Koa &amp; invoke `bootstrap.after()` after startup
---
#### Databases
The project uses Prisma as the intelligent ORM tool by default. Supports `PostgreSQL`, `MySQL` and `SQLite`.
- You can change the data type and connection method in the `.env` file
- After each modification to file `/prisma/schema.prisma`, you need to run `prisma migrate dev` to migrate the database.
- After each modification to file `/prisma/schema.prisma`, you need to run `prisma generate` to sync types.
---
#### About Environments
When nodejs is running, `ENV` does not mean `NODE_ENV`:
- After NodeJS project is built, we always run it as `NODE_ENV=PRODUCTION`, which may affect some framework optimizations.
- `NODE_ENV` only identifies the NodeJS runtime, independent of the business.
- You should use `ENV` to identify the environment.
For the data settings of each environment, you can refer to the following:
- **Development Mode** (`ENV=development`): read configurations from `configs/constants/development.ts` file, but it will still be overwritten by `.env` file.
- **Production Mode** (`ENV=production`): read configurations from `configs/constants/production.ts` file, but it will still be overwritten by `.env` file.
---
#### Reference
- [routing-controllers](https://github.com/typestack/routing-controllers)
- [Prisma](https://www.prisma.io/docs/concepts)
---
#### LICENSE
This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for more info.

View File

@ -0,0 +1,17 @@
import { Server } from 'http'
import { print } from 'configs/utils'
import CONSTANTS from 'configs/constants'
import createServer from 'configs/application'
import { bootstrapAfter } from 'configs/bootstrap'
module.exports = (async (): Promise<Server> => {
try {
const app = await createServer()
return app.listen(CONSTANTS.PORT, () => {
print.log(`server listening on ${CONSTANTS.PORT}, in ${CONSTANTS.ENV_LABEL} mode.`)
bootstrapAfter()
})
} catch (e) {
console.log(e)
}
})()

View File

@ -0,0 +1,19 @@
import server from 'server'
import request from 'supertest'
describe('routers: session', () => {
let app
beforeAll(async () => {
app = await server
})
it('should be return 200 status code', async () => {
const res = await request(app).get('/apis/sessions')
expect(res.status).toEqual(200)
})
afterAll(async done => {
app.close()
done()
})
})

View File

@ -0,0 +1 @@
export * from './sessions.controller'

View File

@ -0,0 +1,31 @@
import {
BadRequestError,
Post,
JsonController,
BodyParam,
Get,
} from 'routing-controllers'
import { SessionsService } from '../services'
import { Prisma } from '@prisma/client'
import { Service } from 'typedi'
@JsonController()
@Service()
export class SessionsController {
constructor(private sessionsService: SessionsService) {}
@Get('/sessions')
async query() {
return []
}
@Post('/sessions')
async create(
@BodyParam('username') name: string,
): Promise<Prisma.SessionGetPayload<any>> {
if (!name) {
throw new BadRequestError('username is required')
}
return await this.sessionsService.create({ name })
}
}

View File

@ -0,0 +1,23 @@
import { PrismaClient } from '@prisma/client'
declare global {
namespace NodeJS {
interface Global {
prisma: PrismaClient
}
}
}
let prisma: PrismaClient
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient()
} else {
if (!global.prisma) {
global.prisma = new PrismaClient()
}
prisma = global.prisma
}
export default prisma

View File

@ -0,0 +1,3 @@
## Jobs
You can add scheduled tasks, email tasks, or any third-party work here.

View File

@ -0,0 +1 @@
export * from './sessions.service'

View File

@ -0,0 +1,21 @@
import { Service } from 'typedi'
import prisma from 'app/helpers/client'
import { Prisma } from '@prisma/client'
@Service()
export class SessionsService {
/**
* Type 'Prisma.SessionCreateInput' is automatically generated.
* Whenever you modify file 'prisma/schema.prisma' and then run command:
* prisma generate
* prisma migrate dev
* The types is automatically updated.
*
* About CRUD: https://www.prisma.io/docs/concepts/components/prisma-client/crud
*/
async create(session: Prisma.SessionCreateInput) {
return prisma.session.create({
data: session,
})
}
}

View File

@ -0,0 +1,19 @@
const { nodeExternalsPlugin } = require('esbuild-node-externals')
require('esbuild')
.build({
entryPoints: ['app.ts'],
bundle: true,
outfile: 'dist/index.js',
platform: 'node',
plugins: [
nodeExternalsPlugin({
dependencies: false,
}),
],
external: ['cors', 'kcors'],
})
.catch(err => {
console.log(err)
process.exit(1)
})

View File

@ -0,0 +1,20 @@
import 'reflect-metadata'
import Koa from 'koa'
import { Container } from 'typedi'
import { routingConfigs } from './routing.options'
import { useMiddlewares } from './koa.middlewares'
import { useKoaServer, useContainer } from 'routing-controllers'
const createServer = async (): Promise<Koa> => {
const koa: Koa = new Koa()
useMiddlewares(koa)
useContainer(Container)
const app: Koa = useKoaServer<Koa>(koa, routingConfigs)
return app
}
export default createServer

View File

@ -0,0 +1,18 @@
import { join } from 'path'
import { print } from './utils'
import dotenv from 'dotenv'
// "before" will trigger before the app lift.
export const bootstrapBefore = (): object => {
// solve ncc path link.
const result = dotenv.config({ path: join(__dirname, '../.env') })
if (result.error) {
print.danger('Environment variable not loaded: not found ".env" file.')
return {}
}
print.log('.env loaded.')
return result.parsed
}
// "after" will trigger after the "container" mounted..
export const bootstrapAfter = (): any => {}

View File

@ -0,0 +1,9 @@
const development = {
ENV_LABEL: 'DEVELOPMENT',
PORT: 3001,
}
export default development
export type EevRecord = typeof development

View File

@ -0,0 +1,5 @@
export enum ENVS {
DEVELOPMENT = 'DEVELOPMENT',
STAGING = 'STAGING',
PRODUCTION = 'PRODUCTION',
}

View File

@ -0,0 +1,54 @@
import { bootstrapBefore } from '../bootstrap'
import development, { EevRecord } from './development'
import staging from './staging'
import production from './production'
import { ENVS } from './envs'
const parsedEnvs = bootstrapBefore()
const getCurrentEnv = (): ENVS => {
const env = process.env?.ENV
if (typeof env === 'undefined') {
console.warn(`/n> ENV is not set, fallback to ${ENVS.DEVELOPMENT}.`)
}
const upperCaseEnv = `${env}`.toUpperCase()
if (upperCaseEnv === ENVS.PRODUCTION) return ENVS.PRODUCTION
if (upperCaseEnv === ENVS.STAGING) return ENVS.STAGING
return ENVS.DEVELOPMENT
}
const getCurrentConstants = (ident: ENVS): EevRecord => {
let constants = development
const source =
ident === ENVS.PRODUCTION
? production
: ident === ENVS.STAGING
? staging
: development
Object.keys(development).forEach(key => {
const sourceValue = source[key]
const processValue = process.env[key]
const parsedValue = parsedEnvs[key]
if (typeof sourceValue !== 'undefined') {
constants[key] = sourceValue
}
if (typeof processValue !== 'undefined') {
constants[key] = processValue
}
if (typeof parsedValue !== 'undefined') {
constants[key] = parsedValue
}
})
constants.ENV_LABEL = source.ENV_LABEL
return constants
}
export const CURRENT_ENV = getCurrentEnv()
export const isProd = () => CURRENT_ENV === ENVS.PRODUCTION
const CONSTANTS = getCurrentConstants(CURRENT_ENV)
export default CONSTANTS

View File

@ -0,0 +1,7 @@
import { EevRecord } from './development'
const production: Partial<EevRecord> = {
ENV_LABEL: 'PRODUCTION',
}
export default production

View File

@ -0,0 +1,7 @@
import { EevRecord } from './development'
const staging: Partial<EevRecord> = {
ENV_LABEL: 'STAGING',
}
export default staging

View File

@ -0,0 +1,12 @@
import { InterceptorInterface, Action, Interceptor } from 'routing-controllers'
import { Service } from 'typedi'
@Interceptor()
@Service()
export class AutoAssignJSONInterceptor implements InterceptorInterface {
intercept(action: Action, content: any): any {
if (typeof content === 'object')
return JSON.stringify(Object.assign({ message: 'ok' }, content))
return JSON.stringify({ message: content })
}
}

View File

@ -0,0 +1,14 @@
import Koa from 'koa'
import logger from 'koa-logger'
import bodyParser from 'koa-bodyparser'
import { isProd } from './constants'
export const useMiddlewares = <T extends Koa>(app: T): T => {
if (isProd()) {
app.use(logger())
}
app.use(bodyParser())
return app
}

View File

@ -0,0 +1,18 @@
import { KoaMiddlewareInterface, Middleware } from 'routing-controllers'
import { Service } from 'typedi'
@Middleware({ type: 'before' })
@Service()
export class HeaderMiddleware implements KoaMiddlewareInterface {
async use(context: any, next: (err?: any) => any): Promise<any> {
context.set('Access-Control-Allow-Methods', 'GET,HEAD,PUT,POST,DELETE,PATCH')
context.set(
'Access-Control-Allow-Origin',
context.request.header.origin || context.request.origin,
)
context.set('Access-Control-Allow-Headers', ['content-type'])
context.set('Access-Control-Allow-Credentials', 'true')
context.set('Content-Type', 'application/json; charset=utf-8')
return next()
}
}

View File

@ -0,0 +1,21 @@
import { RoutingControllersOptions } from 'routing-controllers'
import * as controllers from 'app/controllers'
import * as middlewares from './routing.middlewares'
import * as interceptors from './interceptors'
import { dictToArray } from './utils'
export const routingConfigs: RoutingControllersOptions = {
controllers: dictToArray(controllers),
middlewares: dictToArray(middlewares),
interceptors: dictToArray(interceptors),
// router prefix
// e.g. api => http://hostname:port/{routePrefix}/{controller.method}
routePrefix: '/apis',
// auto validate entity item
// learn more: https://github.com/typestack/class-validator
validation: true,
}

View File

@ -0,0 +1,8 @@
export const dictToArray = (dict: object): Array<any> =>
Object.keys(dict).map(name => dict[name])
export const print = {
log: (text: string) => console.log('\x1b[37m%s \x1b[2m%s\x1b[0m', '>', text),
danger: (text: string) => console.log('\x1b[31m%s \x1b[31m%s\x1b[0m', '>', text),
tip: (text: string) => console.log('\x1b[36m%s \x1b[36m%s\x1b[0m', '>', text),
}

View File

@ -0,0 +1,18 @@
version: '3.3'
services:
restapi.postgres:
container_name: koa-ts
image: postgres:13
volumes:
- postgres:/var/lib/postgres
restart: always
ports:
- ${POSTGRES_PORT}:${POSTGRES_PORT}
env_file:
- ./.env
labels:
com.startupteam.description: "postgres container for koa-ts"
volumes:
postgres: {}

View File

@ -0,0 +1,22 @@
{
"verbose": false,
"debug": false,
"exec": "ts-node -r tsconfig-paths/register ./app.ts",
"ignore": [
"mochawesome-report",
"node_modules",
"./test",
"**/*.d.ts",
"*.test.ts",
"*.spec.ts",
"fixtures/*",
"test/**/*",
"docs/*"
],
"events": {
"restart": ""
},
"watch": ["./app", "./configs", "./app.ts"],
"ext": "ts",
"inspect": true
}

View File

@ -0,0 +1,50 @@
{
"name": "Petstore",
"version": "0.0.1",
"license": "MIT",
"main": "app.ts",
"scripts": {
"dev": "export NODE_ENV=development; ts-node-dev -r tsconfig-paths/register app.ts",
"dev:db": "docker compose -f docker-compose.yml up -d",
"prettier": "prettier --write '**/*.{js,ts}'",
"test": "jest --config .jest.config.js --no-cache --detectOpenHandles",
"prod:build": "node ./build.js",
"prod:start": "prisma generate && prisma migrate deploy && export NODE_ENV=production; node ./dist/index.js"
},
"repository": "git@github.com:unix/koa-ts.git",
"engines": {
"node": ">= 14.x"
},
"prettier": "@geist-ui/prettier-config",
"devDependencies": {
"@geist-ui/prettier-config": "^1.0.1",
"@types/jest": "^25.2.2",
"@types/koa": "^2.13.4",
"@types/koa-bodyparser": "^4.3.5",
"@types/node": "^17.0.8",
"esbuild": "^0.14.11",
"esbuild-node-externals": "^1.4.1",
"jest": "^26.6.3",
"prettier": "^2.5.1",
"prisma": "^4.6.1",
"supertest": "^4.0.2",
"ts-jest": "^26.5.3",
"ts-node-dev": "^2.0.0",
"tsconfig-paths": "^3.12.0",
"typescript": "^4.5.4"
},
"dependencies": {
"@prisma/client": "^4.6.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"dotenv": "^12.0.3",
"koa": "^2.13.4",
"koa-bodyparser": "^4.3.0",
"koa-logger": "^3.2.1",
"koa-multer": "^1.0.2",
"koa-router": "^10.1.1",
"reflect-metadata": "^0.1.13",
"routing-controllers": "^0.9.0",
"typedi": "^0.10.0"
}
}

View File

@ -0,0 +1,14 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Session {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
}

View File

@ -0,0 +1,3 @@
## Test Utils
This folder is used to store test utils, or any mock function.

View File

@ -0,0 +1,39 @@
{
"compilerOptions": {
"allowJs": true,
"moduleResolution": "Node",
"module": "commonjs",
"outDir": "./dist",
"lib": ["ESNext"],
"removeComments": true,
"skipLibCheck": true,
"noImplicitAny": false,
"esModuleInterop": true,
"preserveConstEnums": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"typeRoots": [
"node_modules/@types",
"typings"
],
"baseUrl": ".",
"paths": {
"configs/*": ["./configs/*"],
"app": ["app"]
},
},
"compileOnSave": false,
"include": [
"app/**/*",
"configs/**/*",
"**/*.ts",
"**/*.tsx"
],
"files": [
"app.ts",
],
"exclude": [
"node_modules",
"dist"
]
}

File diff suppressed because it is too large Load Diff