こんにちは、id:c-terashimaです
tl;dr
SCIMとはプロビジョニングやデプロビジョニング用のアカウント・グループ情報をRESTful APIで操作するプロトコルです
基幹システムで管理しているアカウント情報を別システムに流し込むのに利用され、ホームズクラウドでも6月にリリース予定です
spring-boot-starter-scim2
の利用方法をまとめていこうと思います
参考
spring-boot-starter-scim2とは
SpringBoot上で SCIM2 SDK
を利用するためのOSSになります
導入することで以下のメリットがあると考え導入しました
- Request/ResponseのEntity
- ServiceProvider、ResourceTypesなどのエンドポイントの自動生成
- フィルター文字列の解析
環境
開発環境は以下の通りです
- Java 8
- Gradle 5.4.1
- Kotlin 1.3.71
- SpringBoot 2.2.6 RELEASE
build.gradle
以下のようにdependencyを追加します
dependencies { implementation 'com.bettercloud:spring-boot-starter-scim2:1.0.0' implementation 'com.bettercloud:scim2-sdk-common:1.0.0' implementation "com.unboundid.product.scim2:scim2-sdk-common:2.3.3" implementation "com.unboundid.product.scim2:scim2-sdk-server:2.3.3" }
com.bettercloud
では足りない機能をcom.unboundid
で補う必要があるため、追加します
エンドポイント
spring-boot-starter-scim2
は以下のエンドポイントを自動で出力してくれます
ServiceProviderConfig
は application.yml
に出力する情報を記載するだけでOKで、残りの2つは@ScimResource
をControllerクラスに付与するだけです
@ScimResource(description = "Access User Resources", name = "User", schema = UserResource::class)
- /ServiceProviderConfig
- /ResourceTypes
- /Schemas
scim2: service-provider-config: documentationUri: http://www.simplecloud.info patch: supported: true bulk: supported: true maxOperations: 1000 maxPayloadSize: 10000 filter: supported: true maxResults: 100 change-password: supported: false sort: supported: true etag: supported: false authenticationSchemes: - name: SCIM description: SCIM specUri: http://localhost:8080 documentationUri: http://localhost:8080 type: oauthbearertoken primary: true
各項目についてはGithubに説明がありますので、そちらをご覧いただければと思います
/ServiceProviderConfigのResponse JSON
{ "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" ], "patch": { "supported": true }, "bulk": { "supported": false, "maxOperations": 1000, "maxPayloadSize": 10000 }, "filter": { "supported": true, "maxResults": 100 }, "changePassword": { "supported": false }, "sort": { "supported": false }, "etag": { "supported": false }, "meta": { "resourceType": "ServiceProviderConfig", "location": "http://localhost:8080/ServiceProviderConfig" } }
/ResourceTypesのResponse JSON
{ "schemas": [ "urn:ietf:params:scim:api:messages:2.0:ListResponse" ], "id": null, "externalId": null, "meta": null, "totalResults": 2, "Resources": [ { "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:ResourceType" ], "id": "User", "name": "User", "description": "Access User Resources", "endpoint": "/Users", "meta": { "resourceType": "ResourceType", "location": "http://localhost:8080/ResourceTypes/User" } }, { "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:ResourceType" ], "id": "Group", "name": "Group", "description": "Access Group Resources", "endpoint": "/Groups", "meta": { "resourceType": "ResourceType", "location": "http://localhost:8080/ResourceTypes/Group" } } ], "startIndex": 1, "itemsPerPage": 2 }
/SchemasのResponse JSON
出力量が多いので折りたたんでおります
JSONを見る
{ "schemas": [ "urn:ietf:params:scim:api:messages:2.0:ListResponse" ], "id": null, "externalId": null, "meta": null, "totalResults": 1, "Resources": [ { "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:Schema" ], "id": "urn:ietf:params:scim:schemas:core:2.0:User", "name": "User", "description": "User Account", "attributes": [ { "name": "active", "type": "boolean", "multiValued": false, "description": "A Boolean value indicating the User's administrative status.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "addresses", "type": "complex", "subAttributes": [ { "name": "country", "type": "string", "multiValued": false, "description": "The country name component.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "formatted", "type": "string", "multiValued": false, "description": "The full mailing address, formatted for display or use with a mailing label. This attribute MAY contain newlines.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "locality", "type": "string", "multiValued": false, "description": "The city or locality component.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "postalCode", "type": "string", "multiValued": false, "description": "The zipcode or postal code component.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "primary", "type": "boolean", "multiValued": false, "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred address. The primary attribute value 'true' MUST appear no more than once.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "region", "type": "string", "multiValued": false, "description": "The state or region component.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "streetAddress", "type": "string", "multiValued": false, "description": "The full street address component, which may include house number, street name, PO BOX, and multi-line extended street address information. This attribute MAY contain newlines.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "type", "type": "string", "multiValued": false, "description": "A label indicating the attribute's function; e.g., 'work' or 'home'.", "required": false, "canonicalValues": [ "other", "work", "home" ], "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" } ], "multiValued": true, "description": "Physical mailing addresses for this User.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "displayName", "type": "string", "multiValued": false, "description": "The name of the User, suitable for display to end-users. The name SHOULD be the full name of the User being described if known.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "emails", "type": "complex", "subAttributes": [ { "name": "display", "type": "string", "multiValued": false, "description": "A human readable name, primarily used for display purposes.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "primary", "type": "boolean", "multiValued": false, "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred mailing address or primary e-mail address. The primary attribute value 'true' MUST appear no more than once.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "type", "type": "string", "multiValued": false, "description": "A label indicating the attribute's function; e.g., 'work' or 'home'.", "required": false, "canonicalValues": [ "other", "work", "home" ], "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "value", "type": "string", "multiValued": false, "description": "E-mail addresses for the user. The value\nSHOULD be canonicalized by the Service Provider, e.g.\nbjensen@example.com instead of bjensen@EXAMPLE.COM. Canonical Type\nvalues of work, home, and other.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" } ], "multiValued": true, "description": "E-mail addresses for the user. The value SHOULD be canonicalized by the Service Provider, e.g., bjensen@example.com instead of bjensen@EXAMPLE.COM. Canonical Type values of work, home, and other.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "entitlements", "type": "complex", "subAttributes": [ { "name": "display", "type": "string", "multiValued": false, "description": "A human readable name, primarily used for display purposes.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "primary", "type": "boolean", "multiValued": false, "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute. The primary attribute value 'true' MUST appear no more than once.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "type", "type": "string", "multiValued": false, "description": "A label indicating the attribute's function.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "value", "type": "string", "multiValued": false, "description": "The value of an entitlement.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" } ], "multiValued": true, "description": "A list of entitlements for the User that represent a thing the User has.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "groups", "type": "complex", "subAttributes": [ { "name": "display", "type": "string", "multiValued": false, "description": "A human readable name, primarily used for display purposes.", "required": false, "caseExact": false, "mutability": "readOnly", "returned": "default", "uniqueness": "none" }, { "name": "$ref", "type": "reference", "multiValued": false, "description": "The URI of the corresponding Group resource to which the user belongs", "required": false, "caseExact": true, "mutability": "readOnly", "returned": "default", "uniqueness": "none", "referenceTypes": [ "Group", "User" ] }, { "name": "type", "type": "string", "multiValued": false, "description": "A label indicating the attribute's function; e.g., 'direct' or 'indirect'.", "required": false, "canonicalValues": [ "indirect", "direct" ], "caseExact": false, "mutability": "readOnly", "returned": "default", "uniqueness": "none" }, { "name": "value", "type": "string", "multiValued": false, "description": "The identifier of the User's group.", "required": false, "caseExact": false, "mutability": "readOnly", "returned": "default", "uniqueness": "none" } ], "multiValued": true, "description": "A list of groups that the user belongs to, either thorough direct membership, nested groups, or dynamically calculated.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "ims", "type": "complex", "subAttributes": [ { "name": "display", "type": "string", "multiValued": false, "description": "A human readable name, primarily used for display purposes.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "primary", "type": "boolean", "multiValued": false, "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred messenger or primary messenger. The primary attribute value 'true' MUST appear no more than once.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "type", "type": "string", "multiValued": false, "description": "A label indicating the attribute's function; e.g., 'aim', 'gtalk', 'mobile' etc.", "required": false, "canonicalValues": [ "qq", "skype", "gtalk", "aim", "icq", "yahoo", "msn", "xmpp" ], "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "value", "type": "string", "multiValued": false, "description": "Instant messaging address for the User.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" } ], "multiValued": true, "description": "Instant messaging addresses for the User.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "locale", "type": "string", "multiValued": false, "description": "Used to indicate the User's default location for purposes of localizing items such as currency, date time format, numerical representations, etc.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "name", "type": "complex", "subAttributes": [ { "name": "familyName", "type": "string", "multiValued": false, "description": "The family name of the User, or Last Name in most Western languages (for example, Jensen given the full name Ms. Barbara J Jensen, III.).", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "formatted", "type": "string", "multiValued": false, "description": "The full name, including all middle names, titles, and suffixes as appropriate, formatted for display (for example, Ms. Barbara J Jensen, III.).", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "givenName", "type": "string", "multiValued": false, "description": "The given name of the User, or First Name in most Western languages (for example, Barbara given the full name Ms. Barbara J Jensen, III.).", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "honorificPrefix", "type": "string", "multiValued": false, "description": "The honorific prefix(es) of the User, or Title in most Western languages (for example, Ms. given the full name Ms. Barbara J Jensen, III.).", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "honorificSuffix", "type": "string", "multiValued": false, "description": "The honorific suffix(es) of the User, or Suffix in most Western languages (for example, III. given the full name Ms. Barbara J Jensen, III.)", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "middleName", "type": "string", "multiValued": false, "description": "The middle name(s) of the User (for example, Robert given the full name Ms. Barbara J Jensen, III.).", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" } ], "multiValued": false, "description": "The components of the user's real name.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "nickName", "type": "string", "multiValued": false, "description": "The casual way to address the user in real life, e.g.'Bob' or 'Bobby' instead of 'Robert'. This attribute SHOULD NOT be used to represent a User's username (e.g., bjensen or mpepperidge)", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "password", "type": "string", "multiValued": false, "description": "The User's clear text password. This attribute is intended to be used as a means to specify an initial password when creating a new User or to reset an existing User's password.", "required": false, "caseExact": false, "mutability": "writeOnly", "returned": "never", "uniqueness": "none" }, { "name": "phoneNumbers", "type": "complex", "subAttributes": [ { "name": "display", "type": "string", "multiValued": false, "description": "A human readable name, primarily used for display purposes.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "primary", "type": "boolean", "multiValued": false, "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred phone number or primary phone number. The primary attribute value 'true' MUST appear no more than once.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "type", "type": "string", "multiValued": false, "description": "A label indicating the attribute's function; e.g., 'work' or 'home' or 'mobile' etc.", "required": false, "canonicalValues": [ "other", "pager", "work", "mobile", "fax", "home" ], "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "value", "type": "string", "multiValued": false, "description": "Phone number of the User", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" } ], "multiValued": true, "description": "Phone numbers for the User. The value SHOULD be canonicalized by the Service Provider according to format in RFC3966 e.g., 'tel:+1-201-555-0123'. Canonical Type values of work, home, mobile, fax, pager and other.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "photos", "type": "complex", "subAttributes": [ { "name": "display", "type": "string", "multiValued": false, "description": "A human readable name, primarily used for display purposes.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "primary", "type": "boolean", "multiValued": false, "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute, e.g., the preferred messenger or primary messenger. The primary attribute value 'true' MUST appear no more than once.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "type", "type": "string", "multiValued": false, "description": "A label indicating the attribute's function; e.g., 'photo' or 'thumbnail'.", "required": false, "canonicalValues": [ "thumbnail", "photo" ], "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "value", "type": "reference", "multiValued": false, "description": "URI of a photo of the User.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "referenceTypes": [ "external" ] } ], "multiValued": true, "description": "URIs of photos of the User.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "preferredLanguage", "type": "string", "multiValued": false, "description": "Indicates the User's preferred written or spoken language. Generally used for selecting a localized User interface. e.g., 'en_US' specifies the language English and country US.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "profileUrl", "type": "reference", "multiValued": false, "description": "A fully qualified URL to a page representing the User's online profile", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "referenceTypes": [ "external" ] }, { "name": "roles", "type": "complex", "subAttributes": [ { "name": "display", "type": "string", "multiValued": false, "description": "A human readable name, primarily used for display purposes.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "primary", "type": "boolean", "multiValued": false, "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute. The primary attribute value 'true' MUST appear no more than once.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "type", "type": "string", "multiValued": false, "description": "A label indicating the attribute's function.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "value", "type": "string", "multiValued": false, "description": "The value of a role.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" } ], "multiValued": true, "description": "A list of roles for the User that collectively represent who the User is; e.g., 'Student', 'Faculty'.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "timezone", "type": "string", "multiValued": false, "description": "The User's time zone in the 'Olson' timezone database format; e.g.,'America/Los_Angeles'", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "title", "type": "string", "multiValued": false, "description": "The user's title, such as \"Vice President\".", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "userName", "type": "string", "multiValued": false, "description": "Unique identifier for the User typically used by the user to directly authenticate to the service provider.", "required": true, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "server" }, { "name": "userType", "type": "string", "multiValued": false, "description": "Used to identify the organization to user relationship. Typical values used might be 'Contractor', 'Employee', 'Intern', 'Temp', 'External', and 'Unknown' but any value may be used.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "x509Certificates", "type": "complex", "subAttributes": [ { "name": "display", "type": "string", "multiValued": false, "description": "A human readable name, primarily used for display purposes.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "primary", "type": "boolean", "multiValued": false, "description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute. The primary attribute value 'true' MUST appear no more than once.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "type", "type": "string", "multiValued": false, "description": "A label indicating the attribute's function.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, { "name": "value", "type": "binary", "multiValued": false, "description": "The value of a X509 certificate.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" } ], "multiValued": true, "description": "A list of certificates issued to the User.", "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" } ], "meta": { "resourceType": "Schema", "location": "http://localhost:8080/Schemas/urn:ietf:params:scim:schemas:core:2.0:User" } } ], "startIndex": 1, "itemsPerPage": 1 }
UsersとGroups
ユーザとグループのCRUDは実装する必要があります
Get
単体
と複数
のリソースを取得する2つのエンドポイントを作る必要があります
複数のリソースを取得するエンドポイントはFilter指定が可能で、以下のようなクエリパラータでアクセスされます
/Users?filter=userName eq "user name"
eq
はequal
の略で他に指定される条件は以下のようになっています
Operator | Description |
---|---|
eq | equal |
co | contains |
sw | starts with |
pr | present value |
gt | greater than |
ge | greater than or equal |
lt | less than |
le | less than or equal |
and | logical And |
or | logical Or |
これらの条件を独自に実装し絞り込むのはなかなか大変ではありますが、条件解析もフレームワークが用意してくれています
パラメータで受け取った絞り込み文字列をFilter
クラスを通して絞り込みを行います
@GetMapping fun search(request: HttpServletRequest, @ModelAttribute searchRequest: SearchRequest , @RequestParam(value = ApiConstants.QUERY_PARAMETER_FILTER, required = false) filterString: String? ): ResponseEntity<ListResponse<GenericScimResource>> { // 対象全リソース取得 val resources = getResources() // 絞り込み val filter: Filter? = if(filterString != null) Filter.fromString(filterString) else null val result = if(filter != null) { resources.filter { it -> FilterEvaluator.evaluate(filter, it.objectNode) } } else resources val listResponse = ListResponse(result.size, result, 1, result.size) return ResponseEntity.ok(listResponse) }
POST
こちらはユーザやグループを登録するのですが、UserResource
に予めValidation設定が記載されているので、SchemaChecker
を利用して入力チェックを行うことができます
@PostMapping fun create(request: HttpServletRequest, @RequestBody data: UserResource): ResponseEntity<GenericScimResource> { parameterValidation(data) val response = createResource(data) return ResponseEntity.created(createLocation(response.id)).body(response) } private fun parameterValidation(data: UserResource) { val coreSchema = getSchema() val schemaExtensions = getSchemaExtensions() val builder = ResourceTypeDefinition.Builder("test", "/test") .setCoreSchema(coreSchema) .addOptionalSchemaExtension(schemaExtensions) val resourceTypeDefinition: ResourceTypeDefinition = builder.build() val checker = SchemaChecker(resourceTypeDefinition) val resource = checker.removeReadOnlyAttributes(JsonUtils.valueToNode(data)) val results = checker.checkCreate(resource) if (results.syntaxIssues.isNotEmpty()) { throw BadRequestException.invalidSyntax(results.syntaxIssues.joinToString()) } }
PATCH
リソースを一部更新するのに利用されます
以下はグループの名前変更
とメンバー追加
を行っており、複数の更新をサポートする必要があります
{ "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [ { "op": "replace", "path": "displayName", "value": "1879db59-3bdf-4490-ad68-ab880a269474updatedDisplayName" }, { "op": "add", "path": "members", "value": [{ "$ref": null, "value": "f648f8d5ea4e4cd38e9c" } ] }
opには以下の3つが利用可能でEnumで定義されています
- add
- replace
- remove
@PatchMapping("/{id}") fun update(request: HttpServletRequest, @PathVariable("id") id: String , @RequestBody data: PatchRequest): ResponseEntity<Void> { updateResource(data, id) return ResponseEntity.noContent().build() }
総評
exmapleも少なくGitHubのユニットテストを解析しながらの作業でしたが、手間のかかるRequest/Responseクラスの作成や標準エンドポイントの自動出力など大変助かることが多かったかと思います
Bulk
やUsers
、Groups
も標準規約があるのでフレームワークで用意してくれていてもいいのかなと感じました
時間があればPull Requestで改善を試みたいと思います
エンジニア・デザイナーを募集しておりますので、ご興味がある方はこちらからご連絡ください。