Postman v2 format support

right now, you cannot import postman metadata exposed by SS into the latest version of Postman because its complaining about v1 schema and conversion requires an insane amount of npm garbage to be installed.

any chance of adding v2 postman metadata, or links to v1 and 2?

Please submit feature requests to https://servicestack.uservoice.com

Although itā€™s unlikely weā€™ll have any time to look at this in the near future, but implementing Postman was pretty simple, all encapsulated within this PostmanFeature.cs plugin.

If v2 isnā€™t too different should hopefully be fairly easy to implement it starting with a local modified copy.

You can also import the OpenAPI definition produced by the OpenAPI plugin into Postman.

Iā€™ve had to make changes to my [ApiMember] annotations to get the Postman request to generate the way I want. Iā€™m still working on this, but here are some that Iā€™ve found:

  • Specify ExcludeInSchema=true as well as ParameterType=path for those properties I want exposed only as path parameters;
  • Specify ParameterType=model for properties I want included in a raw JSON request body rather than a form parameter;
  • When importing the OpenAPI definition into Postman, under ā€œAdvanced Settingsā€ choose ā€œSchemaā€ for ā€œRequest parameter generationā€ rather than the default ā€œExampleā€, as the plugin doesnā€™t generate examples and you will get a ā€œnullā€ request body.
1 Like

Hi,

I created a VERY rough 2.1 implementation working with the current version of postman - please use it without warranty - or better as starting point for a correct/complete implementation.

cheers

using System;
using System.Collections.Generic;
using System.Linq;
using ServiceStack;
using ServiceStack.DataAnnotations;
using ServiceStack.Host;
using ServiceStack.Text;
using ServiceStack.Web;

namespace ServiceStack.xxx
{
	public class PostmanFeature : IPlugin, Model.IHasStringId
	{
		public string Id { get; set; } = Plugins.Postman;
		public string AtRestPath { get; set; }
		public bool? EnableSessionExport { get; set; }
		public string Headers { get; set; }
		public List<string> DefaultLabelFmt { get; set; }

		public Dictionary<string, string> FriendlyTypeNames = new Dictionary<string, string>
		{
			{"Int32", "int"},
			{"Int64", "long"},
			{"Boolean", "bool"},
			{"String", "string"},
			{"Double", "double"},
			{"Single", "float"},
		};


		/// <summary>
		/// Only generate specified Verb entries for "ANY" routes
		/// </summary>
		public List<string> DefaultVerbsForAny { get; set; }

		public PostmanFeature()
		{
			this.AtRestPath = "/postman";
			this.Headers = "Accept: " + MimeTypes.Json;
			this.DefaultVerbsForAny = new List<string> { HttpMethods.Get };
			this.DefaultLabelFmt = new List<string> { "type" };
		}

		public void Register(IAppHost appHost)
		{
			appHost.RegisterService<PostmanService>(AtRestPath);

			appHost.GetPlugin<MetadataFeature>()
				   .AddPluginLink(AtRestPath.TrimStart('/'), "Postman Metadata");

			if (EnableSessionExport == null)
				EnableSessionExport = appHost.Config.DebugMode;
		}
	}

	[ExcludeMetadata]
	public class Postman
	{
		public List<string> Label { get; set; }
		public bool ExportSession { get; set; }
		public string ssid { get; set; }
		public string sspid { get; set; }
		public string ssopt { get; set; }
	}

	public class PostmanCollectionInfo
	{
		public string name { get; set; }
		public string version { get; set; }
		public string schema { get; set; }
	}

	public class PostmanCollection
	{
		public PostmanCollectionInfo info { get; set; } = new PostmanCollectionInfo();
		public List<PostmanRequest> item { get; set; }
	}

	public class PostmanRequestBody
	{
		public string mode { get; set; } = "formdata";
		public List<PostmanData> formdata { get; set; }
	}

