Skip to content

Instantly share code, notes, and snippets.

@gistlyn
Last active June 24, 2021 10:56
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gistlyn/bc590cd486c6b2588d8baf56b32c6f40 to your computer and use it in GitHub Desktop.
Save gistlyn/bc590cd486c6b2588d8baf56b32c6f40 to your computer and use it in GitHub Desktop.
ArchiveServices backend for New Package UI
// License BSD https://github.com/ServiceStack/servicestack-client/blob/master/LICENSE.txt
// Partial AppConfig used by ArchiveServices.cs
public class AppConfig
{
public Dictionary<string, HashSet<string>> TemplateMixMap { get; set; } = new();
}
public class AppHost : AppHostBase
{
public override void Configure(Container container)
{
var appConfig = new AppConfig();
var templateMix = new TextFileSettings(Path.Combine(HostingEnvironment.ContentRootPath, "template-mix.txt"));
foreach (var entry in templateMix.GetAll())
{
appConfig.TemplateMixMap[entry.Key] = entry.Value.Split(',').Map(x => x.Trim()).ToSet();
}
container.Register(c => appConfig);
}
}
// License BSD https://github.com/ServiceStack/servicestack-client/blob/master/LICENSE.txt
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using ServiceStack;
using ServiceStack.Caching;
using ServiceStack.FluentValidation.Internal;
using ServiceStack.IO;
using ServiceStack.Text;
using www.ServiceModel;
namespace www.ServiceInterface
{
[Route("/archive/{User}/{Repo}")]
public class GetRepoArchive : IReturn<byte[]>
{
[ValidateNotEmpty]
public string User { get; set; }
[ValidateNotEmpty]
public string Repo { get; set; }
public string Name { get; set; }
public string[] Mix { get; set; }
}
public class ArchiveServices : Service
{
public ConcurrentDictionary<string, GistVirtualFiles> MixGists { get; } = new();
public AppConfig AppConfig { get; set; }
public async Task<object> Get(GetRepoArchive request)
{
var user = request.User;
var repo = request.Repo;
var projectName = request.Name ?? "MyApp";
var projectDir = Path.Combine(ArchiveUtils.AppDataPath, "templates", user, repo);
var gateway = ArchiveUtils.CreateGateway();
var now = DateTime.UtcNow;
var mix = request.Mix;
if (!Directory.Exists(projectDir))
{
var downloadUrl = await gateway.GetSourceZipUrlAsync(user, repo);
var cachedVersionPath = ArchiveUtils.DownloadCachedZipUrl(downloadUrl);
var tmpDir = Path.Combine(Path.GetTempPath(), "servicestack", user, repo);
ArchiveUtils.DeleteDirectory(tmpDir);
ArchiveUtils.Print($"ExtractToDirectory: {cachedVersionPath} -> {tmpDir}");
ZipFile.ExtractToDirectory(cachedVersionPath, tmpDir);
ArchiveUtils.MoveDirectory(new DirectoryInfo(tmpDir).GetDirectories().First().FullName,
projectDir.AssertDir());
}
var fs = new FileSystemVirtualFiles(projectDir);
string hostDir = null;
var projectFiles = new HashSet<string>();
var installPackages = new Dictionary<string, string>();
foreach (var file in fs.GetAllFiles())
{
var filePath = file.VirtualPath.ReplaceMyApp(projectName);
if (hostDir == null && ArchiveUtils.HostFiles.Any(f => filePath.EndsWith(f)))
hostDir = filePath.LastLeftPart('/');
projectFiles.Add(filePath);
}
var gistFilesMap = new Dictionary<string, object>();
if (!mix.IsEmpty())
{
if (AppConfig.TemplateMixMap.TryGetValue(user + "/" + repo, out var eligibleMixes))
{
var to = new List<string>();
foreach (var alias in mix)
{
if (eligibleMixes.Contains(alias))
{
to.Add(alias);
}
}
mix = to.ToArray();
}
}
if (!mix.IsEmpty())
{
var links = gateway.GetGistApplyLinks();
var unhandledAliases = mix.ResolveGistAliases(links);
foreach (var gistAlias in unhandledAliases)
{
var gistLink = GistLink.Get(links, gistAlias);
if (gistLink == null)
throw new Exception($"No match found for '{gistAlias}'");
var basePath = gistLink.To == "$HOST"
? hostDir ?? throw new Exception($"Could not determine $HOST folder")
: gistLink.To == "."
? ""
: gistLink.To.TrimStart('/');
var gistFiles = gateway.GetGistFiles(gistLink.Url, out var gistUrl);
foreach (var gistFile in gistFiles)
{
if (gistFile.Key.IndexOf("..", StringComparison.Ordinal) >= 0)
throw new Exception($"Invalid file name '{gistFile.Key}' from '{gistLink.Url}'");
var fileName = gistFile.Key;
var fileContents = gistFile.Value;
if (fileName == "_init")
{
foreach (var line in fileContents.ReadLines())
{
var addPrefix = "dotnet add package ";
if (line.StartsWith(addPrefix))
{
var package = line.Substring(addPrefix.Length);
installPackages[package] = ArchiveUtils.GetPackageVersion(package);
}
}
continue;
}
var resolvedFile = ArchiveUtils.osPaths(Path.Combine(basePath, gistFile.Key.ReplaceMyApp(projectName).TrimEnd('?')));
var noOverride = gistFile.Key.EndsWith("?");
if (noOverride && projectFiles.Contains(resolvedFile))
{
ArchiveUtils.Print($"Skipping existing optional file: {resolvedFile}");
continue;
}
var filePath = resolvedFile;
if (resolvedFile.EndsWith("|base64"))
{
try
{
filePath = filePath.LastLeftPart('|');
var fileBytes = Convert.FromBase64String(fileContents);
gistFilesMap[filePath] = fileBytes;
}
catch (Exception ex)
{
ArchiveUtils.Print($"Could not Convert Base64 binary file '{filePath}': {ex.Message}");
throw;
}
}
else
{
var renamedTxt = fileContents.ReplaceMyApp(projectName);
gistFilesMap[filePath] = renamedTxt;
}
}
}
}
var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
{
foreach (var file in fs.GetAllFiles())
{
var filePath = file.VirtualPath.ReplaceMyApp(projectName);
projectFiles.Add(filePath);
var entry = archive.CreateEntry(filePath);
await using var entryStream = entry.Open();
if (!file.IsBinary())
{
await using var streamWriter = new StreamWriter(entryStream);
var txt = file.ReadAllText();
var renamedTxt = txt.ReplaceMyApp(projectName);
if (installPackages.Count > 0 && filePath.LastRightPart('/') == projectName + ".csproj")
{
var packageRefItemGroupPos = renamedTxt.IndexOf("<PackageReference", StringComparison.Ordinal);
var itemGroupEndPos = renamedTxt.IndexOf("</ItemGroup>", packageRefItemGroupPos, StringComparison.Ordinal);
var prefix = renamedTxt.Substring(0, itemGroupEndPos);
var suffix = renamedTxt.Substring(itemGroupEndPos);
var packageRefs = StringBuilderCache.Allocate();
foreach (var pkgEntry in installPackages)
{
if (packageRefs.Length > 0)
packageRefs.Append(" ");
packageRefs.AppendLine($"<PackageReference Include=\"{pkgEntry.Key}\" Version=\"{pkgEntry.Value}\" />");
}
var newCsProj = prefix + " " + StringBuilderCache.ReturnAndFree(packageRefs) + " " + suffix;
renamedTxt = newCsProj;
}
await streamWriter.WriteAsync(renamedTxt);
}
else
{
await using var bs = file.OpenRead();
await bs.CopyToAsync(entryStream);
}
}
var filePaths = gistFilesMap.Keys.ToList();
foreach (var filePath in filePaths)
{
var obj = gistFilesMap[filePath];
if (obj is byte[] fileBytes)
{
var entry = archive.CreateEntry(filePath);
await using var entryStream = entry.Open();
await entryStream.WriteAsync(fileBytes);
}
else if (obj is string renamedTxt)
{
var entry = archive.CreateEntry(filePath);
await using var entryStream = entry.Open();
await using var streamWriter = new StreamWriter(entryStream);
await streamWriter.WriteAsync(renamedTxt);
}
}
}
ms.Position = 0;
var headerValue =
$"attachment; {HttpExt.GetDispositionFileName($"{projectName}.zip")}; size={ms.Length}; modification-date={DateTime.UtcNow.ToString("R").Replace(",", "")}";
return new HttpResult(ms, MimeTypes.Binary) {
Headers = {
{HttpHeaders.ContentDisposition, headerValue},
}
};
}
}
public static class ArchiveUtils
{
public const bool Verbose = false;
public const string UserAgent = "servicestack.net";
public const string GitHubToken = "<Replace with GitHubToken>";
public const string GistLinksId = "9b32b03f207a191099137429051ebde8";
public static string ContentRootPath => AppHostBase.Instance.GetHostingEnvironment().ContentRootPath;
public static string AppDataPath => Path.Combine(ContentRootPath, "App_Data");
public static GitHubGateway CreateGateway() => new() {
AccessToken = GitHubToken,
UserAgent = UserAgent,
GetJsonFilter = apiUrl => apiUrl.GetJsonFromUrl(req => req.ApplyRequestFilters())
};
public static void ApplyRequestFilters(this HttpWebRequest req)
{
req.UserAgent = UserAgent;
if (!string.IsNullOrEmpty(GitHubToken))
req.Headers["Authorization"] = "token " + GitHubToken;
// Ignore SSL Errors
// req.ServerCertificateValidationCallback = (webReq, cert, chain, errors) => true;
}
public static void Print(Exception ex)
{
if (Verbose) $"ERROR: {ex.Message}".Print();
}
public static void Print(string msg)
{
if (Verbose) msg.Print();
}
public static bool IsBinary(this IVirtualFile file)
{
if (string.IsNullOrEmpty(file.Extension))
return false;
var mimeType = MimeTypes.GetMimeType(file.Extension);
return MimeTypes.IsBinary(mimeType);
}
public static List<KeyValuePair<string, string>> ReplaceTokens { get; set; } = new();
private static string CamelToKebab(string str) =>
Regex.Replace((str ?? ""), "([a-z])([A-Z])", "$1-$2").ToLower();
public static bool IsUrl(this string gistId) => gistId.IndexOf("://", StringComparison.Ordinal) >= 0;
public static readonly ConcurrentDictionary<string, Dictionary<string, string>> GistFilesCache = new();
public static readonly ConcurrentDictionary<string, string> LatestPackageVersionCache = new();
public static string GetPackageVersion(string package)
{
if (package.StartsWith("ServiceStack"))
return "5.*";
return LatestPackageVersionCache.GetOrAdd(package, key => {
var url = $"https://api.nuget.org/v3-flatcontainer/{key}/index.json";
var json = url.GetJsonFromUrl();
var obj = (Dictionary<string,object>) JSON.parse(json);
var versions = (List<object>) obj["versions"];
versions.Reverse();
var lastVersion = versions.Cast<string>().First(x => !x.Contains('-'));
return lastVersion;
});
}
public static string SanitizeProjectName(string projectName)
{
if (string.IsNullOrEmpty(projectName))
return null;
var sepChars = new[] {' ', '-', '+', '_'};
if (projectName.IndexOfAny(sepChars) == -1)
return projectName;
var sb = StringBuilderCache.Allocate();
var words = projectName.Split(sepChars);
foreach (var word in words)
{
if (string.IsNullOrEmpty(word))
continue;
sb.Append(char.ToUpper(word[0])).Append(word.Substring(1));
}
return StringBuilderCache.ReturnAndFree(sb);
}
public static List<string> HostFiles = new() {
"appsettings.json",
"Web.config",
"App.config",
"Startup.cs",
"Program.cs",
};
public static string osPaths(string path) => path.Replace('\\', '/');
public static Dictionary<string, string> GetGistFiles(this GitHubGateway gateway, string gistId)
{
return GistFilesCache.GetOrAdd(gistId, gistKey => {
var json = gateway.GetJson($"/gists/{gistKey}");
return FromJsonGist(json, gistKey);
});
}
public static Dictionary<string, string> GetGistFilesFromUrl(this GitHubGateway gateway, string gistUrl)
{
return GistFilesCache.GetOrAdd(gistUrl, gistKey => {
var json = gistUrl.GetJsonFromUrl(req => req.UserAgent = "ServiceStack");
return FromJsonGist(json, gistKey);
});
}
public static Dictionary<string, string> FromJsonGist(string json, string gistRef)
{
var response = JSON.parse(json);
if (response is Dictionary<string, object> obj &&
obj.TryGetValue("files", out var oFiles) &&
oFiles is Dictionary<string, object> files)
{
var to = new Dictionary<string, string>();
foreach (var entry in files)
{
var meta = (Dictionary<string, object>) entry.Value;
var contents = (string) meta["content"];
var size = (int) meta["size"];
if ((string.IsNullOrEmpty(contents) || contents.Length < size) &&
meta["truncated"] is bool b && b)
{
contents = DownloadCachedStringFromUrl((string) meta["raw_url"]);
}
to[entry.Key] = contents;
}
return to;
}
throw new NotSupportedException($"Invalid gist response returned for '{gistRef}'");
}
internal static string DownloadCachedStringFromUrl(string url)
{
var cachedPath = GetCachedFilePath(url);
var isCached = File.Exists(cachedPath);
if (Verbose && !isCached) $"Downloading uncached '{url}' ...".Print();
if (File.Exists(cachedPath))
return File.ReadAllText(cachedPath);
var text = url.GetStringFromUrl(requestFilter: req => req.UserAgent = UserAgent);
File.WriteAllText(cachedPath, text);
return text;
}
private static readonly ConcurrentDictionary<string, List<GistLink>> GistLinksCache = new();
public static List<GistLink> GetGistApplyLinks(this GitHubGateway gateway) =>
GetGistLinks(gateway, GistLinksId, "mix.md");
private static string GetGistAliasesFilePath() => Path.Combine(ContentRootPath, "gist.aliases.txt");
public static Dictionary<string, string> GetGistAliases(this GitHubGateway gateway)
{
var aliasesPath = GetGistAliasesFilePath();
if (!File.Exists(aliasesPath))
return new Dictionary<string, string>();
var aliases = File.ReadAllText(aliasesPath);
var aliasSettings = aliases.ParseKeyValueText(delimiter: " ");
return aliasSettings;
}
public static List<GistLink> GetGistLinks(this GitHubGateway gateway, string gistId, string name)
{
var gistsIndex = gateway.GetGistFiles(gistId)
.FirstOrDefault(x => x.Key == name);
if (gistsIndex.Key == null)
throw new NotSupportedException($"Could not find '{name}' file in gist '{GistLinksId}'");
return GistLinksCache.GetOrAdd(gistId + ":" + name, key => {
var links = GistLink.Parse(gistsIndex.Value);
return links;
});
}
public static string[] ResolveGistAliases(this string[] gistAliases, List<GistLink> links)
{
var hasNums = gistAliases.Any(x => int.TryParse(x, out _));
if (hasNums)
{
var resolvedAliases = new List<string>();
foreach (var gistAlias in gistAliases)
{
if (!int.TryParse(gistAlias, out var index))
{
resolvedAliases.Add(gistAlias);
continue;
}
if (index <= 0 || index > links.Count)
throw new ArgumentOutOfRangeException(
$"Invalid Index '{index}'. Valid Range: 1...{links.Count - 1}");
resolvedAliases.Add(links[index - 1].Name);
}
gistAliases = resolvedAliases.ToArray();
}
return gistAliases;
}
public static string GetRequiredString(this Dictionary<string, string> map, string name) =>
map.TryGetValue(name, out var value) ? value : throw new Exception($"'{name}' does not exist");
public static bool Exists(this Dictionary<string, string> map, string name) => map.ContainsKey(name);
private static bool IsGistId(string gistAlias)
{
var testGistId = gistAlias.IndexOfAny(new[] {'-', '.', ':'}) >= 0
? null
: gistAlias.IndexOf('/') >= 0
? gistAlias.RightPart('/').Length == 40
? gistAlias.LeftPart('/')
: null
: gistAlias;
return testGistId != null && (testGistId.Length == 20 || testGistId.Length == 32);
}
public static Dictionary<string, string> GetGistFiles(this GitHubGateway gateway, string gistId, out string gistLinkUrl)
{
Dictionary<string, string> gistFiles;
if (!gistId.IsUrl())
{
gistLinkUrl = $"https://gist.github.com/{gistId}";
gistFiles = gateway.GetGistFiles(gistId);
}
else if (gistId.StartsWith("https://gist.github.com/"))
{
gistLinkUrl = gistId;
var gistParts = gistId.Substring("https://gist.github.com/".Length).Split('/');
if (gistParts.Length == 3)
gistId = string.Join("/", gistParts.Skip(1));
else if (gistParts.Length == 2)
gistId = gistParts[0].Length == 20 || gistParts[0].Length == 32
? string.Join("/", gistParts)
: string.Join("/", gistParts.Skip(1));
else if (gistParts.Length == 1)
gistId = gistParts[0];
else throw new Exception($"Invalid Gist URL '{gistId}'");
gistFiles = gateway.GetGistFiles(gistId);
}
else
{
gistLinkUrl = gistId;
gistFiles = gateway.GetGistFilesFromUrl(gistId);
}
return gistFiles;
}
public static string ReplaceMyApp(this string input, string projectName)
{
if (string.IsNullOrEmpty(input) || string.IsNullOrEmpty(projectName) || projectName == "MyApp")
return input;
var condensed = projectName.Replace("_", "");
var projectNameKebab = CamelToKebab(condensed);
var splitPascalCase = condensed.SplitPascalCase();
var ret = input
.Replace("My_App", projectName)
.Replace("MyApp", condensed)
.Replace("My App", splitPascalCase)
.Replace("my-app", projectNameKebab)
.Replace("myapp", condensed.ToLower())
.Replace("my_app", projectName.ToLower());
if (!Env.IsWindows)
ret = ret.Replace("\r", "");
foreach (var replacePair in ReplaceTokens)
{
ret = ret.Replace(replacePair.Key, replacePair.Value);
}
return ret;
}
public static string GetSafeFileName(this string path)
{
var invalidFileNameChars = new HashSet<char>(Path.GetInvalidFileNameChars()) {':'};
var safeFileName = new string(path.Where(c => !invalidFileNameChars.Contains(c)).ToArray());
return safeFileName;
}
public static void DeleteDirectory(string dirPath)
{
if (!Directory.Exists(dirPath)) return;
if (Verbose) $"RMDIR: {dirPath}".Print();
try
{
FileSystemVirtualFiles.DeleteDirectoryRecursive(dirPath);
}
catch (Exception ex)
{
Print(ex);
}
try
{
Directory.Delete(dirPath);
}
catch { }
}
public static string AssertDirectory(this DirectoryInfo dir) =>
FileSystemVirtualFiles.AssertDirectory(dir?.FullName);
public static void MoveDirectory(string fromPath, string toPath)
{
if (Verbose) $"Directory Move: {fromPath} -> {toPath}".Print();
try
{
Directory.GetParent(toPath).AssertDirectory();
Directory.Move(fromPath, toPath);
}
catch (IOException
ex) //Source and destination path must have identical roots. Move will not work across volumes.
{
if (Verbose) $"Directory Move failed: '{ex.Message}', trying COPY Directory...".Print();
if (Verbose) $"Directory Copy: {fromPath} -> {toPath}".Print();
fromPath.CopyAllTo(toPath);
}
}
public static string DownloadCachedZipUrl(string zipUrl)
{
var noCache = zipUrl.IndexOf("master.zip", StringComparison.OrdinalIgnoreCase) >= 0 ||
zipUrl.IndexOf("main.zip", StringComparison.OrdinalIgnoreCase) >= 0;
if (noCache)
{
var tempFile = Path.GetTempFileName();
if (Verbose) $"Downloading {zipUrl} => {tempFile} (nocache)".Print();
FileSystemVirtualFiles.AssertDirectory(Path.GetDirectoryName(tempFile));
DownloadFile(zipUrl, tempFile);
return tempFile;
}
var cachedVersionPath = GetCachedFilePath(zipUrl);
var isCached = File.Exists(cachedVersionPath);
if (Verbose) ((isCached ? "Using cached release: " : "Using new release: ") + cachedVersionPath).Print();
if (!isCached)
{
if (Verbose) $"Downloading {zipUrl} => {cachedVersionPath}".Print();
FileSystemVirtualFiles.AssertDirectory(Path.GetDirectoryName(cachedVersionPath));
DownloadFile(zipUrl, cachedVersionPath);
}
return cachedVersionPath;
}
private static string GetCachedFilePath(string zipUrl)
{
var safeFileName = zipUrl.GetSafeFileName();
var cachedPath = Path.Combine(AppDataPath, "cache", safeFileName);
return cachedPath;
}
public static void DownloadFile(string downloadUrl, string fileName)
{
try
{
DownloadFileInternal(downloadUrl, fileName);
}
catch (WebException e)
{
// New GitHub repos default to being created with 'main' branch, so try that if master doesn't work
if (e.GetStatus() == HttpStatusCode.NotFound &&
downloadUrl.IndexOf("master.zip", StringComparison.Ordinal) >= 0)
{
DownloadFileInternal(downloadUrl.Replace("master.zip", "main.zip"), fileName);
}
}
}
private static void DownloadFileInternal(string downloadUrl, string fileName)
{
var webClient = new WebClient();
webClient.Headers.Add(HttpHeaders.UserAgent, UserAgent);
try
{
webClient.DownloadFile(downloadUrl, fileName);
}
catch (Exception ex)
{
// Trying to download https://github.com/NetCoreTemplates/vue-desktop/archive/master.zip with token resulted in 404, changed to only use token as fallback
if (GitHubToken == null)
throw;
if (Verbose) $"Failed to download '{downloadUrl}': {ex.Message}\nRetrying with token...".Print();
webClient.Headers.Add(HttpHeaders.Authorization, "token " + GitHubToken);
webClient.DownloadFile(downloadUrl, fileName);
}
}
public static void CopyAllTo(this string src, string dst, string[] excludePaths = null)
{
var d = Path.DirectorySeparatorChar;
foreach (string dirPath in Directory.GetDirectories(src, "*.*", SearchOption.AllDirectories))
{
if (!excludePaths.IsEmpty() && excludePaths.Any(x => dirPath.StartsWith(x)))
continue;
if (Verbose) $"MAKEDIR {dirPath.Replace(src, dst)}".Print();
try
{
Directory.CreateDirectory(dirPath.Replace(src, dst));
}
catch { }
}
foreach (string newPath in Directory.GetFiles(src, "*.*", SearchOption.AllDirectories))
{
if (!excludePaths.IsEmpty() && excludePaths.Any(x => newPath.StartsWith(x)))
continue;
try
{
if (Verbose) $"COPY {newPath.Replace(src, dst)}".Print();
if (newPath.EndsWith(".settings"))
{
var text = File.ReadAllText(newPath);
if (text.Contains("debug true"))
{
text = text.Replace("debug true", "debug false");
File.WriteAllText(newPath.Replace(src, dst), text);
continue;
}
}
File.Copy(newPath, newPath.Replace(src, dst), overwrite: true);
}
catch (Exception ex)
{
Console.WriteLine(Verbose ? ex.ToString() : ex.Message);
}
}
}
}
}
NetCoreTemplates/angular-lite-spa auth,auth-db,auth-redis,auth-dynamodb,auth-memory,postgres,sqlserver,mysql,firebird,oracle,redis,dynamodb,ravendb,mongodb,backgroundmq,rabbitmq,sqs,servicebus,redismq,autoquery,autocrudgen,validation,serverevents,openapi,postman2
NetCoreTemplates/react-lite auth,auth-db,auth-redis,auth-dynamodb,auth-memory,postgres,sqlserver,mysql,firebird,oracle,redis,dynamodb,ravendb,mongodb,backgroundmq,rabbitmq,sqs,servicebus,redismq,autoquery,autocrudgen,validation,serverevents,openapi,postman2
NetCoreTemplates/vue-lite auth,auth-db,auth-redis,auth-dynamodb,auth-memory,postgres,sqlserver,mysql,firebird,oracle,redis,dynamodb,ravendb,mongodb,backgroundmq,rabbitmq,sqs,servicebus,redismq,autoquery,autocrudgen,validation,serverevents,openapi,postman2
NetCoreTemplates/grpc auth,auth-db,auth-redis,auth-dynamodb,auth-memory,postgres,sqlserver,mysql,firebird,oracle,redis,dynamodb,ravendb,mongodb,backgroundmq,rabbitmq,sqs,servicebus,redismq,autoquery,autocrudgen,validation,serverevents,openapi,postman2
NetCoreTemplates/mvcidentity auth,auth-db,auth-redis,auth-dynamodb,auth-memory,postgres,sqlserver,mysql,firebird,oracle,redis,dynamodb,ravendb,mongodb,backgroundmq,rabbitmq,sqs,servicebus,redismq,autoquery,autocrudgen,validation,serverevents,openapi,postman2
NetCoreTemplates/mvcidentityserver auth,auth-db,auth-redis,auth-dynamodb,auth-memory,postgres,sqlserver,mysql,firebird,oracle,redis,dynamodb,ravendb,mongodb,backgroundmq,rabbitmq,sqs,servicebus,redismq,autoquery,autocrudgen,validation,serverevents,openapi,postman2
NetCoreTemplates/vue-desktop auth,auth-db,auth-redis,auth-dynamodb,auth-memory,postgres,sqlserver,mysql,firebird,oracle,redis,dynamodb,ravendb,mongodb,backgroundmq,rabbitmq,sqs,servicebus,redismq,autoquery,autocrudgen,validation,serverevents,openapi,postman2
NetCoreTemplates/worker-rabbitmq auth,auth-db,auth-redis,auth-dynamodb,auth-memory,postgres,sqlserver,mysql,firebird,oracle,redis,dynamodb,ravendb,mongodb,backgroundmq,rabbitmq,sqs,servicebus,redismq,autoquery,autocrudgen,validation,serverevents,openapi,postman2
NetCoreTemplates/worker-redismq auth,auth-db,auth-redis,auth-dynamodb,auth-memory,postgres,sqlserver,mysql,firebird,oracle,redis,dynamodb,ravendb,mongodb,backgroundmq,rabbitmq,sqs,servicebus,redismq,autoquery,autocrudgen,validation,serverevents,openapi,postman2
NetCoreTemplates/worker-servicebus auth,auth-db,auth-redis,auth-dynamodb,auth-memory,postgres,sqlserver,mysql,firebird,oracle,redis,dynamodb,ravendb,mongodb,backgroundmq,rabbitmq,sqs,servicebus,redismq,autoquery,autocrudgen,validation,serverevents,openapi,postman2
NetCoreTemplates/worker-sqs auth,auth-db,auth-redis,auth-dynamodb,auth-memory,postgres,sqlserver,mysql,firebird,oracle,redis,dynamodb,ravendb,mongodb,backgroundmq,rabbitmq,sqs,servicebus,redismq,autoquery,autocrudgen,validation,serverevents,openapi,postman2
nukedbit/blazor-wasm-servicestack auth,auth-db,auth-redis,auth-dynamodb,auth-memory,postgres,sqlserver,mysql,firebird,oracle,redis,dynamodb,ravendb,mongodb,backgroundmq,rabbitmq,sqs,servicebus,redismq,autoquery,autocrudgen,validation,serverevents,openapi,postman2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment