diff --git a/source/Superluminal-RESTfulAPI-Tests/RESTfulAPIClientTest.class.st b/source/Superluminal-RESTfulAPI-Tests/RESTfulAPIClientTest.class.st index b8013e3..909b12a 100644 --- a/source/Superluminal-RESTfulAPI-Tests/RESTfulAPIClientTest.class.st +++ b/source/Superluminal-RESTfulAPI-Tests/RESTfulAPIClientTest.class.st @@ -21,6 +21,79 @@ RESTfulAPIClientTest >> apiClient [ ^ self subclassResponsibility ] +{ #category : 'private' } +RESTfulAPIClientTest >> applicationJSONErrorMessageBlock [ + + ^ [ :response | + | message | + message := 'Cannot complete the request'. + response contents ifNotNil: [ :contents | + self + withJsonFrom: contents + do: [ :json | ( json atPath: #( error messageText ) ) ifNotNil: [ :m | message := m ] ] + ]. + message + ] +] + +{ #category : 'private' } +RESTfulAPIClientTest >> badRequestJsonResponse [ + + ^ ZnResponse + badRequest: self location + entity: + ( ZnEntity json: ( self jsonErrorPayloadDescribedBy: self customizedErrorDescription ) ) +] + +{ #category : 'private' } +RESTfulAPIClientTest >> badRequestProblemDetailsResponse [ + + | json | + json := String streamContents: [ :stream | + ( NeoJSONWriter on: stream ) nextPut: ( Dictionary new + at: 'type' put: 'https://example.com/probs/out-of-credit'; + at: 'title' put: 'Out of Credit'; + at: 'status' put: 400; + at: 'detail' put: 'Your current balance is 30, but that costs 50.'; + at: 'instance' put: '/account/12345/msgs/abc'; + yourself ) + ]. + + ^ ZnResponse + badRequest: self location + entity: ( ZnEntity with: json type: 'application/problem+json' asMediaType ) +] + +{ #category : 'private' } +RESTfulAPIClientTest >> buildRESTfulAPIClientHandlingErrorsConfiguredBy: aConfiguringBlock thenDo: aBlock [ + + | restfulAPIClient | + restfulAPIClient := RESTfulAPIClient + buildingHttpClientWith: [ self httpClient ] + cachingIn: ExpiringCache onLocalMemory + handlingErrorsConfiguredBy: aConfiguringBlock. + + [ aBlock cull: restfulAPIClient ] ensure: [ restfulAPIClient finalize ] +] + +{ #category : 'private' } +RESTfulAPIClientTest >> customizedErrorDescription [ + + ^ 'Customized description' +] + +{ #category : 'private' } +RESTfulAPIClientTest >> jsonErrorPayloadDescribedBy: aMessageString [ + + ^ String streamContents: [ :stream | + ( NeoJSONWriter on: stream ) nextPut: ( NeoJSONObject new + at: #error put: ( NeoJSONObject new + at: #messageText put: aMessageString; + yourself ); + yourself ) + ] +] + { #category : 'private' } RESTfulAPIClientTest >> jsonOkResponse [ @@ -33,12 +106,35 @@ RESTfulAPIClientTest >> location [ ^ 'http://localhost' asAbsoluteUrl + resourceIdentifier ] +{ #category : 'private' } +RESTfulAPIClientTest >> notFoundJsonResponse [ + + ^ ZnResponse + notFound: self location + entity: + ( ZnEntity json: ( self jsonErrorPayloadDescribedBy: self customizedErrorDescription ) ) +] + { #category : 'private' } RESTfulAPIClientTest >> notFoundResponse [ ^ ZnResponse notFound: self location ] +{ #category : 'private' } +RESTfulAPIClientTest >> problemDetailsErrorMessageBlock [ + + ^ [ :response | + | message | + message := 'Cannot complete the request'. + response contents ifNotNil: [ :contents | + self + withJsonFrom: contents + do: [ :json | ( json at: #detail ) ifNotNil: [ :m | message := m ] ] ]. + message + ] +] + { #category : 'running' } RESTfulAPIClientTest >> setUp [ @@ -57,58 +153,84 @@ RESTfulAPIClientTest >> tearDown [ { #category : 'tests - DELETE' } RESTfulAPIClientTest >> testDeleteAcceptingWithSuccessfulResponseDo [ - | wasSuccessfull | + | wasSuccessfull | + self configureHttpClientToRespondWith: ZnResponse noContent. + + wasSuccessfull := false. - self configureHttpClientToRespondWith: ZnResponse noContent. - wasSuccessfull := false. - apiClient - deleteAt: self location - accepting: 'application/json;version=1.0.0' asMediaType - withSuccessfulResponseDo: [ :responseContents | wasSuccessfull := true ]. + apiClient + deleteAt: self location + accepting: 'application/json;version=1.0.0' asMediaType + withSuccessfulResponseDo: [ :responseContents | wasSuccessfull := true ]. - self assert: wasSuccessfull + self assert: wasSuccessfull ] { #category : 'tests - DELETE' } RESTfulAPIClientTest >> testDeleteAtSuccess [ - self configureHttpClientToRespondWith: ZnResponse noContent. - self shouldnt: [ apiClient deleteAt: self location ] raise: HTTPClientError + self configureHttpClientToRespondWith: ZnResponse noContent. + self shouldnt: [ apiClient deleteAt: self location ] raise: HTTPClientError ] { #category : 'tests - DELETE' } RESTfulAPIClientTest >> testDeleteAtSuccessWhenCached [ - | etagIsSet | + | etagIsSet | + etagIsSet := false. - etagIsSet := false. - - self configureHttpClientToRespondWith: ( ( self jsonOkResponseWith: #( 1 2 3 ) ) - addCachingDirective: 'Max-Age=60'; - setEntityTag: '"1"' asEntityTag; - yourself ). + self configureHttpClientToRespondWith: ( ( self jsonOkResponseWith: #( 1 2 3 ) ) + addCachingDirective: 'Max-Age=60'; + setEntityTag: '"1"' asEntityTag; + yourself ). - self httpClient whenSend: #setIfMatchTo: evaluate: [ :etag | etagIsSet := etag = '"1"' ]. + self httpClient whenSend: #setIfMatchTo: evaluate: [ :etag | etagIsSet := etag = '"1"' ]. - apiClient get: self location withSuccessfulResponseDo: [ :contents | ]. + apiClient get: self location withSuccessfulResponseDo: [ :contents | ]. - self configureHttpClientToRespondWith: ZnResponse noContent. - self shouldnt: [ apiClient deleteAt: self location ] raise: HTTPClientError. - self assert: etagIsSet + self configureHttpClientToRespondWith: ZnResponse noContent. + self shouldnt: [ apiClient deleteAt: self location ] raise: HTTPClientError. + self assert: etagIsSet ] { #category : 'tests - DELETE' } RESTfulAPIClientTest >> testDeleteNotFound [ - self configureHttpClientToRespondWith: self notFoundResponse. + self configureHttpClientToRespondWith: self notFoundResponse. + + self + should: [ + apiClient + deleteAt: self location + accepting: 'application/json;version=1.0.0' asMediaType + withSuccessfulResponseDo: [ :responseContents | self fail ] + ] + raise: HTTPClientError notFound + withMessageText: 'Cannot complete delete' +] + +{ #category : 'tests - DELETE' } +RESTfulAPIClientTest >> testDeleteNotFoundUsingCustomErrorHandlingBasedOnResponseMediaType [ + + self configureHttpClientToRespondWith: self notFoundJsonResponse. - self - should: [ apiClient - deleteAt: self location - accepting: 'application/json;version=1.0.0' asMediaType - withSuccessfulResponseDo: [ :responseContents | self fail ] - ] - raise: HTTPClientError notFound + self + buildRESTfulAPIClientHandlingErrorsConfiguredBy: [ :client | + client + whenReceivingMediaType: ZnMimeType applicationJson + extractMessageApplying: self applicationJSONErrorMessageBlock + ] + thenDo: [ :restfulAPIClient | + self + should: [ + restfulAPIClient + deleteAt: self location + accepting: 'application/json;version=1.0.0' asMediaType + withSuccessfulResponseDo: [ :responseContents | self fail ] + ] + raise: HTTPClientError notFound + withMessageText: self customizedErrorDescription + ] ] { #category : 'tests - DELETE' } @@ -132,38 +254,38 @@ RESTfulAPIClientTest >> testDeleteTimeout [ { #category : 'tests - GET' } RESTfulAPIClientTest >> testGetAcceptingWithSuccessfulResponseDo [ - self configureHttpClientToRespondWith: self jsonOkResponse. + self configureHttpClientToRespondWith: self jsonOkResponse. - apiClient - get: self location - accepting: 'application/json;version=1.0.0' asMediaType - withSuccessfulResponseDo: [ :responseContents | - self - withJsonFrom: responseContents - do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] - ] + apiClient + get: self location + accepting: 'application/json;version=1.0.0' asMediaType + withSuccessfulResponseDo: [ :responseContents | + self + withJsonFrom: responseContents + do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] + ] ] { #category : 'tests - GET' } RESTfulAPIClientTest >> testGetCached [ - self configureHttpClientToRespondWith: ( ( self jsonOkResponseWith: #( 1 2 3 ) ) - addCachingDirective: 'Max-Age=60'; - yourself ). + self configureHttpClientToRespondWith: ( ( self jsonOkResponseWith: #( 1 2 3 ) ) + addCachingDirective: 'Max-Age=60'; + yourself ). - apiClient get: self location withSuccessfulResponseDo: [ :responseContents | - self - withJsonFrom: responseContents - do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] - ]. + apiClient get: self location withSuccessfulResponseDo: [ :responseContents | + self + withJsonFrom: responseContents + do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] + ]. - self configureHttpClientToRespondWith: self notFoundResponse. + self configureHttpClientToRespondWith: self notFoundResponse. - apiClient get: self location withSuccessfulResponseDo: [ :responseContents | - self - withJsonFrom: responseContents - do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] - ] + apiClient get: self location withSuccessfulResponseDo: [ :responseContents | + self + withJsonFrom: responseContents + do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] + ] ] { #category : 'tests - GET' } @@ -220,27 +342,27 @@ RESTfulAPIClientTest >> testGetDoNotStoreResponseInCache [ { #category : 'tests - GET' } RESTfulAPIClientTest >> testGetIgnoreETagsWhenDoNotStoreCachingPolicyIsInPlace [ - self configureHttpClientToRespondWith: ( ( self jsonOkResponseWith: #( 1 2 3 ) ) - setEntityTag: '"123"'; - addCachingDirective: 'no-store'; - yourself ). + self configureHttpClientToRespondWith: ( ( self jsonOkResponseWith: #( 1 2 3 ) ) + setEntityTag: '"123"'; + addCachingDirective: 'no-store'; + yourself ). - apiClient get: self location withSuccessfulResponseDo: [ :responseContents | - self - withJsonFrom: responseContents - do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] - ]. + apiClient get: self location withSuccessfulResponseDo: [ :responseContents | + self + withJsonFrom: responseContents + do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] + ]. - self configureHttpClientToRespondWith: ( ( self jsonOkResponseWith: #( 1 2 3 ) ) - setEntityTag: '"123"'; - addCachingDirective: 'no-store'; - yourself ). + self configureHttpClientToRespondWith: ( ( self jsonOkResponseWith: #( 1 2 3 ) ) + setEntityTag: '"123"'; + addCachingDirective: 'no-store'; + yourself ). - apiClient get: self location withSuccessfulResponseDo: [ :responseContents | - self - withJsonFrom: responseContents - do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] - ] + apiClient get: self location withSuccessfulResponseDo: [ :responseContents | + self + withJsonFrom: responseContents + do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] + ] ] { #category : 'tests - GET' } @@ -255,6 +377,28 @@ RESTfulAPIClientTest >> testGetNotFound [ withMessageText: 'Cannot complete the request' ] +{ #category : 'tests - GET' } +RESTfulAPIClientTest >> testGetNotFoundUsingCustomErrorHandlingBasedOnResponseMediaType [ + + self configureHttpClientToRespondWith: self notFoundJsonResponse. + + self + buildRESTfulAPIClientHandlingErrorsConfiguredBy: [ :client | + client + whenReceivingMediaType: ZnMimeType applicationJson + extractMessageApplying: self applicationJSONErrorMessageBlock + ] + thenDo: [ :restfulAPIClient | + self + should: [ + restfulAPIClient + get: self location + withSuccessfulResponseDo: [ :responseContents | self fail ] ] + raise: HTTPClientError notFound + withMessageText: self customizedErrorDescription + ] +] + { #category : 'tests - GET' } RESTfulAPIClientTest >> testGetTimeout [ @@ -272,84 +416,110 @@ RESTfulAPIClientTest >> testGetTimeout [ { #category : 'tests - GET' } RESTfulAPIClientTest >> testGetUsingETagInASecondInvocation [ - | ifNoneMatchHeaderWasSet | - - self configureHttpClientToRespondWith: ( ( self jsonOkResponseWith: #( 1 2 3 ) ) - setEntityTag: '"123"'; - yourself ). + | ifNoneMatchHeaderWasSet | + self configureHttpClientToRespondWith: ( ( self jsonOkResponseWith: #( 1 2 3 ) ) + setEntityTag: '"123"'; + yourself ). - apiClient get: self location withSuccessfulResponseDo: [ :responseContents | - self - withJsonFrom: responseContents - do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] - ]. + apiClient get: self location withSuccessfulResponseDo: [ :responseContents | + self + withJsonFrom: responseContents + do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] + ]. - self configureHttpClientToRespondWith: ZnResponse notModified. + self configureHttpClientToRespondWith: ZnResponse notModified. - ifNoneMatchHeaderWasSet := false. - self httpClient whenSend: #setIfNoneMatchTo: evaluate: [ :etag | - self assert: etag equals: '"123"' asEntityTag. - ifNoneMatchHeaderWasSet := true - ]. + ifNoneMatchHeaderWasSet := false. + self httpClient whenSend: #setIfNoneMatchTo: evaluate: [ :etag | + self assert: etag equals: '"123"' asEntityTag. + ifNoneMatchHeaderWasSet := true + ]. - apiClient get: self location withSuccessfulResponseDo: [ :responseContents | - self - withJsonFrom: responseContents - do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] - ]. + apiClient get: self location withSuccessfulResponseDo: [ :responseContents | + self + withJsonFrom: responseContents + do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] + ]. - self assert: ifNoneMatchHeaderWasSet + self assert: ifNoneMatchHeaderWasSet ] { #category : 'tests - GET' } RESTfulAPIClientTest >> testGetWithSuccessfulResponseDo [ - self configureHttpClientToRespondWith: self jsonOkResponse. + self configureHttpClientToRespondWith: self jsonOkResponse. - apiClient get: self location withSuccessfulResponseDo: [ :responseContents | - self - withJsonFrom: responseContents - do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] - ] + apiClient get: self location withSuccessfulResponseDo: [ :responseContents | + self + withJsonFrom: responseContents + do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] + ] ] { #category : 'tests - PATCH' } RESTfulAPIClientTest >> testPatchAtNoContent [ - self configureHttpClientToRespondWith: ZnResponse noContent. - apiClient - patch: ( ZnEntity json: '[]' ) - at: self location - withSuccessfulResponseDo: [ :responseContents | self assert: responseContents isNil ] + self configureHttpClientToRespondWith: ZnResponse noContent. + + apiClient + patch: ( ZnEntity json: '[]' ) + at: self location + withSuccessfulResponseDo: [ :responseContents | self assert: responseContents isNil ] ] { #category : 'tests - PATCH' } RESTfulAPIClientTest >> testPatchAtNotFound [ - self configureHttpClientToRespondWith: self notFoundResponse. - self - should: [ apiClient - patch: ( ZnEntity json: '[]' ) - at: self location - withSuccessfulResponseDo: [ :response | self fail ] - ] - raise: HTTPClientError notFound - withMessageText: 'Cannot complete update' + self configureHttpClientToRespondWith: self notFoundResponse. + + self + should: [ + apiClient + patch: ( ZnEntity json: '[]' ) + at: self location + withSuccessfulResponseDo: [ :response | self fail ] + ] + raise: HTTPClientError notFound + withMessageText: 'Cannot complete update' +] + +{ #category : 'tests - PATCH' } +RESTfulAPIClientTest >> testPatchAtNotFoundUsingCustomErrorHandlingBasedOnResponseMediaType [ + + self configureHttpClientToRespondWith: self notFoundJsonResponse. + + self + buildRESTfulAPIClientHandlingErrorsConfiguredBy: [ :client | + client + whenReceivingMediaType: ZnMimeType applicationJson + extractMessageApplying: self applicationJSONErrorMessageBlock + ] + thenDo: [ :restfulAPIClient | + self + should: [ + restfulAPIClient + patch: ( ZnEntity json: '[]' ) + at: self location + withSuccessfulResponseDo: [ :response | self fail ] + ] + raise: HTTPClientError notFound + withMessageText: self customizedErrorDescription + ] ] { #category : 'tests - PATCH' } RESTfulAPIClientTest >> testPatchAtWithSuccessfulResponseDo [ - self configureHttpClientToRespondWith: self jsonOkResponse. + self configureHttpClientToRespondWith: self jsonOkResponse. - apiClient - patch: ( ZnEntity json: '[]' ) - at: self location - withSuccessfulResponseDo: [ :responseContents | - self - withJsonFrom: responseContents - do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] - ] + apiClient + patch: ( ZnEntity json: '[]' ) + at: self location + withSuccessfulResponseDo: [ :responseContents | + self + withJsonFrom: responseContents + do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] + ] ] { #category : 'tests - PATCH' } @@ -373,16 +543,66 @@ RESTfulAPIClientTest >> testPatchTimeout [ { #category : 'tests - POST' } RESTfulAPIClientTest >> testPostBadRequest [ - self - configureHttpClientToRespondWith: ( ZnResponse badRequest: '' entity: ( ZnEntity text: 'COVID-19' ) ). - self - should: [ apiClient - post: ( ZnEntity json: '["hi"]' ) - at: self location - withSuccessfulResponseDo: [ self fail ] - ] - raise: HTTPClientError badRequest - withMessageText: 'Cannot complete the request' + self configureHttpClientToRespondWith: + ( ZnResponse badRequest: '' entity: ( ZnEntity text: 'COVID-19' ) ). + + self + should: [ + apiClient + post: ( ZnEntity json: '["hi"]' ) + at: self location + withSuccessfulResponseDo: [ self fail ] + ] + raise: HTTPClientError badRequest + withMessageText: 'Cannot complete the request' +] + +{ #category : 'tests - POST' } +RESTfulAPIClientTest >> testPostBadRequestUsingCustomErrorHandlingBasedOnResponseMediaType [ + + self configureHttpClientToRespondWith: self badRequestJsonResponse. + + self + buildRESTfulAPIClientHandlingErrorsConfiguredBy: [ :client | + client + whenReceivingMediaType: ZnMimeType applicationJson + extractMessageApplying: self applicationJSONErrorMessageBlock + ] + thenDo: [ :restfulAPIClient | + self + should: [ + restfulAPIClient + post: ( ZnEntity json: '["hi"]' ) + at: self location + withSuccessfulResponseDo: [ self fail ] + ] + raise: HTTPClientError badRequest + withMessageText: self customizedErrorDescription + ] +] + +{ #category : 'tests - POST' } +RESTfulAPIClientTest >> testPostBadRequestUsingProblemDetails [ + + self configureHttpClientToRespondWith: self badRequestProblemDetailsResponse. + + self + buildRESTfulAPIClientHandlingErrorsConfiguredBy: [ :builder | + builder + whenReceivingMediaType: 'application/problem+json' asMediaType + extractMessageApplying: self problemDetailsErrorMessageBlock + ] + thenDo: [ :restfulAPIClient | + self + should: [ + restfulAPIClient + post: ( ZnEntity json: '["hi"]' ) + at: self location + withSuccessfulResponseDo: [ self fail ] + ] + raise: HTTPClientError badRequest + withMessageText: 'Your current balance is 30, but that costs 50.' + ] ] { #category : 'tests - POST' } @@ -406,46 +626,67 @@ RESTfulAPIClientTest >> testPostTimeout [ { #category : 'tests - POST' } RESTfulAPIClientTest >> testPostWithSuccessfulResponseDo [ - self configureHttpClientToRespondWith: ( ZnResponse created: self location ). - apiClient - post: ( ZnEntity json: '[]' ) - at: self location - withSuccessfulResponseDo: [ :responseContents | self assert: ( responseContents beginsWith: 'Created' ) ] + self configureHttpClientToRespondWith: ( ZnResponse created: self location ). + + apiClient + post: ( ZnEntity json: '[]' ) + at: self location + withSuccessfulResponseDo: [ :responseContents | + self assert: ( responseContents beginsWith: 'Created' ) ] ] { #category : 'tests - PUT' } RESTfulAPIClientTest >> testPutAt [ - | response | - - self configureHttpClientToRespondWith: self jsonOkResponse. + | response | + self configureHttpClientToRespondWith: self jsonOkResponse. - response := apiClient put: ( ZnEntity json: '["hi"]' ) at: self location. + response := apiClient put: ( ZnEntity json: '["hi"]' ) at: self location. - self - withJsonFrom: response contents - do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] + self + withJsonFrom: response contents + do: [ :json | self assert: json hasTheSameElementsInTheSameOrderThat: #( 1 2 3 ) ] ] { #category : 'tests - PUT' } RESTfulAPIClientTest >> testPutAtNoContent [ - | response | + | response | + self configureHttpClientToRespondWith: ZnResponse noContent. - self configureHttpClientToRespondWith: ZnResponse noContent. - response := apiClient put: ( ZnEntity json: '["hi"]' ) at: self location. + response := apiClient put: ( ZnEntity json: '["hi"]' ) at: self location. - self assert: response isNoContent + self assert: response isNoContent ] { #category : 'tests - PUT' } RESTfulAPIClientTest >> testPutAtNotFound [ - self configureHttpClientToRespondWith: self notFoundResponse. - self - should: [ apiClient put: ( ZnEntity json: '["hi"]' ) at: self location ] - raise: HTTPClientError notFound - withMessageText: 'Cannot complete update' + self configureHttpClientToRespondWith: self notFoundResponse. + + self + should: [ apiClient put: ( ZnEntity json: '["hi"]' ) at: self location ] + raise: HTTPClientError notFound + withMessageText: 'Cannot complete update' +] + +{ #category : 'tests - PUT' } +RESTfulAPIClientTest >> testPutAtNotFoundUsingCustomErrorHandlingBasedOnResponseMediaType [ + + self configureHttpClientToRespondWith: self notFoundJsonResponse. + + self + buildRESTfulAPIClientHandlingErrorsConfiguredBy: [ :client | + client + whenReceivingMediaType: ZnMimeType applicationJson + extractMessageApplying: self applicationJSONErrorMessageBlock + ] + thenDo: [ :restfulAPIClient | + self + should: [ restfulAPIClient put: ( ZnEntity json: '["hi"]' ) at: self location ] + raise: HTTPClientError notFound + withMessageText: self customizedErrorDescription + ] ] { #category : 'tests - PUT' } @@ -465,6 +706,7 @@ RESTfulAPIClientTest >> testPutTimeout [ RESTfulAPIClientTest >> testQueryAtAcceptingWithSuccessfulResponseDo [ self configureHttpClientToRespondWith: self jsonOkResponse. + apiClient query: ( ZnEntity json: '[]' ) at: self location @@ -481,6 +723,7 @@ RESTfulAPIClientTest >> testQueryBadRequest [ self configureHttpClientToRespondWith: ( ZnResponse badRequest: '' entity: ( ZnEntity text: 'COVID-19' ) ). + self should: [ apiClient @@ -493,6 +736,31 @@ RESTfulAPIClientTest >> testQueryBadRequest [ withMessageText: 'Cannot complete the request' ] +{ #category : 'tests - QUERY' } +RESTfulAPIClientTest >> testQueryBadRequestUsingCustomErrorHandlingBasedOnResponseMediaType [ + + self configureHttpClientToRespondWith: self badRequestJsonResponse. + + self + buildRESTfulAPIClientHandlingErrorsConfiguredBy: [ :client | + client + whenReceivingMediaType: ZnMimeType applicationJson + extractMessageApplying: self applicationJSONErrorMessageBlock + ] + thenDo: [ :restfulAPIClient | + self + should: [ + restfulAPIClient + query: ( ZnEntity json: '["hi"]' ) + at: self location + accepting: 'application/json;version=1.0.0' asMediaType + withSuccessfulResponseDo: [ self fail ] + ] + raise: HTTPClientError badRequest + withMessageText: self customizedErrorDescription + ] +] + { #category : 'tests - QUERY' } RESTfulAPIClientTest >> testQueryCacheDependsOnBody [ diff --git a/source/Superluminal-RESTfulAPI/CompositeHttpErrorMessagePolicy.class.st b/source/Superluminal-RESTfulAPI/CompositeHttpErrorMessagePolicy.class.st new file mode 100644 index 0000000..1a7ca3e --- /dev/null +++ b/source/Superluminal-RESTfulAPI/CompositeHttpErrorMessagePolicy.class.st @@ -0,0 +1,31 @@ +Class { + #name : 'CompositeHttpErrorMessagePolicy', + #superclass : 'Object', + #instVars : [ + 'policies' + ], + #category : 'Superluminal-RESTfulAPI-Core', + #package : 'Superluminal-RESTfulAPI', + #tag : 'Core' +} + +{ #category : 'instance creation' } +CompositeHttpErrorMessagePolicy class >> ofAll: aPolicyCollection [ + + ^ self new initializeOfAll: aPolicyCollection +] + +{ #category : 'processing' } +CompositeHttpErrorMessagePolicy >> errorMessageFrom: aResponse ifNone: aNoneBlock [ + + ^ policies + detect: [ :policy | policy canHandle: aResponse ] + ifFound: [ :policy | policy errorMessageFrom: aResponse ] + ifNone: aNoneBlock +] + +{ #category : 'initialization' } +CompositeHttpErrorMessagePolicy >> initializeOfAll: aPolicyCollection [ + + policies := aPolicyCollection +] diff --git a/source/Superluminal-RESTfulAPI/HttpErrorHandlingPolicyBuilder.class.st b/source/Superluminal-RESTfulAPI/HttpErrorHandlingPolicyBuilder.class.st new file mode 100644 index 0000000..d338370 --- /dev/null +++ b/source/Superluminal-RESTfulAPI/HttpErrorHandlingPolicyBuilder.class.st @@ -0,0 +1,31 @@ +Class { + #name : 'HttpErrorHandlingPolicyBuilder', + #superclass : 'Object', + #instVars : [ + 'policies' + ], + #category : 'Superluminal-RESTfulAPI-Builders', + #package : 'Superluminal-RESTfulAPI', + #tag : 'Builders' +} + +{ #category : 'building' } +HttpErrorHandlingPolicyBuilder >> build [ + + ^ CompositeHttpErrorMessagePolicy ofAll: policies +] + +{ #category : 'initialization' } +HttpErrorHandlingPolicyBuilder >> initialize [ + + super initialize. + + policies := OrderedCollection new +] + +{ #category : 'configuration' } +HttpErrorHandlingPolicyBuilder >> whenReceivingMediaType: aMediaType extractMessageApplying: aBlock [ + + policies add: + ( HttpMediaTypeErrorMessagePolicy handling: aMediaType extractingMessageWith: aBlock ) +] diff --git a/source/Superluminal-RESTfulAPI/HttpMediaTypeErrorMessagePolicy.class.st b/source/Superluminal-RESTfulAPI/HttpMediaTypeErrorMessagePolicy.class.st new file mode 100644 index 0000000..7f7993e --- /dev/null +++ b/source/Superluminal-RESTfulAPI/HttpMediaTypeErrorMessagePolicy.class.st @@ -0,0 +1,38 @@ +Class { + #name : 'HttpMediaTypeErrorMessagePolicy', + #superclass : 'Object', + #instVars : [ + 'mediaType', + 'extractorBlock' + ], + #category : 'Superluminal-RESTfulAPI-Core', + #package : 'Superluminal-RESTfulAPI', + #tag : 'Core' +} + +{ #category : 'instance creation' } +HttpMediaTypeErrorMessagePolicy class >> handling: aMediaType extractingMessageWith: aBlock [ + + ^ self new initializeHandling: aMediaType extractingMessageWith: aBlock +] + +{ #category : 'testing' } +HttpMediaTypeErrorMessagePolicy >> canHandle: aResponse [ + + ^ aResponse contentType + ifNotNil: [ :contentType | contentType matches: mediaType ] + ifNil: [ false ] +] + +{ #category : 'processing' } +HttpMediaTypeErrorMessagePolicy >> errorMessageFrom: aResponse [ + + ^ extractorBlock value: aResponse +] + +{ #category : 'initialization' } +HttpMediaTypeErrorMessagePolicy >> initializeHandling: aMediaType extractingMessageWith: aBlock [ + + mediaType := aMediaType. + extractorBlock := aBlock +] diff --git a/source/Superluminal-RESTfulAPI/RESTfulAPIClient.class.st b/source/Superluminal-RESTfulAPI/RESTfulAPIClient.class.st index fc2626d..676045d 100644 --- a/source/Superluminal-RESTfulAPI/RESTfulAPIClient.class.st +++ b/source/Superluminal-RESTfulAPI/RESTfulAPIClient.class.st @@ -6,22 +6,44 @@ Class { 'httpClientPoolByAuthority', 'entityTagByLocationCache', 'lastKnownRepresentationByLocation', - 'expiringCache' + 'expiringCache', + 'httpErrorHandlingPolicy' ], - #category : 'Superluminal-RESTfulAPI', - #package : 'Superluminal-RESTfulAPI' + #category : 'Superluminal-RESTfulAPI-Core', + #package : 'Superluminal-RESTfulAPI', + #tag : 'Core' } { #category : 'instance creation' } RESTfulAPIClient class >> buildingHttpClientWith: anHttpClientProvider cachingIn: anExpiringCache [ - ^ self new initializeBuildingHttpClientWith: anHttpClientProvider cachingIn: anExpiringCache + ^ self + buildingHttpClientWith: anHttpClientProvider + cachingIn: anExpiringCache + handlingErrorsConfiguredBy: [ :apiClient | + apiClient whenReceivingMediaType: ZnMimeType applicationJson extractMessageApplying: [ :response | + | message | + message := 'Cannot complete the request'. + response contents ifNotNil: [ :contents | + ( NeoJSONObject fromString: contents ) message ifNotNil: [ :m | message := m ] ]. + message + ] + ] +] + +{ #category : 'instance creation' } +RESTfulAPIClient class >> buildingHttpClientWith: anHttpClientProvider cachingIn: anExpiringCache handlingErrorsConfiguredBy: aBlock [ + + ^ self new + initializeBuildingHttpClientWith: anHttpClientProvider + cachingIn: anExpiringCache + handlingErrorsAccordingTo: aBlock ] { #category : 'instance creation' } RESTfulAPIClient class >> cachingOnLocalMemory [ - ^ self buildingHttpClientWith: [ ZnClient new ] cachingIn: ExpiringCache onLocalMemory + ^ self buildingHttpClientWith: [ ZnClient new ] cachingIn: ExpiringCache onLocalMemory ] { #category : 'private - caching' } @@ -33,20 +55,6 @@ RESTfulAPIClient >> addSetIfNoneMatchHeaderTo: command configuredUsing: request ^command ] -{ #category : 'private - exception handling' } -RESTfulAPIClient >> clientErrorMessageFrom: response [ - - response contentType - ifNotNil: [ :contentType | - ( contentType matches: ZnMimeType applicationJson ) - and: [ response contents - ifNotNil: [ :contents | ( NeoJSONObject fromString: contents ) message ifNotNil: [ :message | ^ message ] ] - ] - ]. - - ^ 'Cannot complete the request' -] - { #category : 'private - inkoving' } RESTfulAPIClient >> clientPoolFor: aLocation [ @@ -95,39 +103,41 @@ RESTfulAPIClient >> deleteAt: aLocation accepting: aMediaType withSuccessfulResp { #category : 'invoking' } RESTfulAPIClient >> deleteAt: aLocation configuredBy: aRequestBuildingBlock withSuccessfulResponseDo: aBlock [ - ^ self - handleExceptionsDuring: [ - | httpRequest response | - - httpRequest := HttpRequest delete: aLocation configuredUsing: aRequestBuildingBlock. - self - withHttpClientFor: aLocation - do: [ :httpClient | response := httpRequest applyOn: httpClient ]. - ( response isSuccess or: [ response isNoContent ] ) - ifTrue: [ expiringCache clearResourceAt: aLocation. - aBlock cull: ( self tryToCacheContentsOf: response basedOn: aLocation ) - ] - ifFalse: [ self signalCannotCompleteDeleteErrorBasedOn: response ] - ] + ^ self handleExceptionsDuring: [ + | httpRequest response | + httpRequest := HttpRequest delete: aLocation configuredUsing: aRequestBuildingBlock. + self + withHttpClientFor: aLocation + do: [ :httpClient | response := httpRequest applyOn: httpClient ]. + response isSuccess + ifTrue: [ + expiringCache clearResourceAt: aLocation. + aBlock cull: ( self tryToCacheContentsOf: response basedOn: aLocation ) + ] + ifFalse: [ self signalCannotCompleteDeleteErrorBasedOn: response ] + ] ] { #category : 'private - inkoving' } RESTfulAPIClient >> execute: httpRequest aimedAt: aLocation [ - ^ self - handleExceptionsDuring: [ - | response contents | - self - withHttpClientFor: aLocation - do: [ :httpClient | response := httpRequest applyOn: httpClient ]. - contents := response isNotModified - ifTrue: [ self lastKnownRepresentationOf: aLocation ] - ifFalse: [ response isSuccess - ifTrue: [ self tryToCacheContentsOf: response basedOn: aLocation ] - ifFalse: [ self raiseErrorBasedOn: response ] - ]. - ExpiringResource for: contents controlledBy: response - ] + ^ self handleExceptionsDuring: [ + | response contents | + self + withHttpClientFor: aLocation + do: [ :httpClient | response := httpRequest applyOn: httpClient ]. + contents := response isNotModified + ifTrue: [ self lastKnownRepresentationOf: aLocation ] + ifFalse: [ + response isSuccess + ifTrue: [ self tryToCacheContentsOf: response basedOn: aLocation ] + ifFalse: [ + self + raiseErrorBasedOn: response + defaultingTo: 'Cannot complete the request' ] + ]. + ExpiringResource for: contents controlledBy: response + ] ] { #category : 'finalization' } @@ -183,14 +193,27 @@ RESTfulAPIClient >> handleExceptionsDuring: anInvokingBlock [ do: [ :signal | HTTPClientError badRequest signal: signal messageText ] ] +{ #category : 'private - exception handling' } +RESTfulAPIClient >> httpErrorClassBasedOn: aResponse [ + + ^ aResponse code > 499 + ifTrue: [ HTTPServerError ] + ifFalse: [ HTTPClientError ] +] + { #category : 'initialization' } -RESTfulAPIClient >> initializeBuildingHttpClientWith: anHttpClientProvider cachingIn: anExpiringCache [ +RESTfulAPIClient >> initializeBuildingHttpClientWith: anHttpClientProvider cachingIn: anExpiringCache handlingErrorsAccordingTo: aBlock [ - httpClientProvider := anHttpClientProvider. - httpClientPoolByAuthority := Dictionary new. - entityTagByLocationCache := Dictionary new. - lastKnownRepresentationByLocation := Dictionary new. - expiringCache := anExpiringCache + | httpErrorHandlingPolicyBuilder | + httpClientProvider := anHttpClientProvider. + httpClientPoolByAuthority := Dictionary new. + entityTagByLocationCache := Dictionary new. + lastKnownRepresentationByLocation := Dictionary new. + expiringCache := anExpiringCache. + + httpErrorHandlingPolicyBuilder := HttpErrorHandlingPolicyBuilder new. + aBlock cull: httpErrorHandlingPolicyBuilder. + httpErrorHandlingPolicy := httpErrorHandlingPolicyBuilder build ] { #category : 'private - caching' } @@ -251,35 +274,34 @@ RESTfulAPIClient >> post: anEntity at: aLocation withSuccessfulResponseDo: aBloc { #category : 'invoking' } RESTfulAPIClient >> postAt: aLocation configuredBy: aRequestBuildingBlock withSuccessfulResponseDo: aBlock [ - ^ self - handleExceptionsDuring: [ - | httpRequest response | - httpRequest := HttpRequest post: aLocation configuredUsing: aRequestBuildingBlock. - self - withHttpClientFor: aLocation - do: [ :httpClient | response := httpRequest applyOn: httpClient ]. - response isSuccess - ifTrue: [ expiringCache clearResourceAt: aLocation. - ( response isCreated and: [ response hasLocation ] ) - then: [ self tryToCacheContentsOf: response basedOn: response location ]. - aBlock value: response contents - ] - ifFalse: [ ( HTTPClientError code: response code ) signal: ( self clientErrorMessageFrom: response ) ] - ] + ^ self handleExceptionsDuring: [ + | httpRequest response | + httpRequest := HttpRequest post: aLocation configuredUsing: aRequestBuildingBlock. + self + withHttpClientFor: aLocation + do: [ :httpClient | response := httpRequest applyOn: httpClient ]. + response isSuccess + ifTrue: [ + expiringCache clearResourceAt: aLocation. + ( response isCreated and: [ response hasLocation ] ) then: [ + self tryToCacheContentsOf: response basedOn: response location ]. + aBlock value: response contents + ] + ifFalse: [ self raiseErrorBasedOn: response defaultingTo: 'Cannot complete the request' ] + ] ] { #category : 'private - inkoving' } -RESTfulAPIClient >> processPutResponse: response at: aLocation whenSuccessfulDo: aBlock [ +RESTfulAPIClient >> processPutResponse: aResponse at: aLocation whenSuccessfulDo: aBlock [ - response isSuccess then: [ - expiringCache clearResourceAt: aLocation. - [ - aBlock value: - (self tryToCacheContentsOf: response basedOn: aLocation) ] unless: - response isNoContent. - ^ response ]. + aResponse isSuccess then: [ + expiringCache clearResourceAt: aLocation. + [ aBlock value: ( self tryToCacheContentsOf: aResponse basedOn: aLocation ) ] unless: + aResponse isNoContent. + ^ aResponse + ]. - ^ self signalCannotCompleteUpdateErrorBasedOn: response + ^ self signalCannotCompleteUpdateErrorBasedOn: aResponse ] { #category : 'invoking - covenience' } @@ -294,15 +316,14 @@ RESTfulAPIClient >> put: anEntity at: aLocation [ { #category : 'invoking' } RESTfulAPIClient >> putAt: aLocation configuredBy: aRequestBuildingBlock withSuccessfulResponseDo: aBlock [ - ^ self - handleExceptionsDuring: [ | httpRequest response | - - httpRequest := HttpRequest put: aLocation configuredUsing: aRequestBuildingBlock. - self - withHttpClientFor: aLocation - do: [ :httpClient | response := httpRequest applyOn: httpClient ]. - self processPutResponse: response at: aLocation whenSuccessfulDo: aBlock - ] + ^ self handleExceptionsDuring: [ + | httpRequest response | + httpRequest := HttpRequest put: aLocation configuredUsing: aRequestBuildingBlock. + self + withHttpClientFor: aLocation + do: [ :httpClient | response := httpRequest applyOn: httpClient ]. + self processPutResponse: response at: aLocation whenSuccessfulDo: aBlock + ] ] { #category : 'invoking - covenience' } @@ -328,18 +349,14 @@ RESTfulAPIClient >> queryAt: aLocation configuredBy: aRequestBuildingBlock withS do: aMonadicBlock ] -{ #category : 'private - inkoving' } -RESTfulAPIClient >> raiseErrorBasedOn: httpResponse [ +{ #category : 'private - exception handling' } +RESTfulAPIClient >> raiseErrorBasedOn: aResponse defaultingTo: aDefaultMessage [ - | message httpErrorClass | + | httpErrorClass message | + httpErrorClass := self httpErrorClassBasedOn: aResponse. + message := httpErrorHandlingPolicy errorMessageFrom: aResponse ifNone: [ aDefaultMessage ]. - message := [ ( NeoJSONObject fromString: httpResponse contents ) at: #message ] - on: Error - do: [ :aSignal | aSignal return: 'Cannot complete the request' ]. - httpErrorClass := httpResponse code > 499 - ifTrue: [ HTTPServerError ] - ifFalse: [ HTTPClientError ]. - ( httpErrorClass code: httpResponse code ) signal: message + ( httpErrorClass code: aResponse code ) signal: message ] { #category : 'private - inkoving' } @@ -356,36 +373,35 @@ RESTfulAPIClient >> requestToGet: aLocation configuredUsing: aRequestBuildingBlo ] { #category : 'private - exception handling' } -RESTfulAPIClient >> signalCannotCompleteDeleteErrorBasedOn: response [ +RESTfulAPIClient >> signalCannotCompleteDeleteErrorBasedOn: aResponse [ - ^ ( HTTPClientError code: response code ) signal: 'Cannot complete delete' + ^ self raiseErrorBasedOn: aResponse defaultingTo: 'Cannot complete delete' ] { #category : 'private - exception handling' } -RESTfulAPIClient >> signalCannotCompleteUpdateErrorBasedOn: response [ +RESTfulAPIClient >> signalCannotCompleteUpdateErrorBasedOn: aResponse [ - ^ ( HTTPClientError code: response code ) signal: 'Cannot complete update' + ^ self raiseErrorBasedOn: aResponse defaultingTo: 'Cannot complete update' ] { #category : 'private - caching' } -RESTfulAPIClient >> tryToCacheContentsOf: anHttpResponse basedOn: aLocation [ - - | contents normalizedLocation | - - contents := anHttpResponse contents. - normalizedLocation := self normalize: aLocation. - - anHttpResponse - withEntityTagDo: [ :entityTag | - entityTagByLocationCache at: normalizedLocation put: entityTag. - anHttpResponse canBeStoredInCache then: [ - lastKnownRepresentationByLocation at: normalizedLocation put: contents ] - ] - ifAbsent: [ - entityTagByLocationCache removeKey: normalizedLocation ifAbsent: [ ]. - lastKnownRepresentationByLocation removeKey: normalizedLocation ifAbsent: [ ] - ]. - ^ contents +RESTfulAPIClient >> tryToCacheContentsOf: aResponse basedOn: aLocation [ + + | contents normalizedLocation | + contents := aResponse contents. + normalizedLocation := self normalize: aLocation. + + aResponse + withEntityTagDo: [ :entityTag | + entityTagByLocationCache at: normalizedLocation put: entityTag. + aResponse canBeStoredInCache then: [ + lastKnownRepresentationByLocation at: normalizedLocation put: contents ] + ] + ifAbsent: [ + entityTagByLocationCache removeKey: normalizedLocation ifAbsent: [ ]. + lastKnownRepresentationByLocation removeKey: normalizedLocation ifAbsent: [ ] + ]. + ^ contents ] { #category : 'private - caching' }