media_types-serialization 1.0.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,8 +15,7 @@ require 'active_support/concern'
15
15
  require 'active_support/core_ext/module/attribute_accessors'
16
16
  require 'active_support/core_ext/object/blank'
17
17
 
18
- require 'http_headers/accept'
19
-
18
+ require 'media_types/serialization/utils/accept_header'
20
19
  require 'media_types/serialization/base'
21
20
  require 'media_types/serialization/error'
22
21
  require 'media_types/serialization/serialization_dsl'
@@ -44,7 +43,8 @@ end
44
43
  module MediaTypes
45
44
  module Serialization
46
45
 
47
- HEADER_ACCEPT = 'HTTP_ACCEPT'
46
+ HEADER_ACCEPT = 'HTTP_ACCEPT'.freeze
47
+ HEADER_ACCEPT_LANGUAGE = 'HTTP_ACCEPT_LANGUAGE'.freeze
48
48
 
49
49
  mattr_accessor :json_encoder, :json_decoder
50
50
  if defined?(::Oj)
@@ -183,7 +183,60 @@ module MediaTypes
183
183
  @serialization_output_registrations = @serialization_output_registrations.merge(mergeable_outputs)
184
184
  end
185
185
  end
186
-
186
+
187
+ def allow_output_html(as: nil, view: nil, layout: nil, formats: [:html], variants: nil, **filter_opts)
188
+ before_action(**filter_opts) do
189
+ raise SerializersAlreadyFrozenError if defined? @serialization_frozen
190
+
191
+ @serialization_output_registrations ||= SerializationRegistration.new(:output)
192
+
193
+ html_registration = SerializationRegistration.new(:output)
194
+ output_identifier = 'text/html'
195
+ output_identifier += "; variant=#{as}" unless as.nil?
196
+
197
+ validator = FakeValidator.new(as.nil? ? 'text/html' : as)
198
+
199
+ block = lambda { |_, _, controller|
200
+ options = {}
201
+ options[:layout] = layout unless layout.nil?
202
+ options[:template] = view unless view.nil?
203
+ options[:formats] = formats unless formats.nil?
204
+ options[:variants] = variants unless variants.nil?
205
+
206
+ controller.render_to_string(**options)
207
+ }
208
+
209
+ html_registration.register_block(nil, validator, nil, block, true, wildcards: true)
210
+ html_registration.registrations[validator.identifier].display_identifier = output_identifier
211
+ html_registration.registrations["#{validator.identifier.split('/')[0]}/*"].display_identifier = output_identifier
212
+ html_registration.registrations['*/*'].display_identifier = output_identifier
213
+
214
+ @serialization_output_registrations = @serialization_output_registrations.merge(html_registration)
215
+ end
216
+ end
217
+
218
+ def allow_output_docs(description, **filter_opts)
219
+ before_action(**filter_opts) do
220
+ raise SerializersAlreadyFrozenError if defined? @serialization_frozen
221
+
222
+ @serialization_output_registrations ||= SerializationRegistration.new(:output)
223
+
224
+ docs_registration = SerializationRegistration.new(:output)
225
+ validator = FakeValidator.new('text/vnd.delftsolutions.docs')
226
+
227
+ block = lambda { |_, _, _|
228
+ description
229
+ }
230
+
231
+ docs_registration.register_block(nil, validator, nil, block, true, wildcards: true)
232
+ docs_registration.registrations['text/vnd.delftsolutions.docs'].display_identifier = 'text/plain; charset=utf-8'
233
+ docs_registration.registrations['text/*'].display_identifier = 'text/plain; charset=utf-8'
234
+ docs_registration.registrations['*/*'].display_identifier = 'text/plain; charset=utf-8'
235
+
236
+ @serialization_output_registrations = @serialization_output_registrations.merge(docs_registration)
237
+ end
238
+ end
239
+
187
240
  def allow_api_viewer(serializer: MediaTypes::Serialization::Serializers::ApiViewer, **filter_opts)
188
241
  before_action do
189
242
  @serialization_api_viewer_enabled ||= {}
@@ -216,7 +269,7 @@ module MediaTypes
216
269
  raise ArrayInViewParameterError, :allow_input_serializer if view.is_a? Array
217
270
  views = [view] if views.nil?
