Repository: washingtonpost/ans-schema (js)

Issue:
Add support for "Attached Redirects"
# Problem

There are cases where users want to store content in the Content API (or related systems) but want that content, when directly requested by url, to return another piece of content instead.  Information about where the content will redirect if navigated to directly is also useful.

# Proposal

Allow `related_content.redirect`, if present, to be a list of redirect objects instead of content or reference objects.  Content API will use redirect information from that redirect object in the fetch by canonical url endpoint.

# Example

```
{
  "_id": "ID_A",
  "type": "video",
  "version": "0.5.8",
  "canonical_url": "/path/to/some/video",
  "headlines": {
    "basic": "This video should only be shown embedded within an article."
  },
  "streams": [
    {
      "height": 360,
      "width": 640,
      "filesize": 5369656,
      "stream_type": "ts",
      "url": "https://videos.posttv.com/washpost-production/The_Washington_Post/20170223/58af2f07e4b0c9994378e6ff/58af3662e4b09a0a172ed097_t_1487877764511_mobile.m3u8",
      "bitrate": 300,
      "provider": "elastictranscoder"
    }
  ],
  "related_content": {
    "redirect": [
      {
        "type": "redirect",
        "version": "0.5.8",
        "canonical_url": "/path/to/some/video",
        "redirect_url": "/path/to/some/article"
      }
    ]
  }
}
```

Pull request:
Make related_content.redirect a reserved slot for a redirect object.
Fixes #111

Fixes some typos.

Patch:
diff --git a/lib/upvert/0.5.7.js b/lib/upvert/0.5.7.js
index b490fec..24c9336 100644
--- a/lib/upvert/0.5.7.js
+++ b/lib/upvert/0.5.7.js
@@ -43,6 +43,22 @@ var convertTag = function convertTag(old_tag) {
 };
 
 
+// In 0.5.8, "related_content.redirect" is a reserved key. It was not largely in use before, so we'll just delete.
+var convertRelatedContent = function convertRelatedContent(old_related_content) {
+
+  // Related Content is a recursion point, so first convert children
+  old_related_content = convert(old_related_content);
+
+  // Delete on the way out.
+  if (_.has(old_related_content, 'redirect')) {
+    delete old_related_content.redirect;
+  }
+
+  return old_related_content;
+};
+
+
+
 // Move label from undefined object to new format
 var convertLabel = function convertLabel(old_label) {
 
@@ -133,6 +149,9 @@ var convertDocument = function convertDocument(doc) {
     else if (key === 'comments') {
       result[key] = convertComments(value);
     }
+    else if (key === 'related_content') {
+      result[key] = convertRelatedContent(value);
+    }
     else if (_.includes(recursion_keys, key)) {
       result[key] = convert(value);
     }
diff --git a/package.json b/package.json
index b2b8143..2c9a9d8 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "ans-schema",
   "description": "The Washington Post's Arc Native Specification",
-  "version": "1.2.14",
+  "version": "1.2.15",
   "homepage": "https://github.com/washingtonpost/ans-schema",
   "repository": {
     "type": "git",
diff --git a/src/main/resources/schema/ans/0.5.8/planning_note_operation.json b/src/main/resources/schema/ans/0.5.8/planning_note_operation.json
index 36aca75..887ba63 100644
--- a/src/main/resources/schema/ans/0.5.8/planning_note_operation.json
+++ b/src/main/resources/schema/ans/0.5.8/planning_note_operation.json
@@ -22,7 +22,7 @@
       },
       "id": {
         "type": "string",
-        "description": "The id of the item being operated",
+        "description": "The id of the item being operated"
       },
       "organization_id": {
         "type": "string",
@@ -39,4 +39,4 @@
     },
     "required": [ "type", "operation", "id", "organization_id", "note" ]
   }]
-}
\ No newline at end of file
+}
diff --git a/src/main/resources/schema/ans/0.5.8/platform_pitch_operation.json b/src/main/resources/schema/ans/0.5.8/platform_pitch_operation.json
index 89581e8..a7dfa1c 100644
--- a/src/main/resources/schema/ans/0.5.8/platform_pitch_operation.json
+++ b/src/main/resources/schema/ans/0.5.8/platform_pitch_operation.json
@@ -22,7 +22,7 @@
       },
       "id": {
         "type": "string",
-        "description": "The id of the item being operated",
+        "description": "The id of the item being operated"
       },
       "organization_id": {
         "type": "string",
@@ -39,4 +39,4 @@
     },
     "required": [ "type", "operation", "id", "organization_id", "body" ]
   }]
