diff --git a/source/Stargate-Examples/Pet.class.st b/source/Stargate-Examples/Pet.class.st new file mode 100644 index 0000000..2f81704 --- /dev/null +++ b/source/Stargate-Examples/Pet.class.st @@ -0,0 +1,56 @@ +" +I'm a toy abstraction used just as an example +" +Class { + #name : #Pet, + #superclass : #Object, + #instVars : [ + 'name', + 'type', + 'status' + ], + #category : #'Stargate-Examples' +} + +{ #category : #'instance creation' } +Pet class >> named: aName ofType: aPetType [ + + ^ self named: aName ofType: aPetType withStatus: 'new' +] + +{ #category : #'instance creation' } +Pet class >> named: aName ofType: aPetType withStatus: status [ + + ^ self new initializeNamed: aName ofType: aPetType withStatus: status +] + +{ #category : #initialization } +Pet >> initializeNamed: aName ofType: aPetType withStatus: aStatus [ + + name := aName. + type := aPetType. + status := aStatus +] + +{ #category : #accessing } +Pet >> name [ + ^ name +] + +{ #category : #accessing } +Pet >> status [ + ^ status +] + +{ #category : #updating } +Pet >> synchronizeWith: aPet [ + + name := aPet name. + type := aPet type. + status := aPet status +] + +{ #category : #accessing } +Pet >> type [ + ^ type +] diff --git a/source/Stargate-Examples/PetsRESTfulWebService.class.st b/source/Stargate-Examples/PetsRESTfulWebService.class.st index 7c2369a..50e19ba 100644 --- a/source/Stargate-Examples/PetsRESTfulWebService.class.st +++ b/source/Stargate-Examples/PetsRESTfulWebService.class.st @@ -1,3 +1,6 @@ +" +I'm an example RESTful Web Service implementing Pet resource management +" Class { #name : #PetsRESTfulWebService, #superclass : #RESTfulWebService, @@ -13,11 +16,14 @@ Class { PetsRESTfulWebService >> createPetBasedOn: anHttpRequest within: aContext [ ^ self - withCreatedResourceDo: [ :pet | - pets add: pet. - pet at: #status put: 'new'. - petById at: nextId put: pet. - nextId := nextId + 1 ] + withCreatedResourceDo: [ :resource | + | newPet | + + newPet := Pet named: resource name ofType: resource type. + pets add: newPet. + petById at: nextId put: newPet. + nextId := nextId + 1. + newPet ] decodedUsing: self specification petMappingKey basedOn: anHttpRequest within: aContext @@ -43,6 +49,16 @@ PetsRESTfulWebService >> getPetBasedOn: anHttpRequest within: aContext [ within: aContext ] +{ #category : #API } +PetsRESTfulWebService >> getPetsBasedOn: anHttpRequest within: aContext [ + + ^ self + get: [ self pets ] + asCollectionEncodedUsing: self specification petsMappingKey + basedOn: anHttpRequest + within: aContext +] + { #category : #initialization } PetsRESTfulWebService >> initialize [ @@ -89,11 +105,11 @@ PetsRESTfulWebService >> updatePetStatusBasedOn: anHttpRequest within: aContext resource := self decode: anHttpRequest contents - at: #pets + at: self specification petMappingKey from: anHttpRequest contentType within: aContext. pet := self petIdentifiedUsing: anHttpRequest. - pet at: #status put: (resource at: #status). + pet synchronizeWith: (Pet named: (resource at: #name ifAbsent: [ pet name ]) ofType: (resource at: #type ifAbsent: [ pet type ]) withStatus: (resource at: #status ifAbsent: [ pet status ])). pet ] encodedUsing: self specification petMappingKey basedOn: anHttpRequest diff --git a/source/Stargate-Examples/PetsRESTfulWebServiceSpecification.class.st b/source/Stargate-Examples/PetsRESTfulWebServiceSpecification.class.st index da1efda..96dc4c3 100644 --- a/source/Stargate-Examples/PetsRESTfulWebServiceSpecification.class.st +++ b/source/Stargate-Examples/PetsRESTfulWebServiceSpecification.class.st @@ -20,17 +20,39 @@ PetsRESTfulWebServiceSpecification >> addJsonEncoderVersion1dot0dot0MappingIn: a using: [ :pet :context | String streamContents: [ :stream | - (HypermediaAwareJSONWriter on: stream) - mediaControls: (context objectUnder: #mediaControls ifNone: [ #() ]) asDictionary; + (NeoJSONWriter on: stream) + for: Pet + do: [ :mapping | + mapping + mapInstVars; + mapHypermediaControlsIn: context ]; nextPut: pet ] ]; addRuleToEncode: self petMappingKey to: self petSummaryVersion1dot0dot0MediaType using: [ :pet :context | String streamContents: [ :stream | - (HypermediaAwareJSONWriter on: stream) - mediaControls: (context objectUnder: #mediaControls ifNone: [ #() ]) asDictionary; - nextPut: {('name' -> pet name)} asDictionary ] ] + (NeoJSONWriter on: stream) + for: Pet + do: [ :mapping | + mapping + mapInstVar: #name; + mapHypermediaControlsIn: context ]; + nextPut: pet ] ]; + addDefaultRuleToEncode: self petsMappingKey + to: self petSummaryVersion1dot0dot0MediaType + using: [ :pets :context | + String + streamContents: [ :stream | + (NeoJSONWriter on: stream) + for: Pet + do: [ :mapping | + mapping + mapInstVar: #name; + mapAsHypermediaControls: [ :pet | context hypermediaControlsFor: pet ] ]; + nextPut: + {('items' -> pets). + ('links' -> context hypermediaControls asDictionary)} asDictionary ] ] ] { #category : #routes } @@ -78,7 +100,7 @@ PetsRESTfulWebServiceSpecification >> identifierKey [ { #category : #accessing } PetsRESTfulWebServiceSpecification >> petMappingKey [ - ^ #pets + ^ #pet ] { #category : #'accessing - media types' } @@ -93,6 +115,12 @@ PetsRESTfulWebServiceSpecification >> petVersion1dot0dot0MediaType [ ^ 'application/vnd.stargate.pet+json;version=1.0.0' asZnMimeType ] +{ #category : #accessing } +PetsRESTfulWebServiceSpecification >> petsMappingKey [ + + ^ #pets +] + { #category : #routes } PetsRESTfulWebServiceSpecification >> updatePetRoute [ diff --git a/source/Stargate-REST-API-Tests/PetsRESTfulWebServiceTest.class.st b/source/Stargate-REST-API-Tests/PetsRESTfulWebServiceTest.class.st index dbd3ef0..5449ef9 100644 --- a/source/Stargate-REST-API-Tests/PetsRESTfulWebServiceTest.class.st +++ b/source/Stargate-REST-API-Tests/PetsRESTfulWebServiceTest.class.st @@ -7,10 +7,22 @@ Class { #category : #'Stargate-REST-API-Tests' } +{ #category : #'private - support' } +PetsRESTfulWebServiceTest >> baseUrl [ + + ^ 'https://pets.example.com' asZnUrl +] + +{ #category : #'private - support' } +PetsRESTfulWebServiceTest >> petsUrl [ + + ^ self baseUrl / '/pets/' asZnUrl +] + { #category : #'private - support' } PetsRESTfulWebServiceTest >> requestToCreatePetFrom: aJson [ - ^ (ZnRequest post: 'http://BASE_URL/pets') + ^ (ZnRequest post: self petsUrl) entity: (ZnEntity with: aJson type: webService specification petVersion1dot0dot0MediaType); yourself ] @@ -18,7 +30,7 @@ PetsRESTfulWebServiceTest >> requestToCreatePetFrom: aJson [ { #category : #'private - support' } PetsRESTfulWebServiceTest >> requestToDeletePetIdentifiedBy: anIdentifier [ - ^ TeaRequest fromZnRequest: (ZnRequest delete: ('http://BASE_URL/pets/<1p>' expandMacrosWith: anIdentifier)) pathParams: {(#identifier -> anIdentifier)} asDictionary + ^ TeaRequest fromZnRequest: (ZnRequest delete: (self urlForPetIdentifiedBy: anIdentifier)) pathParams: {(#identifier -> anIdentifier)} asDictionary ] { #category : #'private - support' } @@ -26,18 +38,37 @@ PetsRESTfulWebServiceTest >> requestToGetPetIdentifiedBy: anIdentifier accepting ^ TeaRequest fromZnRequest: - ((ZnRequest get: ('http://BASE_URL/pets/<1p>' expandMacrosWith: anIdentifier)) + ((ZnRequest get: (self urlForPetIdentifiedBy: anIdentifier)) setAccept: anAcceptHeader; yourself) pathParams: {(#identifier -> anIdentifier)} asDictionary ] +{ #category : #'private - support' } +PetsRESTfulWebServiceTest >> requestToGetPetsAccepting: anAcceptHeader [ + + ^ (ZnRequest get: self petsUrl) + setAccept: anAcceptHeader; + yourself +] + +{ #category : #'private - support' } +PetsRESTfulWebServiceTest >> requestToUpdatePetIdentifiedBy: anIdentifier nameTo: aName [ + + ^ TeaRequest + fromZnRequest: + ((ZnRequest patch: (self urlForPetIdentifiedBy: anIdentifier)) + entity: (ZnEntity with: ('{"name":"<1s>"}' expandMacrosWith: aName) type: webService specification petVersion1dot0dot0MediaType); + yourself) + pathParams: {(#identifier -> anIdentifier)} asDictionary +] + { #category : #'private - support' } PetsRESTfulWebServiceTest >> requestToUpdatePetIdentifiedBy: anIdentifier statusTo: aStatus [ ^ TeaRequest fromZnRequest: - ((ZnRequest patch: ('http://BASE_URL/pets/<1p>' expandMacrosWith: anIdentifier)) + ((ZnRequest patch: (self urlForPetIdentifiedBy: anIdentifier)) entity: (ZnEntity with: ('{"status":"<1s>"}' expandMacrosWith: aStatus) type: webService specification petVersion1dot0dot0MediaType); yourself) pathParams: {(#identifier -> anIdentifier)} asDictionary @@ -47,7 +78,7 @@ PetsRESTfulWebServiceTest >> requestToUpdatePetIdentifiedBy: anIdentifier status PetsRESTfulWebServiceTest >> setUp [ webService := PetsRESTfulWebService new. - webService serverUrl: 'https://pets.example.com' asZnUrl + webService serverUrl: self baseUrl asZnUrl ] { #category : #tests } @@ -140,7 +171,63 @@ PetsRESTfulWebServiceTest >> testGetPetSummaryJustCreated [ self assert: json name equals: 'Firulais'; assert: json links self equals: 'https://pets.example.com/pets/1'; - assert: json type isNil ] + assert: json type isNil; + assert: json status isNil ] +] + +{ #category : #tests } +PetsRESTfulWebServiceTest >> testGetPets [ + + | response | + + self assert: webService pets isEmpty. + + response := webService getPetsBasedOn: (self requestToGetPetsAccepting: '*/*') within: HttpRequestContext new. + + self + assert: response isSuccess; + assert: response status equals: 200; + assert: response contentType asZnMimeType equals: webService specification petSummaryVersion1dot0dot0MediaType. + + self + withJsonFromContentsIn: response + do: [ :json | + self + assert: json items isEmpty; + assert: json links self equals: 'https://pets.example.com/pets/' ] +] + +{ #category : #tests } +PetsRESTfulWebServiceTest >> testGetPetsNotEmpty [ + + | response | + + self assert: webService pets isEmpty. + + webService createPetBasedOn: (self requestToCreatePetFrom: '{"name":"Firulais","type":"dog"}') within: HttpRequestContext new. + + self assert: webService pets notEmpty. + + response := webService getPetsBasedOn: (self requestToGetPetsAccepting: '*/*') within: HttpRequestContext new. + + self + assert: response isSuccess; + assert: response status equals: 200; + assert: response contentType asZnMimeType equals: webService specification petSummaryVersion1dot0dot0MediaType. + + self + withJsonFromContentsIn: response + do: [ :json | + | dogSummary | + + self + assert: json links self equals: 'https://pets.example.com/pets/'; + assert: json items size equals: 1. + dogSummary := json items first. + self + assert: dogSummary name equals: 'Firulais'; + assert: dogSummary links self equals: 'https://pets.example.com/pets/1'; + assert: dogSummary type isNil ] ] { #category : #tests } @@ -161,6 +248,37 @@ PetsRESTfulWebServiceTest >> testPetCreation [ assert: webService pets first name equals: 'Firulais' ] +{ #category : #tests } +PetsRESTfulWebServiceTest >> testUpdatePetName [ + + | response | + + self assert: webService pets isEmpty. + + self assert: (webService createPetBasedOn: (self requestToCreatePetFrom: '{"name":"Firulais","type":"dog"}') within: HttpRequestContext new) isSuccess. + + self assert: webService pets first name equals: 'Firulais'. + + response := webService updatePetStatusBasedOn: (self requestToUpdatePetIdentifiedBy: 1 nameTo: 'Mendieta') within: HttpRequestContext new. + + self + assert: response isSuccess; + assert: response status equals: 200; + assert: response hasEntity; + assert: webService pets size equals: 1; + assert: webService pets first name equals: 'Mendieta'; + assert: webService pets first status equals: 'new'. + + self + withJsonFromContentsIn: response + do: [ :json | + self + assert: json name equals: 'Mendieta'; + assert: json type equals: 'dog'; + assert: json status equals: 'new'; + assert: json links self equals: 'https://pets.example.com/pets/1' ] +] + { #category : #tests } PetsRESTfulWebServiceTest >> testUpdatePetStatus [ @@ -194,6 +312,12 @@ PetsRESTfulWebServiceTest >> testUpdatePetStatus [ assert: json links self equals: 'https://pets.example.com/pets/1' ] ] +{ #category : #'private - support' } +PetsRESTfulWebServiceTest >> urlForPetIdentifiedBy: anIdentifier [ + + ^ self petsUrl / anIdentifier printString asZnUrl +] + { #category : #'private - support' } PetsRESTfulWebServiceTest >> withJsonFromContentsIn: httpResponse do: aBlock [ diff --git a/source/Stargate-REST-API/HttpRequestContext.class.st b/source/Stargate-REST-API/HttpRequestContext.class.st index 8df3219..f6ca85c 100644 --- a/source/Stargate-REST-API/HttpRequestContext.class.st +++ b/source/Stargate-REST-API/HttpRequestContext.class.st @@ -1,3 +1,7 @@ +" +I'm a context that gets created each time an Http Request is proceesed. I can carry contextual information to be easily used further in the processing pipeline. +I also provide some facilities to manage Hypermedia Controls. +" Class { #name : #HttpRequestContext, #superclass : #Object, @@ -10,14 +14,45 @@ Class { { #category : #accessing } HttpRequestContext >> hold: anObject under: aConcept [ - knownObjects at: aConcept put: anObject + knownObjects at: aConcept put: anObject. + ^ anObject +] + +{ #category : #hypermedia } +HttpRequestContext >> holdAsHypermediaControls: aControlCollection [ + + self hold: aControlCollection under: #hypermediaControls +] + +{ #category : #hypermedia } +HttpRequestContext >> holdAsHypermediaControls: aControlCollection forSubresource: aResource [ + + | subResourcesHypermediaControls | + + subResourcesHypermediaControls := self objectUnder: #subResourceHypermediaControls ifNone: [ self hold: IdentityDictionary new under: #subResourceHypermediaControls ]. + subResourcesHypermediaControls at: aResource put: aControlCollection +] + +{ #category : #hypermedia } +HttpRequestContext >> hypermediaControls [ + + ^ self objectUnder: #hypermediaControls ifNone: [ #() ] +] + +{ #category : #hypermedia } +HttpRequestContext >> hypermediaControlsFor: aResource [ + + | hypermediaControls | + + hypermediaControls := self objectUnder: #subResourceHypermediaControls ifNone: [ ^ #() ]. + ^ hypermediaControls at: aResource ifAbsent: [ #() ] ] { #category : #initialization } HttpRequestContext >> initialize [ super initialize. - knownObjects := Dictionary new + knownObjects := IdentityDictionary new ] { #category : #accessing } diff --git a/source/Stargate-REST-API/HypermediaAwareJSONWriter.class.st b/source/Stargate-REST-API/HypermediaAwareJSONWriter.class.st deleted file mode 100644 index fe90cf2..0000000 --- a/source/Stargate-REST-API/HypermediaAwareJSONWriter.class.st +++ /dev/null @@ -1,36 +0,0 @@ -Class { - #name : #HypermediaAwareJSONWriter, - #superclass : #NeoJSONWriter, - #instVars : [ - 'mediaControls' - ], - #category : #'Stargate-REST-API' -} - -{ #category : #'initialize-release' } -HypermediaAwareJSONWriter >> initialize [ - - super initialize. - mediaControls := Dictionary new -] - -{ #category : #'initialize-release' } -HypermediaAwareJSONWriter >> mediaControls: aDictionary [ - - mediaControls := aDictionary -] - -{ #category : #writing } -HypermediaAwareJSONWriter >> writeMapStreamingDo: block [ - - | controls | - - writeStream nextPut: ${. - controls := mediaControls. - mediaControls := Dictionary new. - self writeStreamingDo: block. - controls - ifNotEmpty: [ self mapElementSeparator. - self encodeKey: 'links' value: controls ]. - writeStream nextPut: $} -] diff --git a/source/Stargate-REST-API/NeoJSONObjectMapping.extension.st b/source/Stargate-REST-API/NeoJSONObjectMapping.extension.st new file mode 100644 index 0000000..2f3ac99 --- /dev/null +++ b/source/Stargate-REST-API/NeoJSONObjectMapping.extension.st @@ -0,0 +1,13 @@ +Extension { #name : #NeoJSONObjectMapping } + +{ #category : #'*Stargate-REST-API' } +NeoJSONObjectMapping >> mapAsHypermediaControls: aBlock [ + + self mapProperty: #links getter: [ :object | (aBlock cull: object) asDictionary ] setter: [ :object :value | ] +] + +{ #category : #'*Stargate-REST-API' } +NeoJSONObjectMapping >> mapHypermediaControlsIn: aContext [ + + self mapAsHypermediaControls: [ aContext hypermediaControls ] +] diff --git a/source/Stargate-REST-API/RESTfulWebService.class.st b/source/Stargate-REST-API/RESTfulWebService.class.st index 1093167..1f6c0f1 100644 --- a/source/Stargate-REST-API/RESTfulWebService.class.st +++ b/source/Stargate-REST-API/RESTfulWebService.class.st @@ -50,22 +50,40 @@ RESTfulWebService >> evaluateQuery: aQueryEvaluationBlock [ ] { #category : #'private - API' } -RESTfulWebService >> get: aQueryEvaluationBlock encodedUsing: aKey basedOn: anHttpRequest within: aContext [ +RESTfulWebService >> get: aQueryEvaluationBlock asCollectionEncodedUsing: aKey basedOn: anHttpRequest within: aContext [ - | mediaType result | + | mediaType | mediaType := self targetMediaTypeFrom: anHttpRequest. - ^ [ | encodedResult | + ^ [ | resourceCollection encodedResourceCollection | - result := self evaluateQuery: aQueryEvaluationBlock. - aContext hold: {('self' -> (self locationOf: result))} under: #mediaControls. - encodedResult := self - encode: result + resourceCollection := self evaluateQuery: aQueryEvaluationBlock. + + aContext holdAsHypermediaControls: {('self' -> anHttpRequest absoluteUrl printString)}. + resourceCollection do: [ :resource | aContext holdAsHypermediaControls: (self mediaControlsFor: resource) forSubresource: resource ]. + + encodedResourceCollection := self + encode: resourceCollection at: aKey to: mediaType within: aContext. - ZnResponse ok: encodedResult ] + ZnResponse ok: encodedResourceCollection ] + on: ConflictingObjectFound + do: [ :error | HTTPClientError signalConflict: error messageText ] +] + +{ #category : #'private - API' } +RESTfulWebService >> get: aQueryEvaluationBlock encodedUsing: aKey basedOn: anHttpRequest within: aContext [ + + | mediaType resource | + + mediaType := self targetMediaTypeFrom: anHttpRequest. + + ^ [ + resource := self evaluateQuery: aQueryEvaluationBlock. + aContext holdAsHypermediaControls: (self mediaControlsFor: resource). + ZnResponse ok: (self encode: resource at: aKey to: mediaType within: aContext) ] on: ConflictingObjectFound do: [ :error | HTTPClientError signalConflict: error messageText ] ] @@ -84,6 +102,12 @@ RESTfulWebService >> locationOf: resource [ self subclassResponsibility ] +{ #category : #'private - accessing' } +RESTfulWebService >> mediaControlsFor: result [ + + ^ {('self' -> (self locationOf: result))} +] + { #category : #configuring } RESTfulWebService >> serverUrl: aServerUrl [ @@ -110,17 +134,17 @@ RESTfulWebService >> targetMediaTypeFrom: anHttpRequest [ { #category : #'private - API' } RESTfulWebService >> withCreatedResourceDo: aBlock decodedUsing: aKey basedOn: anHttpRequest within: aContext [ - | resource | + | decodedRepresentation newResource | - resource := self + decodedRepresentation := self decode: anHttpRequest contents at: aKey from: anHttpRequest contentType within: aContext. - [ aBlock value: resource ] + newResource := [ aBlock value: decodedRepresentation ] on: ConflictingObjectFound do: [ :signal | HTTPClientError signalConflict: signal messageText ]. - ^ ZnResponse created: (self locationOf: resource) + ^ ZnResponse created: (self locationOf: newResource) ]