218
271
  raise ViewsNotAnArrayError unless views.is_a? Array
219
-
272
+
220
273
  before_action do
221
274
  @serialization_available_serializers ||= {}
222
275
  @serialization_available_serializers[:input] ||= {}
@@ -259,8 +312,8 @@ module MediaTypes
259
312
  ##
260
313
  # Freezes additions to the serializes and notifies the controller what it will be able to respond to.
261
314
  #
262
- def freeze_io!
263
- before_action :serializer_freeze_io_internal
315
+ def freeze_io!(**filter_opts)
316
+ before_action :serializer_freeze_io_internal, **filter_opts
264
317
 
265
318
  output_error MediaTypes::Serialization::NoInputReceivedError do |p, error|
266
319
  p.title 'Providing input is mandatory. Please set a Content-Type', lang: 'en'
@@ -283,11 +336,6 @@ module MediaTypes
283
336
  end
284
337
  # rubocop:enable Metrics/BlockLength
285
338
 
286
- included do
287
- protected
288
-
289
- end
290
-
291
339
  protected
292
340
 
293
341
  def serialize(victim, media_type, serializer: Object.new, links: [], vary: ['Accept'])
@@ -307,6 +355,7 @@ module MediaTypes
307
355
  if obj == MEDIA_TYPES_SERIALIZATION_OBJ_IS_UNDEFINED && block.nil?
308
356
  raise 'render_media was called without an object. Please provide one or supply a block to match the serializer.'
309
357
  end
358
+
310
359
  obj = nil if obj == MEDIA_TYPES_SERIALIZATION_OBJ_IS_UNDEFINED
311
360
 
312
361
  raise SerializersNotFrozenError unless defined? @serialization_frozen
@@ -336,10 +385,17 @@ module MediaTypes
336
385
  selector.instance_exec(&block)
337
386
 
338
387
  raise UnmatchedSerializerError, serializer unless selector.matched
388
+
339
389
  obj = selector.value
340
390
  end
341
391
 
342
- serialization_render_resolved(obj: obj, serializer: serializer, identifier: identifier, registrations: registration, options: options)
392
+ serialization_render_resolved(
393
+ obj: obj,
394
+ serializer: serializer,
395
+ identifier: identifier,
396
+ registrations: registration,
397
+ options: options
398
+ )
343
399
  end
344
400
 
345
401
  def deserialize(request)
@@ -358,6 +414,7 @@ module MediaTypes
358
414
  raise SerializersNotFrozenError unless defined?(@serialization_frozen)
359
415
  raise NoInputReceivedError if request.content_type.blank?
360
416
  raise InputNotAcceptableError unless @serialization_input_registrations.has? request.content_type
417
+
361
418
  @serialization_input_registrations.call(@serialization_decoded_input, request.content_type, self)
362
419
  end
363
420
 
@@ -366,8 +423,9 @@ module MediaTypes
366
423
  return nil if identifier.nil?
367
424
 
368
425
  registration = registration.registrations[identifier]
369
-
426
+
370
427
  raise 'Assertion failed, inconsistent answer from resolve_media_type' if registration.nil?
428
+
371
429
  registration.serializer
372
430
  end
373
431
 
@@ -375,8 +433,12 @@ module MediaTypes
375
433
 
376
434
  def resolve_media_type(request, registration, allow_last: true)
377
435
  if defined? @serialization_override_accept
378
- @serialization_override_accept = registration.registrations.keys.last if allow_last && @serialization_override_accept == 'last'
436
+ if allow_last && @serialization_override_accept == 'last'
437
+ @serialization_override_accept = registration.registrations.keys.last
438
+ end
439
+
379
440
  return nil unless registration.has? @serialization_override_accept
441
+
380
442
  return @serialization_override_accept
381
443
  end
382
444
 
@@ -386,7 +448,7 @@ module MediaTypes
386
448
  #
387
449
  #
388
450
 
389
- accept_header = HttpHeaders::Accept.new(request.get_header(HEADER_ACCEPT)) || ''
451
+ accept_header = Utils::AcceptHeader.new(request.get_header(HEADER_ACCEPT)) || ''
390
452
  accept_header.each do |mime_type|
391
453
  stripped = mime_type.to_s.split(';')[0]
392
454
  next unless registration.has? stripped
