Skip to content

Commit

Permalink
feat(type-safe-api): separate model projects per model language (#872)
Browse files Browse the repository at this point in the history
Rather than sharing a single `TypeSafeApiModelProject` for both Smithy and OpenAPI, we separate them
into their own projects. This improves the ability to add future additional model languages since
they can inherit from other projen project types (not just `Project`). This also makes instantiating
standalone Smithy libraries more intuitive as they are created with `SmithyModelProject`, rather
than `TypeSafeApiModelProject` and modelLanguage set to SMITHY, therefore no `!` needs to be used to
access the possibly undefined `smithy` property.

BREAKING CHANGE: Removed `TypeSafeApiModelProject`. Please instantiate Smithy shape libraries with
`SmithyModelProject`.

re #825
  • Loading branch information
cogwirrel authored Oct 25, 2024
1 parent 2f33f29 commit 8d9e216
Show file tree
Hide file tree
Showing 29 changed files with 882 additions and 587 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ export class CloudscapeReactTsWebsiteProject extends ReactTypeScriptProject {
`mkdir -p ${targetApiSpecFolder} && cp ${path.relative(
this.outdir,
tsApi.model.outdir
)}/.api.json ${targetApiSpecPath}`
)}/${tsApi.model.parsedSpecFile} ${targetApiSpecPath}`
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,92 +112,83 @@ If you would like to introduce tags without breaking existing clients, we recomm

### I have multiple Smithy-based APIs, can they share common structures?

Yes. You can create a `TypeSafeApiModelProject` on its own to create a standalone Smithy model library, which can contain the shared structures.
Yes. You can create a `SmithyModelProject` on its own to create a standalone Smithy model library, which can contain the shared structures.

You can consume the library using the `addSmithyDeps` method, which adds a local file dependency to the built Smithy jar.

=== "TS"

```ts
// Standalone model project, used as our model library
const shapes = new TypeSafeApiModelProject({
const shapes = new SmithyModelProject({
name: "shapes",
parent: monorepo,
outdir: "packages/shapes",
modelLanguage: ModelLanguage.SMITHY,
modelOptions: {
smithy: {
serviceName: {
namespace: "com.my.shared.shapes",
serviceName: "Ignored",
},
smithyOptions: {
serviceName: {
namespace: "com.my.shared.shapes",
serviceName: "Ignored",
},
},
});

const api = new TypeSafeApiProject({ ... });

// Add the implicit monorepo dependency (if using the monorepo) to ensure the shape library is built before the api model
monorepo.addImplicitDependency(api.model, shapes);
monorepo.addImplicitDependency(api.model.smithy!, shapes);

// Add a local file dependency on the built shapes jar
api.model.smithy!.addSmithyDeps(shapes.smithy!);
api.model.smithy!.definition.addSmithyDeps(shapes.definition);
```

=== "JAVA"

```java
// Standalone model project, used as our model library
TypeSafeApiModelProject shapes = TypeSafeApiModelProject.Builder.create()
SmithyModelProject shapes = SmithyModelProject.Builder.create()
.name("shapes")
.parent(monorepo)
.outdir("packages/shapes")
.modelLanguage(ModelLanguage.getSMITHY())
.modelOptions(ModelOptions.builder()
.smithy(SmithyModelOptions.builder()
.serviceName(SmithyServiceName.builder()
.namespace("com.my.shared.shapes")
.serviceName("Ignored")
.build())
.build())
.build())
.smithyOptions(SmithyModelOptions.builder()
.serviceName(SmithyServiceName.builder()
.namespace("com.my.shared.shapes")
.serviceName("Ignored")
.build())
.build())
.build();

TypeSafeApiProject api = new TypeSafeApiProject(TypeSafeApiProjectOptions.builder()....build();

// Add the implicit monorepo dependency (if using the monorepo) to ensure the shape library is built before the api model
monorepo.addImplicitDependency(api.getModel(), shapes);
monorepo.addImplicitDependency(api.getModel().getSmithy(), shapes.getDefinition());

// Add a local file dependency on the built shapes jar
api.model.smithy.addSmithyDeps(shapes.getSmithy());
api.getModel().getSmithy().getDefinition().addSmithyDeps(shapes.getSmithy());
```

=== "PYTHON"

```python
# Standalone model project, used as our model library
shapes = TypeSafeApiModelProject(
shapes = SmithyModelProject(
name="shapes",
parent=monorepo,
outdir="packages/shapes",
model_language=ModelLanguage.SMITHY,
model_options=ModelOptions(
smithy=SmithyModelOptions(
service_name=SmithyServiceName(
namespace="com.my.shared.shapes",
service_name="Ignored"
)
smithy_options=SmithyModelOptions(
service_name=SmithyServiceName(
namespace="com.my.shared.shapes",
service_name="Ignored"
)
)
)

api = TypeSafeApiProject(...)

# Add the implicit monorepo dependency (if using the monorepo) to ensure the shape library is built before the api model
monorepo.add_implicit_dependency(api.model, shapes)
monorepo.add_implicit_dependency(api.model.smithy, shapes)

# Add a local file dependency on the built shapes jar
api.model.smithy.add_smithy_deps(shapes.smithy)
api.model.smithy.definition.add_smithy_deps(shapes.definition)
```

### How do I debug my API locally?
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*! Copyright [Amazon.com](http://amazon.com/), Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0 */
import { ProjectUtils } from "@aws/monorepo";
import { Component, Project, Task } from "projen";

/**
* Component which manages a "generate" task for a project
*/
export class GenerateTask extends Component {
/**
* Retrieves an instance of GenerateTask if one is associated to the given project.
*
* @param project project instance.
*/
static of(project: Project): Task | undefined {
return (
project.components.find((c) =>
ProjectUtils.isNamedInstanceOf(c, GenerateTask)
) as GenerateTask | undefined
)?.task;
}

/**
* Retrieves an instance of GenerateTask if one is associated to the given project,
* otherwise creates a GenerateTask instance for the project.
*
* @param project project instance.
*/
static ensure(project: Project): Task {
return GenerateTask.of(project) || new GenerateTask(project).task;
}

public readonly task: Task;

constructor(project: Project) {
super(project);

this.task =
project.tasks.tryFind("generate") ?? project.addTask("generate");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
SPDX-License-Identifier: Apache-2.0 */
import { ProjectUtils } from "@aws/monorepo";
import { Component, Project } from "projen";
import { TypeScriptProject } from "projen/lib/typescript";
import { NodeProject } from "projen/lib/javascript";
import { getTypeSafeApiTaskEnvironment } from "./utils";

/**
Expand Down Expand Up @@ -41,8 +41,8 @@ export class TypeSafeApiCommandEnvironment extends Component {
project.tasks.addEnvironment(key, value)
);

// TypeScript projects need a dev dependency on PDK to ensure npx resolves to the correct pdk
if (ProjectUtils.isNamedInstanceOf(project, TypeScriptProject)) {
// Node projects need a dev dependency on PDK to ensure npx resolves to the correct pdk
if (ProjectUtils.isNamedInstanceOf(project, NodeProject)) {
project.addDevDeps("@aws/pdk@^0");
}
}
Expand Down
126 changes: 122 additions & 4 deletions packages/type-safe-api/src/project/codegen/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,6 @@ import {
WebSocketDocumentationFormat,
WebSocketLibrary,
} from "../languages";
import {
GeneratedDocumentationOptions,
GeneratedWebSocketDocumentationOptions,
} from "../types";
import { GeneratedAsyncApiHtmlDocumentationProject } from "./documentation/generated-asyncapi-html-documentation-project";
import { GeneratedAsyncApiMarkdownDocumentationProject } from "./documentation/generated-asyncapi-markdown-documentation-project";
import { GeneratedHtmlRedocDocumentationProject } from "./documentation/generated-html-redoc-documentation-project";
Expand Down Expand Up @@ -64,6 +60,21 @@ import {
GeneratedTypescriptRuntimeProject,
GeneratedTypescriptTypesProjectOptions,
} from "./runtime/generated-typescript-runtime-project";
import { OpenApiAsyncModelProject } from "../model/openapi/open-api-async-model-project";
import { OpenApiModelProject } from "../model/openapi/open-api-model-project";
import { SmithyAsyncModelProject } from "../model/smithy/smithy-async-model-project";
import { SmithyModelProject } from "../model/smithy/smithy-model-project";
import { TypeSafeApiAsyncModelBuildOutputOptions } from "../model/type-safe-api-async-model-build";
import { TypeSafeApiModelBuildOutputOptions } from "../model/type-safe-api-model-build";
import { TypeSafeApiModelProjectOptions } from "../model/type-safe-api-model-project";
import { TypeSafeWebSocketApiModelProjectOptions } from "../model/type-safe-websocket-api-model-project";
import {
GeneratedDocumentationOptions,
GeneratedWebSocketDocumentationOptions,
ModelLanguage,
ModelProject,
WebSocketModelProject,
} from "../types";

const logger = getLogger();

Expand Down Expand Up @@ -648,6 +659,113 @@ export const generateLibraryProjects = (
return generatedLibraries;
};

export interface CommonModelProjectOptions {
readonly name: string;
readonly parent?: Project;
readonly outdir: string;
}

export interface GenerateModelProjectOptions
extends CommonModelProjectOptions,
TypeSafeApiModelProjectOptions,
TypeSafeApiModelBuildOutputOptions {}

export const generateModelProject = ({
modelLanguage,
modelOptions,
...options
}: GenerateModelProjectOptions): ModelProject => {
if (modelLanguage === ModelLanguage.SMITHY) {
if (!modelOptions.smithy) {
throw new Error(
`modelOptions.smithy is required when selected model language is ${ModelLanguage.SMITHY}`
);
}

const smithy = new SmithyModelProject({
...options,
smithyOptions: modelOptions.smithy,
});
return {
smithy,
parsedSpecFile: options.parsedSpecFile,
apiName: smithy.apiName,
outdir: smithy.outdir,
};
} else if (modelLanguage === ModelLanguage.OPENAPI) {
if (!modelOptions.openapi) {
throw new Error(
`modelOptions.openapi is required when selected model language is ${ModelLanguage.OPENAPI}`
);
}

const openapi = new OpenApiModelProject({
...options,
openApiOptions: modelOptions.openapi,
});
return {
openapi,
parsedSpecFile: options.parsedSpecFile,
apiName: openapi.apiName,
outdir: openapi.outdir,
};
} else {
throw new Error(`Unknown model language ${modelLanguage}`);
}
};

export interface GenerateAsyncModelProjectOptions
extends CommonModelProjectOptions,
TypeSafeWebSocketApiModelProjectOptions,
TypeSafeApiAsyncModelBuildOutputOptions,
TypeSafeApiModelBuildOutputOptions {}

export const generateAsyncModelProject = ({
modelLanguage,
modelOptions,
...options
}: GenerateAsyncModelProjectOptions): WebSocketModelProject => {
if (modelLanguage === ModelLanguage.SMITHY) {
if (!modelOptions.smithy) {
throw new Error(
`modelOptions.smithy is required when selected model language is ${ModelLanguage.SMITHY}`
);
}

const smithy = new SmithyAsyncModelProject({
...options,
smithyOptions: modelOptions.smithy,
});
return {
smithy,
parsedSpecFile: options.parsedSpecFile,
asyncApiSpecFile: options.asyncApiSpecFile,
apiName: smithy.apiName,
outdir: smithy.outdir,
};
} else if (modelLanguage === ModelLanguage.OPENAPI) {
if (!modelOptions.openapi) {
throw new Error(
`modelOptions.openapi is required when selected model language is ${ModelLanguage.OPENAPI}`
);
}

const openapi = new OpenApiAsyncModelProject({
...options,
openApiOptions: modelOptions.openapi,
});
return {
openapi,
parsedSpecFile: options.parsedSpecFile,
asyncApiSpecFile: options.asyncApiSpecFile,
apiName: openapi.apiName,
outdir: openapi.outdir,
};
} else {
throw new Error(`Unknown model language ${modelLanguage}`);
}
};

/**
* Returns a generated client project for the given language
*/
Expand Down
7 changes: 6 additions & 1 deletion packages/type-safe-api/src/project/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/*! Copyright [Amazon.com](http://amazon.com/), Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0 */
export * from "./model/type-safe-api-model-project-base";
export * from "./model/type-safe-api-model-build";
export * from "./model/type-safe-api-async-model-build";
export * from "./model/smithy/smithy-project-definition";
export * from "./model/openapi/open-api-project-definition";
export * from "./type-safe-api-project";
Expand All @@ -12,6 +13,10 @@ export * from "./model/type-safe-websocket-api-model-project";
export * from "./model/smithy/smithy-async-definition";
export * from "./model/openapi/open-api-async-definition";
export * from "./model/smithy/types";
export * from "./model/openapi/open-api-model-project";
export * from "./model/openapi/open-api-async-model-project";
export * from "./model/smithy/smithy-async-model-project";
export * from "./model/smithy/smithy-model-project";
export * from "./languages";
export * from "./types";
export * from "./typescript-project-options";
Expand Down
40 changes: 40 additions & 0 deletions packages/type-safe-api/src/project/model/model-readme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*! Copyright [Amazon.com](http://amazon.com/), Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0 */
import * as path from "path";
import { Project, SampleFile } from "projen";
import { ModelLanguage } from "../types";

/**
* Options for the model readme
*/
export interface ModelReadmeOptions {
/**
* Language for the model
*/
readonly modelLanguage: ModelLanguage;
/**
* Type of API
*/
readonly apiType: "rest" | "async";
}

/**
* README.md file for a model project
*/
export class ModelReadme extends SampleFile {
constructor(project: Project, options: ModelReadmeOptions) {
super(project, "README.md", {
sourcePath: path.resolve(
__dirname,
"..",
"..",
"..",
"samples",
"type-safe-api",
"readme",
`model-${options.apiType}`,
`${options.modelLanguage}.md`
),
});
}
}
Loading

0 comments on commit 8d9e216

Please sign in to comment.