joosy 0.1.0.RC1 → 0.1.0.RC2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. data/Gemfile +1 -0
  2. data/Gemfile.lock +8 -1
  3. data/MIT-LICENSE +2 -2
  4. data/README.md +89 -0
  5. data/app/assets/javascripts/joosy/core/application.js.coffee +25 -5
  6. data/app/assets/javascripts/joosy/core/form.js.coffee +212 -22
  7. data/app/assets/javascripts/joosy/core/helpers.js.coffee +11 -1
  8. data/app/assets/javascripts/joosy/core/joosy.js.coffee +22 -17
  9. data/app/assets/javascripts/joosy/core/layout.js.coffee +17 -7
  10. data/app/assets/javascripts/joosy/core/modules/container.js.coffee +19 -15
  11. data/app/assets/javascripts/joosy/core/modules/events.js.coffee +10 -9
  12. data/app/assets/javascripts/joosy/core/modules/filters.js.coffee +16 -12
  13. data/app/assets/javascripts/joosy/core/modules/log.js.coffee +8 -5
  14. data/app/assets/javascripts/joosy/core/modules/module.js.coffee +31 -21
  15. data/app/assets/javascripts/joosy/core/modules/renderer.js.coffee +114 -51
  16. data/app/assets/javascripts/joosy/core/modules/time_manager.js.coffee +2 -2
  17. data/app/assets/javascripts/joosy/core/modules/widgets_manager.js.coffee +10 -10
  18. data/app/assets/javascripts/joosy/core/page.js.coffee +31 -21
  19. data/app/assets/javascripts/joosy/core/preloader.js.coffee +3 -3
  20. data/app/assets/javascripts/joosy/core/resource/collection.js.coffee +137 -0
  21. data/app/assets/javascripts/joosy/core/resource/generic.js.coffee +178 -13
  22. data/app/assets/javascripts/joosy/core/resource/rest.js.coffee +167 -44
  23. data/app/assets/javascripts/joosy/core/resource/rest_collection.js.coffee +100 -32
  24. data/app/assets/javascripts/joosy/core/router.js.coffee +23 -25
  25. data/app/assets/javascripts/joosy/core/templaters/rails_jst.js.coffee +19 -3
  26. data/app/assets/javascripts/joosy/core/widget.js.coffee +7 -9
  27. data/app/assets/javascripts/joosy/preloaders/caching.js.coffee +117 -57
  28. data/app/assets/javascripts/joosy/preloaders/inline.js.coffee +23 -24
  29. data/app/helpers/joosy/sprockets_helper.rb +1 -1
  30. data/lib/joosy/forms.rb +2 -12
  31. data/lib/joosy/rails/version.rb +1 -1
  32. data/lib/rails/generators/joosy/templates/app/pages/template.js.coffee +1 -1
  33. data/lib/rails/generators/joosy/templates/app/resources/template.js.coffee +1 -1
  34. data/spec/javascripts/joosy/core/form_spec.js.coffee +55 -12
  35. data/spec/javascripts/joosy/core/layout_spec.js.coffee +1 -1
  36. data/spec/javascripts/joosy/core/modules/container_spec.js.coffee +0 -1
  37. data/spec/javascripts/joosy/core/modules/module_spec.js.coffee +1 -1
  38. data/spec/javascripts/joosy/core/modules/renderer_spec.js.coffee +39 -3
  39. data/spec/javascripts/joosy/core/modules/time_manager_spec.js.coffee +1 -1
  40. data/spec/javascripts/joosy/core/page_spec.js.coffee +4 -1
  41. data/spec/javascripts/joosy/core/resource/collection_spec.js.coffee +84 -0
  42. data/spec/javascripts/joosy/core/resource/generic_spec.js.coffee +86 -3
  43. data/spec/javascripts/joosy/core/resource/rest_collection_spec.js.coffee +15 -22
  44. data/spec/javascripts/joosy/core/resource/rest_spec.js.coffee +27 -4
  45. data/spec/javascripts/joosy/core/widget_spec.js.coffee +3 -14
  46. metadata +21 -19
  47. data/README.rdoc +0 -3
@@ -1,5 +1,5 @@
1
1
  # Preloader stub
2
- @Preloader = Object.extended
2
+ @Preloader =
3
3
  load: (libraries, options) ->