@@ -403,7 +465,7 @@ module MediaTypes
403
465
  identifier = serializer.validator.identifier
404
466
  obj = { request: request, registrations: registrations }
405
467
  new_registrations = serializer.outputs_for(views: [nil])
406
-
468
+
407
469
  serialization_render_resolved(obj: obj, serializer: serializer, identifier: identifier, registrations: new_registrations, options: {})
408
470
  response.status = :not_acceptable
409
471
  end
@@ -424,7 +486,10 @@ module MediaTypes
424
486
  input_is_allowed = @serialization_input_registrations.has? request.content_type unless request.content_type.blank?
425
487
 
426
488
  unless input_is_allowed || all_allowed
427
- serializers = @serialization_unsupported_media_type_serializer || [MediaTypes::Serialization::Serializers::ProblemSerializer, MediaTypes::Serialization::Serializers::FallbackUnsupportedMediaTypeSerializer]
489
+ serializers = @serialization_unsupported_media_type_serializer || [
490
+ MediaTypes::Serialization::Serializers::ProblemSerializer,
491
+ MediaTypes::Serialization::Serializers::FallbackUnsupportedMediaTypeSerializer
492
+ ]
428
493
  registrations = SerializationRegistration.new(:output)
429
494
  serializers.each do |s|
430
495
  registrations = registrations.merge(s.outputs_for(views: [nil, :html]))
@@ -452,7 +517,10 @@ module MediaTypes
452
517
  input_data = request.body.read
453
518
  @serialization_decoded_input = @serialization_input_registrations.decode(input_data, request.content_type, self)
454
519
  rescue InputValidationFailedError => e
455
- serializers = @serialization_input_validation_failed_serializer || [MediaTypes::Serialization::Serializers::ProblemSerializer, MediaTypes::Serialization::Serializers::InputValidationErrorSerializer]
520
+ serializers = @serialization_input_validation_failed_serializer || [
521
+ MediaTypes::Serialization::Serializers::ProblemSerializer,
522
+ MediaTypes::Serialization::Serializers::InputValidationErrorSerializer
523
+ ]
456
524
  registrations = SerializationRegistration.new(:output)
457
525
  serializers.each do |s|
458
526
  registrations = registrations.merge(s.outputs_for(views: [nil, :html]))
@@ -498,7 +566,13 @@ module MediaTypes
498
566
  actions: @serialization_available_serializers,
499
567
  }
500
568
 
501
- serialization_render_resolved obj: input, serializer: description_serializer, identifier: endpoint_matched_identifier, registrations: @serialization_output_registrations, options: {}
569
+ serialization_render_resolved(
570
+ obj: input,
571
+ serializer: description_serializer,
572
+ identifier: endpoint_matched_identifier,
573
+ registrations: @serialization_output_registrations,
574
+ options: {}
575
+ )
502
576
  return
503
577
  end
504
578
 
@@ -30,6 +30,10 @@ module MediaTypes
30
30
  self.serializer_disable_wildcards = true
31
31
  end
32
32
 
33
+ def enable_wildcards
34
+ self.serializer_disable_wildcards = false
35
+ end
36
+
33
37
  def output(view: nil, version: nil, versions: nil, &block)
34
38
  versions = [version] if versions.nil?
35
39
  raise VersionsNotAnArrayError unless versions.is_a? Array
@@ -57,18 +61,18 @@ module MediaTypes
57
61
  end
58
62
  end
59
63
 
60
- def output_alias(media_type_identifier, view: nil)
64
+ def output_alias(media_type_identifier, view: nil, hide_variant: false)
61
65
  validator = serializer_validator.view(view)
62
66
  victim_identifier = validator.identifier
63
67
 
64
- serializer_output_registration.register_alias(self, media_type_identifier, victim_identifier, false, wildcards: !self.serializer_disable_wildcards)
68
+ serializer_output_registration.register_alias(self, media_type_identifier, victim_identifier, false, hide_variant, wildcards: !self.serializer_disable_wildcards)
65
69
  end
66
70
 
67
- def output_alias_optional(media_type_identifier, view: nil)
71
+ def output_alias_optional(media_type_identifier, view: nil, hide_variant: false)
68
72
  validator = serializer_validator.view(view)
