AIP with Sisyphus
To help us follow the Google API Improvement Proposals, Sisyphus implements many standards that are not provided for implementation in the AIP.
AIP-122 Resource Name
A standard resource name format is provided in the AIP-122 specification and is a unique identifier for representing resources in the system.
Sisyphus generates a special type for all resource name definitions, which is used to easily extract individual parts of the resource name in the code.
message Book {
option (google.api.resource) = {
type: "library.googleapis.com/Book"
pattern: "publishers/{publisher}/books/{book}"
};
// Book's resource name
// formatted as: publishers/{publisher}/books/{book}
string name = 1;
// Other fields
}
The above code will additionally generate a Name
interface type to represent the resource name of any pattern and
generate a concrete implementation type for each pattern that implements the Name
interface.
interface Book : Message<Book, MutableBook> {
// Book's resource name interface type
public interface Name : ResourceName {
// The implementation type generated by the specific pattern in the resource name.
// When the resource name has more than one pattern, multiple implementation types may be generated
public class Base(`data`: Map<String, String>) : AbstractResourceName(data), Name {
}
}
// The string type of all referenced resource names will also be automatically changed to the corresponding interface type
val name: Name
}
In addition, if there are fields of type string
referenced to the resource name, they will also be automatically
changed to the specified interface type, e.g.
message ListBooksRequest {
// publishers of the book
// Format: publishers/{publisher_id}
string parent = 1 [(google.api.resource_reference) = {
type: "library.googleapis.com/Publisher"
}];
// Other fields (e.g. page_size, page_token, filter, etc.)...
}
will change the type of the parent field to Publisher.Name
.
interface ListBooksRequest : Message<ListBooksRequest, MutableListBooksRequest> {
// The string type of the referenced resource name will also be automatically changed to the corresponding interface type
val parent: Publisher.Name
}
You can create a Name
object in Kotlin using Name.of
, Name.invoke
or Name.tryCreate
.
val name = Book.Name.of("publisher_id", "book_id") // just fill in the variable part
val name =
Book.Name("publishers/123/books/456") // resolve a resource name in its entirety, and automatically create an UnknownResourceName when an unsupported pattern is encountered
val name =
Book.Name.tryCreate("publishers/123/books/456") // Try to create a resource name, and return null when an unsupported pattern is encountered
When you need to access a specific part of a resource name, you can directly use the corresponding variable name to get the specified part, e.g.
val publisherId = name.publisherId
val bookId = name.bookId
AIP-127 HTTP and gRPC Transcoding
In the previous section we mentioned how to use transcoding in Sisyphus to access the gRPC API via HTTP/Json.
In most cases, the semantics of the HTTP RequestHeader and the gRPC Metadata are not identical, so when accessing the gRPC API using HTTP, we also need to configure the transformation of the HTTP Header into the gRPC Metadata when accessing the gRPC API using HTTP.
Use the TranscodingHeaderExporter
to customize this process.
@Component
class MyTranscodingHeaderExporter : TranscodingHeaderExporter {
private val keys = mutableMapOf<String, Metadata.Key<String>>()
private val allowedHeader = setOf(
HttpHeaders.AUTHORIZATION.lowercase(),
HttpHeaders.COOKIE.lowercase(),
HttpHeaders.FROM.lowercase(),
HttpHeaders.HOST.lowercase(),
HttpHeaders.REFERER.lowercase(),
HttpHeaders.ACCEPT_LANGUAGE.lowercase(),
HttpHeaders.PRAGMA.lowercase(),
)
private fun key(header: String): Metadata.Key<String> {
return keys.getOrPut(header) {
Metadata.Key.of(header, Metadata.ASCII_STRING_MARSHALLER)
}
}
override fun export(request: ServerRequest, metadata: Metadata) {
for ((key, values) in request.headers().asHttpHeaders()) {
val normalized = key.lowercase()
if (normalized in allowedHeader) {
metadata.put(key(normalized), values.joinToString(","))
}
}
}
}
In addition, due to the inherent limitations of the gRPC client, gRPC requests cannot be customized with User-Agent
,
we can also use TranscodingHeaderExporter
to convert the HTTP User-Agent to another gRPC Metadata to the gRPC service.
override fun export(request: ServerRequest, metadata: Metadata) {
for ((key, values) in request.headers().asHttpHeaders()) {
if (key.equals(HttpHeaders.USER_AGENT, ignoreCase = true)) {
// Convert User-Agent to X-User-Agent
metadata.put(key("x-user-agent"), values.joinToString(","))
continue
}
val normalized = key.lowercase()
if (normalized in allowedHeader) {
metadata.put(key(normalized), values.joinToString(","))
}
}
}
The HTTP/Json interface is typically used in browsers, for which we also need to support preflight requests. Sisyphus will automatically support preflight requests based on the configuration of the supported gRPC methods.
However, when we want to return the custom Metadata of the gRPC response to the client, due to browser limitations, we need to enumerate the possible response headers in the preflight request in order to be read by the front-end.
The TranscodingCorsConfigurationInterceptor
interface can be used to customize all preflight responses.
@Component
class MyTranscodingCorsConfigurationInterceptor : TranscodingCorsConfigurationInterceptor {
override fun intercept(
config: CorsConfiguration,
method: ServerMethodDefinition<*, *>,
pattern: HttpRule.Pattern<*>,
path: String
): CorsConfiguration {
config.addExposedHeader("request-id")
return config
}
}
AIP-132 Standard methods: List
List interfaces are common in API design, and Sisyphus provides a number of tools to make it easy for developers to quickly create list API.
Sorting
In the List interface we may need to support the order_by
field to specify the sorting rules for the list,
the sisyphus-dsl component provides OrderDsl to
parse the Ordering syntax in the AIP-132.
val start = OrderDsl.parse("foo desc, bar")
In addition, if JDBC is used as the backend database,
the sisyphus-jdbc middleware can translate
the ordering syntax into order by
part of SQL.
val orderByBuilder = orderByBuilder {
field("foo", Tables.BAR.FOO)
}
jooq.selectFrom(Tables.BAR).orderBy(orderByBuilder.build(orderBy))
Use the orderByBuilder
DSL to quickly embed ordering syntax into a JOOQ query.
Filtering
Sisyphus also implements a corresponding tool for filters DSL, but since the implementation of filters is more complex, we will cover the details in AIP-160 below.
Pagination
Sisyphus recommends customizing a Protobuf Message to store the paging context and encode its binary serialization as
UrlSafeBase64 as a page_token
.
Here are two very common paging contexts for reference.
message IndexAnchorPaging {
int64 index = 1;
}
message OffsetPaging {
int32 offset = 1;
}
IndexAnchorPaging
is anchor-based paging, can be used for high performance paging, but can not support page skipping,
usually used on the client side, its meaning is the index of the last element of the previous page, when getting the
next page just need to provide WHERE id > paging.token
to get the next page.
OffsetPaging
is an offset-based paging, which is worse in large amount of data, but more flexible and can support page
skipping, usually used in the management backend, and the SQL statement translated as LIMIT offset, limit
is needed to
get the next page.
AIP-160 Filtering
Filtering is an important part of the List interface and Sisyphus has gone to great lengths to simplify the work of developers in this section, providing a very simple DSL to help developers translate the Filtering syntax structure into traditional SQL.
Filter in SQL
This is the most efficient way to implement filters, leaving the filter to the database to be processed, ensuring that a
specified amount of data is returned, as shown
in [sisyphus-jdbc](https://github.com/ButterCam/sisyphus/tree/master/middleware/sisyphus- jdbc) The sqlBuilder
DSL is
used in the middleware to create the TableSqlBuilder
.
val sqlBuilder = sqlBuilder(Tables.FOO) {
field("name", Tables.FOO.ID) {
Foo.Name.tryCreate(it as String)?.note?.toLong()
}
field("bar", Tables.FOO.BAR)
field("title", Tables.BAZ.TITLE)
library(object : FilterStandardLibrary() {
fun withBaz(): Join {
return Join {
it.leftJoin(Tables.BAZ).on(Tables.BAZ.FOO_ID.eq(Tables.FOO.ID))
}
}
})
}
Specify the field names that can be used in the filtering statement in the sqlBuilder
DSL and map them to fields in
the database, as well as specify a custom transformation function.
Use this sqlBuilder to build SQL with JOOQ.
sqlBuilder.select(jooq, filter)
.orderBy(orderByBuilder.build(orderBy))
And the client just needs to fill in the filter
field of the List interface with a filtering statement
like name='foos/1' OR baz=2
, which automatically translates to
SELECT *
FROM foo
WHERE (id = 1 OR baz = 2)
You can also extend FilterStandardLibrary
to provide functions that can be used in filtering statements, and to
enable join queries if the function returns a Join
object.
When the client fills in the filter field with title='FooBar' withBaz()
, Sisyphus applies the Join value of WithBaz
to the JOOQ query.
SELECT *
FROM foo
LEFT JOIN baz ON baz.foo_id = foo.id
WHERE (title = 'FooBar')
When a function returns Condition
, the function can be written in a condition, allowing for complex queries that are
not possible with filter statements, or limited queries that are wrapped for security.
library(object : FilterStandardLibrary() {
fun safeQuery(): Condition {
return Tables.FOO.PASSWORD.eq("123456")
}
})
When the client fills in the filter field with bar='foobar'' AND safeQuery()
, the filter will be converted to the
following SQL statement.
SELECT *
FROM foo
WHERE (baz = 2 AND password = '123456')
Filter in Code
Another type of filtering is code filtering, which means that the data found in the database is logically filtered by local code. The filters in this way will be more powerful, but there may also case empty pages.
Sisyphus uses CEL to describe this filter.
The full name of CEL is Common Expression Language, a non-Turing-complete expression language proposed by Google that can be deeply integrated with Protobuf.
CelEngine is used in sisyphus-dsl to execute CEL expressions.
val celEngine = CelEngine()
val filteredItems = foos.filter {
celEngine.eval(
"foo.bar == 'foobar' && foo.title == 'FooBar'",
mapOf("foo" to it)
)
}
In addition to being used as a filter, CEL can be used in a variety of scenarios that require dynamic code execution, such as permission checking, dynamic content, etc.
AIP-161 Field masks
When implementing the Update interface, we often require partial update functionality, where the client can choose to update some fields instead of the entire object. Usually, this can be achieved by using a null value that is not updated, but this makes it impossible to distinguish whether the user wants to delete the field or does not want to update the field.
In this case, FieldMask
can be used to solve this problem by passing an additional update_mask
field to guide the
update operation of the service field.
Foo.resolveMask(updateMask).paths.forEach { field ->
when (field) {
Foo.BAR_FIELD_NAME -> this.bar = foo.bar.takeIf { foo.hasBar() }
Foo.TITLE_FIELD_NAME -> this.title = foo.title.takeIf { foo.hasTitle() }
}
}
Use the resolveMask
method to normalize update_mask and access each field to be updated via paths.