4
- @.merge options
5
- @complete?.call window
4
+ @[key] = val for key, val of options
5
+ @complete?.call window
@@ -0,0 +1,137 @@
1
+ #
2
+ # Basic collection of Resources
3
+ # Turns JSON array into array of Resources and manages them
4
+ #
5
+ # Generally you should not use Collection directly. It will be
6
+ # automatically created by things like Joosy.Resource.Generic#map
7
+ # or Joosy.Resource.REST#find.
8
+ #
9
+ # Example:
10
+ # class R extends Joosy.Resource.Generic
11
+ # @entity 'r'
12
+ #
13
+ # collection = new Joosy.Resource.Collection(R)
14
+ #
15
+ # collection.reset [{foo: 'bar'}, {foo: 'baz'}]
16
+ # collection.each (resource) ->
17
+ # resource('foo')
18
+ #
19
+ class Joosy.Resource.Collection extends Joosy.Module
20
+ @include Joosy.Modules.Events
21
+
22
+ #
23
+ # Allows to modify data before it gets stored
24
+ #
25
+ # @param [Function] action `(Object) -> Object` to call
26
+ #
27
+ @beforeLoad: (action) -> @::__beforeLoad = action
28
+
29
+ #
30
+ # Modelized data storage
31
+ #
32
+ data: []
33
+
34
+ #
35
+ # @param [Class] model Resource class this collection will handle
36
+ #
37
+ constructor: (@model) ->
38
+
39
+ #
40
+ # Clears the storage and attempts to import given JSON
41
+ #
42
+ # @param [Object] entities Entities to import
43
+ # @param [Boolean] notify Indicates whether to trigger 'changed' event
44
+ #
45
+ reset: (entities, notify=true) ->
46
+ if @__beforeLoad?
47
+ entities = @__beforeLoad entities
48
+
49
+ @data = @modelize entities
50
+
51
+ if notify
52
+ @trigger 'changed'
53
+ this
54
+
55
+ #
56
+ # Turns Objects array into array of Resources
57
+ #
58
+ # @param [Array] collection Array of Objects
59
+ #
60
+ modelize: (collection) ->
61
+ root = @model::__entityName.pluralize()
62
+
63
+ if collection not instanceof Array
64
+ collection = collection?[root.camelize(false)]
65
+
66
+ if collection not instanceof Array
67
+ throw new Error "Can not read incoming JSON"
68
+
69
+ collection.map (x) =>
70
+ @model.create x
71
+
72
+ #
73
+ # Calls callback for each Resource inside Collection
74
+ #
75
+ # @param [Function] callback
76
+ #
77
+ each: (callback) ->
78
+ @data.each callback
79
+
80
+ #
81
+ # Gets first resource matching description (see Sugar.js Array#find)
82
+ #
83
+ # @param [Function] description Callback matcher
84
+ #
85
+ find: (description) ->
86
+ @data.find description
87
+
88
+ #
89
+ # Gets resource by id
90
+ #
91
+ # @param [Integer] id Id to find
92
+ #
93
+ findById: (id) ->
94
+ @data.find (x) -> x('id').toString() == id.toString()
95
+
96
+ #
97
+ # Gets resource by its index inside collection
98
+ #
99
+ # @param [Integer] i Index
100
+ #
101
+ at: (i) ->
102
+ @data[i]
103
+
104
+ #
105
+ # Removes resource from collection by its index or by === comparison
106
+ #
107
+ # @param [Integer] target Index
108
+ # @param [Resource] target Resource by itself
109
+ # @param [Boolean] notify Indicates whether to trigger 'changed' event
110
+ #
111
+ remove: (target, notify=true) ->
112
+ if Object.isNumber target
113
+ index = target
114
+ else
115
+ index = @data.indexOf target
116
+ if index >= 0
117
+ result = @data.splice(index, 1)[0]
118
+ if notify
119
+ @trigger 'changed'
120
+ result
121
+
122
+ #
123
+ # Adds resource to collection to given index or to the end
124
+ #
125
+ # @param [Resource] element Resource to add
126
+ # @param [Integer] index Index to add to. If omited will be pushed to the end
127
+ # @param [Boolean] notify Indicates whether to trigger 'changed' event
128
+ #
129
+ add: (element, index=false, notify=true) ->
130
+ if index
131
+ @data.splice index, 0, element
132
+ else
133
+ @data.push element
134
+
135
+ if notify
136
+ @trigger 'changed'
137
+ element
@@ -1,12 +1,129 @@
1
+ #
2
+ # Basic data wrapper with triggering
3
+ #
4
+ # Example:
5
+ # class R extends Joosy.Resource.Generic
6
+ # @entity 'r'
7
+ #
8
+ # @beforeLoad (data) ->
9
+ # data.real = true
10
+ #
11
+ # r = R.create {r: {foo: {bar: 'baz'}}}
12
+ #
13
+ # r('foo') # {baz: 'baz'}
14
+ # r('real') # true
15
+ # r('foo.bar') # baz
16
+ # r('foo.bar', 'fluffy') # triggers 'changed'
17
+ #
1
18
  class Joosy.Resource.Generic extends Joosy.Module