69
73
  victim_identifier = validator.identifier
70
74
 
71
- serializer_output_registration.register_alias(self, media_type_identifier, victim_identifier, true, wildcards: !self.serializer_disable_wildcards)
75
+ serializer_output_registration.register_alias(self, media_type_identifier, victim_identifier, true, hide_variant, wildcards: !self.serializer_disable_wildcards)
72
76
  end
73
77
 
74
78
  def input(view: nil, version: nil, versions: nil, &block)
@@ -102,22 +106,22 @@ module MediaTypes
102
106
  validator = serializer_validator.view(view)
103
107
  victim_identifier = validator.identifier
104
108
 
105
- serializer_input_registration.register_alias(self, media_type_identifier, victim_identifier, false)
109
+ serializer_input_registration.register_alias(self, media_type_identifier, victim_identifier, false, true, wildcards: false)
106
110
  end
107
111
 
108
112
  def input_alias_optional(media_type_identifier, view: nil)
109
113
  validator = serializer_validator.view(view)
110
114
  victim_identifier = validator.identifier
111
115
 
112
- serializer_input_registration.register_alias(self, media_type_identifier, victim_identifier, true)
116
+ serializer_input_registration.register_alias(self, media_type_identifier, victim_identifier, true, true, wildcards: false)
113
117
  end
114
118
 
115
- def serialize(victim, media_type_identifier, context, dsl: nil, raw: nil)
119
+ def serialize(victim, media_type_identifier, context:, dsl: nil, raw: nil)
116
120
  dsl ||= SerializationDSL.new(self, context: context)
117
121
  serializer_output_registration.call(victim, media_type_identifier.to_s, context, dsl: dsl, raw: raw)
118
122
  end
119
123
 
120
- def deserialize(victim, media_type_identifier, context)
124
+ def deserialize(victim, media_type_identifier, context:)
121
125
  serializer_input_registration.call(victim, media_type_identifier, context)
122
126
  end
123
127
 
@@ -73,21 +73,29 @@ module MediaTypes
73
73
  def initialize(identifier, inout)
74
74
  super(
75
75
  "Serializer tried to define an #{inout}_alias that points to the media type identifier #{identifier} but no such #{inout} has been defined yet. Please move the #{inout} definition above the alias.\n\n" \
76
- "Move the output definition above the alias:\n" \
76
+ "Move the #{inout} definition above the alias:\n" \
77
77
  "\n" \
78
78
  "class MySerializer < MediaTypes::Serialization::Base\n" \
79
79
  "#...\n" \
80
- "output do\n" \
80
+ "#{inout} do\n" \
81
81
  " # ...\n" \
82
82
  "end\n" \
83
83
  "\n" \
84
- "output_alias 'text/html'\n" \
84
+ "#{inout}_alias 'text/html'\n" \
85
85
  "# ^----- move here\n" \
86
86
  'end'
87
87
  )
88
88
  end
89
89
  end
90
90
 
91
+ class VersionedAliasDefinitionError < ConfigurationError
92
+ def initialize(identifier, inout, prefix_match)
93
+ super(
94
+ "Serializer tried to define an #{inout}_alias that points to the media type identifier #{identifier} but no such #{inout} has been defined yet. An #{inout} named #{prefix_match} was found. Often this can be fixed by providing an #{inout} with a nil version."
95
+ )
96
+ end
97
+ end
98
+
91
99
  class DuplicateDefinitionError < ConfigurationError
92
100
  def initialize(identifier, inout)
93
101
  super("Serializer tried to define an #{inout} using the media type identifier #{identifier}, but another #{inout} was already defined with that identifier. Please remove one of the two.")
@@ -27,8 +27,8 @@ class FakeValidator
27
27
  def identifier
28
28
  suffix = suffixes[[internal_view, internal_version]] || ''
29
29
  result = prefix
30
- result += '.' + internal_view.to_s unless internal_view.nil?
31
30
  result += '.v' + internal_version.to_s unless internal_version.nil?
31
+ result += '.' + internal_view.to_s unless internal_view.nil?
32
32
  result += '+' + suffix.to_s unless suffix.empty?
33
33
 
34
34
  result
@@ -56,7 +56,7 @@ module MediaTypes
56
56
  array.each do |e|
57
57
  child_links = []