	public class PostmanRequestURL
	{
		public string raw { get; set; }
		public string protocol { get; set; }
		public string host { get; set; }
		public List<object> segments { get; set; }
		public IEnumerable<string> path { get; set; }
		public string port { get; set; }
		public IEnumerable<PostmanRequestKeyValue> query { get; set; }
		public IEnumerable<PostmanRequestKeyValue> variable { get; set; }
	}

	public class PostmanRequestDetails
	{
		public PostmanRequestURL url { get; set; }
		public string method { get; set; }
		public string header { get; set; }

		public PostmanRequestBody body { get; set; }
	}

	public class PostmanRequestKeyValue
	{
		public string value { get; set; }
		public string key { get; set; }
	}

	public class PostmanRequest
	{
		public PostmanRequest()
		{
			//responses = new List<string>();
			request = new PostmanRequestDetails();
		}
		// V2
		public string name { get; set; }
		public PostmanRequestDetails request { get; set; }


		//public string collectionId { get; set; }
		//public string id { get; set; }
		//public string description { get; set; }
		//public Dictionary<string, string> pathVariables { get; set; }
		//public string dataMode { get; set; }
		//public long time { get; set; }
		//public int version { get; set; }
		//public List<string> responses { get; set; }
	}

	public class PostmanData
	{
		public string key { get; set; }
		public string value { get; set; }
		public string type { get; set; }
	}

	[DefaultRequest(typeof(Postman))]
	[Restrict(VisibilityTo = RequestAttributes.None)]
	public class PostmanService : Service
	{
		[AddHeader(ContentType = MimeTypes.Json)]
		public object Any(Postman request)
		{
			var feature = HostContext.GetPlugin<PostmanFeature>();

			if (request.ExportSession)
			{
				if (feature.EnableSessionExport != true)
					throw new ArgumentException("PostmanFeature.EnableSessionExport is not enabled");

				var url = Request.GetBaseUrl()
					.CombineWith(Request.PathInfo)
					.AddQueryParam("ssopt", Request.GetItemOrCookie(SessionFeature.SessionOptionsKey))
					.AddQueryParam("sspid", Request.GetPermanentSessionId())
					.AddQueryParam("ssid", Request.GetTemporarySessionId());

				return HttpResult.Redirect(url);
			}

			var id = SessionExtensions.CreateRandomSessionId();
			var ret = new PostmanCollection
			{
				info = new PostmanCollectionInfo()
				{
					version = "1",
					name = HostContext.AppHost.ServiceName,
					schema = "https://schema.getpostman.com/json/collection/v2.0.0/collection.json"
				},
				item = GetRequests(request, id, HostContext.Metadata.OperationsMap.Values),
			};

			return ret;
		}