2
19
  @include Joosy.Modules.Log
3
20
  @include Joosy.Modules.Events
4
21
 
22
+ #
23
+ # Sets the data source description (which is NOT required)
24
+ # This has no use in Generic but is required in any descendant
25
+ #
26
+ # @param [mixed] Source can be any type including lambda
27
+ # If lambda is given resource will not expect direct .create() calls
28
+ # You'll have to prepare descendant with .at() first
29
+ #
30
+ # Example:
31
+ # Class Y extends Joosy.Resource.Generic
32
+ # @source 'fluffies'
33
+ # class R extends Joosy.Resource.Generic
34
+ # @source -> (path) "/"+path
35
+ #
36
+ # r = Y.create{} # will work as expected
37
+ # r = R.create {} # will raise exception
38
+ # r = R.at('foo/bar').create {} # will work as expected
39
+ #
40
+ @source: (source) -> @__source = source
41
+
42
+ #
43
+ # Required to do some magic like skipping the root node
44
+ #
45
+ # @param [String] name Singular name of resource
46
+ #
47
+ @entity: (name) -> @::__entityName = name
48
+
49
+ #
50
+ # Sets the collection of current Resource
51
+ #
52
+ # @param [Object] klass Class to assign as collection
53
+ #
54
+ @collection: (klass) -> @::__collection = -> klass
55
+
56
+ #
57
+ # Default value of resource collection
58
+ # Will try to seek for EntityNamesCollection
59
+ # Will fallback to Joosy.Resource.Collection
60
+ #
61
+ __collection: ->
62
+ named = @__entityName.camelize().pluralize() + 'Collection'
63
+ if window[named] then window[named] else Joosy.Resource.Collection
64
+
65
+ #
66
+ # Allows to modify data before it gets stored
67
+ #
68
+ # @param [Function] action `(Object) -> Object` to call
69
+ #
5
70
  @beforeLoad: (action) -> @::__beforeLoad = action
6
71
 
7
- @create: ->
72
+ #
73
+ # Dynamically creates collection of inline resources
74
+ # Inline resource share the instance with direct data and therefore can be used
75
+ # to better handle inline changes
76
+ #
77
+ # Example:
78
+ # class Zombie extends Joosy.Resource.Generic
79
+ # @entity 'a'
80
+ # class Puppy extends Joosy.Resource.Generic
81
+ # @entity 'b'
82
+ # @maps 'zombies'
83
+ #
84
+ # p = Puppy.create {zombies: [{foo: 'bar'}]}
85
+ #
86
+ # p('zombies') # Direct access: [{foo: 'bar'}]
87
+ # p.zombies # Wrapped Collection of Zombie instances
88
+ # p.zombies.at(0)('foo') # bar
89
+ #
90
+ # @param [String] name Pluralized name of property to define
91
+ # @param [Class] klass Resource class to instantiate
92
+ #
93
+ @map: (name, klass=false) ->
94
+ unless klass
95
+ klass = window[name.singularize().camelize()]
96
+
97
+ if !klass
98
+ throw new Error "#{Joosy.Module.__className @}> class can not be detected for '#{name}' mapping"
99
+
100
+ @beforeLoad (data) ->
101
+ @[name] = new (klass::__collection()) klass
102
+ if Object.isArray data[name]
103
+ @[name].reset data[name]
104
+ data
105
+
106
+ #
107
+ # Creates the descnendant of current resource with proper source
108
+ # Should be used together with lambda source (see @source for example)
109
+ #
110
+ @at: ->
111
+ if !Object.isFunction @__source
112
+ throw new Error "#{Joosy.Module.__className @}> should be created directly (without `at')"
113
+
114
+ class clone extends this
115
+ clone.__source = @__source arguments...
116
+ clone
117
+
118
+ #
119
+ # Wraps instance of resource inside shim-function allowing to track
120
+ # data changes. See class example
121
+ #
122
+ # @return [Joosy.Resource.Generic]
123
+ #
124
+ @create: ->
8
125
  shim = ->