58
58
  context = SerializationDSL.new(__getobj__, child_links, context: @serialization_context)
59
- serializer.serialize(e, identifier, @serialization_context, dsl: context)
59
+ serializer.serialize(e, identifier, context: @serialization_context, dsl: context)
60
60
 
61
61
  self_links = child_links.select { |l| l[:rel] == :self }
62
62
  raise NoSelfLinkProvidedError, identifier unless self_links.any?
@@ -79,7 +79,7 @@ module MediaTypes
79
79
 
80
80
  array.each do |e|
81
81
  context = SerializationDSL.new(__getobj__, [], @serialization_vary, context: @serialization_context)
82
- result = serializer.serialize(e, identifier, @serialization_context, dsl: context, raw: true)
82
+ result = serializer.serialize(e, identifier, context: @serialization_context, dsl: context, raw: true)
83
83
 
84
84
  result = block.call(result) unless block.nil?
85
85
 
@@ -105,7 +105,7 @@ module MediaTypes
105
105
  def emit
106
106
  serialization_dsl_result
107
107
  end
108
-
108
+
109
109
  def object(&block)
110
110
  context = SerializationDSL.new(__getobj__, @serialization_links, @serialization_vary, context: @serialization_context)
111
111
  context.instance_exec(&block)
@@ -31,14 +31,23 @@ module MediaTypes
31
31
  register_wildcards(identifier, registration) if wildcards && inout == :output
32
32
  end
33
33
 
34
- def register_alias(serializer, alias_identifier, target_identifier, optional, wildcards: true)
35
- raise DuplicateDefinitionError.new(identifier, inout) if registrations.key? alias_identifier
34
+ def register_alias(serializer, alias_identifier, target_identifier, optional, hide_variant, wildcards: true)
35
+ raise DuplicateDefinitionError.new(alias_identifier, inout) if registrations.key? alias_identifier
36
36
 
37
- raise UnbackedAliasDefinitionError.new(target_identifier, inout) unless registrations.key? target_identifier
37
+ unless registrations.key? target_identifier
38
+ potential_match = registrations.keys.find do |k|
39
+ k.starts_with? target_identifier
40
+ end
41
+ raise VersionedAliasDefinitionError.new(target_identifier, inout, potential_match) unless potential_match.nil?
42
+ raise UnbackedAliasDefinitionError.new(target_identifier, inout)
43
+ end
38
44
 
39
45
  target = registrations[target_identifier]
40
46
 
41
- registration = SerializationAliasRegistration.new serializer, inout, target.validator, alias_identifier, target, optional
47
+ result_content_type = alias_identifier
48
+ result_content_type += "; variant=#{target_identifier}" unless hide_variant
49
+
50
+ registration = SerializationAliasRegistration.new serializer, inout, target.validator, result_content_type, target, optional, hide_variant
42
51
  registrations[alias_identifier] = registration
43
52
 
44
53
  register_wildcards(alias_identifier, registration) if wildcards && inout == :output
@@ -103,7 +112,7 @@ module MediaTypes
103
112
  private
104
113
 
105
114
  def register_wildcards(identifier, registration)
106
- new_alias = SerializationAliasRegistration.new registration.serializer, registration.inout, registration.validator, identifier, registration, true
115
+ new_alias = SerializationAliasRegistration.new registration.serializer, registration.inout, registration.validator, identifier, registration, true, true
107
116
 
108
117
  registrations['*/*'] = new_alias unless has? '*/*'
109
118
 
@@ -160,14 +169,14 @@ module MediaTypes
160
169
  begin
161
170
  victim = MediaTypes::Serialization.json_decoder.call(victim)
162
171
  validator.validate!(victim)
163
- rescue MediaTypes::Scheme::ValidationError, Oj::ParseError, JSON::ParserError => inner
172
+ rescue MediaTypes::Scheme::ValidationError, Oj::ParseError, JSON::ParserError, EncodingError => inner
164
173
  raise InputValidationFailedError, inner
165
174
  end
166
175
  else
167
176
  begin
168
177
  victim = MediaTypes::Serialization.json_decoder.call(victim)
169
178
  validator.validate!(victim)