		public List<PostmanRequest> GetRequests(Postman request, string parentId, IEnumerable<Operation> operations)
		{
			var ret = new List<PostmanRequest>();
			var feature = HostContext.GetPlugin<PostmanFeature>();

			var headers = feature.Headers ?? ("Accept: " + MimeTypes.Json);

			if (Response is IHttpResponse httpRes)
			{
				if (request.ssopt != null
					|| request.sspid != null
					|| request.ssid != null)
				{
					if (feature.EnableSessionExport != true)
					{
						throw new ArgumentException("PostmanFeature.EnableSessionExport is not enabled");
					}
				}

				if (request.ssopt != null)
				{
					Request.AddSessionOptions(request.ssopt);
				}
				if (request.sspid != null)
				{
					httpRes.Cookies.AddPermanentCookie(SessionFeature.PermanentSessionId, request.sspid);
				}
				if (request.ssid != null)
				{
					httpRes.Cookies.AddSessionCookie(SessionFeature.SessionId, request.ssid,
						(HostContext.Config.UseSecureCookies && Request.IsSecureConnection));
				}
			}

			foreach (var op in operations)
			{
				Uri url = null;
				List<object> segments;

				if (!HostContext.Metadata.IsVisible(base.Request, op))
					continue;

				var allVerbs = op.Actions.Concat(
					op.Routes.SelectMany(x => x.Verbs))
						.SelectMany(x => x == ActionContext.AnyAction
						? feature.DefaultVerbsForAny
						: new List<string> { x })
					.ToHashSet();

				var propertyTypes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
				op.RequestType.GetSerializableFields()
					.Each(x => propertyTypes[x.Name] = x.FieldType.AsFriendlyName(feature));
				op.RequestType.GetSerializableProperties()
					.Each(x => propertyTypes[x.Name] = x.PropertyType.AsFriendlyName(feature));

				foreach (var route in op.Routes)
				{
					var routeVerbs = route.Verbs.Contains(ActionContext.AnyAction)
						? feature.DefaultVerbsForAny.ToArray()
						: route.Verbs;

					var restRoute = route.ToRestRoute();

					foreach (var verb in routeVerbs)
					{
						allVerbs.Remove(verb); //exclude handled verbs

						var routeData = restRoute.QueryStringVariables
							.Map(x => new PostmanData
							{
								key = x,
								value = "",
								type = "text",
							})
							.ApplyPropertyTypes(propertyTypes);

						url = new Uri(Request.GetBaseUrl().CombineWith(restRoute.Path.ToPostmanPathVariables()));
						ret.Add(new PostmanRequest
						{
							request = new PostmanRequestDetails()
							{
								url = new PostmanRequestURL()
								{
									raw = url.OriginalString,
									host = url.Host,
									port = url.Port.ToString(),
									protocol = url.Scheme,
									path = url.LocalPath.Split("/", StringSplitOptions.RemoveEmptyEntries),
									query = !HttpUtils.HasRequestBody(verb) ? routeData.Select(x => x.key).ApplyPropertyTypes(propertyTypes).Select(x => new PostmanRequestKeyValue { key = x.Key, value = x.Value }) : null,
									variable = restRoute.Variables.Any() ? restRoute.Variables.Select(x => new PostmanRequestKeyValue { key = x }) : null
								},
								method = verb,
								body = new PostmanRequestBody()
								{
									formdata = HttpUtils.HasRequestBody(verb)
									? routeData
									: null,
								},
								header = headers,
							},
							//collectionId = parentId,
							//id = SessionExtensions.CreateRandomSessionId(),
							name = GetName(feature, request, op.RequestType, restRoute.Path),
							//description = op.RequestType.GetDescription(),

							//dataMode = "params",
							//version = 2,
							//time = DateTime.UtcNow.ToUnixTimeMs(),
						});
					}
				}

				var emptyRequest = op.RequestType.CreateInstance();
				var virtualPath = emptyRequest.ToReplyUrlOnly();

				var requestParams = propertyTypes
					.Map(x => new PostmanData
					{
						key = x.Key,
						value = x.Value,
						type = "text",
					});

				url = new Uri(Request.GetBaseUrl().CombineWith(virtualPath));

				ret.AddRange(allVerbs.Select(verb =>
					new PostmanRequest
					{
						request = new PostmanRequestDetails()
						{
							url = new PostmanRequestURL()
							{
								raw = url.OriginalString,
								host = url.Host,
								port = url.Port.ToString(),
								protocol = url.Scheme,
								path = url.LocalPath.Split("/", StringSplitOptions.RemoveEmptyEntries),
								query = !HttpUtils.HasRequestBody(verb) ? requestParams.Select(x => x.key).Where(x => !x.StartsWith(":")).ApplyPropertyTypes(propertyTypes).Select(x => new PostmanRequestKeyValue { key = x.Key, value = x.Value }) : null,
								variable = url.Segments.Any(x => x.StartsWith(":")) ? url.Segments.Where(x => x.StartsWith(":")).Select(x => new PostmanRequestKeyValue { key = x.Replace(":", ""), value = "" }) : null
							},
							method = verb,
							body = new PostmanRequestBody()
							{
								formdata = HttpUtils.HasRequestBody(verb)
								? requestParams
								: null,
							},
							header = headers,
						},
						//collectionId = parentId,
						//id = SessionExtensions.CreateRandomSessionId(),
						//pathVariables = !HttpUtils.HasRequestBody(verb)
						//    ? requestParams.Select(x => x.key)
						//        .ApplyPropertyTypes(propertyTypes)
						//    : null,
						name = GetName(feature, request, op.RequestType, virtualPath),
						//description = op.RequestType.GetDescription(),
						//dataMode = "params",
						//version = 2,
						//time = DateTime.UtcNow.ToUnixTimeMs(),
					}));
			}

			return ret;
		}