9
- shim.__call.apply(shim, arguments)
126
+ shim.__call.apply shim, arguments
10
127
 
11
128
  if shim.__proto__
12
129
  shim.__proto__ = @prototype
@@ -16,22 +133,46 @@ class Joosy.Resource.Generic extends Joosy.Module
16
133
 
17
134
  shim.constructor = @
18
135
 
19
- @apply(shim, arguments)
136
+ @apply shim, arguments
20
137
 
21
138
  shim
22
139
 
140
+ #
141
+ # Should NOT be called directly, use .create() instead
142
+ #
143
+ # @param [Object] data Data to store
144
+ #
23
145
  constructor: (data) ->
24
- @__fillData(data)
25
-
146
+ @__fillData data
147
+
148
+ #
149
+ # Getter for wrapped data
150
+ #
151
+ # @param [String] path Attribute name to get. Can contain dots to get inline Objects values
152
+ # @return [mixed]
153
+ #
26
154
  get: (path) ->
27
- target = @__callTarget(path)
155
+ target = @__callTarget path
28
156
  target[0][target[1]]
29
157
 
158
+ #
159
+ # Setter for wrapped data, triggers 'changed'
160
+ #
161
+ # @param [String] path Attribute name to set. Can contain dots to get inline Objects values
162
+ # @param [mixed] value Value to set
163
+ #
30
164
  set: (path, value) ->
31
- target = @__callTarget(path)
165
+ target = @__callTarget path
32
166
  target[0][target[1]] = value
33
167
  @trigger 'changed'
168
+ null
34
169
 
170
+ #
171
+ # Locates the actual instance of dotted path from get/set
172
+ #
173
+ # @param [String] path Path to the attribute ('foo.bar')
174
+ # @return [Array] Instance of object containing last step of path and keyword for required field
175
+ #
35
176
  __callTarget: (path) ->
36
177
  if path.has(/\./) && !@e[path]?
37
178
  path = path.split '.'
@@ -46,16 +187,40 @@ class Joosy.Resource.Generic extends Joosy.Module
46
187
  else
47
188
  [@e, path]
48
189
 
190
+ #
191
+ # Wrapper for .create() magic
192
+ #
49
193
  __call: (path, value) ->
50
- if value
194
+ if arguments.length > 1
51
195
  @set path, value
52
196
  else
53
197
  @get path
54
198
 
55
- __fillData: (data) ->
56
- @e = @__prepareData(data)
199
+ #
200
+ # Defines how exactly prepared data should be saved
201
+ #
202
+ # @param [Object] data Raw data to store
203
+ #
204
+ __fillData: (data, notify=true) ->
205
+ @e = @__prepareData data
206
+
207
+ if notify
208
+ @trigger 'changed'
209
+
210
+ null
57
211
 
58
- __prepareData: (data) ->
59
- data = Object.extended(data)
60
- data = @__beforeLoad(data) if @__beforeLoad?
212
+ #
213
+ # Prepares raw data: cuts the root node if it exists, runs before filters
214
+ #
215
+ # @param [Object] data Raw data to prepare
216
+ # @return [Object]
217
+ #
218
+ __prepareData: (data) ->
219
+ if Object.isObject(data) && Object.keys(data).length == 1 && @__entityName
220
+ name = @__entityName.camelize(false)
221
+ data = data[name] if data[name]
222
+
223
+ if @__beforeLoad?
224
+ data = @__beforeLoad data
225
+
61
226
  data
@@ -1,76 +1,199 @@
1
1
  #= require ./rest_collection
2
2
 
