Skip to content

Commit

Permalink
[Issue #23] Update opportunity routes (#53)
Browse files Browse the repository at this point in the history
* feat: Adds set of response wrapper models
* refactor: Updates the Routes.Opportunities interface
* feat: Adds an API service
* refactor: Adds template to Opportunity model
* docs: Updates README
* refactor: Removes OpportunityExamples.complete
Having the complete example introduces a TypeSpec compilation error
when users define a custom field on the templated Opportunity
* build: Bump prerelease version
  • Loading branch information
widal001 authored Jan 27, 2025
1 parent d687938 commit 32cb954
Show file tree
Hide file tree
Showing 10 changed files with 202 additions and 43 deletions.
49 changes: 22 additions & 27 deletions specs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ A basic project structure that uses the library might look like this:
└── tspconfig.yaml # Manages TypeSpec configuration, including emitters
```

### Define a custom field
### Define custom fields

Define custom fields on an existing model by extending the `CustomField` model from the `@common-grants/core` library.
The Opportunity model is templated to support custom fields. First define your custom fields by extending the `CustomField` model:

```typespec
// models.tsp
Expand All @@ -41,32 +41,28 @@ using CommonGrants.Models;
namespace CustomAPI.CustomModels;
// Define a custom field
model Agency extends CustomField {
name: "Agency";
type: CustomFieldType.string;
@example("Department of Transportation")
value: string;
description: "The unique identifier for a given opportunity within this API";
description: "The agency responsible for this opportunity";
}
```

Then extend the base `Opportunity` model to include these custom fields in the `customFields` property:

```typespec
// Include code from the previous code block
model CustomOpportunity extends Opportunity {
customFields: {
agency: Agency;
};
// Create a map of custom fields
model CustomFields extends CustomFieldMap {
agency: Agency;
}
// Create a custom Opportunity type using the template
alias CustomOpportunity = Opportunity<CustomFields>;
```

### Override a default route
### Override default routes

Use this `CustomOpportunity` model to override the the default routes from the `@common-grants/core` library.
The router interfaces are templated to support your custom models. Override them like this:

```typespec
// routes.tsp
Expand All @@ -75,15 +71,16 @@ import "@common-grants/core";
import "./models.tsp"; // Import the custom field and model from above
using CommonGrants.Routes;
using Http;
using TypeSpec.Http;
namespace CustomAPI.CustomRoutes;
@tag("Search")
@route("/opportunities")
namespace CustomAPI.CustomRoutes {
alias OpportunitiesRouter = Opportunities;
// Reuse the existing the existing routes from the core library
interface CustomOpportunities extends Opportunities {
// Override the existing @read() route with the custom model
@summary("View an opportunity")
@get read(@path id: string): CustomModels.CustomOpportunity;
// Use the default model for list but custom model for read
op list is OpportunitiesRouter.list;
op read is OpportunitiesRouter.read<CustomModels.CustomOpportunity>;
}
```

Expand All @@ -108,15 +105,13 @@ namespace CustomAPI;

### Generate the OpenAPI spec

Finally, generate an OpenAPI specification from your `main.tsp` file.

You can either specify the OpenAPI emitter directly via the CLI:
Generate an OpenAPI specification from your `main.tsp` file using either the CLI:

```bash
npx tsp compile main.tsp --emit "@typespec/openapi3"
```

Or you can specify the emitter in the `tspconfig.yaml` file and run `tsp compile main.tsp`:
Or specify the emitter in `tspconfig.yaml`:

```yaml
# tspconfig.yaml
Expand Down
16 changes: 15 additions & 1 deletion specs/lib/main.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,19 @@ import "../dist/src/index.js";
import "./models/index.tsp";
import "./routes/index.tsp";

using TypeSpec.Http;

// Define the top-level namespace for the library
namespace CommonGrants;
@service({
name: "Base API",
})
namespace CommonGrants.API;

@tag("Search")
@route("/opportunities")
namespace Opportunities {
alias Router = Routes.Opportunities;

op List is Router.list;
op Read is Router.read;
}
3 changes: 3 additions & 0 deletions specs/lib/models/custom-field.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ enum CustomFieldType {
array,
}

/** An object that maps field names to CustomField instances */
model CustomFieldMap is Record<CustomField>;

@example(
CustomFieldExamples.programArea,
#{ title: "String field for program area" }
Expand Down
29 changes: 23 additions & 6 deletions specs/lib/models/opportunity.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,32 @@ namespace CommonGrants.Models;
// Model definition
// ########################################

@example(
OpportunityExamples.complete,
#{ title: "Complete opportunity with all fields" }
)
/** The base model for a funding opportunity.
*
* It supports customization by templating the customFields property.
*
* @template Fields A CustomFieldMap object with user-defined custom fields,
* that should be included in a given implementation of the Opportunity model.
* @example Declare a new Opportunity model with custom fields
*
* ```typespec
* model Agency extends CustomField {
* type: CustomFieldType.string;
* value: string;
* }
*
* model NewFields extends CustomFieldMap {
* agency: Agency;
* }
*
* alias CustomOpportunity = Opportunity<NewFields>;
* ```
*/
@example(
OpportunityExamples.minimal,
#{ title: "Minimal opportunity with required fields only" }
)
model Opportunity {
model Opportunity<Fields = CustomFieldMap> {
/** Globally unique id for the opportunity */
id: uuid;

Expand All @@ -32,7 +49,7 @@ model Opportunity {
applicationTimeline?: Event[];

/** Additional custom fields specific to this opportunity */
customFields?: Record<CustomField>;
customFields?: Fields;
}

// ########################################
Expand Down
17 changes: 17 additions & 0 deletions specs/lib/responses/errors.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Responses.Error;

@error
model Error {
@example(400)
status: int32;

/** Human-readable error message */
@example("Error")
message: string;

/** List of errors */
errors: Array<unknown>;
}

alias Unauthorized = Error & Http.UnauthorizedResponse;
alias NotFound = Error & Http.NotFoundResponse;
6 changes: 6 additions & 0 deletions specs/lib/responses/index.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import "@typespec/http";

import "./errors.tsp";
import "./success.tsp";

namespace Responses;
55 changes: 55 additions & 0 deletions specs/lib/responses/success.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
namespace Responses.Success;

model Success {
@example(200)
status: int32;

@example("Success")
message: string;
}

/** Template for normal response data */
model Ok<T> extends Success {
// Inherit the 200 status code
...Http.OkResponse;

/** Response data */
data: T;
}

/** Template for paginated responses */
model Paginated<T> extends Success {
// Inherit the 200 status code
...Http.OkResponse;

/** Items from the current page */
@pageItems
items: T[];

/** Details about the paginated results */
paginationInfo: {
/** Current page number (indexing starts at 1) */
@example(1)
page: int32;

/** Number of items per page */
@example(20)
pageSize: integer;

/** Total number of items across all pages */
@example(100)
totalItems: integer;

/** Total number of pages */
@example(5)
totalPages: integer;

/** URL for the next page if available */
@example("/opportunities?page=2&pageSize=20")
nextPageUrl?: string;

/** URL for the previous page if available */
@example("/opportunities?page=1&pageSize=20")
previousPageUrl?: string;
};
}
64 changes: 58 additions & 6 deletions specs/lib/routes/opportunities.tsp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "../models/index.tsp";
import "../responses/index.tsp";

// Define the top-level namespace for CommonGrants routes
namespace CommonGrants.Routes;
Expand All @@ -7,17 +8,68 @@ namespace CommonGrants.Routes;
// these include the decorators @route, @get, etc.
using TypeSpec.Http;
using TypeSpec.Rest;
using Responses;

// Define the `/opportunities` router
@route("/opportunities")
model PaginatedQuery {
/** The page to return */
@query
@pageIndex
page?: int32 = 1;

/** The number of items to return per page */
@query
@pageSize
perPage?: int32 = 100;
}

/** A re-usable interface for an Opportunities router
*
* To implement this interface, we recommend declaring a namespace,
* instantiating the router using `alias` (instead of `extends`),
* and decorating the namespace with `@route` and `@tag` since they aren't
* inherited directly from the interface.
*
* For more information, see
* [TypeSpec docs](https://typespec.io/docs/language-basics/interfaces/#templating-interface-operations)
*
* @example Using the the default type for the list operation and
* a custom model for the read operation:
* ```typespec
* using TypeSpec.Http;
*
* @tag("Search")
* @route("/opportunities/")
* namespace Opportunities {
* alias Router = Routes.Opportunities
*
* op list is Router.list;
* op read is Router.read<CustomOpportunity>;
*
* }
* ```
*/
interface Opportunities {
/** `GET /opportunities/` Get a paginated list of opportunities
*
* @template T Type of the paginated response model.
* Must be an extension of Models.Opportunity. Default is Models.Opportunity.
*/
@summary("List opportunities")
@doc("Get a list of opportunities")
@get
list(): Models.Opportunity[];
@doc("Get a paginated list of opportunities")
@list
list<T extends Models.Opportunity = Models.Opportunity>(
...PaginatedQuery,
): Success.Paginated<T> | Error.Unauthorized;

/** `GET /opportunities/<id>` View opportunity details
*
* @template T Type of the response model.
* Must be an extension of Models.Opportunity. Default is Models.Opportunity.
*/
@summary("View opportunity")
@doc("View additional details about an opportunity")
@get
read(@path title: string): Models.Opportunity;
read<T extends Models.Opportunity = Models.Opportunity>(
@path id: Models.uuid,
): Success.Ok<T> | Error.NotFound | Error.Unauthorized;
}
4 changes: 2 additions & 2 deletions specs/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion specs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@common-grants/core",
"version": "0.1.0-alpha.5",
"version": "0.1.0-alpha.6",
"description": "TypeSpec library for defining grant opportunity data models and APIs",
"type": "module",
"main": "dist/src/index.js",
Expand Down

0 comments on commit 32cb954

Please sign in to comment.