170
- rescue MediaTypes::Scheme::ValidationError, JSON::ParserError => inner
179
+ rescue MediaTypes::Scheme::ValidationError, JSON::ParserError, EncodingError => inner
171
180
  raise InputValidationFailedError, inner
172
181
  end
173
182
  end
@@ -204,9 +213,10 @@ module MediaTypes
204
213
 
205
214
  # A registration that calls another registration when called.
206
215
  class SerializationAliasRegistration < SerializationBaseRegistration
207
- def initialize(serializer, inout, validator, display_identifier, target, optional)
216
+ def initialize(serializer, inout, validator, display_identifier, target, optional, hide_variant)
208
217
  self.target = target
209
218
  self.optional = optional
219
+ self.hide_variant = hide_variant
210
220
  super(serializer, inout, validator, display_identifier)
211
221
  end
212
222
 
@@ -229,7 +239,7 @@ module MediaTypes
229
239
  target.call(victim, context, dsl: dsl, raw: raw)
230
240
  end
231
241
 
232
- attr_accessor :target, :optional
242
+ attr_accessor :target, :optional, :hide_variant
233
243
  end
234
244
  end
235
245
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'erb'
4
4
  require 'media_types/serialization/base'
5
+ require 'media_types/serialization/utils/accept_language_header'
5
6
 
6
7
  module MediaTypes
7
8
  module Serialization
@@ -9,13 +10,19 @@ module MediaTypes
9
10
  class ProblemSerializer < MediaTypes::Serialization::Base
10
11
 
11
12
  unvalidated 'application/vnd.delftsolutions.problem'
13
+ disable_wildcards
12
14
 
13
15
  output do |problem, _, context|
14
16
  raise 'No translations defined, add at least one title' unless problem.translations.keys.any?
15
17
 
16
- # TODO: content-language selection
17
-
18
- translation = problem.translations[problem.translations.keys.first]
18
+ accept_language_header = Utils::AcceptLanguageHeader.new(context.request.get_header(HEADER_ACCEPT_LANGUAGE) || '')
19
+ translation_entry = accept_language_header.map do |locale|
20
+ problem.translations.keys.find do |l|
21
+ l.start_with? locale.locale
22
+ end
23
+ end.compact.first || problem.translations.keys.first
24
+ translation = problem.translations[translation_entry]
25
+
19
26
  title = translation[:title]
20
27
  detail = translation[:detail] || problem.error.message
21
28
 
@@ -33,12 +40,19 @@ module MediaTypes
33
40
  output_alias 'application/problem+json'
34
41
 
35
42
  output_raw view: :html do |problem, _, context|
36
- # TODO: content-language selection
37
-
38
- translation = problem.translations[problem.translations.keys.first]
43
+ accept_language_header = Utils::AcceptLanguageHeader.new(context.request.get_header(HEADER_ACCEPT_LANGUAGE) || '')
44
+ translation_entry = accept_language_header.map do |locale|
45
+ problem.translations.keys.find do |l|
46
+ l.starts_with? locale.locale
47
+ end
48
+ end.compact.first || problem.translations.keys.first
49
+ translation = problem.translations[translation_entry]
50
+
39
51
  title = translation[:title]
40
52
  detail = translation[:detail] || problem.error.message
41
53
 
54
+ detail_lang = translation[:detail].nil? ? 'en' : translation_entry
55
+
42
56
  input = OpenStruct.new(
43
57
  title: title,
44
58
  detail: detail,
@@ -61,11 +75,11 @@ module MediaTypes
61
75
  </header>
62
76
  <section id="content">
63
77
  <nav>
64
- <section id="description">
78
+ <section id="description" lang="#{translation_entry}">
65
79
  <h2><a href="<%= help_url %>"><%= CGI::escapeHTML(title) %></a></h2>
66
80
  </section>
67
81
  </nav>
68
- <main>
82
+ <main lang="#{detail_lang}">
69
83
  <p><%= detail %>
70
84
  </main>
71
85
  </section>
@@ -76,8 +90,7 @@ module MediaTypes
76
90
  template.result(input.instance_eval { binding })
77
91
  end
78
92
 
79
- # Hack: results in the alias being registered as */* wildcard
80
- self.serializer_output_registration.registrations.delete('*/*')
93
+ enable_wildcards
81
94
 
82
95
  output_alias_optional 'text/html', view: :html
83
96