		public string GetName(PostmanFeature feature, Postman request, Type requestType, string virtualPath)
		{
			var fragments = request.Label ?? feature.DefaultLabelFmt;
			var sb = StringBuilderCache.Allocate();
			foreach (var fragment in fragments)
			{
				var parts = fragment.ToLower().Split(':');
				var asEnglish = parts.Length > 1 && parts[1] == "english";

				if (parts[0] == "type")
				{
					sb.Append(asEnglish ? requestType.Name.ToEnglish() : requestType.Name);
				}
				else if (parts[0] == "route")
				{
					sb.Append(virtualPath);
				}
				else
				{
					sb.Append(parts[0]);
				}
			}
			return StringBuilderCache.ReturnAndFree(sb);
		}
	}

	public static class PostmanExtensions
	{
		public static string ToPostmanPathVariables(this string path)
		{
			//return path.TrimEnd('*');
			return path.Replace("{", ":").Replace("}", "").TrimEnd('*');
		}

		public static string AsFriendlyName(this Type type, PostmanFeature feature)
		{
			var parts = type.Name.SplitOnFirst('`');
			var typeName = parts[0].LeftPart('[');
			var suffix = "";

			var nullableType = Nullable.GetUnderlyingType(type);
			if (nullableType != null)
			{
				typeName = nullableType.Name;
				suffix = "?";
			}
			else if (type.IsArray)
			{
				suffix = "[]";
			}
			else if (type.IsGenericType)
			{
				var args = type.GetGenericArguments().Map(x =>
					x.AsFriendlyName(feature));
				suffix = $"<{string.Join(",", args.ToArray())}>";
			}

			return feature.FriendlyTypeNames.TryGetValue(typeName, out var friendlyName)
				? friendlyName + suffix
				: typeName + suffix;
		}

		public static List<PostmanData> ApplyPropertyTypes(this List<PostmanData> data,
			Dictionary<string, string> typeMap, string defaultValue = "")
		{
			string typeName;
			data.Each(x => x.value = typeMap.TryGetValue(x.key, out typeName) ? typeName : x.value ?? defaultValue);
			return data;
		}

		public static Dictionary<string, string> ApplyPropertyTypes(this IEnumerable<string> names,
			Dictionary<string, string> typeMap,
			string defaultValue = "")
		{
			var to = new Dictionary<string, string>();
			string typeName;
			names.Each(x => to[x] = typeMap.TryGetValue(x, out typeName) ? typeName : defaultValue);
			return to;
		}
	}
}

Great thx.

Iā€™ve decided to publish it to this mix gist instead so people can configure it in their projects now with:

$ x mix postman2

That way people can try & modify it outside of ServiceStackā€™s long release cycle and whoeverā€™s using it can contribute a more complete impl & fixes as they find it. After itā€™s stabilized we can look into bundling in into ServiceStack .dllā€™s.

Hey!
Thanks for this. I tried adding the file/classes, and then changed the plugin add like so:
Plugins.Add(new Postman2Feature()); // was before: Plugins.Add(new PostmanFeature());

When I run it, I get an Exception on this row in Postman2Feature.Register(IAppHost appHost):

appHost.RegisterService<Postman2Service>(AtRestPath);

System.Reflection.AmbiguousMatchException: ā€˜Could not register Request ā€˜ServiceStack.Postmanā€™ with service ā€˜ServiceStack.Postman2Serviceā€™ as it has already been assigned to another service.
Each Request DTO can only be handled by 1 service.ā€™

I have no idea where this would already be registered. If I remove the Plugin.Add, no Postman support exists. Any input would be appreciated.

Itā€™s a Modular Startup feature that registers itself so you shouldnā€™t try registering it again. I.e. remove any manual plugin registrations.