3
+ #
4
+ # Resource with the HTTP REST as the backend
5
+ #
6
+ # Example:
7
+ # class Rocket extends Joosy.Resource.REST
8
+ # @entity 'rocket'
9
+ #
10
+ # r = Rocket.find {speed: 'fast'} # queries /rockets/?speed=fast to get RESTCollection
11
+ # r = Rocket.find 1 # queries /rockets/1 to get Rocket instance
12
+ #
13
+ # class Engine extends Joosy.Resource.REST
14
+ # @entity 'engine'
15
+ # @source ->
16
+ # (rocket) "/rockets/#{rocket 'id'}/engines"
17
+ #
18
+ # e = Engine.at(r).find {oil: true} # queries /rockets/1/engies?oil=true
19
+ #
3
20
  class Joosy.Resource.REST extends Joosy.Resource.Generic
4
21
 
22
+ #
23
+ # Default primary key field 'id'
24
+ #
5
25
  __primaryKey: 'id'
6
-
7
- @entity: (name) -> @::__entityName = name
8
- @source: (source) -> @::__source = source
26
+
27
+
28
+ #
29
+ # Default value of resource collection
30
+ # Will try to seek for EntityNamesCollection
31
+ # Will fallback to Joosy.Resource.RESTCollection
32
+ #
33
+ __collection: ->
34
+ named = @__entityName.camelize().pluralize() + 'Collection'
35
+ if window[named] then window[named] else Joosy.Resource.RESTCollection
36
+
37
+ #
38
+ # Sets the field containing primary key
39
+ #
40
+ # It has no direct use inside the REST resource itself and can be omited
41
+ # That's said: REST resource can work without primary at all. It's here
42
+ # just to improve end-users experience for cases when primary exists.
43
+ #
44
+ # @param [String] primary Name of the field
45
+ #
9
46
  @primary: (primary) -> @::__primaryKey = primary
10
47
 
11
- constructor: (description) ->
12
- if @constructor.__isId(description)
48
+ #
49
+ # Should NOT be called directly, use .create() instead
50
+ #
51
+ # @param [Integer|String|Object] description ID of entity or full data to store
52
+ #
53
+ constructor: (description={}) ->
54
+ if @constructor.__isId description
13
55
  @id = description
14
56
  else
15
57
  super description
16
58
  @id = @e[@__primaryKey]
17
59
 
18
- @entityName: ->
19
- unless @::hasOwnProperty '__entityName'
20
- throw new Error "Joosy.Resource.REST does not have entity name"
21
-
22
- @::__entityName
23
-
24
- # Returns single entity if int/string given
60
+ #
61
+ # Queries for REST data and creates resources instances
62
+ #
63
+ # Returns single entity if integer or string given
25
64
  # Returns collection if no value or Object (with parameters) given
26
- @find: (description, callback, options) ->
27
- if @__isId(description)
28
- resource = new @(description)
29
- resource.fetch callback, options
65
+ #
66
+ # If first parameter is a Function it's considered as a result callback
67
+ # In this case parameters will be considered equal to {}
68
+ #
69
+ # Example:
70
+ # class Rocket extends Joosy.Resource.REST
71
+ # @entity 'rocket'
72
+ #
73
+ # Rocket.find 1
74
+ # Rocket.find {type: 'nuclear'}, (data) -> data
75
+ # Rocket.find (data) -> data
76
+ # Rocket.find 1,
77
+ # success: (data) -> data)
78
+ # cache: true
79
+ #
80
+ # @param [Integer|String|Object] description ID of entity or full data to store
81
+ # @param [Function|Object] options AJAX options.
82
+ # Will be considered as a success callback if function given
83
+ #
84
+ # @return [Joosy.Resource.REST|Joosy.Resource.RESTCollection]
85
+ #
86
+ @find: (description, options) ->
87
+ if Object.isFunction options
88
+ options = {success: options}
89
+
90
+ if @__isId description
91
+ resource = @create description
92
+ resource.fetch options
30
93
  resource
31
94
  else
32
- if Object.isFunction(description) && !callback
33
- callback = description
95
+ if !options? && Object.isFunction description
96
+ options = {success: description}
34
97
  description = undefined
35
- resources = new Joosy.Resource.RESTCollection(@, description)
36
- resources.fetch callback, options
98
+ resources = new (@::__collection()) this, description
99
+ resources.fetch options
37
100
  resources
38
101
 