-}
\ No newline at end of file
+}
diff --git a/src/main/resources/schema/ans/0.5.8/publication_pitch_operation.json b/src/main/resources/schema/ans/0.5.8/publication_pitch_operation.json
index 8c5b690..46eff47 100644
--- a/src/main/resources/schema/ans/0.5.8/publication_pitch_operation.json
+++ b/src/main/resources/schema/ans/0.5.8/publication_pitch_operation.json
@@ -22,7 +22,7 @@
       },
       "id": {
         "type": "string",
-        "description": "The id of the item being operated",
+        "description": "The id of the item being operated"
       },
       "organization_id": {
         "type": "string",
@@ -39,4 +39,4 @@
     },
     "required": [ "type", "operation", "id", "organization_id", "body" ]
   }]
-}
\ No newline at end of file
+}
diff --git a/src/main/resources/schema/ans/0.5.8/traits/trait_related_content.json b/src/main/resources/schema/ans/0.5.8/traits/trait_related_content.json
index 4f92253..2e55611 100644
--- a/src/main/resources/schema/ans/0.5.8/traits/trait_related_content.json
+++ b/src/main/resources/schema/ans/0.5.8/traits/trait_related_content.json
@@ -4,6 +4,16 @@
   "title": "Related_Content",
   "description": "Lists of content items or references this story is related to, arbitrarily keyed. In the Arc ecosystem, references in this object will be denormalized into the fully-inflated content objects they represent.",
   "type": "object",
+  "properties": {
+    "redirect": {
+      "description": "An attached redirect. In Arc, when this content item is fetched by url, content api will instead return this redirect object with appropriate headers.  In all other cases, this content should be treated normally.",
+      "type": "array",
+      "maxItems": 1,
+      "items": {
+        "$ref": "https://raw.githubusercontent.com/washingtonpost/ans-schema/master/src/main/resources/schema/ans/0.5.8/redirect.json"
+      }
+    }
+  },
   "patternProperties": {
     ".*": {
       "type": "array",


Test patch:
diff --git a/tests/fixtures/schema/0.5.8/story-fixture-good.json b/tests/fixtures/schema/0.5.8/story-fixture-good.json
index df742cf..c1eb965 100644
--- a/tests/fixtures/schema/0.5.8/story-fixture-good.json
+++ b/tests/fixtures/schema/0.5.8/story-fixture-good.json
@@ -48,7 +48,7 @@
     "country_name": "USA"
   },
   "copyright": "(c) 2015 The Washington Post, Inc.",
-  "canonical_url": "http://www.washingtonpost.com/local/anesthesiologist-trashes-sedated-patient-jury-orders-her-to-pay-500000/2015/06/23/cae05c00-18f3-11e5-ab92-c75ae6ab94b5_story.html",
+  "canonical_url": "/local/anesthesiologist-trashes-sedated-patient-jury-orders-her-to-pay-500000/2015/06/23/cae05c00-18f3-11e5-ab92-c75ae6ab94b5_story.html",
   "short_url": "http://wapo.st/1Crp6bY",
   "headlines": {
     "basic": "The default headline for this story",
@@ -78,7 +78,13 @@
           }
         }
       }
