restility 0.0.3
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.
- data/License.txt +340 -0
- data/bin/rest_doc +120 -0
- data/bin/rest_test +151 -0
- data/config/hoe.rb +68 -0
- data/config/requirements.rb +16 -0
- data/lib/doc_book_printer.rb +158 -0
- data/lib/rest.rb +459 -0
- data/lib/rest_htmlprinter.rb +191 -0
- data/lib/rest_test.rb +351 -0
- data/script/destroy +14 -0
- data/script/generate +14 -0
- data/tasks/deployment.rake +27 -0
- data/tasks/environment.rake +7 -0
- data/tasks/website.rake +9 -0
- data/test/test_helper.rb +2 -0
- data/test/test_restility.rb +11 -0
- metadata +94 -0
@@ -0,0 +1,191 @@
|
|
1
|
+
#!/usr/bin/ruby
|
2
|
+
|
3
|
+
require "rest"
|
4
|
+
require 'active_support/builder' unless defined?(Builder)
|
5
|
+
|
6
|
+
class HtmlPrinter < Printer
|
7
|
+
|
8
|
+
attr_accessor :output_dir
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
super()
|
12
|
+
@output_dir = "html"
|
13
|
+
@xml_examples = Hash.new
|
14
|
+
@xml_schemas = Hash.new
|
15
|
+
|
16
|
+
@docbook_tag_mapping = {
|
17
|
+
"command" => "tt",
|
18
|
+
"filename" => "tt",
|
19
|
+
"emphasis" => "em",
|
20
|
+
"replaceable" => "em",
|
21
|
+
}
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
def do_prepare
|
26
|
+
unless File.exists? @output_dir
|
27
|
+
Dir.mkdir @output_dir
|
28
|
+
end
|
29
|
+
@index = File.new( @output_dir + "/index.html", "w" )
|
30
|
+
@html = Builder::XmlMarkup.new( :target => @index, :indent => 2 )
|
31
|
+
@html.comment! "This file was generated by restility at #{Time.now}"
|
32
|
+
end
|
33
|
+
|
34
|
+
def do_finish
|
35
|
+
puts "Written #{@index.path}"
|
36
|
+
|
37
|
+
@xml_examples.each do |f,b|
|
38
|
+
if !XmlFile.exist?( f )
|
39
|
+
STDERR.puts "XML Example '#{f}' is missing."
|
40
|
+
else
|
41
|
+
XmlFile.copy f, @output_dir
|
42
|
+
end
|
43
|
+
end
|
44
|
+
@xml_schemas.each do |f,b|
|
45
|
+
if !XmlFile.exist?( f )
|
46
|
+
STDERR.puts "XML Schema '#{f}' is missing."
|
47
|
+
else
|
48
|
+
XmlFile.copy f, @output_dir
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
@index.close
|
53
|
+
end
|
54
|
+
|
55
|
+
def print_section section
|
56
|
+
if ( !section.root? )
|
57
|
+
tag = "h#{section.level}"
|
58
|
+
@html.tag!( tag, section )
|
59
|
+
end
|
60
|
+
section.print_children self
|
61
|
+
end
|
62
|
+
|
63
|
+
def print_request request
|
64
|
+
@html.div( "class" => "request" ) do
|
65
|
+
|
66
|
+
@html.p do
|
67
|
+
@html.a( "name" => request.id ) do
|
68
|
+
@html.b request.to_s
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
if false
|
73
|
+
host = request.host
|
74
|
+
if ( host )
|
75
|
+
@html.p "Host: " + host.name
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
if request.parameters.size > 0
|
80
|
+
@html.p "Arguments:"
|
81
|
+
@html.ul do
|
82
|
+
request.parameters.each do |p|
|
83
|
+
@html.li p.to_s
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
request.print_children self
|
88
|
+
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def replace_docbook_tags text
|
93
|
+
@docbook_tag_mapping.each do |docbook, html|
|
94
|
+
text.gsub! "<#{docbook}>", "<#{html}>"
|
95
|
+
text.gsub! "</#{docbook}>", "</#{html}>"
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def print_text text
|
100
|
+
@html.p do |p|
|
101
|
+
text.text.each do |t|
|
102
|
+
replace_docbook_tags t
|
103
|
+
p << t << "\n"
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def print_parameter parameter
|
109
|
+
end
|
110
|
+
|
111
|
+
def print_host host
|
112
|
+
@html.p "Host: " + host.name
|
113
|
+
end
|
114
|
+
|
115
|
+
def print_result result
|
116
|
+
@html.p "Result: " + result.name
|
117
|
+
end
|
118
|
+
|
119
|
+
def print_body body
|
120
|
+
@html.p "Body: " + body.name
|
121
|
+
end
|
122
|
+
|
123
|
+
def print_xmlresult result
|
124
|
+
print_xml_links "Result", result.name, result.schema
|
125
|
+
end
|
126
|
+
|
127
|
+
def print_xmlbody body
|
128
|
+
print_xml_links "Body", body.name, body.schema
|
129
|
+
end
|
130
|
+
|
131
|
+
def print_xml_links title, xmlname, schema
|
132
|
+
example = xmlname + ".xml"
|
133
|
+
if ( !schema || schema.empty? )
|
134
|
+
schema = xmlname + ".xsd"
|
135
|
+
end
|
136
|
+
@xml_examples[ example ] = true
|
137
|
+
@xml_schemas[ schema ] = true
|
138
|
+
@html.p do |p|
|
139
|
+
p << title
|
140
|
+
p << ": "
|
141
|
+
has_example = XmlFile.exist? example
|
142
|
+
has_schema = XmlFile.exist? schema
|
143
|
+
if has_example
|
144
|
+
@html.a( "Example", "href" => example )
|
145
|
+
end
|
146
|
+
if has_schema
|
147
|
+
p << " ";
|
148
|
+
@html.a( "Schema", "href" => schema )
|
149
|
+
end
|
150
|
+
if( !has_example && !has_schema )
|
151
|
+
p << xmlname
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def print_contents contents
|
157
|
+
@html.tag! "h#{contents.level}", "Table of Contents"
|
158
|
+
@html.p do |p|
|
159
|
+
p << create_contents_list( contents.root, 1 )
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def create_contents_list section, min_level
|
164
|
+
result = ""
|
165
|
+
section.children.each do |s|
|
166
|
+
if ( s.is_a? Section )
|
167
|
+
result += create_contents_list s, min_level
|
168
|
+
end
|
169
|
+
if ( s.is_a? Request )
|
170
|
+
result += "<li><a href=\"##{s.id}\">" + h( s.to_s ) + "</a></li>\n"
|
171
|
+
end
|
172
|
+
end
|
173
|
+
endresult = ""
|
174
|
+
if ( !result.empty? )
|
175
|
+
if ( section.level > min_level )
|
176
|
+
endresult = "<li>" + h( section.to_s ) + "</li>\n"
|
177
|
+
end
|
178
|
+
if ( section.level >= min_level )
|
179
|
+
endresult += "<ul>\n" + result + "</ul>\n"
|
180
|
+
else
|
181
|
+
endresult = result
|
182
|
+
end
|
183
|
+
end
|
184
|
+
endresult
|
185
|
+
end
|
186
|
+
|
187
|
+
def print_version version
|
188
|
+
@html.p "Version: " + version.to_s
|
189
|
+
end
|
190
|
+
|
191
|
+
end
|
data/lib/rest_test.rb
ADDED
@@ -0,0 +1,351 @@
|
|
1
|
+
require "net/https"
|
2
|
+
require "tempfile"
|
3
|
+
|
4
|
+
class ParameterError < Exception
|
5
|
+
end
|
6
|
+
|
7
|
+
class TestContext
|
8
|
+
|
9
|
+
attr_writer :show_xmlbody, :request_filter, :show_passed, :output_html
|
10
|
+
|
11
|
+
def initialize requests
|
12
|
+
@host_aliases = Hash.new
|
13
|
+
|
14
|
+
@output = ""
|
15
|
+
|
16
|
+
@requests = requests
|
17
|
+
start
|
18
|
+
end
|
19
|
+
|
20
|
+
def start
|
21
|
+
@tested = 0
|
22
|
+
@unsupported = 0
|
23
|
+
@failed = 0
|
24
|
+
@passed = 0
|
25
|
+
@error = 0
|
26
|
+
@skipped = 0
|
27
|
+
end
|
28
|
+
|
29
|
+
def bold str
|
30
|
+
if @output_html
|
31
|
+
str.gsub! /</, "<"
|
32
|
+
str.gsub! />/, ">"
|
33
|
+
"<b>#{str}</b>"
|
34
|
+
else
|
35
|
+
"\033[1m#{str}\033[0m"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def red str
|
40
|
+
bold str
|
41
|
+
# "\E[31m#{str}\E[30m"
|
42
|
+
end
|
43
|
+
|
44
|
+
def green str
|
45
|
+
bold str
|
46
|
+
end
|
47
|
+
|
48
|
+
def magenta str
|
49
|
+
bold str
|
50
|
+
end
|
51
|
+
|
52
|
+
def get_binding
|
53
|
+
return binding()
|
54
|
+
end
|
55
|
+
|
56
|
+
def unsupported
|
57
|
+
out magenta( " UNSUPPORTED" )
|
58
|
+
@unsupported += 1
|
59
|
+
out_flush
|
60
|
+
end
|
61
|
+
|
62
|
+
def failed
|
63
|
+
out red( " FAILED" )
|
64
|
+
@failed += 1
|
65
|
+
out_flush
|
66
|
+
end
|
67
|
+
|
68
|
+
def passed
|
69
|
+
out green( " PASSED" )
|
70
|
+
@passed += 1
|
71
|
+
if ( @show_passed )
|
72
|
+
out_flush
|
73
|
+
else
|
74
|
+
out_clear
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def skipped
|
79
|
+
# out magenta( " SKIPPED" )
|
80
|
+
@skipped += 1
|
81
|
+
out_flush
|
82
|
+
end
|
83
|
+
|
84
|
+
def error str = nil
|
85
|
+
error_str = " ERROR"
|
86
|
+
if ( str )
|
87
|
+
error_str += ": " + str
|
88
|
+
end
|
89
|
+
out red( error_str )
|
90
|
+
@error += 1
|
91
|
+
out_flush
|
92
|
+
end
|
93
|
+
|
94
|
+
def alias_host old, new
|
95
|
+
@host_aliases[ old ] = new
|
96
|
+
end
|
97
|
+
|
98
|
+
def out str
|
99
|
+
@output += str + "\n";
|
100
|
+
end
|
101
|
+
|
102
|
+
def out_clear
|
103
|
+
@output = ""
|
104
|
+
end
|
105
|
+
|
106
|
+
def out_flush
|
107
|
+
print @output
|
108
|
+
out_clear
|
109
|
+
end
|
110
|
+
|
111
|
+
def request arg, return_code = nil, xml_check_wanted = true
|
112
|
+
@tested += 1
|
113
|
+
|
114
|
+
if ( @request_filter && arg !~ /#{@request_filter}/ )
|
115
|
+
skipped
|
116
|
+
return nil
|
117
|
+
end
|
118
|
+
|
119
|
+
out bold( "REQUEST: " + arg )
|
120
|
+
|
121
|
+
request = @requests.find { |r|
|
122
|
+
r.to_s == arg
|
123
|
+
}
|
124
|
+
|
125
|
+
if ( !request )
|
126
|
+
STDERR.puts " Request not defined"
|
127
|
+
return nil
|
128
|
+
end
|
129
|
+
|
130
|
+
xml_bodies = request.all_children XmlBody
|
131
|
+
if ( !xml_bodies.empty? )
|
132
|
+
xml_body = xml_bodies[0]
|
133
|
+
out " XMLBODY: " + xml_body.name
|
134
|
+
end
|
135
|
+
|
136
|
+
xml_results = request.all_children XmlResult
|
137
|
+
if ( !xml_results.empty? )
|
138
|
+
xml_result = xml_results[0]
|
139
|
+
out " XMLRESULT: " + xml_result.name
|
140
|
+
end
|
141
|
+
|
142
|
+
out " host: '#{request.host}'"
|
143
|
+
|
144
|
+
host = request.host.to_s
|
145
|
+
if ( !host || host.empty? )
|
146
|
+
error "No host defined"
|
147
|
+
return nil
|
148
|
+
end
|
149
|
+
|
150
|
+
if @host_aliases[ host ]
|
151
|
+
host = @host_aliases[ host ]
|
152
|
+
end
|
153
|
+
|
154
|
+
out " aliased host: #{host}"
|
155
|
+
|
156
|
+
begin
|
157
|
+
path = substitute_parameters request
|
158
|
+
rescue ParameterError
|
159
|
+
error
|
160
|
+
return nil
|
161
|
+
end
|
162
|
+
|
163
|
+
out " Path: " + path
|
164
|
+
|
165
|
+
splitted_host = host.split( ":" )
|
166
|
+
|
167
|
+
host_name = splitted_host[0]
|
168
|
+
host_port = splitted_host[1]
|
169
|
+
|
170
|
+
out " Host name: #{host_name} port: #{host_port}"
|
171
|
+
|
172
|
+
if ( request.verb == "GET" )
|
173
|
+
req = Net::HTTP::Get.new( path )
|
174
|
+
if ( true||@user )
|
175
|
+
req.basic_auth( @user, @password )
|
176
|
+
end
|
177
|
+
response = Net::HTTP.start( host_name, host_port ) do |http|
|
178
|
+
http.request( req )
|
179
|
+
end
|
180
|
+
if ( response.is_a? Net::HTTPRedirection )
|
181
|
+
location = URI.parse response["location"]
|
182
|
+
out " Redirected to #{location}, scheme is #{location.scheme}"
|
183
|
+
http = Net::HTTP.new( location.host, location.port )
|
184
|
+
if location.scheme == "https"
|
185
|
+
http.use_ssl = true
|
186
|
+
end
|
187
|
+
http.start do |http|
|
188
|
+
req = Net::HTTP::Get.new( location.path )
|
189
|
+
|
190
|
+
if ( @user )
|
191
|
+
out " setting user #{@user}"
|
192
|
+
req.basic_auth( @user, @password )
|
193
|
+
end
|
194
|
+
|
195
|
+
out " calling #{location.host}, #{location.port}"
|
196
|
+
response = http.request( req )
|
197
|
+
end
|
198
|
+
end
|
199
|
+
elsif( request.verb == "POST" )
|
200
|
+
req = Net::HTTP::Post.new( path )
|
201
|
+
if ( @user )
|
202
|
+
req.basic_auth( @user, @password )
|
203
|
+
end
|
204
|
+
response = Net::HTTP.start( host_name, host_port ) do |http|
|
205
|
+
http.request( req, "" )
|
206
|
+
end
|
207
|
+
elsif( request.verb == "PUT" )
|
208
|
+
if ( !@data_body )
|
209
|
+
error "No body data defined for PUT"
|
210
|
+
return nil
|
211
|
+
end
|
212
|
+
|
213
|
+
if ( xml_body && @show_xmlbody )
|
214
|
+
out "Request body:"
|
215
|
+
out @data_body
|
216
|
+
end
|
217
|
+
|
218
|
+
req = Net::HTTP::Put.new( path )
|
219
|
+
if ( @user )
|
220
|
+
req.basic_auth( @user, @password )
|
221
|
+
end
|
222
|
+
response = Net::HTTP.start( host_name, host_port ) do |http|
|
223
|
+
http.request( req, @data_body )
|
224
|
+
end
|
225
|
+
else
|
226
|
+
STDERR.puts " Test of method '#{request.verb}' not supported yet."
|
227
|
+
unsupported
|
228
|
+
return nil
|
229
|
+
end
|
230
|
+
|
231
|
+
if ( response )
|
232
|
+
out " return code: #{response.code}"
|
233
|
+
if ( xml_result && @show_xmlbody )
|
234
|
+
out "Response body:"
|
235
|
+
out response.body
|
236
|
+
end
|
237
|
+
|
238
|
+
if ( ( return_code && response.code == return_code.to_s ) ||
|
239
|
+
( response.is_a? Net::HTTPSuccess ) )
|
240
|
+
if ( xml_check_wanted && xml_result )
|
241
|
+
if ( xml_result.schema )
|
242
|
+
schema_file = xml_result.schema
|
243
|
+
else
|
244
|
+
schema_file = xml_result.name + ".xsd"
|
245
|
+
end
|
246
|
+
if ( validate_xml response.body, schema_file )
|
247
|
+
out " Response validates against schema '#{schema_file}'"
|
248
|
+
passed
|
249
|
+
else
|
250
|
+
failed
|
251
|
+
end
|
252
|
+
else
|
253
|
+
passed
|
254
|
+
end
|
255
|
+
else
|
256
|
+
failed
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
response
|
261
|
+
|
262
|
+
end
|
263
|
+
|
264
|
+
def substitute_parameters request
|
265
|
+
path = request.path.clone
|
266
|
+
|
267
|
+
request.parameters.each do |parameter|
|
268
|
+
p = parameter.name
|
269
|
+
arg = eval( "@arg_#{parameter.name}" )
|
270
|
+
if ( !arg )
|
271
|
+
out " Can't substitute parameter '#{p}'. " +
|
272
|
+
"No variable @arg_#{p} defined."
|
273
|
+
raise ParameterError
|
274
|
+
end
|
275
|
+
path.gsub! /<#{p}>/, arg
|
276
|
+
end
|
277
|
+
|
278
|
+
path
|
279
|
+
end
|
280
|
+
|
281
|
+
def validate_xml xml, schema_file
|
282
|
+
tmp = Tempfile.new('rest_test_validator')
|
283
|
+
tmp.print xml
|
284
|
+
tmp_path = tmp.path
|
285
|
+
tmp.close
|
286
|
+
|
287
|
+
found_schema_file = XmlFile.find_file schema_file
|
288
|
+
|
289
|
+
if ( !found_schema_file )
|
290
|
+
out " Unable to find schema file '#{schema_file}'"
|
291
|
+
return false
|
292
|
+
end
|
293
|
+
|
294
|
+
cmd = "/usr/bin/xmllint --noout --schema #{found_schema_file} #{tmp_path} 2>&1"
|
295
|
+
# puts "CMD: " + cmd
|
296
|
+
output = `#{cmd}`
|
297
|
+
if $?.exitstatus > 0
|
298
|
+
out "xmllint return value: #{$?.exitstatus}"
|
299
|
+
out output
|
300
|
+
return false
|
301
|
+
end
|
302
|
+
return true
|
303
|
+
end
|
304
|
+
|
305
|
+
def print_summary
|
306
|
+
undefined = @tested - @unsupported - @failed - @passed - @error - @skipped
|
307
|
+
|
308
|
+
puts "#tester passed #{@passed}"
|
309
|
+
puts "#tester failed #{@failed}"
|
310
|
+
puts "#tester error #{@error}"
|
311
|
+
puts "#tester skipped #{@unsupported + @skipped + undefined}"
|
312
|
+
|
313
|
+
puts
|
314
|
+
|
315
|
+
puts "Total #{@tested} tests"
|
316
|
+
puts " #{@passed} passed"
|
317
|
+
puts " #{@failed} failed"
|
318
|
+
if ( @unsupported > 0 )
|
319
|
+
puts " #{@unsupported} unsupported"
|
320
|
+
end
|
321
|
+
if ( @error > 0 )
|
322
|
+
puts " #{@error} errors"
|
323
|
+
end
|
324
|
+
if ( @skipped > 0 )
|
325
|
+
puts " #{@skipped} skipped"
|
326
|
+
end
|
327
|
+
if ( undefined > 0 )
|
328
|
+
puts " #{undefined} undefined"
|
329
|
+
end
|
330
|
+
|
331
|
+
end
|
332
|
+
|
333
|
+
end
|
334
|
+
|
335
|
+
class TestRunner
|
336
|
+
|
337
|
+
attr_reader :context
|
338
|
+
|
339
|
+
def initialize requests
|
340
|
+
@context = TestContext.new requests
|
341
|
+
end
|
342
|
+
|
343
|
+
def run testfile
|
344
|
+
File.open testfile do |file|
|
345
|
+
eval( file.read, @context.get_binding )
|
346
|
+
end
|
347
|
+
|
348
|
+
@context.print_summary
|
349
|
+
end
|
350
|
+
|
351
|
+
end
|
data/script/destroy
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
APP_ROOT = File.join(File.dirname(__FILE__), '..')
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'rubigen'
|
6
|
+
rescue LoadError
|
7
|
+
require 'rubygems'
|
8
|
+
require 'rubigen'
|
9
|
+
end
|
10
|
+
require 'rubigen/scripts/destroy'
|
11
|
+
|
12
|
+
ARGV.shift if ['--help', '-h'].include?(ARGV[0])
|
13
|
+
RubiGen::Base.use_component_sources! [:rubygems, :newgem, :newgem_theme]
|
14
|
+
RubiGen::Scripts::Destroy.new.run(ARGV)
|
data/script/generate
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
APP_ROOT = File.join(File.dirname(__FILE__), '..')
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'rubigen'
|
6
|
+
rescue LoadError
|
7
|
+
require 'rubygems'
|
8
|
+
require 'rubigen'
|
9
|
+
end
|
10
|
+
require 'rubigen/scripts/generate'
|
11
|
+
|
12
|
+
ARGV.shift if ['--help', '-h'].include?(ARGV[0])
|
13
|
+
RubiGen::Base.use_component_sources! [:rubygems, :newgem, :newgem_theme]
|
14
|
+
RubiGen::Scripts::Generate.new.run(ARGV)
|
@@ -0,0 +1,27 @@
|
|
1
|
+
desc 'Release the website and new gem version'
|
2
|
+
task :deploy => [:check_version, :website, :release] do
|
3
|
+
puts "Remember to create SVN tag:"
|
4
|
+
puts "svn copy svn+ssh://#{rubyforge_username}@rubyforge.org/var/svn/#{PATH}/trunk " +
|
5
|
+
"svn+ssh://#{rubyforge_username}@rubyforge.org/var/svn/#{PATH}/tags/REL-#{VERS} "
|
6
|
+
puts "Suggested comment:"
|
7
|
+
puts "Tagging release #{CHANGES}"
|
8
|
+
end
|
9
|
+
|
10
|
+
desc 'Runs tasks website_generate and install_gem as a local deployment of the gem'
|
11
|
+
task :local_deploy => [:website_generate, :install_gem]
|
12
|
+
|
13
|
+
task :check_version do
|
14
|
+
unless ENV['VERSION']
|
15
|
+
puts 'Must pass a VERSION=x.y.z release version'
|
16
|
+
exit
|
17
|
+
end
|
18
|
+
unless ENV['VERSION'] == VERS
|
19
|
+
puts "Please update your version.rb to match the release version, currently #{VERS}"
|
20
|
+
exit
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
desc 'Install the package as a gem, without generating documentation(ri/rdoc)'
|
25
|
+
task :install_gem_no_doc => [:clean, :package] do
|
26
|
+
sh "#{'sudo ' unless Hoe::WINDOZE }gem install pkg/*.gem --no-rdoc --no-ri"
|
27
|
+
end
|
data/tasks/website.rake
ADDED
data/test/test_helper.rb
ADDED