39
- fetch: (callback, options) ->
40
- @constructor.__ajax 'get', @constructor.__buildSource(extension: @id), options, (e) =>
41
- @__fillData(e)
42
- callback?(this)
102
+ #
103
+ # Queries the resource url and reloads the data from server
104
+ #
105
+ # @param [Function|Object] options AJAX options.
106
+ # Will be considered as a success callback if function given
107
+ # @return [Joosy.Resource.REST]
108
+ #
109
+ fetch: (options) ->
110
+ if Object.isFunction options
111
+ callback = options
112
+ else
113
+ callback = options?.success
114
+ delete options?.success
43
115
 
116
+ @constructor.__ajax 'get', @constructor.__buildSource(extension: @id), options, (e) =>
117
+ @__fillData e, false
118
+ callback? this
119
+ @trigger 'changed'
44
120
  this
45
121
 
46
122
  save: ->
47
123
 
48
- destroy: (callback, options) ->
124
+ #
125
+ # Destroys the resource by DELETE query
126
+ #
127
+ # @param [Function|Object] options AJAX options.
128
+ # Will be considered as a success callback if function given
129
+ # @return [Joosy.Resource.REST]
130
+ #
131
+ destroy: (options) ->
132
+ if Object.isFunction options
133
+ callback = options
134
+ else
135
+ callback = options?.success
136
+ delete options?.success
137
+
49
138
  @constructor.__ajax 'delete', @constructor.__buildSource(extension: @id), options, (e) =>
50
- callback?(this)
51
-
139
+ callback? this
52
140
  this
53
-
54
- @__isId: (something) -> Object.isNumber(something) || Object.isString(something)
55
-
141
+
142
+ #
143
+ # Requests the REST member URL with POST or any method given in options.type
144
+ #
145
+ # @param [String] ending Member url (like 'foo' or 'foo/bar')
146
+ # @param [Function|Object] options AJAX options.
147
+ # Will be considered as a success callback if function given
148
+ #
149
+ request: (ending, options) ->
150
+ if Object.isFunction options
151
+ callback = options
152
+ else
153
+ callback = options?.success
154
+ delete options?.success
155
+
156
+ if options.method || options.type
157
+ type = options.method || options.type
158
+ else
159
+ type = 'post'
160
+
161
+ @constructor.__ajax type, @constructor.__buildSource(extension: "#{@id}/#{ending}"), options, callback
162
+
163
+ #
164
+ # Checks if given description can be considered as ID
165
+ #
166
+ # @param [Integer|String|Object] something Value to test
167
+ # @return [Boolean]
168
+ #
169
+ @__isId: (something) ->
170
+ Object.isNumber(something) || Object.isString(something)
171
+
172
+ #
173
+ # jQuery AJAX wrapper
174
+ #
175
+ # @param [String] method HTTP Method (GET/POST/PUT/DELETE)
176
+ # @param [String] url URL to query
177
+ # @param [Object] options AJAX options to pass with request
178
+ # @param [Function] callback XHR callback
179
+ #
56
180
  @__ajax: (method, url, options={}, callback) ->
57
- $.ajax url, Object.extended(
181
+ $.ajax url, Joosy.Module.merge options,
58
182
  type: method
59
183
  success: callback
60
184
  cache: false
61
185
  dataType: 'json'
62
- ).merge options
63
186
 
187
+ #
188
+ # Builds URL for current resource location
189
+ #
190
+ # @param [Object] options Handling options
191
+ # extension: string to add to resource base url
192
+ # params: GET-params to add to resulting url
193
+ #
64
194
  @__buildSource: (options={}) ->
65
- unless @::hasOwnProperty '__source'
66
- @::__source = "/" + @entityName().pluralize()
67
-
68
- source = Joosy.buildUrl("#{@::__source}/#{options.extension || ''}", options.params)
69
-
70
- __fillData: (data) ->
71
- data = @__prepareData data
72
-
73
- if Object.isObject(data) && data[@constructor.entityName()] && data.keys().length == 1
74
- @e = Object.extended data[@constructor.entityName()]
75
- else
76
- @e = data
195
+ unless @hasOwnProperty '__source'
196
+ @__source = "/" + @::__entityName.pluralize()
197
+
198
+ source = if Object.isFunction(@__source) then @__source() else @__source
199
+ source = Joosy.buildUrl "#{source}/#{options.extension || ''}", options.params