-    ]
+    ],
+    "redirect": [{
+      "type": "redirect",
+      "version": "0.5.8",
+      "canonical_url": "/local/anesthesiologist-trashes-sedated-patient-jury-orders-her-to-pay-500000/2015/06/23/cae05c00-18f3-11e5-ab92-c75ae6ab94b5_story.html",
+      "redirect_url": "http://www.washingtonpost.com/editors-note/published-too-soon.html"
+    }]
   },
   "promo_items": {
     "basic": {
diff --git a/tests/fixtures/schema/0.5.8/trait-related-content-bad-attached-redirect-has-multiple-items.json b/tests/fixtures/schema/0.5.8/trait-related-content-bad-attached-redirect-has-multiple-items.json
new file mode 100644
index 0000000..add8235
--- /dev/null
+++ b/tests/fixtures/schema/0.5.8/trait-related-content-bad-attached-redirect-has-multiple-items.json
@@ -0,0 +1,30 @@
+{
+  "basic": [
+    {
+      "type": "story",
+      "version": "0.5.8"
+    },
+    {
+      "type": "reference",
+      "referent": {
+        "type": "story",
+        "id": "ABCDEFG",
+        "provider": ""
+      }
+    }
+  ],
+  "redirect": [
+    {
+      "type": "redirect",
+      "version": "0.5.8",
+      "canonical_url": "/here",
+      "redirect_url": "/there"
+    },
+    {
+      "type": "redirect",
+      "version": "0.5.8",
+      "canonical_url": "/here",
+      "redirect_url": "/there2"
+    }
+  ]
+}
diff --git a/tests/fixtures/schema/0.5.8/trait-related-content-bad-attached-redirect-is-not-redirect.json b/tests/fixtures/schema/0.5.8/trait-related-content-bad-attached-redirect-is-not-redirect.json
new file mode 100644
index 0000000..c156485
--- /dev/null
+++ b/tests/fixtures/schema/0.5.8/trait-related-content-bad-attached-redirect-is-not-redirect.json
@@ -0,0 +1,26 @@
+{
+  "basic": [
+    {
+      "type": "story",
+      "version": "0.5.8"
+    },
+    {
+      "type": "reference",
+      "referent": {
+        "type": "story",
+        "id": "ABCDEFG",
+        "provider": ""
+      }
+    }
+  ],
+  "redirect": [
+    {
+      "type": "reference",
+      "referent": {
+        "type": "story",
+        "id": "ABCDEFG",
+        "provider": ""
+      }
+    }
+  ]
+}
diff --git a/tests/fixtures/schema/0.5.8/video-fixture-good-attached-redirect.json b/tests/fixtures/schema/0.5.8/video-fixture-good-attached-redirect.json
new file mode 100644
index 0000000..13bff2f
--- /dev/null
+++ b/tests/fixtures/schema/0.5.8/video-fixture-good-attached-redirect.json
@@ -0,0 +1,97 @@
+{
+  "_id": "unique ANS id",
+  "type": "video",
+  "version": "0.5.8",
+
+  "embed_html": "<iframe src=\"https:\/\/videos.posttv.com\/washpost-production\/The%20Washington%20Post\/20150701\/55944729e4b082c8417f4483\/55944904e4b0ef3ccc0da042_t_1435781395718_mobile.m3u8\"><\/iframe>",
+  "subtitles": {
+    "subtitlesUrls": [
+      {
+        "url": "https:\/\/closedcaptions.posttv.com\/06-30-2015\/5592c09fe4b082c8417f3f72_5592c013e4b082c8417f3f71\/t_1435683757146.dfxp",
+        "format": "DFXP"
+      },
+      {
+        "url": "https:\/\/closedcaptions.posttv.com\/06-30-2015\/5592c09fe4b082c8417f3f72_5592c013e4b082c8417f3f71\/t_1435683757146.rawtext",
+        "format": "RAW_TEXT"
+      }
+    ],
+    "confidence": 0.657583939
+  },
+  "promo_image": {
+    "height": 640,
+    "width": 800,
+    "caption": "US Supreme Court Justice Antonin Scalia testifies before the Senate Judiciary Committee",
+    "url": "https:\/\/img.washingtonpost.com\/rf\/image_908w\/2010-2019\/WashingtonPost\/2012\/06\/29\/Outlook\/Advance\/Images\/511969927-363.jpg",
+    "location": "Washington, D.C.",
+    "created_date": "2015-06-25T09:50:50.52Z",
+    "version": "0.5.8",
+    "type": "image",
+    "_id": "unique image id"
+  },
+  "streams": [
+    {
+      "provider": "elastictranscoder",
+      "bitrate": 600,
+      "url": "https:\/\/videos.posttv.com\/washpost-production\/The%20Washington%20Post\/20150701\/55944729e4b082c8417f4483\/55944904e4b0ef3ccc0da042_t_1435781395718_mobile.m3u8",
+      "streamType": "ts",
+      "videoCodec": "",
+      "audioCodec": "",
+      "fileSize": 4443944,
+      "width": 640,
+      "height": 360
+    },
+    {
+      "provider": "elastictranscoder",
+      "bitrate": 300,
+      "url": "https:\/\/videos.posttv.com\/washpost-production\/The%20Washington%20Post\/20150701\/55944729e4b082c8417f4483\/55944904e4b0ef3ccc0da042_t_1435781395718_mobile.m3u8",
+      "streamType": "ts",
+      "videoCodec": "",
+      "audioCodec": "",
+      "fileSize": 2738596,
+      "width": 640,
+      "height": 360
+    }
+  ],
+  "youtube_content_id": "nKW3wvhnZoE",
+  "video_type": "clip",
+  "rating": "graphic",
+  "transcript": "This is a transcript of all the words said in the video. This can go on for quite some time.",
+  "duration": 139000,
+  "content_restrictions": {
+    "content_code": "green"
+  },
+  "description": {
+    "basic": "We went out in 25+ knots and massive waves with two 420's and a 470 off Sandringham Yacht Club... The result was a broken camera and some almost broken sailors. We hope you enjoy the carnage and speed. "
+  },
+  "canonical_url": "/video/youtube/iZ-nBDcMlWs",
+  "related_content": {
+    "redirect": [{
+      "type": "redirect",
+      "version": "0.5.8",
+      "canonical_url": "/video/youtube/iZ-nBDcMlWs",
+      "redirect_url": "http://www.washingtonpost.com/local/anesthesiologist-trashes-sedated-patient-jury-orders-her-to-pay-500000/2015/06/23/cae05c00-18f3-11e5-ab92-c75ae6ab94b5_story.html"
+    }],
+    "misc": [
+      {
+        "type": "image",
+        "version": "0.5.8",
+        "url": "https:\/\/img.washingtonpost.com\/rf\/image_908w\/2010-2019\/WashingtonPost\/2012\/06\/29\/Outlook\/Advance\/Images\/511969927-363.jpg"
+      }
+    ]
+  },
+
+  "copyright": "(c) 2015, The Washington Post, Inc",
+  "credits": {
+    "by": [
+      {
+        "type": "author",
+        "name": "Bruce Harris"
+      }
+    ]
+  },
+  "created_date": "2015-06-25T09:50:50.52Z",
+  "additional_properties": {
+    "foo": "bar"
+  }
+
+}
diff --git a/tests/fixtures/transforms/0.5.7/video-fixture-good-related-content-redirect.json b/tests/fixtures/transforms/0.5.7/video-fixture-good-related-content-redirect.json
new file mode 100644
index 0000000..ea2e8c3
--- /dev/null
+++ b/tests/fixtures/transforms/0.5.7/video-fixture-good-related-content-redirect.json
@@ -0,0 +1,26 @@
+{
+  "type": "video",
+  "version": "0.5.7",
+  "related_content": {
+    "basic": [{
+      "type": "story",
+      "version": "0.5.8",
+      "headlines": {
+        "basic": "Dog Bites Man."
+      },
+      "related_content": {
+        "redirect": [{
+          "type": "story",
+          "version": "0.5.8",
+          "canonical_url": "/recursive/redirect/item"
+        }]
+      }
+    }],
+    "redirect": [{
+      "type": "story",
+      "version": "0.5.7",
+      "_id": "ABCDEFG",
+      "canonical_url": "/other/story/there"
+    }]
+  }
+}
diff --git a/tests/schema-tests-05.js b/tests/schema-tests-05.js
index 0d406c9..49d5c6c 100644
--- a/tests/schema-tests-05.js
+++ b/tests/schema-tests-05.js
@@ -749,7 +749,7 @@ describe("Schema: ", function() {
           });
           it("should reject publication pitch event status containing uppercase characters", function() {
             validateIfFixtureExists(version, '/traits/trait_publication_pitch_event.json', 'trait-pitch-event-fixture-uppercase-status', false);
-          });           
+          });
         });
 
         describe("Workflow (0.5.8+)", function() {
@@ -758,7 +758,7 @@ describe("Schema: ", function() {
           });
           it("should reject invalid workflow status code", function() {
             validateIfFixtureExists(version, '/traits/trait_workflow.json', 'trait-workflow-fixture-bad-code', false);
-          });          
+          });
         });
 
         describe("Planning (0.5.8+)", function() {
@@ -767,6 +767,14 @@ describe("Schema: ", function() {
           });
         });
 
+
+        describe("Related Content (0.5.8+)", function() {
+          it("should not validate related_content.redirect that is not a redirect or that has too many items", function() {
+            validateIfFixtureExists(version, '/traits/trait_related_content.json', 'trait-related-content-bad-attached-redirect-has-multiple-items', false);
+            validateIfFixtureExists(version, '/traits/trait_related_content.json', 'trait-related-content-bad-attached-redirect-is-not-redirect', false);
+          });
+        });
+
         describe("Story Elements ", function() {
           var type_prefix = "/story_elements";
 
diff --git a/tests/transform-tests.js b/tests/transform-tests.js
index b0d8714..795a3d7 100644
--- a/tests/transform-tests.js
+++ b/tests/transform-tests.js
@@ -600,6 +600,14 @@ describe("Transformations: ", function() {
         });
       });
 
+      describe("Related Content", function() {
+        it("should delete related_content.redirect", function() {
+          var result = transforms.upvert(fixtures['0.5.7']['video-fixture-good-related-content-redirect'], '0.5.8');
+          result.related_content.should.not.have.property("redirect");
+          result.related_content.basic[0].related_content.should.not.have.property('redirect');
+        });
+      });
+
 
     });
   });